diff --git a/.env.sample b/.env.sample index 56a75721..97584537 100644 --- a/.env.sample +++ b/.env.sample @@ -22,8 +22,8 @@ PDB_MIRROR_PATH= # Example: /path/to/ccd_mirror/M/MYLIGAND1/MYLIGAND1.cif CCD_MIRROR_PATH= -# --- Local MSA directories --- -LOCAL_MSA_DIRS= +# --- MSA directories --- +PROTEIN_MSA_DIRS= # --- External tools --- @@ -37,6 +37,20 @@ X3DNA_PATH= # Example: /path/to/hhsuite/build/bin/hhfilter HHFILTER_PATH= +# The `HHBLITS_PATH` is a path to the hhblits tool from the HH-suite, which is used for +# CPU-based MSA generation (--backend hhblits). +# Example: /path/to/hhsuite/build/bin/hhblits +HHBLITS_PATH= + +# HH-suite database paths (required for --backend hhblits) +# UniRef30 database for HHblits +# Example: /local/databases/uniclust/UniRef30_2022_02 +HHBLITS_UNIREF30_DB_PATH= + +# BFD database for HHblits (used as fallback when UniRef30 yields insufficient sequences) +# Example: /local/databases/bfd/bfd_metaclust_clu_complete_id30_c90_final_seq.sorted_opt +HHBLITS_BFD_DB_PATH= + # The `MMSEQS2_PATH` is a path to the mmseqs2 tool, which is used for fast sequence searching. # Example: /path/to/mmseqs-gpu/bin/mmseqs MMSEQS2_PATH= diff --git a/.github/ci/run_tests.sh b/.github/ci/run_tests.sh index dd515d98..eb1785cf 100755 --- a/.github/ci/run_tests.sh +++ b/.github/ci/run_tests.sh @@ -4,8 +4,27 @@ echo "Running from $PWD" export OPENBLAS_NUM_THREADS=1 export OMP_NUM_THREADS=1 -apptainer_path=/net/software/containers/users/ncorley/atomworks/atomworks_dev.sif -tar -xf /net/lab/pub/atomworks/test_pack_latest.tar.gz -C tests/data > /dev/null 2>&1 +: "${ATOMWORKS_CI_CONTAINER:?Set the AtomWorks CI container path}" +: "${ATOMWORKS_CI_TEST_PACK:?Set the AtomWorks CI test-pack path}" + +# Give the container a job-local /tmp instead of the compute node's. +ci_tmp=$(mktemp -d "$PWD/.ci-tmp.XXXXXX") +trap 'rm -rf "$ci_tmp"' EXIT + +run_in_container() { + apptainer exec \ + --bind "$PWD:/workspace" \ + --bind "$ci_tmp:/tmp" \ + --pwd /workspace \ + --env PYTHONPATH=/workspace/src \ + --env TMPDIR=/tmp \ + "$ATOMWORKS_CI_CONTAINER" "$@" +} + +# Extract the shared test pack (non-PDB fixtures). The PDB mirror itself is +# resolved by tests/conftest.py, which prefers the lab-wide frozen PDB copy. +mkdir -p tests/data +tar -xf "$ATOMWORKS_CI_TEST_PACK" -C tests/data # Get max processes from environment variable, default to 24 if not set N_CPU=${N_CPU:-24} @@ -13,13 +32,13 @@ echo "Running tests with max $N_CPU CPUs" # Ensure we can collect all tests (i.e. imports succeed) echo "Testing imports by trying to collect all tests" -apptainer exec --bind $PWD:/workspace --pwd /workspace --env PYTHONPATH=/workspace/src $apptainer_path pytest -m "not benchmark" --collect-only tests/ +run_in_container pytest -m "not benchmark" --collect-only tests/ # Run the tests in coverage mode (with 24 CPUs) -apptainer exec --bind $PWD:/workspace --pwd /workspace --env PYTHONPATH=/workspace/src $apptainer_path pytest -m "not benchmark" --cov=atomworks --cov-report=xml -n=auto --maxprocesses=$N_CPU --dist=worksteal tests/ +run_in_container pytest -m "not benchmark" --cov=atomworks --cov-report=xml -n=auto --maxprocesses=$N_CPU --dist=worksteal tests/ # Require at least 80% coverage -apptainer exec --bind $PWD:/workspace --pwd /workspace --env PYTHONPATH=/workspace/src $apptainer_path coverage report --fail-under=80 +run_in_container coverage report --fail-under=80 # Output the coverage in a format GitLab can parse -apptainer exec --bind $PWD:/workspace --pwd /workspace --env PYTHONPATH=/workspace/src $apptainer_path coverage report | tail -n 1 | awk '{print "TOTAL", $NF}' +run_in_container coverage report | tail -n 1 | awk '{print "TOTAL", $NF}' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4a355d75..7b3cd285 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,19 +1,13 @@ ## 📋 PR Checklist - [ ] This PR is tagged as a [draft](https://github.blog/news-insights/product-news/introducing-draft-pull-requests/) if it is still under development and not ready for review. - > This avoids auto-triggering the slower tests in the CI and needlessly wasting resources. + > Drafts still run CI. - [ ] I have ensured that all my commits follow [angular commit message conventions](https://www.conventionalcommits.org/en/v1.0.0-beta.4/). > Format: `[optional scope]: ` > Example: `fix(af3): add missing crop transform to the af3 pipeline` > - > This affects semantic versioning as follows: - > - `fix`: patch version increment (0.0.1 → 0.0.2) - > - `feat`: minor version increment (0.0.1 → 0.1.0) - > - `BREAKING CHANGE`: major version increment (0.0.1 → 1.0.0) - > - All other types do not affect versioning - > - > The format ensures readable changelogs through auto-generation from commit messages. + > Releases use the explicit version in `pyproject.toml` and a matching `v` tag. - [ ] I have run `make format` on the codebase before submitting the PR (this autoformats the code and lints it). diff --git a/.github/release.py b/.github/release.py new file mode 100644 index 00000000..1c2ddf12 --- /dev/null +++ b/.github/release.py @@ -0,0 +1,101 @@ +"""Validate AtomWorks release tags and prepare versioned documentation.""" + +import json +import os +import shutil +import sys +import tarfile +import tomllib +import zipfile +from pathlib import Path + +from packaging.version import Version + +DOCS_URL = "https://rosettacommons.github.io/atomworks" + + +def verify_distributions(dist: Path, source: Path) -> None: + """Require every package source and data file in both release archives, byte for byte.""" + (wheel,) = dist.glob("*.whl") + (sdist,) = dist.glob("*.tar.gz") + files = [path for path in source.rglob("*") if path.is_file() and "__pycache__" not in path.parts] + if not files: + raise ValueError(f"No package files found in {source}") + with zipfile.ZipFile(wheel) as binary, tarfile.open(sdist) as archive: + for path in files: + relative = path.relative_to(source).as_posix() + member = f"{sdist.name.removesuffix('.tar.gz')}/src/{relative}" + archived = archive.extractfile(member) + expected = path.read_bytes() + if archived is None or archived.read() != expected or binary.read(relative) != expected: + raise ValueError(f"Release archive differs from source: {relative}") + + +def release_version(tag: str) -> Version: + """Accept canonical version tags suitable for a public package release.""" + version = Version(tag.removeprefix("v")) + if tag != f"v{version}" or version.is_devrelease or version.local: + raise ValueError(f"Expected a canonical release tag, got {tag!r}") + return version + + +def verify_tag(tag: str, project_file: Path) -> Version: + """Require the release tag to match the package's declared version exactly.""" + version = release_version(tag) + project_version = tomllib.loads(project_file.read_text())["project"]["version"] + if tag != f"v{project_version}": + raise ValueError(f"Tag {tag!r} does not match project.version {project_version!r}") + return version + + +def prepare_docs(tag: str, html: Path, pages: Path) -> None: + """Keep published version pages intact and point latest at the newest stable release.""" + release_version(tag) + if not (html / "index.html").is_file(): + raise FileNotFoundError(f"Documentation index missing from {html}") + destination = pages / tag + if not destination.exists(): + shutil.copytree(html, destination) + versions = [] + for directory in pages.iterdir(): + if not directory.is_dir() or not (directory / "index.html").is_file(): + continue + try: + version = release_version(directory.name) + except ValueError: + continue + versions.append((version, directory.name)) + versions.sort(reverse=True) + stable = next((name for version, name in versions if not version.is_prerelease), None) + latest = pages / "latest" + if stable: + if latest.exists(): + shutil.rmtree(latest) + shutil.copytree(pages / stable, latest) + switcher = [{"version": "latest", "url": f"{DOCS_URL}/latest/index.html", "name": "latest"}] if stable else [] + switcher.extend( + {"version": str(version), "url": f"{DOCS_URL}/{name}/index.html", "name": name, "preferred": name == stable} + for version, name in versions + ) + targets = [pages, *(pages / name for _, name in versions)] + if stable: + targets.append(latest) + for directory in targets: + switcher_path = directory / "_static" / "switcher.json" + switcher_path.parent.mkdir(parents=True, exist_ok=True) + switcher_path.write_text(json.dumps(switcher, indent=2) + "\n") + + +if __name__ == "__main__": + if sys.argv[1:] == ["artifacts"]: + verify_distributions(Path("dist"), Path("src")) + raise SystemExit(0) + tag = os.environ["GITHUB_REF_NAME"] + version = verify_tag(tag, Path("pyproject.toml")) + if sys.argv[1:] == ["verify"]: + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"prerelease={str(version.is_prerelease).lower()}\n") + elif sys.argv[1:] == ["docs"]: + prepare_docs(tag, Path("docs/_build/html"), Path("gh-pages")) + else: + raise SystemExit("Usage: python .github/release.py {verify|docs|artifacts}") diff --git a/.github/tests/test_release.py b/.github/tests/test_release.py new file mode 100644 index 00000000..988924d1 --- /dev/null +++ b/.github/tests/test_release.py @@ -0,0 +1,98 @@ +"""Check package contents, tag consistency, and docs promotion without publishing.""" + +import json +import sys +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from release import prepare_docs, verify_distributions, verify_tag + + +class ReleaseTests(unittest.TestCase): + def setUp(self) -> None: # noqa: N802 + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.project = self.root / "pyproject.toml" + self.html = self.root / "html" + self.html.mkdir() + (self.html / "index.html").write_text("new documentation") + self.pages = self.root / "pages" + self.pages.mkdir() + + def test_tag_must_match_project_version(self) -> None: + for value in ("2.3.0", "2.3.0rc1"): + with self.subTest(value=value): + self.project.write_text(f'[project]\nversion = "{value}"\n') + self.assertEqual(str(verify_tag(f"v{value}", self.project)), value) + with self.assertRaises(ValueError): + verify_tag("v9.0.0", self.project) + + def test_distribution_contents(self) -> None: + source = self.root / "src" + package = source / "atomworks" + package.mkdir(parents=True) + (package / "__init__.py").write_text('"""AtomWorks."""\n') + (package / "parameters.json").write_text('{"charge": -1}\n') + dist = self.root / "dist" + dist.mkdir() + for case in ("complete", "empty-wheel", "missing-data", "changed-data", "relocated-source"): + with self.subTest(case=case): + with zipfile.ZipFile(dist / "atomworks-2.3.0-py3-none-any.whl", "w") as wheel: + for path in package.iterdir(): + if case == "empty-wheel" or (case == "missing-data" and path.suffix == ".json"): + continue + data = b"{}" if case == "changed-data" and path.suffix == ".json" else path.read_bytes() + wheel.writestr(path.relative_to(source).as_posix(), data) + with tarfile.open(dist / "atomworks-2.3.0.tar.gz", "w:gz") as archive: + prefix = "" if case == "relocated-source" else "src/" + archive.add(package, arcname=f"atomworks-2.3.0/{prefix}atomworks") + if case == "complete": + verify_distributions(dist, source) + else: + with self.assertRaises((KeyError, ValueError)): + verify_distributions(dist, source) + + def test_rejects_nonrelease_tags(self) -> None: + self.project.write_text('[project]\nversion = "2.3.0"\n') + for tag in ("2.3.0", "production", "v2.3.0.dev1", "v2.3.0+local", "v2.3.0/extra"): + with self.subTest(tag=tag), self.assertRaises(ValueError): + verify_tag(tag, self.project) + + def test_old_release_and_prerelease_do_not_replace_latest(self) -> None: + for version in ("v2.2.10", "v2.2.9", "v2.3.0rc1"): + (self.html / "index.html").write_text(version) + prepare_docs(version, self.html, self.pages) + self.assertEqual((self.pages / "latest/index.html").read_text(), "v2.2.10") + switcher = json.loads((self.pages / "_static/switcher.json").read_text()) + self.assertEqual([entry["name"] for entry in switcher], ["latest", "v2.3.0rc1", "v2.2.10", "v2.2.9"]) + self.assertEqual([entry["name"] for entry in switcher if entry.get("preferred")], ["v2.2.10"]) + self.assertEqual(switcher, json.loads((self.pages / "v2.2.9/_static/switcher.json").read_text())) + + def test_new_stable_release_updates_latest_without_stale_files(self) -> None: + prepare_docs("v2.0.0", self.html, self.pages) + (self.pages / "latest/removed.html").write_text("old page") + (self.html / "index.html").write_text("new stable") + prepare_docs("v2.3.0", self.html, self.pages) + self.assertEqual((self.pages / "latest/index.html").read_text(), "new stable") + self.assertFalse((self.pages / "latest/removed.html").exists()) + + def test_retry_preserves_published_version_pages(self) -> None: + prepare_docs("v2.3.0", self.html, self.pages) + (self.html / "index.html").write_text("different build") + prepare_docs("v2.3.0", self.html, self.pages) + self.assertEqual((self.pages / "v2.3.0/index.html").read_text(), "new documentation") + + def test_first_prerelease_has_switcher_without_stable_alias(self) -> None: + prepare_docs("v2.3.0rc1", self.html, self.pages) + self.assertFalse((self.pages / "latest").exists()) + switcher = json.loads((self.pages / "_static/switcher.json").read_text()) + self.assertEqual([entry["name"] for entry in switcher], ["v2.3.0rc1"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/lint_and_test.yaml b/.github/workflows/lint_and_test.yaml deleted file mode 100644 index 84742e63..00000000 --- a/.github/workflows/lint_and_test.yaml +++ /dev/null @@ -1,116 +0,0 @@ -name: lint_and_test - -on: - push: - branches: - - main - - production - paths: - - 'src/**' - - 'tests/**' - - '.github/workflows/**' - pull_request: - branches: - - main - - dev - - staging - - production - paths: - - 'src/**' - - 'tests/**' - - '.github/workflows/**' - pull_request_target: - types: [ready_for_review] - paths: - - 'src/**' - - 'tests/**' - - '.github/workflows/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - lint: - name: ruff - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Extract ruff version from pyproject.toml - run: | - echo "RUFF_VERSION=$(grep -E 'ruff.*=' pyproject.toml | sed -E 's/.*\"ruff==([^\"]+)\".*/\1/')" >> $GITHUB_ENV - echo "Extracted Ruff version: ${{ env.RUFF_VERSION }}" - - name: Install ruff - run: pip install ruff==${{ env.RUFF_VERSION }} - - name: ruff format (check code formatting) - run: ruff format --diff - - name: ruff check (lint code base) - run: ruff check src tests - - test_github: - name: pytest (github) - runs-on: ubuntu-latest - timeout-minutes: 45 - needs: lint - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install system dependencies - run: | - # Check if rsync is already installed - if ! command -v rsync &> /dev/null; then - echo "Installing rsync..." - sudo apt-get update - sudo apt-get install -y rsync - else - echo "rsync is already available" - fi - - - name: Setup uv - uses: astral-sh/setup-uv@v4 - with: - version: "latest" - - - name: Install dependencies with uv - run: | - uv pip install -e ".[ml,openbabel,dev]" --system - - - name: Setup test data - run: | - atomworks setup tests - - - name: Run pytest - run: | - export OPENBLAS_NUM_THREADS=1 - export OMP_NUM_THREADS=1 - # Get number of CPUs available - N_CPU=$(nproc) - coverage erase - echo "Running tests with max $N_CPU CPUs" - # Run the tests with coverage - PDB_MIRROR_PATH=tests/data/pdb CCD_MIRROR_PATH=tests/data/ccd pytest -n auto -m "not benchmark" tests/io --cov=atomworks --cov-append --cov-report= - PDB_MIRROR_PATH=tests/data/pdb CCD_MIRROR_PATH=tests/data/ccd pytest -n auto -m "not benchmark and not slow" tests/ml --cov=atomworks --cov-append --cov-report= --no-cov-on-fail - #run slow test on 1 processor to limit memory usage - PDB_MIRROR_PATH=tests/data/pdb CCD_MIRROR_PATH=tests/data/ccd pytest -n 1 -m "not benchmark and slow" tests/ml --cov=atomworks --cov-append --cov-report= - # Currently, running tests in different steps, so we do not go over memory limits - # PDB_MIRROR_PATH=tests/data/pdb CCD_MIRROR_PATH=tests/data/ccd pytest -m "not benchmark" -n auto --maxprocesses=$N_CPU --dist=worksteal --cov=atomworks --cov-report=xml --cov-report=term --cov-report=term-missing tests/ - - # combine coverage - coverage report -m - coverage xml -o coverage.xml - - - name: Upload coverage report - uses: actions/upload-artifact@v4 - with: - name: coverage-report-github - path: coverage.xml - if: always() diff --git a/.github/workflows/release_and_docs.yaml b/.github/workflows/release_and_docs.yaml index ba67708e..812893a0 100644 --- a/.github/workflows/release_and_docs.yaml +++ b/.github/workflows/release_and_docs.yaml @@ -1,159 +1,197 @@ -name: Release and Deploy Versioned Docs +name: CI and release on: push: - branches: - - production + branches: [dev, main, production, release/atomworks-3-0] + tags: ['v*'] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - release_and_docs: + build: runs-on: ubuntu-latest - permissions: - contents: write - packages: write - issues: write - pull-requests: write - id-token: write - + outputs: + prerelease: ${{ steps.version.outputs.prerelease }} steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v4 + python-version: '3.12' + - run: python -m pip install build twine packaging + - run: python -m unittest discover -s .github/tests -v + - name: Verify release version + id: version + if: startsWith(github.ref, 'refs/tags/') + run: python .github/release.py verify + - run: python -m build + - run: python .github/release.py artifacts + - run: python -m twine check --strict dist/* + - uses: actions/upload-artifact@v4 with: - node-version: '20' + name: distributions + path: dist/* + if-no-files-found: error - - name: Set up Python - uses: actions/setup-python@v5 + wheel: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ['3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.12" - - - name: Install Python build dependencies + python-version: ${{ matrix.python }} + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + - name: Install core wheel and exercise it outside the checkout run: | - pip install build twine + python -m venv "$RUNNER_TEMP/core" + "$RUNNER_TEMP/core/bin/python" -m pip install dist/*.whl + cp docs/examples/plot_protonation.py "$RUNNER_TEMP/smoke.py" + cd "$RUNNER_TEMP" + core/bin/python -m pip check + core/bin/python -c "import importlib.util; assert importlib.util.find_spec('torch') is None" + core/bin/python smoke.py + core/bin/aw --help + core/bin/aw msa --help + if core/bin/aw msa >msa.log 2>&1; then exit 1; fi + grep -F "pip install 'atomworks[ml]'" msa.log - - name: Build Python package - run: python -m build + science: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + OPENBLAS_NUM_THREADS: '1' + OMP_NUM_THREADS: '1' + PYTHONPATH: ${{ github.workspace }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: astral-sh/setup-uv@v6 + - run: | + uv pip install --system torch --index-url https://download.pytorch.org/whl/cpu + uv pip install --system '.[ml,dev,openbabel,ase]' + uv pip check --system + for command in generate filter find organize; do + aw msa "$command" --help + done + - run: ruff format --check . && ruff check src tests .github + - name: Download public test fixtures + run: | + mkdir -p tests/data + curl --fail --location --retry 3 https://github.com/RosettaCommons/atomworks/releases/download/test-data-3.0.0/atomworks-test-data-3.0.0.tar.gz | tar -xz -C tests/data + - name: Run scientific CPU tests, including experimental protonation + run: | + pytest tests -n 2 --dist=worksteal -m 'not benchmark and not slow and not requires_digs and not requires_pymol_remote and not requires_x3dna' --cov=atomworks --cov-report=xml --junitxml=tests.xml + pytest tests -n 1 -m 'slow and not benchmark and not requires_digs and not requires_pymol_remote and not requires_x3dna' --cov=atomworks --cov-append --cov-report=xml --junitxml=slow-tests.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: scientific-test-reports + path: | + *tests.xml + coverage.xml + - uses: codecov/codecov-action@v5 + if: success() + with: + files: coverage.xml + fail_ci_if_error: false - - name: Install Node dependencies - run: > - npm install --no-save - semantic-release - @semantic-release/commit-analyzer - @semantic-release/release-notes-generator - semantic-release-pypi - @semantic-release/git - @semantic-release/github + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: astral-sh/setup-uv@v6 + - run: | + uv pip install --system torch --index-url https://download.pytorch.org/whl/cpu + uv pip install --system '.[ml,docs,ase,openbabel]' -r docs/docs_requirements.txt + - name: Build documentation and execute the offline example + working-directory: docs + run: make html + - uses: actions/upload-artifact@v4 + with: + name: documentation + path: docs/_build/html + if-no-files-found: error - - name: Run semantic-release - id: release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN_PROD }} - PYPI_REPO_URL: https://upload.pypi.org/legacy/ - run: | - npx semantic-release - VERSION=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || echo "no-version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Released version: $VERSION" + publish: + needs: [build, wheel, science, docs] + if: github.repository == 'RosettaCommons/atomworks' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/atomworks + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 - - name: Build documentation - run: | - pip install -e ".[ml,dev]" - cd docs/ - pip install -r docs_requirements.txt - make html + release: + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + make_latest: legacy + prerelease: ${{ needs.build.outputs.prerelease == 'true' }} - - name: Checkout gh-pages - uses: actions/checkout@v4 + deploy-docs: + needs: [docs, publish] + runs-on: ubuntu-latest + concurrency: + group: atomworks-versioned-docs + cancel-in-progress: false + queue: max + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install packaging + - uses: actions/download-artifact@v4 + with: + name: documentation + path: docs/_build/html + - uses: actions/checkout@v4 with: ref: gh-pages path: gh-pages - fetch-depth: 0 - - - name: Deploy versioned docs - run: | - VERSION="${{ steps.release.outputs.version }}" - - # Only deploy if we have a valid version - if [[ "$VERSION" != "no-version" ]]; then - echo "Deploying documentation for version: $VERSION" - - # Create version directory and copy docs - mkdir -p "gh-pages/$VERSION" - rsync -av --delete docs/_build/html/ "gh-pages/$VERSION/" - - # Also create/update 'latest' to point to this version - mkdir -p gh-pages/latest - rsync -av --delete docs/_build/html/ gh-pages/latest/ - - # Build switcher.json for all versions - cd gh-pages - versions=() - - # Collect all version directories - for d in */; do - d=${d%/} # Remove trailing slash - if [[ "$d" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$d" == "latest" ]]; then - versions+=("$d") - fi - done - - # Sort versions (latest first, then reverse semver order) - IFS=$'\n' sorted_versions=($(printf '%s\n' "${versions[@]}" | sort -V -r)) - - # Create switcher.json content - switcher_content="[" - first=true - - for v in "${sorted_versions[@]}"; do - if [[ "$first" == "true" ]]; then - first=false - else - switcher_content="$switcher_content," - fi - - if [[ "$v" == "latest" ]]; then - entry="{\"version\": \"latest\", \"url\": \"https://baker-laboratory.github.io/atomworks-dev/latest/index.html\", \"name\": \"latest\"}" - else - disp="${v#v}" - # Mark the newest stable version as preferred - if [[ "$v" == "$VERSION" ]]; then - preferred="true" - else - preferred="false" - fi - entry="{\"version\": \"$disp\", \"url\": \"https://baker-laboratory.github.io/atomworks-dev/$v/index.html\", \"name\": \"v$disp\", \"preferred\": $preferred}" - fi - - switcher_content="$switcher_content$entry" - done - - switcher_content="$switcher_content]" - - # Write switcher.json to all version directories - for v in "${sorted_versions[@]}"; do - mkdir -p "$v/_static" - echo "$switcher_content" > "$v/_static/switcher.json" - echo "Created switcher.json for $v" - done - - echo "Switcher content: $switcher_content" - else - echo "No version tag found, skipping documentation deployment" - fi - - - name: Deploy to GitHub Pages - if: steps.release.outputs.version != 'no-version' + - run: python .github/release.py docs + - name: Publish versioned documentation uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages publish_dir: gh-pages - keep_files: true # Keep existing versions - force_orphan: false # Don't force orphan, preserve history - user_name: 'github-actions[bot]' - user_email: 'github-actions[bot]@users.noreply.github.com' diff --git a/.gitignore b/.gitignore index a529a755..4aa405f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ biotite # github pages docs/_build/* docs/auto_examples/* +docs/sg_execution_times.rst # Python files @@ -109,15 +110,6 @@ celerybeat-schedule # SageMath parsed files *.sage.py -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - # github pages docs/_build/* docs/auto_examples/* @@ -155,6 +147,7 @@ cython_debug/ *.pkl *.parquet *.csv +*.ciflmdb-lock # Temporary test files tests/test_outputs @@ -166,14 +159,10 @@ tinker/ # Local scripts **/logs +.ipd/ # AI **/.claude -CLAUDE.md .github/workflows/claude*.yml -# Local docker files -Dockerfile - -# Local dev containers .devcontainer diff --git a/tests/ml/conditions/__init__.py b/.project-root similarity index 100% rename from tests/ml/conditions/__init__.py rename to .project-root diff --git a/.releaserc.js b/.releaserc.js deleted file mode 100644 index 5c6cd60a..00000000 --- a/.releaserc.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - branches: ['production', 'main'], - plugins: [ - '@semantic-release/commit-analyzer', - '@semantic-release/release-notes-generator', - [ - 'semantic-release-pypi', - { - pypiPublish: true, - repoUrl: 'https://upload.pypi.org/legacy/', - distDir: 'dist/', - setupPy: false, - pypiToken: process.env.PYPI_TOKEN_PROD, - } - ], - '@semantic-release/github', - [ - '@semantic-release/git', - { - assets: ['pyproject.toml'], - message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}' - } - ] - ] - }; \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2ef7058b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,224 @@ +# AGENTS.md + +This is the working guide for AI coding agents in AtomWorks. It is model-agnostic and must work in a standalone checkout. When this repository is vendored by another project, also follow the parent repository's instructions; neither guide replaces the other. + +Keep this file concise enough to scan, but update it when commands, layout, or invariants change. The README and `docs/` are the user-facing source of truth. Executable code, tests, `pyproject.toml`, and CI define current behavior when documentation disagrees. + +## Repository and Git safety + +- Work from the repository root, which contains `pyproject.toml` and `Makefile`. +- Check `git status --short` before editing. Preserve existing user changes and keep unrelated changes out of the task. +- Do not discard work with `git reset`, `git clean`, broad `git checkout`/`git restore`, or equivalent destructive commands unless the user explicitly authorizes the exact operation. +- Do not create or switch branches, stage, commit, amend, rebase, stash, or push unless the user explicitly asks. When authorized, stage only intended paths and inspect the staged diff before committing. +- Do not work directly on a shared integration branch such as `dev`, `staging`, `main`, or `production`. Use the branch and PR target requested by the user. +- Use focused commits that form one logical unit. Conventional prefixes used here include `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, and `style`. +- PRs normally target the branch from which the work was based. The internal repository commonly uses `dev`; the public contributor workflow in `docs/contributor_guide.rst` targets `staging`. Verify the intended remote and base rather than guessing. +- Before a PR, review every changed line, verify the target and changed-file list, remove debug artifacts and temporary files, and report exactly which checks ran. + +For PRs explicitly targeting `staging`, preserve the repository's promotion boundary. The PR diff must not include: + +- any `AGENTS.md` file; +- `.ipd/` or its contents; +- `.env`; +- `src/atomworks/ml/databases/` or its contents. + +Check with `git diff origin/staging...HEAD --name-only`. Do not rewrite, reset, or cherry-pick user history merely to remove an excluded file without explicit permission. + +## Standalone and vendored environments + +AtomWorks requires Python 3.11 or newer; CI and Ruff target Python 3.12. For a standalone checkout: + +```bash +uv venv --python 3.12 +source .venv/bin/activate +make install +``` + +`make install` installs `.[dev,ml,openbabel]`. Install narrower or additional extras directly when appropriate: + +```bash +uv pip install -e ".[dev]" +uv pip install -e ".[dev,ml,openbabel,docs]" +``` + +When working from the RFProteína submodule at `lib/atomworks`, use the parent environment prepared by the parent repository: + +```bash +source ../../.venv/bin/activate +uv pip install -e ".[dev,ml,openbabel,docs]" +``` + +Do not assume the parent checkout or virtualenv exists in standalone AtomWorks. Conversely, do not invent a second submodule-local environment when the parent workflow owns the environment. + +Dependency rules: + +- `pyproject.toml` is the source of truth. +- AtomWorks requires exactly `biotite==1.6.0`; import-time patches depend on that version. Do not relax or duplicate the pin without validating all affected consumers. +- Torch-dependent functionality belongs behind the `ml` extra. +- S3, ASE, Open Babel, PoseBusters, catcif, PyMOL, and documentation support are optional. Core imports and ordinary tests must not accidentally require unrelated extras. +- Environment variables such as `PDB_MIRROR_PATH` and `CCD_MIRROR_PATH` point to external data. Never commit secrets or machine-specific paths. + +## Architecture map + +AtomWorks is a structural-biology data toolkit centered on Biotite `AtomArray` and `AtomArrayStack` objects. + +- `src/atomworks/io/`: parsing, configuration, components, transforms, structural utilities, annotations, chemistry, and format conversion. +- `src/atomworks/io/parser.py`: public `parse()` entry point. Parsing is configured with `ParseConfig`; mmCIF is preferred for complete structural metadata. +- `src/atomworks/ml/`: reusable ML datasets, parsers/loaders, preprocessing, samplers, conditions, pipelines, transforms, and utilities. +- `src/atomworks/ml/transforms/base.py`: `Transform`, `Compose`, transform-history tracking, validation, and pipeline error behavior. +- `src/atomworks/ml/pipelines/`: reusable model-oriented transform compositions. +- `src/atomworks_cli/`: Typer applications exposed as `atomworks` and `aw`. +- `tests/io/` and `tests/ml/`: tests mirror the two principal library layers; speed tests live under `tests/io/speed/`. + +Use `atomworks.io` for model-independent structure behavior. Use `atomworks.ml` for generally reusable dataset and featurization behavior. Keep downstream-model-specific schemas, conditions, and training policy in the downstream project when they are not broadly useful. + +Important concepts: + +- AtomWorks patches selected Biotite behavior at import time. Check `src/atomworks/biotite_patch.py` before changing code that relies on residue boundaries, annotations, bonds, or assembly identity. +- A PN unit is a "polymer XOR non-polymer unit." It behaves roughly like a structural unit: a polymer chain is one PN unit, while covalently connected non-polymer chains may form one PN unit. +- Transforms consume and return dictionaries, often containing an `atom_array`, and may mutate or replace values. Their required keys, produced keys, mutation, ordering requirements, and incompatible predecessors are part of the contract. +- Pipelines are ordered transform compositions. Validate behavior in the composition, not only in isolated transforms. + +## Structural and scientific invariants + +- Preserve `AtomArray` length and alignment across coordinates, annotations, bonds, masks, and parallel arrays unless the operation deliberately filters or expands atoms. +- Preserve chain, entity, PN-unit, molecule, instance, model, and transformation identity. Symmetry copies can share ordinary residue identifiers, so do not identify residues by `chain_id` and `res_id` alone. +- Parsed results conventionally contain `asym_unit`, `assemblies`, `chain_info`, `ligand_info`, `metadata`, and `extra_info`. Confirm the current parser contract and tests before changing this schema. +- Preserve bond indices and chemical annotations through slicing, concatenation, atomization, missing-atom handling, and file round trips. +- Standard annotations and encodings are shared vocabulary. Extend centralized definitions rather than creating alternate spellings in individual transforms or downstream projects. +- State assumptions about model selection, resolved atoms, polymer types, coordinate units, required annotations, and mutation whenever they are not evident from the API. +- Avoid implicit copies or in-place mutation of shared structures. Copy deliberately when isolation is required and test whether annotations, bonds, and custom CCD data survive. +- Chemical cleanup, leaving atoms, protonation, chirality, automorphisms, covalent modifications, symmetry, and CCD lookup are scientific behavior. Changes require focused edge-case tests. + +### Efficient residue and segment operations + +Use patched residue-boundary utilities and contiguous slices rather than rebuilding a full boolean mask for every residue: + +```python +residue_bounds = struc.get_residue_starts(atom_array, add_exclusive_stop=True) +for start, stop in zip(residue_bounds[:-1], residue_bounds[1:], strict=True): + residue = atom_array[start:stop] +``` + +The patched boundary logic accounts for available assembly identity such as `transformation_id`. Use selection helpers in `atomworks.io.utils.selection` when their semantics fit. Before optimizing, verify that the array is sorted into contiguous units and that slicing preserves intended identity and bond behavior. + +## Implementation conventions + +- Survey nearby code and tests before adding an abstraction. Follow established parser, transform, pipeline, and CLI patterns. +- Keep changes focused. Apply DRY to genuinely shared behavior and YAGNI to speculative flexibility; do not create a framework for one call site. +- Prefer module-level functions for stateless behavior. Use classes when state, a transform/parser protocol, or inheritance makes them appropriate. +- Add type annotations to production function signatures. Use `jaxtyping` for public or non-obvious tensor contracts where named axes improve correctness; do not force it onto trivial or genuinely dynamic internals. +- Preserve device, dtype, shape, and batch semantics in ML code. +- Use descriptive `snake_case` for functions and variables, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants, and a leading underscore for private members. Standard domain abbreviations such as `pdb_id` and `msa` are fine. +- Let clear native Python errors propagate. Add explicit validation for user-facing APIs, silent-corruption risks, scientific invariants, or materially clearer context. Catch exceptions only to recover, clean up resources, or add actionable information; do not use broad catches with silent defaults. +- Error messages should identify the missing key, annotation, dependency, file, or invalid scientific value. +- Avoid lazy imports unless an optional dependency, import cycle, or meaningful startup cost requires them. +- Comments and docstrings describe only current behavior, current invariants, and current interfaces. Do not document old bugs, former implementations, migration history, or superseded reasoning. +- Keep consecutive `#` comment blocks to at most two lines. Move longer API or behavioral detail into a concise docstring and broad design material into `docs/`. Prefer clear names and structure over explanatory comments. + +## Testing + +Prefer the smallest relevant test first, then expand according to risk. New functionality and bug fixes should normally include tests. + +```bash +# One test or module +pytest tests/io/path/to/test_file.py::test_name -q +pytest tests/ml/path/to/test_file.py -q + +# Full suite with coverage +make test + +# Full suite in parallel +make parallel_test + +# Common local selection without very-slow tests +PDB_MIRROR_PATH=tests/data/pdb pytest tests -m "not very_slow" + +# Parser performance benchmarks +pytest tests/io/speed --benchmark-time-unit=s --benchmark-warmup=False --benchmark-min-rounds=3 +``` + +Testing principles: + +- Assert observable behavior and scientific invariants, not private implementation details. +- Prefer a small number of meaningful parameterized cases over many near-identical tests. +- For transforms, test input requirements, output keys, mutation/copy behavior, annotation and bond alignment, empty/minimal inputs, and composition with neighboring transforms. +- Use property- or invariant-oriented tests for filtering, cropping, permutation, atomization, and round trips. Examples include length/alignment, idempotence where promised, bond validity, and preservation of untouched annotations. +- Test parsing and chemistry across representative polymers, ligands, covalent modifications, alternate locations, assemblies, missing atoms, and symmetry when relevant. +- Use fixtures for shared synthetic structures and `cached_parse()` from testing utilities for repository structures. +- Name tests `test__` and apply existing markers accurately. Do not make an ordinary unit test depend on a network service or unavailable optional extra. +- Do not add tests for trivial getters, obvious delegation, or implementation details already covered by a stronger integration test. + +### Test data and regression policy + +Download the public parser test pack when necessary: + +```bash +atomworks setup tests +``` + +The downloaded pack does not necessarily contain every PDB ID referenced by the entire suite. Prefer configured readable PDB/CCD mirrors when available. A `FileNotFoundError` below the selected mirror indicates missing test data; it is not permission to skip the test or loosen its assertions. + +Regression baselines define intended scientific behavior. Never make a regression test pass by filtering mismatches, lowering overlap or numeric thresholds, excluding annotations, widening tolerances without scientific justification, or adding special-case skips. Determine whether the implementation, expectation, or environment is wrong. Regenerate stored baselines only with explicit user intent and explain the behavioral reason. + +When AtomWorks is vendored by another project, validate both layers: the smallest relevant AtomWorks tests and a downstream integration test that exercises the changed contract. + +## Formatting and documentation + +Non-mutating checks: + +```bash +ruff format --check . +ruff check src tests +``` + +Apply formatting only when authorized to modify the affected files: + +```bash +make format +``` + +`make format` rewrites files. Review its diff and avoid formatting unrelated user work. Ruff is configured in this repository's `pyproject.toml` with a 120-column limit and Python 3.12 target. + +Public APIs need concise Google-style docstrings: + +- A clear one-line summary is enough for a simple function. +- Add elaboration only for behavior, mutation, structural assumptions, tensor axes, coordinate frames, or units that are not obvious. +- Use `Args`, `Returns`/`Yields`, `Raises`, `Examples`, `References`, and `See Also` only when they add information. Do not repeat types already expressed by annotations. +- Document only exceptions that the function intentionally raises or that callers must meaningfully handle. +- Use examples for non-obvious workflows, not every helper. +- Use Sphinx/reStructuredText roles such as `:py:func:`, `:py:class:`, and `:py:meth:` for API cross-references. + +Build the documentation with: + +```bash +uv pip install -r docs/docs_requirements.txt +make -C docs html +``` + +Treat Sphinx warnings, broken references, and failed autodoc imports as problems to investigate. When building both AtomWorks and a vendoring project's docs, separate environments may be needed if their documentation requirements select incompatible dependency versions. + +## Completion and PR review + +Before handing off or opening a PR: + +1. Review `git status`, all unstaged changes, and the exact staged diff if staging was authorized. +2. Confirm the implementation is focused, typed, and consistent with nearby APIs; remove debug prints, breakpoints, commented-out code, accidental TODOs, backups, and generated outputs. +3. Confirm public behavior and non-obvious structural contracts are documented without historical commentary or long comment blocks. +4. Run targeted tests, non-mutating Ruff checks, and broader tests/docs in proportion to the change. Do not claim checks that were not run. +5. Verify no regression expectation was weakened and no baseline was regenerated without explicit intent. +6. Verify the PR remote, base branch, title, changed-file list, and any `staging` exclusions. +7. Summarize behavior changed, validation performed, and anything skipped because data, optional dependencies, hardware, or time was unavailable. + +## Where to look first + +- Installation and test data: `docs/installation.rst`, `docs/mirrors.rst` +- Contributor and PR workflow: `docs/contributor_guide.rst`, `.github/pull_request_template.md` +- Dependency and Ruff configuration: `pyproject.toml` +- CI behavior: `.github/workflows/`, `.github/ci/` +- Parsing contract: `src/atomworks/io/parser.py`, `src/atomworks/io/config.py`, parser tests +- Structural utilities and annotations: `src/atomworks/io/utils/`, `src/atomworks/constants.py` +- Transform protocol: `src/atomworks/ml/transforms/base.py`, transform and pipeline tests +- CLI behavior: `src/atomworks_cli/` + +If this guide disagrees with current code or tests, verify intended public behavior in README/docs and update this file as part of the same change. diff --git a/README.md b/README.md index fbc60c00..87821b3d 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,24 @@ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![CI](https://github.com/RosettaCommons/atomworks/actions/workflows/release_and_docs.yaml/badge.svg?branch=release%2Fatomworks-3-0)](https://github.com/RosettaCommons/atomworks/actions/workflows/release_and_docs.yaml) [![PyPI version](https://img.shields.io/pypi/v/atomworks.svg)](https://pypi.org/project/atomworks/) [![Python versions](https://img.shields.io/pypi/pyversions/atomworks.svg)](https://pypi.org/project/atomworks/) [![Documentation Status](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://rosettacommons.github.io/atomworks/latest/) [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) -> [!Warning] -> A vulnerability from pyarrow (an AtomWorks dependency) has been identified, you can read more about it [here](https://advisories.gitlab.com/pypi/pyarrow/CVE-2026-25087/). If your system can use pyarrow>=23.0.1, update the `pyproject.toml` a newer version before installing the AtomWorks package. -
- atomworks logo + + + AtomWorks logo +
**atomworks** is an open-source platform that maximizes research velocity for biomolecular modeling tasks. Much like how [Torchvision](https://docs.pytorch.org/vision/stable/index.html) enables rapid prototyping within the vision domain, and [Torchaudio](https://docs.pytorch.org/audio/main/) within the audio domain, AtomWorks aims to accelerate development and experimentation within biomolecular modeling. -> **⚠️ Notice:** We are currently finalizing some cleanup work within our repositories. Please expect the APIs (e.g., function and class names, inputs and outputs) to stabilize within the next one week. Thank you for your patience! +Upgrading to 3.0? See the [migration guide](https://github.com/RosettaCommons/atomworks/blob/release/atomworks-3-0/docs/migration/migration-short.md). If you're looking for the models themselves (e.g., RF3, RFD3, MPNN) that integrate with AtomWorks rather than the underlying framework, check out [Foundry](https://github.com/RosettaCommons/foundry) -> **💡 Note:** Not sure where to start? We've made some [examples in the AtomWorks documentation](https://rosettacommons.github.io/atomworks/latest/auto_examples/index.html) that work through several helpful scenarios; a full tutorial is under construction! +> **💡 Note:** Start with the [AtomWorks examples](https://rosettacommons.github.io/atomworks/latest/auto_examples/index.html) or the [model-building tutorial](https://github.com/RosettaCommons/atomworks/blob/release/atomworks-3-0/docs/how_to_build_a_model/index.rst). AtomWorks is composed of two symbiotic libraries: @@ -32,7 +33,7 @@ AtomWorks is built atop [biotite](https://www.biotite-python.org/): We are grate ## atomworks.io -> *A general-purpose Python toolkit for cleaning, standardizing, and manipulating with biomolecular structure files - built atop [biotite](https://www.biotite-python.org/): +> *A general-purpose Python toolkit for cleaning, standardizing, and manipulating biomolecular structure files, built on [Biotite](https://www.biotite-python.org/).* **atomworks.io** lets you: @@ -54,7 +55,7 @@ We have found `atomworks.io` to be generally useful to a broad bioinformatics an - An extensible framework, integrated with `atomworks.io`, to write `Transforms` for arbitrary use cases - Pre-built datasets and samplers suitable for most model training scenarios -Within the AtomWorks paradigm, the output of each `Transform` is not an opaque dictionary with model-specific tensors but instead an updated version of our atom-level structural representation (Biotite's `AtomArray`). Operations within – and between – pipelines thus maintain a common vocabulary of inputs and outputs. +Transforms consume and return dictionaries containing structural data, annotations and features. Biotite's `AtomArray` provides their shared atom-level representation. We have found that `atomworks.ml` **dramatically** reduces the overhead of starting, and completing, many ML projects; research topics that once took months now achieve signal within weeks if not days, accelerating the pace of innovation. @@ -75,14 +76,14 @@ We have found that `atomworks.ml` **dramatically** reduces the overhead of start --- ## Installation -> Note: AtomWorks requires Python >= 3.11 and [`dotenv`](https://pypi.org/project/python-dotenv/#file-format) +AtomWorks requires Python 3.11 or newer. Pip installs its dependencies automatically. ```shell pip install atomworks # base installation version without torch (for only atomworks.io) pip install "atomworks[ml]" # with torch and ML dependencies (for atomworks.io plus atomworks.ml) pip install "atomworks[dev]" # with development dependencies pip install "atomworks[openbabel]" # with [Open Babel](https://openbabel.org/) and its dependencies -pip install "atomworks[ml,openbabel,dev]" # with all dependencies +pip install "atomworks[ml,openbabel,dev]" # ML, Open Babel, and development tools ``` *Running multiple of these installations will just add to the installed dependencies and will not install multiple installations of atomworks.* @@ -92,6 +93,12 @@ If you are using [uv](https://docs.astral.sh/uv/reference/policies/versioning/) uv pip install "atomworks[ml,openbabel,dev]" ``` +To install the AtomWorks 3.0 release branch before it is published on PyPI: + +```shell +pip install "atomworks[ml] @ git+https://github.com/RosettaCommons/atomworks.git@release/atomworks-3-0" +``` + For more advanced setup options (including how to run workflows via apptainers) see the [full documentation](https://rosettacommons.github.io/atomworks/latest/index.html). --- @@ -106,10 +113,11 @@ To parse a pdb file (parse = load, clean, annotate relevant metadata such as ent ```python -from atomworks.io.parser import parse +from atomworks.io import parse +from atomworks.io.config import ParseConfig from biotite.structure import AtomArrayStack -result = parse(filename="3nez.cif.gz") +result = parse("3nez.cif.gz", config=ParseConfig.from_preset("rcsb")) asym_unit: AtomArrayStack = result["asym_unit"] assemblies: dict[str, AtomArrayStack] = result["assemblies"] @@ -130,13 +138,13 @@ The output of `parse` includes: See [usage examples](https://rosettacommons.github.io/atomworks/latest/auto_examples/index.html) for more examples of the use of `parse()`. All of the provided examples make use of this method. See [API reference documentation](https://rosettacommons.github.io/atomworks/latest/io/parser.html) for more information on this method. -If you just want to load a file, you can use the `load_any` function: +For minimal processing, use the same parser with the `minimal` preset: ```python -from atomworks.io.utils.io_utils import load_any +from atomworks.io import parse from biotite.structure import AtomArray -atom_array: AtomArray = load_any("3nez.cif.gz", model=1) # model=1 means that we want to load the model 1 (i.e. the first model) rather than a stack of all models in the file +atom_array: AtomArray = parse("3nez.cif.gz", config="minimal")["asym_unit"][0] ``` --- diff --git a/docs/Makefile b/docs/Makefile index 41c270bb..5117fbf5 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= +SPHINXOPTS ?= -W --keep-going SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build @@ -17,4 +17,4 @@ help: # 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) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/atomworks_logo_dark.svg b/docs/_static/atomworks_logo_dark.svg index 955c3fa1..8c49e7e5 100644 --- a/docs/_static/atomworks_logo_dark.svg +++ b/docs/_static/atomworks_logo_dark.svg @@ -1 +1 @@ -AT MW RKS \ No newline at end of file +AT MW RKS diff --git a/docs/_static/selection/interactive.html b/docs/_static/selection/interactive.html new file mode 100644 index 00000000..0c37ff9e --- /dev/null +++ b/docs/_static/selection/interactive.html @@ -0,0 +1,68 @@ + + + +
+
+
+
+
pick a syntax above · drag to rotate · scroll to zoom · click a highlighted atom to label it
+
+ diff --git a/docs/conf.py b/docs/conf.py index 9755db2e..73de1615 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,7 +6,6 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os -import re import sys sys.path.insert(0, os.path.abspath("../src")) @@ -17,24 +16,8 @@ copyright = "2025, bakerlab" author = "bakerlab" -# Get the raw version from atomworks -raw_version = atomworks.__version__ -print(f"Raw version from atomworks: {raw_version}") - -# Extract clean version for documentation -# Handle formats like: v2.29.0, v2.29.0+dev26.ad450d1, v2.29.0-dirty, v2.29.0+dev26.ad450d1-dirty -version_match = re.match(r"^v?(\d+\.\d+\.\d+)", str(raw_version)) -if version_match: - version = version_match.group(1) -else: - # Fallback if regex doesn't match - version = str(raw_version).lstrip("v").split("+")[0].split("-")[0] - -print(f"Clean version for docs: {version}") - -# For version switcher, we want to match against the exact version format in switcher.json -# This should match what your GitHub workflow generates -switcher_version = version # Use clean version for matching +release = os.environ.get("ATOMWORKS_DOCS_VERSION", str(atomworks.__version__).removeprefix("v")) +version = release # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -44,13 +27,17 @@ "sphinx.ext.viewcode", # Add source code links "sphinx.ext.napoleon", # Google/NumPy style docstrings "sphinx_gallery.gen_gallery", # Generates auto_examples/ from examples/ - "myst_parser", # Support for Markdown files - "sphinx_design", # For better layout and design components + "sphinxcontrib.typer", + "myst_parser", # Support Markdown tutorial pages + "sphinx_design", # Render collapsible tutorial code examples ] +myst_heading_anchors = 3 +html_favicon = "_static/favicon-32x32.png" templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "examples/GALLERY_HEADER.rst", "ml/preprocessing.rst"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "examples/GALLERY_HEADER.rst"] +napoleon_use_ivar = True # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output @@ -74,31 +61,17 @@ }, "navbar_start": ["navbar-logo", "version-switcher"], "switcher": { - "json_url": "https://baker-laboratory.github.io/atomworks-dev/latest/_static/switcher.json", - "version_match": switcher_version, + "json_url": "https://rosettacommons.github.io/atomworks/_static/switcher.json", + "version_match": release, }, - "favicons": [ - { - "rel": "icon", - "sizes": "16x16", - "href": "favicon-16x16.png", - }, - { - "rel": "icon", - "sizes": "32x32", - "href": "favicon-32x32.png", - }, - ], } sphinx_gallery_conf = { + "filename_pattern": r"/plot_", + "abort_on_example_error": True, "examples_dirs": "examples", # path to your example scripts "gallery_dirs": "auto_examples", # where to put the generated gallery "image_scrapers": ("matplotlib",), "thumbnail_size": (350, 350), "default_thumb_file": "./_static/atomworks_logo_color.svg", } - -html_js_files = [ - ('https://scripts.simpleanalyticscdn.com/latest.js', {'async': 'async', 'defer': 'defer'}), -] \ No newline at end of file diff --git a/docs/contributor_guide.rst b/docs/contributor_guide.rst index 1c466eb1..b934e1ac 100644 --- a/docs/contributor_guide.rst +++ b/docs/contributor_guide.rst @@ -11,10 +11,10 @@ As you code ------------- 1. **Reduce cognitive overhead:** - + a. Pick meaningful, descriptive variable names. - - b. Write docstrings (leverage AI!) and comments. To be used in the API documentation the docstring should + + b. Write docstrings (leverage AI!) and comments. To be used in the API documentation the docstring should follow the Google style guide: `Google Python Style Guide `_. c. Follow the `Python Zen `_ – explicit is better than implicit, etc. @@ -24,20 +24,20 @@ As you code As you commit --------------- -1. Keep commits as "one logical unit". This means that each commit should be a set of related changes +1. Keep commits as "one logical unit". This means that each commit should be a set of related changes that accomplish one task, fix one bug, or implement one feature. Using an editor like `VS Code `_ - or using `GitHub Desktop `_ can help you stage related changes together. + or using `GitHub Desktop `_ can help you stage related changes together. -2. Adhere to `semantic commit conventions `_. +2. Adhere to `semantic commit conventions `_. -3. Format & lint your code (``make format``). +3. Format & lint your code (``make format``). -4. Submit a draft PR so people know you are working on this & can provide advice/feedback early on. +4. Submit a draft PR so people know you are working on this & can provide advice/feedback early on. As you finalize a PR --------------------- -1. To make a PR merge your branch to **staging**. The maintainers will regularly merge staging into production. +1. Target **release/atomworks-3-0** for the AtomWorks 3.0 release. 2. Keep overall PR under <400 LOC (lines of code) (Rule of thumb: 500 LOC takes about 1h to review). 3. Read and fill out the `PR checklist `_. @@ -58,7 +58,7 @@ Aside from having AtomWorks and its dependencies installed, to build the documen uv pip install -r docs/docs_requirements.txt To build the documentation, navigate to the ``docs`` directory and run: - + .. code-block:: bash make html @@ -82,13 +82,46 @@ PR Hygiene When contributing to this repository, please follow these steps: 1. Clone the repository -2. Create the development environment (see the *Local Conda Environment* section in the Installation Guide). -3. Create a new branch for your changes. +2. Create the development environment (see the :doc:`installation` guide). +3. Create a new branch for your changes. - Use the following convention to name your branch: ``/``. Categories: ``feat``, ``fix``, ``hotfix``, ``refactor``, ``docs``, ``perf``. - Example: ``feat/support-rdkit-small-molecule`` -4. Make and commit your changes on your new branch. +4. Make and commit your changes on your new branch. - Run autoformatting tools (``make format``) before committing. - Use commit messages like ``: ``. Types: ``feat``, ``fix``, ``refactor``, ``docs``, ``chore``, ``wip``. - Example: ``git commit -m "docs: add contributing guidelines"`` -5. Open a pull request to ``staging`` and describe your changes. +5. Open a pull request to ``release/atomworks-3-0`` and describe your changes. 6. Wait for review and merge your changes. + + +Publishing a release +-------------------- + +Releases use the explicit version in ``pyproject.toml`` and a matching Git tag. +Branch pushes and PR merges do not publish packages. + +One-time repository setup: + +- Configure the PyPI ``atomworks`` Trusted Publisher: owner ``RosettaCommons``, + repository ``atomworks``, workflow ``release_and_docs.yaml``, environment ``pypi``. +- Protect the ``pypi`` environment with reviewer approval and allow version tags. +- Keep GitHub Pages Source as **Deploy from a branch**, using ``gh-pages`` and + ``/ (root)``. The workflow preserves versioned pages on that branch. + +For 3.0.0, merge the reviewed PRs, wait for CI on the final release commit, and +confirm that ``project.version`` is ``3.0.0`` and the tag/PyPI version are unused. +From a checkout whose ``origin`` is the public repository: + +.. code-block:: bash + + git fetch origin + git tag -a v3.0.0 origin/release/atomworks-3-0 -m "AtomWorks 3.0.0" + git push origin refs/tags/v3.0.0 + +The tag runs package checks, installed-wheel checks, scientific CPU tests +on GitHub-hosted runners and the docs build. After they pass, approve ``pypi`` to publish the checked +archives; subsequent jobs create the GitHub Release and deploy documentation. +Verify a fresh ``pip install atomworks==3.0.0``, ``aw --help`` and the versioned docs. + +Prereleases follow the same process without replacing stable ``latest`` docs. +Never move a published tag or reuse a published package version. diff --git a/docs/cookbook.rst b/docs/cookbook.rst new file mode 100644 index 00000000..0a1247f7 --- /dev/null +++ b/docs/cookbook.rst @@ -0,0 +1,296 @@ +Cookbook +======== + +The atomworks cookbook is intended to be a quick reference for various common tasks. Note the code snippets may not be complete as-written, and may require imports or other preparatory steps. + +As the AtomArray object comes from biotite, the `biotite documentation `__ and `api reference `__ contains additional resources for working with it. + +Input +----- + +Standard Local File Input +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Accepts common structure input formats (mmCIF, PDB, mmjson and BCIF/BinaryCIF), both with filenames and file-like objects. CIF files can also be gzip- or Zstandard-compressed. + +See :doc:`the Parser tutorial page ` for details on parameters and output. You can also find the API docs for the ``parse`` function :func:`here `. The example below shows how to set a few common options with ``ParseConfig`` (their defaults are shown):: + + import atomworks.io + import atomworks.io.config + + result_dict = atomworks.io.parse( + filename, + config=atomworks.io.config.ParseConfig(add_missing_atoms=True, remove_waters=True, hydrogen_policy="keep"), + ) + + # Extract relevant AtomArray: + asym_unit = result_dict["asym_unit"][0] # The full asymmetric unit + assembly = result_dict["assemblies"]["1"][0] # The Biological Assembly/biounit (possibly comprising multiple asym_units) + +Load from Clean Files +~~~~~~~~~~~~~~~~~~~~~ + +For previously processed structures (e.g. those which have already passed through +an AtomWorks preparation pipeline), use the ``minimal`` parser preset. This keeps +waters and loads the structure without adding missing atoms:: + + import atomworks.io + + atom_array = atomworks.io.parse(filename, config="minimal")["asym_unit"][0] + +Database Input +~~~~~~~~~~~~~~ + +The :func:`~atomworks.io.parser.parse` function can be used to download the given PDB id from the RCSB website:: + + result_dict = atomworks.io.parse( atomworks.io.utils.testing.get_pdb_path_or_buffer( PDBID ) ) + +CCD/SDF/SMILES Input +~~~~~~~~~~~~~~~~~~~~ + +:func:`~atomworks.io.tools.inference.ccd_code_to_annotated_atom_array`, :func:`~atomworks.io.tools.inference.sdf_to_annotated_atom_array`, :func:`~atomworks.io.tools.inference.smiles_to_annotated_atom_array` can be used to convert CCD codes, SDF files, or SMILES strings into annotated AtomArrays, respectively.:: + + atom_array = atomworks.io.tools.inference.ccd_code_to_annotated_atom_array( ccd_code, chain_id ) + atom_array = atomworks.io.tools.inference.sdf_to_annotated_atom_array( sdf_filename, chain_id ) + atom_array = atomworks.io.tools.inference.smiles_to_annotated_atom_array( smiles_string, chain_id ) + +Sequence Input +~~~~~~~~~~~~~~ + +The following is a simple procedure for how to create an annotated AtomArray from a sequence string:: + + chain_type = atomworks.enums.ChainType.POLYPEPTIDE_L + + # If you have RF3-style sequence string with parenthesized CCD codes: + ccd_list = atomworks.io.tools.fasta.one_letter_to_ccd_code( atomworks.io.tools.fasta.split_generalized_fasta_sequence( sequence_string ), chain_type=chain_type ) + + # ccd_list is just a list with one CCD code per entry. + + atom_array = atomworks.io.tools.inference.sequence_to_annotated_atom_array( ccd_list, chain_id, chain_type=chain_type ) + # Caution! The atom array will not contain coordinates. + +API docs for these functions: + +- :attr:`~atomworks.enums.ChainType.POLYPEPTIDE_L` +- :func:`~atomworks.io.tools.fasta.one_letter_to_ccd_code` +- :func:`~atomworks.io.tools.fasta.split_generalized_fasta_sequence` +- :func:`~atomworks.io.tools.inference.ccd_code_to_annotated_atom_array` + +Output +------ + +mmCIF output +~~~~~~~~~~~~ + +:func:`~atomworks.io.utils.io_utils.to_cif_file` supports `.cif`, `.cif.gz` and `.bcif` outputs:: + + import atomworks.io.utils.io_utils + + atomworks.io.utils.io_utils.to_cif_file(atom_array, filename, chain_disambiguation="chain_iid") + +You can also write a CIF to an in-memory buffer and read it back into AtomWorks. +The example below keeps track of the different copies of a chain in a biological +assembly using ``transformation_id``:: + + import atomworks.io + import atomworks.io.utils.io_utils + + buffer = atomworks.io.utils.io_utils.to_cif_buffer( + atom_array, + config=atomworks.io.utils.io_utils.CIFWriteConfig( + include_entity_categories=True, + chain_disambiguation="transformation_id", + ), + ) + restored = atomworks.io.parse(buffer, config="minimal")["asym_unit"][0] + +If you plan to open the CIF in another program, use ``chain_disambiguation="chain_iid"`` +to give each chain copy a unique name. The ``transformation_id`` option keeps the +original chain names and records the copies in a form AtomWorks can read. +To save additional annotations, check the writer options and whether the program +reading the file supports them. Parsing the file again can still change some +annotations, even with the ``minimal`` preset. + +Legacy PDB output +~~~~~~~~~~~~~~~~~ + +While the use of :func:`~atomworks.io.utils.io_utils.to_pdb_string` is possible for creating legacy PDB files, use of mmCIF instead is recommended:: + + import atomworks.io.utils.io_utils + import numpy + + resolved = atom_array[numpy.isfinite(atom_array.coord).all(axis=-1)] + with open(filename, "w") as f: + f.write(atomworks.io.utils.io_utils.to_pdb_string(resolved)) + +This example leaves out atoms with missing coordinates, which PDB cannot store. +Use mmCIF if your chain, residue, or atom identifiers exceed the limits of the PDB format. + +SDF/SMILES output +~~~~~~~~~~~~~~~~~ + +This procedure may not work reliably for multi-residue AtomArrays (or too-small fractions of a single residue):: + + rdmol = atomworks.io.tools.rdkit.atom_array_to_rdkit( atom_array ) + + smiles = rdkit.Chem.MolToSmiles( rdmol ) + + with rdkit.Chem.SDWriter( sdf_filename ) as w: + w.write( rdmol ) + +API docs for these functions: + +- :func:`~atomworks.io.tools.rdkit.atom_array_to_rdkit` +- `MolToSmiles `_ +- `SDWriter `_ + +FASTA output +~~~~~~~~~~~~ + +`biotite.structure.to_sequence `_ will raise an error if the atom_array contains non-polymeric residues:: + + results = biotite.structure.to_sequence( atom_array ) + + seq_list = results[0] # The second entry in the returned tuple is chain_start_indices + + +Structure Manipulation +---------------------- + +Combining AtomArrays +~~~~~~~~~~~~~~~~~~~~ + +For example, to combine a receptor with a ligand:: + + combined_atom_array = atom_array_1 + atom_array_2 + +Subsetting AtomArray +~~~~~~~~~~~~~~~~~~~~ + +AtomArray can be subsetted by indexing with a Boolean array, resulting in another AtomArray:: + + present = atom_array[ atom_array.occupancy > 0 ] + calpha = atom_array[ atom_array.atom_name == "CA" ] + chainA = atom_array[ atom_array.chain_id == "A" ] + gly = atom_array[ atom_array.res_name == "GLY" ] + polymer = atom_array[ atom_array.is_polymer ] + not_bb = atom_array[ ~biotite.structure.filter_peptide_backbone(atom_array) ] + res_B34 = atom_array[ (atom_array.chain_id == "B") & (atom_array.res_id == 34) ] # Parenthesis are needed. + +:mod:`~atomworks.io.transforms.atom_array` contains a number of helpful utility functions (primarily used in structure loading):: + + no_waters = atomworks.io.transforms.atom_array.remove_waters( atom_array ) + no_ccd = atomworks.io.transforms.atom_array.remove_ccd_components( atom_array, ccd_code_list ) + no_hydro = atomworks.io.transforms.atom_array.remove_hydrogens( atom_array ) + + +The ``biotite.structure`` module contains a number of `filter `_ functions to help subset the AtomArray:: + + sugars = atom_array[ biotite.structure.filter_carbohydrates(atom_array) ] + +Each selection uses annotations on the AtomArray, such as ``occupancy`` or +``is_polymer``. If your assembly contains several copies of the same chain, +select by ``chain_iid`` or ``pn_unit_iid`` to choose just one copy before selecting +its residues. The backbone filter in the example applies to proteins. + +Changing annotations +~~~~~~~~~~~~~~~~~~~~ + +Note this naive manipulation doesn't update the ``_id/_iid/pn_unit/molecule/etc.`` identity correspondences:: + + atom_array.chain_id[ atom_array.is_polymer ] = "A" + atom_array.res_id[ atom_array.res_id == 1004 ] = 4 + +Coordinate Manipulation +----------------------- + +Simple Translation/Rotation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + translated = biotite.structure.translate( atom_array, (x,y,z) ) # Displacement can be any array-shaped + rotated = biotite.structure.rotate_about_axis( atom_array, axis, angle, center ) # Center can be omitted to rotate around origin + +API docs for these functions: + +- `biotite.structure.translate `_ +- `biotite.structure.rotate_about_axis `_ + +Alignment +~~~~~~~~~ + +If the AtomArrays have identical atom layouts:: + + superimposed, transformation = biotite.structure.superimpose( fixed, mobile ) + + superimposed2 = transformation.apply( mobile2 ) # Apply same transformation to different AtomArray + +If the structures aren't identical, the following uses sequence alignments to find pairings:: + + superimposed, transformation, fixed_indices, mobile_indices = biotite.structure.superimpose_homologs( fixed, mobile ) + +If there's low sequence similarity, the following uses structural similarity to find pairings:: + + superimposed, transformation, fixed_indices, mobile_indices = biotite.structure.superimpose_structural_homologs( fixed, mobile ) + +API docs for these functions: + +- `biotite.structure.superimpose `_ +- `biotite.structure.superimpose_homologs `_ +- `biotite.structure.superimpose_structural_homologs `_ + + +Calculations +------------ + +Distances/Angles +~~~~~~~~~~~~~~~~ + +Geometry calculations take AtomArrays all of N entries (or one entry to be broadcasted), resulting in a numpy array of N distances:: + + distances = biotite.structure.distance( atom_array_1, atom_array_2 ) # In Angstroms + angles = biotite.structure.angle( atom_array_1, atom_array_2, atom_array_3 ) # In radians + dihedrals = biotite.structure.dihedral( atom_array_1, atom_array_2, atom_array_3, atom_array_4 ) # In radians + +There's convenience functions for standard protein dihedrals:: + + bb_dihedral = biotite.structure.dihedral_backbone( atom_array ) # tuple of (phi, psi, omega) array + +API docs for these functions: + +- `biotite.structure.distance `_ +- `biotite.structure.angle `_ +- `biotite.structure.dihedral `_ +- `biotite.structure.dihedral_backbone `_ + +Structural Comparison +~~~~~~~~~~~~~~~~~~~~~ + +:: + + rmsd = biotite.structure.rmsd( reference, subject ) # RMSD without superposition + lddt = biotite.structure.lddt( reference, subject ) + # fixed_indices & mobile_indices can come from superimpose_structural_homologs() + tm_score = biotite.structure.tm_score( reference, subject, fixed_indices, mobile_indices ) + +API docs for these functions: + +- `biotite.structure.rmsd `_ +- `biotite.structure.lddt `_ +- `biotite.structure.tm_score `_ + +Visualization +------------- + +Viewer +~~~~~~ + +Interactive viewer (:func:`~atomworks.io.utils.visualize.view`) (for Notebooks):: + + atomworks.io.utils.visualize.view(atom_array) + +Other +----- + +If there are other common tasks you think are worth including here, please `open an issue `_ on Github. diff --git a/docs/docs_requirements.txt b/docs/docs_requirements.txt index f09486f5..fee6b7b8 100644 --- a/docs/docs_requirements.txt +++ b/docs/docs_requirements.txt @@ -7,4 +7,5 @@ sphinx-gallery>=0.8.1,<1 ghp-import>=2.0.0,<3 pandoc>=2.0.0,<3 myst-parser>=5.0.0 -sphinx_design>=0.7.0 +sphinx-design>=0.6.0,<1 +sphinxcontrib-typer>=0.7.2,<1 diff --git a/docs/examples/annotate_and_save_structures.py b/docs/examples/annotate_and_save_structures.py index 9b3d3276..d7ae8de9 100644 --- a/docs/examples/annotate_and_save_structures.py +++ b/docs/examples/annotate_and_save_structures.py @@ -26,6 +26,7 @@ import numpy as np from atomworks.io import parse +from atomworks.io.config import ParseConfig from atomworks.io.utils.io_utils import to_cif_file from atomworks.io.utils.testing import get_pdb_path_or_buffer from atomworks.io.utils.visualize import view @@ -37,7 +38,8 @@ pdb_path = get_pdb_path_or_buffer(example_pdb_id) # Parse the structure (no need to add missing atoms, since we would just remove them in the following step) -atom_array = parse(pdb_path, add_missing_atoms=False, fix_formal_charges=False)["assemblies"]["1"][0] +config = ParseConfig(add_missing_atoms=False) +atom_array = parse(pdb_path, config=config)["assemblies"]["1"][0] print(f"Loaded structure with {len(atom_array)} atoms") print(f"Chains: {np.unique(atom_array.chain_id)}") diff --git a/docs/examples/dataset_exploration.py b/docs/examples/dataset_exploration.py index 2c2b01e7..bf5c4448 100644 --- a/docs/examples/dataset_exploration.py +++ b/docs/examples/dataset_exploration.py @@ -225,13 +225,12 @@ def simple_loading_fn(raw_data: Any) -> dict: # **NOTE**: Because a given PDB ID may contain many interfaces and thus may appear multiple times in our dataset, we must also incorporate the `assembly_id` and the `pn_unit_iids` of the two interacting chains within the `example_id`. from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader dataset = PandasDataset( data=interfaces_df, name="interfaces_dataset", - # We use a pre-built loader that takes in a list of column names and returns a loader function - loader=create_loader_with_query_pn_units(pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"]), + loader=create_structure_loader(column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]}), transform=pipe, ) diff --git a/docs/examples/load_and_visualize_structures.py b/docs/examples/load_and_visualize_structures.py index 5c02b258..71becdc7 100644 --- a/docs/examples/load_and_visualize_structures.py +++ b/docs/examples/load_and_visualize_structures.py @@ -46,7 +46,7 @@ # - ``remove_waters``: Whether to remove water molecules (True by default) # - ``remove_ccds``: CCD codes to filter out (Default is a list of common crystallization aids, e.g., GOL, SO4, etc.) # - ``add_missing_atoms``: Whether to add missing (e.g., unresolved) heavy atoms (True by default) -# - ``hydrogen_policy``: How to handle hydrogens (e.g., "keep", "remove", or "infer"). Default is "keep". +# - ``hydrogen_policy``: How to handle hydrogens ("keep" or "remove"). Default is "keep". # ... and many more! # ``parse`` returns a dictionary with several data fields; see the API docs for full details. @@ -82,7 +82,7 @@ print("Available annotations:") annotations = atom_array.get_annotation_categories() for i, annotation in enumerate(annotations): - print(f" {i + 1:2d}. {annotation}") + print(f" {i+1:2d}. {annotation}") # %% diff --git a/docs/examples/plot_protonation.py b/docs/examples/plot_protonation.py new file mode 100644 index 00000000..0fc64313 --- /dev/null +++ b/docs/examples/plot_protonation.py @@ -0,0 +1,48 @@ +""" +Parse, protonate and round-trip a molecule +========================================== + +This offline example uses ethanol from Biotite's bundled CCD. The same code runs +against the installed core wheel in CI, without PyTorch or a PDB mirror. +""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import numpy as np + +from atomworks.experimental.protonation import assign_hydrogens, place_hydrogens +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from atomworks.io.utils.ccd import get_ccd_component_from_biotite +from atomworks.io.utils.io_utils import to_cif_file + +# %% +# Prepare a heavy-atom input and parse it with explicit configuration. +component = get_ccd_component_from_biotite("EOH") +component.chain_id[:] = "A" +component.res_id[:] = 1 +component.hetero[:] = True +heavy = component[component.element != "H"] +config = ParseConfig.from_preset("minimal", hydrogen_policy="keep", add_id_and_entity_annotations=True) + +with TemporaryDirectory() as directory: + path = Path(directory) / "ethanol.cif" + to_cif_file(heavy, path, ccd_entries={"EOH": component}) + atoms = parse(path, config=config)["asym_unit"][0] + + # Assignment chooses the state; placement supplies hydrogen coordinates. + state = assign_hydrogens(atoms, ph=7.4) + protonated = place_hydrogens(state) + np.testing.assert_array_equal(protonated.coord[protonated.element != "H"], atoms.coord) + assert np.count_nonzero(protonated.element == "H") == 6 + assert np.isfinite(protonated.coord).all() + + # Write and reparse the complete molecule without changing its chemistry. + to_cif_file(protonated, path, ccd_entries={"EOH": component}) + restored = parse(path, config=config)["asym_unit"][0] + np.testing.assert_array_equal(restored.atom_name, protonated.atom_name) + np.testing.assert_allclose(restored.coord, protonated.coord, atol=0.001) + np.testing.assert_array_equal(restored.bonds.as_array(), protonated.bonds.as_array()) + +print("Ethanol: 3 heavy atoms and 6 hydrogens; coordinates and bonds round-trip.") diff --git a/docs/how_to_build_a_model/how_to_build_a_model_part1.md b/docs/how_to_build_a_model/how_to_build_a_model_part1.md index 982fef6a..d5015310 100644 --- a/docs/how_to_build_a_model/how_to_build_a_model_part1.md +++ b/docs/how_to_build_a_model/how_to_build_a_model_part1.md @@ -1,35 +1,40 @@ -# Part 1: Cleaning the Data +# Part 1: Prepare the Data ## Table of Contents -- {ref}`aw_build_model_p1_intro` -- {ref}`aw_build_model_p1_prereq` -- {ref}`aw_build_model_p1_setup` -- {ref}`aw_build_model_p1_tutorial` - - {ref}`aw_build_model_p1_load` - - {ref}`aw_build_model_p1_merge` - - {ref}`aw_build_model_p1_clean` - - {ref}`aw_build_model_p1_new_cols` - - {ref}`aw_build_model_p1_split` -- {ref}`aw_build_model_p1_next` -- {ref}`aw_build_model_p1_glossary` - -(aw_build_model_p1_intro)= +- [Introduction](#introduction) +- [Prerequisites](#prerequisites) +- [Download current metadata and structures](aw_build_model_p1_setup) +- [Inspect the metadata tables](aw_build_model_p1_inspect) +- [Merge the datasets](#merge-the-datasets) +- [Clean the data](#clean-the-data) +- [Resolve structure paths](#adding-new-columns) +- [Split by protein cluster](#split-by-protein-cluster) +- [Save the results](#save-the-results) +- [Check the outputs](aw_build_model_p1_check) +- [What Next?](#what-next) +- [Glossary](#glossary) + ## Introduction -This is the first in a series of tutorials that walks you through how to use AtomWorks to build a machine learning model for determining the pose of a ligand in a protein binding pocket from start to finish. + +This is the first in a series of tutorials that walks you through how to use AtomWorks to build a machine learning model for determining the pose of a ligand in a protein binding pocket, from start to finish. **In this installment, you will learn how to use the [IO functionalities in AtomWorks](../io.rst) to prepare your data for use in a machine learning model.** -By the end this first part of the **How to Build a Model with AtomWorks** tutorial series you will have created a script that will collect, clean, and split the data we need to train our model in to training, validation, and test sets. +By the end of this first part of the **How to Build a Model with AtomWorks** tutorial series, you will have written a script that collects, cleans, and splits the data we need to train our model. You'll end up with four Parquet files: +- `all.parquet`: every selected example and its assigned split +- `train.parquet`: the examples used to train the model +- `validation.parquet`: the examples used to validate the model +- `test.parquet`: the examples used to test the model ```{important} -For those that want to use the tutorial text as structure and hints to write your own script, we have hidden the code in collapsible cells. +For those that want to use the tutorial text as structure and hints to write your own script, we have hidden the code in collapsible cells. -The full script, `data_cleaning_script.py`, is provided in the [tutorial files](./scripts/index.rst). +The full script, `data_cleaning_script.py`, is provided in the [tutorial files](./scripts/index.rst). ``` -(aw_build_model_p1_prereq)= ## Prerequisites + Before starting this tutorial it is assumed that you have: - An intermediate knowledge of Python and the [Pandas](https://pandas.pydata.org/) library. - A working installation of [AtomWorks](https://rosettacommons.github.io/atomworks/latest/). Only the `io` side will be used for this first part of the How to Build a Model tutorial series, however other parts will require the `ml` side. @@ -37,243 +42,239 @@ Before starting this tutorial it is assumed that you have: - At least a portion of the PDB mirrored - the full mirror takes ~100GB of space. ```{note} -If you do not have over 100GB of space on your computing system, you can use a subset of the PDB instead of the full PDB mirror. See [Data Mirrors](../mirrors.rst) for how to only download specific PDB IDs. +If you do not have over 100GB of space on your computing system, you can use a subset of the PDB instead of the full PDB mirror. See [Data Mirrors](../mirrors.rst) for how to only download specific PDB IDs. Make sure you have the `PDB_MIRROR_PATH` environment variable set to wherever you put it before moving on. ``` (aw_build_model_p1_setup)= -## Setup -AtomWorks provides a few parquet files that already contains various pieces of metadata about the various structures included in the PDB. We will use these as our starting point. +## Setup -Download and decompress the parquet files via: +AtomWorks provides a few Parquet files that contain metadata about the structures included in the [PDB](https://www.rcsb.org/). We will use these as our starting point. + +Download the parquet files with the AtomWorks CLI: ```bash -wget https://files.ipd.uw.edu/pub/atomworks/dfs/pdb/2026_01_06.tar.gz -tar -xvf 2026_01_06.tar.gz +atomworks setup metadata data/pdb_metadata ``` -After decompressing the folder you should see three parquet files: -- **`assemblies.parquet`:** There are multiple bio assemblies stored in a single [PDB](https://www.rcsb.org/), each assembly will have multiple chains, interfaces, etc. -- **`interfaces.parquet`:** Contains metadata for all binary interfaces in the [PDB](https://www.rcsb.org/) -- **`pn_units.parquet`:** Contains metadata for each PN unit in the [PDB](https://www.rcsb.org/) - -We will only use `interfaces.parquet` and `pn_units.parquet` in this tutorial. +This creates the `data/pdb_metadata` directory (if it doesn't already exist) and populates it with several parquet files, including `shared/interfaces_df.parquet` and `shared/pn_units_df.parquet`. We will only use those two files in this tutorial. -(aw_build_model_p1_tutorial)= -## Creating Cleaned Parquet Files +(aw_build_model_p1_inspect)= +## Inspect the metadata tables -(aw_build_model_p1_load)= -### Loading the parquet file using Pandas -Let's first take a look at the information contained in the parquet file. Parquet files are not human parsable, but we can use [Pandas](https://pandas.pydata.org/) to inspect it. +Let's first take a look at the information contained in the parquet files. Parquet files are not human parsable, but we can use [Pandas](https://pandas.pydata.org/) to inspect them. ````{dropdown} Click to see the code -Load in the datasets: +Load in the datasets: ```python import pandas as pd -interfaces = pd.read_parquet("2026_01_06/interfaces.parquet") -pn_units = pd.read_parquet("2026_01_06/pn_units.parquet") +interfaces = pd.read_parquet("data/pdb_metadata/shared/interfaces_df.parquet") +pn_units = pd.read_parquet("data/pdb_metadata/shared/pn_units_df.parquet") ``` -View the dataset columns: +View the dataset columns: ```python interfaces.columns pn_units.columns ``` ```` -Let's take a closer look at a few of the columns in the interfaces parquet: +Let's take a closer look at a few of the columns in the interfaces parquet: - `pdb_id`: The identifier for the specific structure in the PDB. - `assembly_id`: Integer label for what assembly the given interface belongs to. There may be multiple assemblies in a single PDB structure. -- `pn_unit_1_iid`: Label for the first PN unit in the interface. -- `pn_unit_2_iid`: Label for the second PN unit in the interface. +- `altloc_seed`: Seed used to resolve which alternate locations were kept when the structure was processed. +- `pn_unit_1_iid` / `pn_unit_2_iid`: Labels for the two PN units in the interface. +- `pn_unit_1_type` / `pn_unit_2_type`: Chain type of each PN unit (see AtomWorks' `ChainType` enum) - for example polypeptide, DNA, RNA, or non-polymer. +- `pn_unit_1_is_polymer` / `pn_unit_2_is_polymer`: Boolean for whether each PN unit is a polymer. - `involves_loi`: Boolean for if the LOI (Ligand of Interest) is part of the interface. -- `is_inter_molecule`: Boolean for if the interfaces is between two molecules (True) or within the same molecule (False). +- `is_inter_molecule`: Boolean for if the interface is between two molecules (True) or within the same molecule (False). - `involves_metal`: Boolean for if the interface involves a metal atom. - `involves_covalent_modification`: Boolean for if the interface involves a covalent modification (e.g. glycosylation). - `num_contacts`: Number of contacts between the two PN units that create the interface. -- `min_distance`: Minimum distance between the contacts that create the interface. +- `example_id`: A label that already uniquely identifies the interface. +- `path`: Path to the structure file AtomWorks used to produce this row (we'll replace this with a path into your own PDB mirror in [Resolve structure paths](#adding-new-columns)). -Let's also look at a few columns of interest from the PN units parquet file: -- `pn_unit_iid`: Label for the specific PN unit in the structure that the row corresponds to. -- `is_polymer`: Boolean for whether the PN unit is a polymer -- `num_resolved_residues`: Number of resolved residues in the structure. +Let's also look at a few columns of interest from the PN units parquet file: +- `q_pn_unit_iid`: Label for the specific PN unit in the structure that the row corresponds to. +- `q_pn_unit_num_resolved_residues`: Number of resolved residues in the structure. +- `cluster`: Sequence-identity cluster the PN unit belongs to. Only polymers are clustered; non-polymers (ligands) have a `Null` value here. -We encourage you to take a closer look at these datasets on your own. For some suggestions on what to do, see the collapsible group below: +```{note} +PN means "polymer or non-polymer." A PN unit is usually a polymer chain or a +small molecule, though covalently connected components may be grouped together. +``` + +We encourage you to take a closer look at these datasets on your own. For some suggestions on what to do, see the collapsible group below: ````{dropdown} Click to see the code See the first 5 rows of specific columns in a dataset: ```python -interfaces[["pdb_id","assembly_id", "pn_unit_1_iid", "pn_unit_2_iid"]].head() +interfaces[["pdb_id", "assembly_id", "pn_unit_1_iid", "pn_unit_2_iid"]].head() ``` -See the unique values of a given column: +See the unique values of a given column: ```python -interfaces["assembly_id"].unique() +interfaces["pn_unit_1_type"].unique() ``` -Determine the datatype of the data stored in a particular column: +Determine the datatype of the data stored in a particular column: ```python -interfaces[["is_inter_molecule"]].dtype +interfaces[["is_inter_molecule"]].dtypes ``` ```` -(aw_build_model_p1_merge)= -### Merging Datasets -While the interfaces parquet has most of the data we need to train our model to predict poses for ligands binding to protein pockets, we need the information stored in the `is_polymer` and `num_resolved_residues` columns in the PN units dataset as well. We will use the information in `is_polymer` to ensure that the interfaces we are looking at are between a protein (polymer) and ligand (non-polymer). We will use the information in `num_resolved_residues` to make sure our dataset remains small enough to train our model on a single GPU. +Since `interfaces_df.parquet` and `pn_units_df.parquet` are generated by AtomWorks itself and can change between releases, it's good practice to double-check that the columns you're about to rely on are actually present before writing a long script around them: -We will need to merge this information with the interfaces dataset twice, one for each PN unit involved in each interface. Keep in mind that you need the information in `pdb_id`, `assembly_id`, and `pn_unit_iid` to uniquely identify a structure! +````{dropdown} Click to see the code +```python +import pyarrow.parquet as pq + +required_interface_columns = { + "pdb_id", "assembly_id", "altloc_seed", "pn_unit_1_iid", "pn_unit_2_iid", + "pn_unit_1_type", "pn_unit_2_type", "pn_unit_1_is_polymer", "pn_unit_2_is_polymer", + "involves_loi", "is_inter_molecule", "involves_metal", + "involves_covalent_modification", "example_id", "path", +} +missing = required_interface_columns.difference(pq.read_schema("data/pdb_metadata/shared/interfaces_df.parquet").names) +assert not missing, f"interfaces_df is missing columns: {missing}" +``` +```` + +## Merge the datasets + +The interfaces table already tells us whether each PN unit is a polymer and what type of chain it is, but it doesn't contain resolved-residue counts or cluster labels - those live in the PN units table. We'll need to merge that information in twice, once for each PN unit involved in an interface. Keep in mind that you need `pdb_id`, `assembly_id`, `altloc_seed`, *and* `pn_unit_iid` together to uniquely identify a PN unit, since a structure can have more than one alternate-location seed. The code that follows in the rest of this tutorial can be found in `data_cleaning_script.py` in the [tutorial files](./scripts/index.rst). ````{dropdown} Click here to see how to merge these datasets. -Create two copies of the relevant columns in the PN units dataset, one for each PN unit involved in the interface. -```python -u1_cols = pn_units[["pdb_id", "assembly_id", "pn_unit_iid", "is_polymer", "num_resolved_residues"]].copy() -u2_cols = pn_units[["pdb_id", "assembly_id", "pn_unit_iid", "is_polymer", "num_resolved_residues"]].copy() -``` -Rename the columns so that they match what is in the interfaces dataset and to ensure the new information is distinguishable between the two PN units. +Create two renamed copies of the relevant PN-unit columns, one for each side of the interface: ```python -u1_cols = u1_cols.rename(columns={ - "pn_unit_iid": "pn_unit_1_iid", - "is_polymer": "u1_is_polymer", - "num_resolved_residues": "u1_num_resolved_residues" +pn_columns = pn_units[["pdb_id", "assembly_id", "altloc_seed", "q_pn_unit_iid", "q_pn_unit_num_resolved_residues", "cluster"]] + +side_1 = pn_columns.rename(columns={ + "q_pn_unit_iid": "pn_unit_1_iid", + "q_pn_unit_num_resolved_residues": "pn_unit_1_num_resolved_residues", + "cluster": "pn_unit_1_cluster", }) -u2_cols = u2_cols.rename(columns={ - "pn_unit_iid": "pn_unit_2_iid", - "is_polymer": "u2_is_polymer", - "num_resolved_residues": "u2_num_resolved_residues" +side_2 = pn_columns.rename(columns={ + "q_pn_unit_iid": "pn_unit_2_iid", + "q_pn_unit_num_resolved_residues": "pn_unit_2_num_resolved_residues", + "cluster": "pn_unit_2_cluster", }) ``` -Actually merge the three datasets together: +Merge both sides into the interfaces dataframe: ```python -df = interfaces.merge(u1_cols, on=["pdb_id", "assembly_id", "pn_unit_1_iid"], how="inner") -df = df.merge(u2_cols, on=["pdb_id", "assembly_id", "pn_unit_2_iid"], how="inner") +df = interfaces.merge(side_1, on=["pdb_id", "assembly_id", "altloc_seed", "pn_unit_1_iid"], how="left") +df = df.merge(side_2, on=["pdb_id", "assembly_id", "altloc_seed", "pn_unit_2_iid"], how="left") ``` ```` -Check that the merge occurred correctly by printing out the columns of the new data frame, inspecting the first few rows of the new data frame, etc. +Check that the merge occurred correctly by printing out the columns of the new data frame, inspecting the first few rows, and checking `len(df)` before and after. -(aw_build_model_p1_clean)= -### Cleaning the Data -For the purposes of this tutorial, we want to remove rows where `involves_covalent_modification` is `True` and where the interface involves non-protein and non-ligand chains since we are looking for interfaces that are between a protein and a ligand. +## Clean the data -This means we only want to keep rows where: +For the purposes of this tutorial, we want to keep only interfaces that are between a protein and a ligand, aren't covalently modified, and are small enough to train our model on quickly. This means we only want to keep rows where: - `involves_loi` is `True`. - `is_inter_molecule` is `True`. +- `involves_metal` is `False`. - `involves_covalent_modification` is `False`. -- We want one PN unit involved in the interface to be a polymer and the other to be non-polymer. -- The total number of resolved residues for the pocket/ligand combination should be less than 200, to keep the training time relatively short. +- Exactly one PN unit involved in the interface is a polymer, and that polymer is a protein (not DNA or RNA). +- The total number of resolved residues for the pocket/ligand combination is less than 200, to keep training time relatively short. -We also want to make sure we remove any duplicates. The `u1` and `u2` labels are arbitrary, so it's possible for two rows to be identical. +We also want to remove any rows whose residue counts didn't merge and any duplicate examples. ````{dropdown} Click to see the code -Keep only rows where at least one PN unit is a ligand of interest: +Keep only rows where the LOI is part of the interface: ```python -df = df[df["involves_loi"]==True] +df = df[df["involves_loi"].eq(True)] ``` Keep only rows where the interfaces are intermolecular: ```python -df = df[df["is_inter_molecule"]==True] +df = df[df["is_inter_molecule"].eq(True)] ``` -Remove metal mediated-interfaces: +Remove metal-mediated interfaces: ```python -df = df[df["involves_metal"]!=True] +df = df[df["involves_metal"].ne(True)] ``` -Remove covalently modified residues +Remove covalently modified interfaces: ```python -df = df[df["involves_covalent_modification"] != True] +df = df[df["involves_covalent_modification"].ne(True)] ``` -Keep only interfaces where one PN unit is a polymer (protein) and one is not (ligand): +Keep only interfaces where exactly one PN unit is a polymer: ```python -df = df[df["u1_is_polymer"] != df["u2_is_polymer"]] +exactly_one_polymer = df["pn_unit_1_is_polymer"] ^ df["pn_unit_2_is_polymer"] +df = df[exactly_one_polymer] ``` -Keep only small examples: -df = df[(df["u1_num_resolved_residues"] + df["u2_num_resolved_residues"]) < 200 ] +`is_polymer` alone isn't quite enough here, though - DNA and RNA chains are polymers too, and we specifically want proteins. Use the `pn_unit_type` columns together with AtomWorks' `ChainType` enum to check that the polymer side is a D- or L-polypeptide: +```python +import numpy as np +from atomworks.enums import ChainType -Remove duplicates -df = df.drop_duplications(subset["pdb_id", "assembly_id", "pn_unit_1_iid", "pn_unit_2_iid"]) +protein_chain_types = {int(ChainType.POLYPEPTIDE_D), int(ChainType.POLYPEPTIDE_L)} +protein_side_type = np.where(df["pn_unit_1_is_polymer"], df["pn_unit_1_type"], df["pn_unit_2_type"]) +df = df[pd.Series(protein_side_type, index=df.index).isin(protein_chain_types)] +``` +Drop rows where the residue counts didn't merge, then keep only small examples: +```python +residue_columns = ["pn_unit_1_num_resolved_residues", "pn_unit_2_num_resolved_residues"] +df = df.dropna(subset=residue_columns) +df = df[df[residue_columns].sum(axis="columns") < 200] +``` +Remove duplicates: +```python +df = df.drop_duplicates(subset=["example_id"]) +``` ```` You can check to make sure these filters are actually being applied to your data frame by checking the `len` of the data frame before and after applying each filter. -To make sure the filter is doing what you expect, you can try running this procedure on a small subset of the data or locating specific rows in the larger dataset that should/should not be impacted by each filtering step. - -(aw_build_model_p1_new_cols)= -### Adding New Columns -It will be useful later on if one row contains unique labels for each remaining interface. Right now information from four columns (`pdb_id`, `assembly_id`, `pn_unit_1_iid`, and `pn_unit_2_iid`) are required to uniquely identify an interface in our dataset. Let's add a new column (`example_id`) to our dataframe and store our custom lable there. +To make sure a filter is doing what you expect, you can try running it on a small subset of the data, or locate specific rows in the larger dataset that should/should not be impacted by that filtering step. +You can also add a test to make sure `example_id` is still unique after dropping duplicates: ````{dropdown} Click to see the code. ```python -df["example_id"] = (df["pdb_id"] + "_" + - df["assembly_id"] + "_" + - df["pn_unit_1_iid"] + "_" + - df["pn_unit_2_iid"]) +assert df["example_id"].is_unique ``` ```` -It will also be useful during training if our dataset already contains the path to the structure file in our PDB mirror. Add this information as a new column (`path`) to your dataset. -````{dropdown} Click to see the code. -```python -PDB_MIRROR_PATH = os.environ.get("PDB_MIRROR_PATH", "/PATH/TO/pdb_mirror") -df["path"] = df["pdb_id"].str.lower().map( - lambda x: f"{PDB_MIRROR_PATH}/{x[1:3]}/{x}.cif.gz" -) -``` -Replace `"/PATH/TO/pdb_mirror"` if this environment variable is not already set. If it is set, you can leave this second argument blank. -```` - -To check that your code works correctly, make sure these new columns are present when you run `df.columns` and check the first few values in each. Are they what you expected? +(aw_build_model_p1_new_cols)= +### Adding New Columns -You can also add a test to ensure that all the values in `example_id` are unique: +It will be useful during training for our dataset to contain the path to each structure file in your PDB mirror. The `path` column that came with `interfaces_df.parquet` may point at internally processed `.cif` files, so we need to overwrite it with a path into your own mirror instead. The CLI's PDB mirror stores structures divided into two-letter subdirectories as gzipped mmCIF files: ````{dropdown} Click to see the code. ```python -assert df["example_id"].nunique() == len(df), "example_id is not unique!" +import os +from pathlib import Path + +pdb_mirror = Path(os.environ["PDB_MIRROR_PATH"]).expanduser().resolve() + +def resolve_path(pdb_id: str) -> str: + pdb_id = pdb_id.lower() + return str(pdb_mirror / pdb_id[1:3] / f"{pdb_id}.cif.gz") + +df["path"] = df["pdb_id"].map(resolve_path) ``` ```` -(aw_build_model_p1_split)= -### Training, Testing, and Validation Sets -Now that we have the data, we need to split it up into three sets: `test`, `train`, and `val` (short for validation). There are many ways to do this and which is best will depend on your data and what you are trying to accomplish with your model. +To check that this worked, look at a few values in `df["path"]` and confirm the files actually exist on disk, e.g. with `Path(df["path"].iloc[0]).exists()`. -Here, we will use the `protein_cluster_30` column in the PN units data frame to split up our data. This column groups proteins by 30% sequence identity, meaning that it contains hash-based IDs that uniquely identify a cluster of proteins sharing more than 30% sequence identity. We will do an 80/10/10 split: 80% of the data will be in training, 10% in test, and 10% in validation. +## Split by protein cluster -We will use this column to split the data by cluster, instead of individual rows. This will prevent the model from seeing near-identical protein pockets between the training and testing sets. +Now that we have the data, we need to split it up into three sets: `train`, `validation`, and `test`. There are many ways to do this and which is best will depend on your data and what you are trying to accomplish with your model. -Isolate this column and the data from the PN units data frame that uniquely identifies each row and merge it with the data frame. Remember that we will need to perform the merge twice, since there are two PN unites in each interface. +Here, we will use the `cluster` column we merged in from the PN units data frame to split up our data. This column groups proteins by sequence identity, meaning it contains hash-based IDs that uniquely identify a cluster of related proteins. We will do an 80/10/10 split: 80% of the data will be in training, 10% in validation, and 10% in test. -````{dropdown} Click to see the code. -```python -protein_clusters = pn_units[["pdb_id", "assembly_id", "pn_unit_iid", "protein_cluster_30"]].copy() - -df = df.merge( - protein_clusters.rename(columns={ - "pn_unit_iid": "pn_unit_1_iid", - "protein_cluster_30": "u1_cluster" - }), - on=["pdb_id", "assembly_id", "pn_unit_1_iid"], - how="left" -) - -df = df.merge( - protein_clusters.rename(columns={ - "pn_unit_iid": "pn_unit_2_iid", - "protein_cluster_30": "u2_cluster" - }), - on=["pdb_id", "assembly_id", "pn_unit_2_iid"], - how="left" -) -``` -```` +We use this column to split the data by cluster, instead of by individual row. This prevents the model from seeing near-identical protein pockets in both the training and evaluation sets. -Only the side of the interface that corresponds to the polymer will have a value for `protein_cluster_30`, the ligand will always have a `Null` value. Instead of having to check both `u2_cluster` and `u1_cluster` to determine which cluster our interface belongs to, let's put the information all in one column: +Only the side of the interface that corresponds to the polymer will have a value for `cluster` - the ligand side will always be `Null`. Instead of having to check both `pn_unit_1_cluster` and `pn_unit_2_cluster` to determine which cluster an interface belongs to, let's put that information in one column: ````{dropdown} Click to see the code. ```python df["protein_cluster"] = np.where( - df["u1_is_polymer"], - df["u1_cluster"], - df["u2_cluster"] + df["pn_unit_1_is_polymer"], + df["pn_unit_1_cluster"], + df["pn_unit_2_cluster"], ) ``` ```` -Check to see if there are any cases where no cluster was assigned. +Check to see if there are any cases where no cluster was assigned: ````{dropdown} Click to see the code. ```python # There are several ways to check, here we will just count the number of Null values: @@ -281,71 +282,105 @@ len(df[df["protein_cluster"].isna()]) ``` ```` -If any exist, we want to remove them from our dataset. They likely point to RNA/DNA chains, very short peptides, or low quality entries. +If any exist, we want to remove them from our dataset. They likely point to very short peptides or low-quality entries that didn't get clustered. ````{dropdown} Click to see the code. ```python df = df[df["protein_cluster"].notna()].reset_index(drop=True) ``` ```` -Before splitting the data up, let's shuffle the unique clusters. We use a seed of 42 for reproducibility. Use this seed if you want to exactly replicate what was produced in this segment of the tutorial. +Before splitting the data up, let's shuffle the unique clusters. We use a seed of 42 for reproducibility - use this seed if you want to exactly replicate what was produced in this segment of the tutorial. ````{dropdown} Click to see the code. ```python -unique_clusters = df["protein_cluster"].unique() +unique_clusters = df["protein_cluster"].drop_duplicates().to_numpy(copy=True) rng = np.random.default_rng(seed=42) rng.shuffle(unique_clusters) ``` ```` -Now we can finally spit the data into separate datasets and save them as parquet files for future use: +Now we can finally split the data into separate datasets and save them as parquet files for future use: ````{dropdown} Click to see the code. ```python n = len(unique_clusters) n_train = int(0.8 * n) -n_val = int(0.1 * n) +n_validation = int(0.1 * n) # test gets the remainder to avoid off-by-one gaps train_clusters = set(unique_clusters[:n_train]) -val_clusters = set(unique_clusters[n_train : n_train + n_val]) -test_clusters = set(unique_clusters[n_train + n_val :]) - -def assign_split(cluster): - if cluster in train_clusters: return "train" - if cluster in val_clusters: return "val" - if cluster in test_clusters: return "test" +validation_clusters = set(unique_clusters[n_train : n_train + n_validation]) +test_clusters = set(unique_clusters[n_train + n_validation :]) + +def assign_split(cluster: object) -> str: + if cluster in train_clusters: + return "train" + if cluster in validation_clusters: + return "validation" + if cluster in test_clusters: + return "test" return "unassigned" # rows where cluster was null df["split"] = df["protein_cluster"].map(assign_split) +``` +```` + +As a final sanity check, make sure that one protein cluster never shows up in more than one split - this is the key leakage check: +````{dropdown} Click to see the code. +```python +assert df.groupby("protein_cluster")["split"].nunique().eq(1).all() +``` +```` -df_train = df[df["split"] == "train"].reset_index(drop=True) -df_val = df[df["split"] == "val"].reset_index(drop=True) -df_test = df[df["split"] == "test"].reset_index(drop=True) +## Save the results + +Now we can save each split as its own parquet file for future use, alongside one file containing everything: +````{dropdown} Click to see the code. +```python +import os os.makedirs("splits", exist_ok=True) -df_train.to_parquet("splits/train.parquet", index=False) -df_val.to_parquet("splits/val.parquet", index=False) -df_test.to_parquet("splits/test.parquet", index=False) +df.to_parquet("splits/all.parquet", index=False) +for split in ("train", "validation", "test"): + df[df["split"] == split].reset_index(drop=True).to_parquet(f"splits/{split}.parquet", index=False) +``` +```` + +Run the complete script with the path to the interface metadata you downloaded earlier: +```bash +python docs/how_to_build_a_model/scripts/data_cleaning_script.py \ + data/pdb_metadata/shared/interfaces_df.parquet \ + --pdb-mirror "$PDB_MIRROR_PATH" \ + --output-dir splits +``` +The script prints the number of examples assigned to each split. + +(aw_build_model_p1_check)= +## Check the outputs + +````{dropdown} Click to see the code. +```python +train = pd.read_parquet("splits/train.parquet") +validation = pd.read_parquet("splits/validation.parquet") +test = pd.read_parquet("splits/test.parquet") + +assert set(train["protein_cluster"]).isdisjoint(validation["protein_cluster"]) +assert set(train["protein_cluster"]).isdisjoint(test["protein_cluster"]) +assert set(validation["protein_cluster"]).isdisjoint(test["protein_cluster"]) ``` ```` -You now have created the datasets you need to train, test, and validate the machine learning model you'll create as you continue to go through the **How to Build a Model Using AtomWorks** tutorial series. +You now have created the datasets you need to train, test, and validate the machine learning model you'll build as you continue through the **How to Build a Model with AtomWorks** tutorial series. -(aw_build_model_p1_next)= -## What Next? +## What Next? [How to Build a Model with AtomWorks Part 2](how_to_build_a_model_part2.md) -(aw_build_model_p1_glossary)= ## Glossary **Parquet:** a columnar binary file format for storing large tabular datasets efficiently. **PN unit:** AtomWorks' unit of "Polymer or Non-polymer"; a single chain, ligand, or other discrete molecular entity within a structure. -**Interface:** a pair of PN units in contact within a bio assembly, characterized by columns like `num_contacts` and `min_distance`. +**Interface:** a pair of PN units in contact within a bio assembly, characterized by columns like `num_contacts`. **LOI (Ligand of Interest):** the ligand being tracked for a given interface; used to identify protein-ligand interfaces in the dataset. -**Sequence identity cluster:** a group of protein chains that share more than a threshold percentage of sequence identity (e.g. 30%); used to split data so that similar proteins don't leak across train/val/test sets. - - - +**Sequence identity cluster:** a group of protein chains that share a threshold percentage of sequence identity; used to split data so that similar proteins don't leak across train/validation/test sets. diff --git a/docs/how_to_build_a_model/how_to_build_a_model_part2.md b/docs/how_to_build_a_model/how_to_build_a_model_part2.md index 7374781e..f35e3bbd 100644 --- a/docs/how_to_build_a_model/how_to_build_a_model_part2.md +++ b/docs/how_to_build_a_model/how_to_build_a_model_part2.md @@ -15,31 +15,36 @@ (aw_build_model_p2_intro)= ## Introduction -This is the second tutorial in the **How to Build a Model with AtomWorks** series. In [Part 1](how_to_build_a_model_part1.md) you cleaned the PDB metadata and saved `train`/`val`/`test` parquet splits. + +This is the second tutorial in the **How to Build a Model with AtomWorks** series. In [Part 1](how_to_build_a_model_part1.md) you cleaned the PDB metadata and saved `train`/`validation`/`test` parquet splits. **In this installment, you will learn how to transform each parquet file into tensors that can be used in a machine learning model.** -More specifically, you will learn how to: +More specifically, you will learn how to: + - create custom transforms - create a transform pipeline - utilize the `PandasDataset` class to go from parquets to trainable tensors -By the end of this part of the tutorial series you will have two python files: -- `transforms.py`: Definition of custom tensors +By the end of this part of the tutorial series you will have two python files: + +- `transforms.py`: Definition of custom transforms - `smoke_test.py`: Testing the pipeline by loading a single example ```{important} -This tutorial will walk you through the creation of these two scripts. +This tutorial will walk you through the creation of these two scripts. -For those who want to use the tutorial text as structure and hints to write your own code, the solutions are hidden in collapsible cells. The new code in cells with repeated code is highlighted. +For those who want to use the tutorial text as structure and hints to write your own code, the solutions are hidden in collapsible cells. The new code in cells with repeated code is highlighted. -If you would like to see the full scripts, they are provided in the [tutorial files](./scripts/index.rst). +If you would like to see the full scripts, they are provided in the [tutorial files](./scripts/index.rst). ``` (aw_build_model_p2_prereq)= ## Prerequisites + Before starting this part it is assumed that you have: -- Completed [Part 1](how_to_build_a_model_part1.md) and saved `splits/train.parquet`, `splits/val.parquet`, and `splits/test.parquet`. + +- Completed [Part 1](how_to_build_a_model_part1.md) and saved `splits/train.parquet`, `splits/validation.parquet`, and `splits/test.parquet`. - A working installation of AtomWorks, including the `ml` side. - A (partial) PDB mirror set up so the loader can find the structure file for each row (see [Data Mirrors](../mirrors.rst)). - Familiarity with `biotite.structure.AtomArray`, [NumPy](https://numpy.org/doc/stable/index.html), and [SciPy](https://scipy.org/). @@ -50,14 +55,15 @@ The transforms you write here operate on a `biotite` `AtomArray`. If you have no (aw_build_model_p2_goal)= ## The Goal of the Pipeline + Our task is pose generation: given a protein pocket and a small-molecule ligand (from the parquet files we generated in [Part 1](how_to_build_a_model_part1.md)), predict plausible bound cartesian coordinates for every ligand atom. To get there, each raw structure needs to be reduced to just the binding pocket and converted into tensors. -We will create a transform pipeline that can take protein/ligand pairs from the parquet files we created in [Part 1](how_to_build_a_model_part1.md), reduce the information to just the binding pocket, and convert them into tensors that can be used in the training of our machine learning model. +We will create a transform pipeline that can take protein/ligand pairs from the parquet files we created in [Part 1](how_to_build_a_model_part1.md), reduce the information to just the binding pocket, and convert them into tensors that can be used in the training of our machine learning model. We will apply four transforms in order, two that already exist in AtomWorks and two that we will write ourselves: - **`RemoveHydrogens`** *(exists already)*: drops hydrogen atoms. -- **`RemoveUnresolvedAtoms`** *(exists already)*: drops atoms with no resolved coordinates. +- **`RemoveUnresolvedAtoms`** *(exists already)*: drops atoms with occupancy below the default threshold of 0.5. - **`CropToPocket`** *(new)*: spatially crops the structure to the atoms within a radius of the ligand. - **`FeaturizeForDocking`** *(new)*: converts the `AtomArray` into the tensors our model needs. @@ -65,36 +71,38 @@ A fifth transform, `ConvertToTorch`, converts the NumPy features into `torch` te (aw_build_model_p2_wire)= ## Wiring Up the Dataset and Loader + Before writing any transforms, let's confirm we can load a single example. AtomWorks provides two pieces we need: - {py:class}`~atomworks.ml.datasets.PandasDataset` wraps a parquet/DataFrame and applies a loader and transform to each row. -- {py:func}`~atomworks.ml.datasets.loaders.create_loader_with_query_pn_units` returns a picklable loader that parses the CIF file for each row and attaches the *query* PN unit IIDs to the example, so we know which chain(s) the interface of interest involves. +- {py:func}`~atomworks.ml.datasets.loaders.create_structure_loader` returns a picklable loader that parses the CIF file for each row and maps metadata columns to example keys, so we know which PN units the interface of interest involves. -It is worth reading the documentation for both (linked above) before using them. You can search the API docs, or use `help()` at a Python prompt. There is also more detail on `PandasDataset` in the {ref}`sphx_glr_auto_examples_dataset_exploration.py` example. +Read the documentation for both (linked above) before using them. You can search the API docs, or use `help()` at a Python prompt. There is also more detail on `PandasDataset` in the {ref}`sphx_glr_auto_examples_dataset_exploration.py` example. ````{dropdown} Click to see how to inspect the documentation in a Python prompt ```python from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader help(PandasDataset) -help(create_loader_with_query_pn_units) +help(create_structure_loader) ``` ```` Let's create our PandasDataset. We need to supply it with + - `data`: let's use train.parquet - `name`: however you want to label this structure, in the tutorial we'll call it `docking_train` - `id_column`: The column that has the unique identifier for each of piece of data, `example_id` -- `loader`: we will use `create_loader_with_query_pn_units` here +- `loader`: we will use `create_structure_loader` here -As a quick check that everything fits together, write a script (`smoke_test.py`) that reads `train.parquet`, builds a `PandasDataset` from it, and uses `create_loader_with_query_pn_units` as the loader. +As a quick check that everything fits together, write a script (`smoke_test.py`) that reads `train.parquet`, builds a `PandasDataset` from it, and uses `create_structure_loader` as the loader. ````{dropdown} Click to see the code ```python import pandas as pd from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader df_train = pd.read_parquet("splits/train.parquet") @@ -102,15 +110,19 @@ dataset = PandasDataset( data=df_train, name="docking_train", id_column="example_id", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader=create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, ), ) ``` -Note that loader is told which columns hold the query PN unit IIDs. +The loader is told which columns hold the query PN-unit IIDs and polymer flags. It also receives `altloc_seed` to reproduce the alternate-location selection used by the metadata. ```` -Now load one example and inspect it. This tells us what keys the loader attaches and confirms the parquet, the loader, and your PDB mirror are all talking to each other. +Now load one example and inspect it. This tells us what keys the loader attaches and confirms the parquet, the loader, and your PDB mirror are all talking to each other. ````{dropdown} Click to see the code ```python @@ -130,14 +142,13 @@ else: ``` ```` -You should see that the example `type` is ``. +You should see that the example `type` is ``. -Among the keys you should see `atom_array`, `query_pn_unit_iids`, and `chain_info`. These are the pieces of information that At -omWorks has pulled from the parquet files that we will use in our transforms pipeline: +Among the keys you should see `atom_array`, `query_pn_unit_iids`, and `query_is_polymer`. These are the pieces of information that AtomWorks has pulled from the parquet files that we will use in our transforms pipeline: - **`atom_array`** is the {py:class}`~biotite.structure.AtomArray` for this structure. Every atom carries a `pn_unit_iid` annotation (`atom_array.pn_unit_iid`), tagging it with the PN unit it belongs to, using the same string format as `query_pn_unit_iids` below. -- **`query_pn_unit_iids`** is a list of PN unit IID strings, one per column named in `pn_unit_iid_colnames`. Each IID has the form `"{chain_id}_{transformation_id}"` (e.g. `"A_1"`), or a comma-joined list of such tokens for a covalently-linked, multi-chain unit (e.g. `"G_1,R_1"`). Since we asked for two columns (`pn_unit_1_iid` and `pn_unit_2_iid`), this list has two entries: the two sides of the interface we are studying. -- **`chain_info`** is a dictionary keyed by `chain_id` (e.g. `"A"`, note this is just the chain id, not the full PN unit IID), where each value is a dict of per-chain metadata parsed from the CIF file, including `is_polymer` (bool), `chain_type`, and the chain's sequence. `CropToPocket` will use `is_polymer` to work out which side of the interface is the ligand. +- **`query_pn_unit_iids`** is a list of PN unit IID strings, one per column mapped to `query_pn_unit_iids`. Each IID has the form `"{chain_id}_{transformation_id}"` (e.g. `"A_1"`), or a comma-joined list of such tokens for a covalently-linked, multi-chain unit (e.g. `"G_1,R_1"`). Since we asked for two columns (`pn_unit_1_iid` and `pn_unit_2_iid`), this list has two entries: the two sides of the interface we are studying. +- **`query_is_polymer`** is a list of polymer flags, in the same order as `query_pn_unit_iids`. Part 1 keeps exactly one polymer side, so `CropToPocket` can use these flags to work out which side is the protein and which is the ligand. ```{tip} The loader also attaches other keys to `example`, such as `example_id`, `metadata`, and `ligand_info`, however the three listed above are the ones this tutorial's transforms rely on. @@ -145,6 +156,7 @@ The loader also attaches other keys to `example`, such as `example_id`, `metadat (aw_build_model_p2_compose)= ## Building the Transform Pipeline + We can start creating our pipeline by adding to the code in our `smoke_test.py` file. We will use the {py:class}`~atomworks.ml.transforms.base.Compose` class to store the transforms, in an object we'll call `pipe`, in the order we want them to be applied and then add that object to our `dataset`. Let's start with the two transforms that are native to AtomWorks, {py:class}`~atomworks.ml.transforms.filters.RemoveHydrogens` and {py:class}`~atomworks.ml.transforms.filters.RemoveUnresolvedAtoms`: @@ -152,11 +164,11 @@ Let's start with the two transforms that are native to AtomWorks, {py:class}`~at ````{dropdown} Click to see the code ```{code-block} python -:emphasize-lines: 4-5,9-12,21 +:emphasize-lines: 4-5,9-12,25 import pandas as pd from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms from atomworks.ml.transforms.base import Compose @@ -171,8 +183,12 @@ dataset = PandasDataset( data=df_train, name="docking_train", id_column="example_id", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader=create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, ), transform=pipe, ) @@ -184,23 +200,28 @@ dataset = PandasDataset( Now we need the two transforms that do not yet exist: `CropToPocket` and `FeaturizeForDocking`. The {ref}`sphx_glr_auto_examples_pocket_conditioning_transform.py` example is a great place to start. ```{tip} -Write your transforms in a separate `transforms.py` file. Each transform is a class whose `forward()` method calls a standalone function of the same (snake_case) name. Keeping the logic in a standalone function makes it easy to test and reuse outside the transform machinery. +Write your transforms in a separate `transforms.py` file. Each transform is a class whose `forward()` method calls a standalone function of the same name in snake_case. Keeping the logic in a standalone function makes it easy to test and reuse outside the transform machinery. ``` (aw_build_model_p2_crop)= #### `CropToPocket` -The `CropToPocket` transform will take the protein-lingand interfaces from our dataset and crop them to just the pocket. We will build `CropToPocket` up incrementally, confirming it plugs into `Compose` at each stage. + +The `CropToPocket` transform will take the protein-ligand interfaces from our dataset and crop them to just the pocket. We will build `CropToPocket` up incrementally, confirming it plugs into `Compose` at each stage. ##### Start with a Shell + Begin with a class that has an `__init__(radius=10.0)` and a `forward()` that just returns the input `data` unchanged. Confirm it plugs into `Compose()`. ````{dropdown} Click to see the code In `transforms.py`: ```python +from typing import ClassVar + +from atomworks.ml.transforms._checks import check_atom_array_annotation, check_contains_keys from atomworks.ml.transforms.base import Transform class CropToPocket(Transform): - def __init__(self, radius: float = 10.0): + def __init__(self, radius: float = 10.0) -> None: super().__init__() self.radius = radius @@ -222,36 +243,36 @@ pipe = Compose([ ``` ```` ##### Input Validation -We need to add input validation to our class, add a `check_input()` method. In `check_input()`, assert that `data` contains the three keys the transform needs: `atom_array`, `query_pn_unit_iids`, and `chain_info`. + +We need to add input validation to our class, add a `check_input()` method. In `check_input()`, check that `data` contains `atom_array`, `query_pn_unit_iids`, and `query_is_polymer`, and that the atom array has a `pn_unit_iid` annotation. ````{dropdown} Click to see the code In the `CropToPocket` class: ```python def check_input(self, data: dict) -> None: - assert "atom_array" in data, "Missing atom_array" - assert "query_pn_unit_iids" in data, "Missing query_pn_unit_iids" - assert "chain_info" in data, "Missing chain_info" + check_contains_keys(data, ["atom_array", "query_pn_unit_iids", "query_is_polymer"]) + check_atom_array_annotation(data, ["pn_unit_iid"]) ``` ```` ##### Write the `crop_to_pocket` Function -Add a standalone `crop_to_pocket()` function that `forward()` calls. It should take `atom_array`, `query_pn_unit_iids`, `chain_info`, and `radius` as inputs. For now, just copy the `AtomArray` and return it. + +Add a standalone `crop_to_pocket()` function that `forward()` calls. It should take `atom_array`, `query_pn_unit_iids`, `query_is_polymer`, and `radius` as inputs. For now, just return the `AtomArray`. ````{dropdown} Click to see the code In `transforms.py`: ```{code-block} python -:emphasize-lines: 4-11,15-22 +:emphasize-lines: 4-10,14-21 import numpy as np from biotite.structure import AtomArray def crop_to_pocket( atom_array: AtomArray, - query_pn_unit_iids: list, - chain_info: dict, + query_pn_unit_iids: list[str], + query_is_polymer: list[bool], radius: float = 10.0, ) -> AtomArray: - atom_array = atom_array.copy() return atom_array class CropToPocket(Transform): @@ -260,46 +281,35 @@ class CropToPocket(Transform): data["atom_array"] = crop_to_pocket( data["atom_array"], query_pn_unit_iids=data["query_pn_unit_iids"], - chain_info=data["chain_info"], + query_is_polymer=data["query_is_polymer"], radius=self.radius, ) return data ``` ```` -Each interface has two query PN units, a ligand and a protein (polymer). Add to the `crop_to_pocket` function to use `chain_info` to correctly assign the `iid`s to `ligand_iid` and `protien_iid`. If exactly one side is a polymer, the non-polymer side is the ligand. If the flags are ambiguous, fall back to a heuristic: the side with fewer atoms is treated as the ligand. +Each interface has two query PN units, a ligand and a protein (polymer). Add to the `crop_to_pocket` function to use `query_is_polymer` to assign the IIDs to `ligand_iid` and `protein_iid`. Exactly one side must be a polymer; raise an error if the flags are ambiguous. ````{dropdown} Click to see the code ```{code-block} python -:emphasize-lines: 9-31 +:emphasize-lines: 8-17 def crop_to_pocket( atom_array: AtomArray, - query_pn_unit_iids: list, - chain_info: dict, + query_pn_unit_iids: list[str], + query_is_polymer: list[bool], radius: float = 10.0, ) -> AtomArray: - atom_array = atom_array.copy() - - iid_a, iid_b = query_pn_unit_iids - chain_a = iid_a.split("_")[0] - chain_b = iid_b.split("_")[0] - - # chain_info is a dictionary of metadata about the chains - a_is_polymer = chain_info.get(chain_a, {}).get("is_polymer", True) - b_is_polymer = chain_info.get(chain_b, {}).get("is_polymer", True) - - if not a_is_polymer: - ligand_iid, protein_iid = iid_a, iid_b - elif not b_is_polymer: - ligand_iid, protein_iid = iid_b, iid_a - else: - mask_a = atom_array.pn_unit_iid == iid_a - mask_b = atom_array.pn_unit_iid == iid_b - if mask_a.sum() <= mask_b.sum(): - ligand_iid, protein_iid = iid_a, iid_b - else: - ligand_iid, protein_iid = iid_b, iid_a + """Keep the complete ligand and protein atoms within ``radius`` angstroms of it.""" + if radius <= 0: + raise ValueError(f"Pocket radius must be positive, got {radius}") + if len(query_pn_unit_iids) != 2 or len(query_is_polymer) != 2 or sum(query_is_polymer) != 1: + raise ValueError("Expected two query PN units with exactly one polymer") + + protein_side = 0 if query_is_polymer[0] else 1 + ligand_side = 1 - protein_side + ligand_iid = query_pn_unit_iids[ligand_side] + protein_iid = query_pn_unit_iids[protein_side] print("ligand_iid:", ligand_iid) print("protein_iid:", protein_iid) @@ -326,6 +336,7 @@ Create boolean masks to make sure that the PN unit actually contains atoms whose ```` ##### Add the Spatial Crop with a KD-tree + Now for the actual crop. We keep every ligand atom plus every protein atom within a given `radius` (in angstroms) of any ligand atom. ```{note} @@ -334,40 +345,29 @@ A **KD-tree** (k-dimensional tree) is a data structure that partitions points in ````{dropdown} Click to see the code ```{code-block} python -:emphasize-lines: 1,30-55 +:emphasize-lines: 1,30-39 from scipy.spatial import cKDTree def crop_to_pocket( atom_array: AtomArray, - query_pn_unit_iids: list, - chain_info: dict, + query_pn_unit_iids: list[str], + query_is_polymer: list[bool], radius: float = 10.0, ) -> AtomArray: - atom_array = atom_array.copy() - - iid_a, iid_b = query_pn_unit_iids - chain_a = iid_a.split("_")[0] - chain_b = iid_b.split("_")[0] - - a_is_polymer = chain_info.get(chain_a, {}).get("is_polymer", True) - b_is_polymer = chain_info.get(chain_b, {}).get("is_polymer", True) - - if not a_is_polymer: - ligand_iid, protein_iid = iid_a, iid_b - elif not b_is_polymer: - ligand_iid, protein_iid = iid_b, iid_a - else: - mask_a = atom_array.pn_unit_iid == iid_a - mask_b = atom_array.pn_unit_iid == iid_b - if mask_a.sum() <= mask_b.sum(): - ligand_iid, protein_iid = iid_a, iid_b - else: - ligand_iid, protein_iid = iid_b, iid_a + """Keep the complete ligand and protein atoms within ``radius`` angstroms of it.""" + if radius <= 0: + raise ValueError(f"Pocket radius must be positive, got {radius}") + if len(query_pn_unit_iids) != 2 or len(query_is_polymer) != 2 or sum(query_is_polymer) != 1: + raise ValueError("Expected two query PN units with exactly one polymer") + + protein_side = 0 if query_is_polymer[0] else 1 + ligand_side = 1 - protein_side + ligand_iid = query_pn_unit_iids[ligand_side] + protein_iid = query_pn_unit_iids[protein_side] ligand_mask = atom_array.pn_unit_iid == ligand_iid protein_mask = atom_array.pn_unit_iid == protein_iid - ligand_coords = atom_array.coord[ligand_mask] protein_coords = atom_array.coord[protein_mask] @@ -376,26 +376,22 @@ def crop_to_pocket( if len(protein_coords) == 0: raise ValueError(f"Protein {protein_iid} has no atoms") - tree = cKDTree(protein_coords) - neighbor_indices = tree.query_ball_point(ligand_coords, r=radius) - - total_neighbors = sum(len(n) for n in neighbor_indices) - if total_neighbors == 0: - raise ValueError(f"No protein atoms found within {radius}A of ligand {ligand_iid}") + neighbors = cKDTree(protein_coords).query_ball_point(ligand_coords, r=radius) + if not any(neighbors): + raise ValueError(f"No protein atoms found within {radius} Å of ligand {ligand_iid}") - pocket_local_indices = np.unique(np.concatenate(neighbor_indices).astype(int)) - protein_global_indices = np.where(protein_mask)[0] - pocket_global_indices = protein_global_indices[pocket_local_indices] - ligand_global_indices = np.where(ligand_mask)[0] + pocket_local_indices = np.unique(np.concatenate(neighbors).astype(int)) + protein_global_indices = np.flatnonzero(protein_mask) + ligand_global_indices = np.flatnonzero(ligand_mask) + keep = np.sort(np.concatenate([protein_global_indices[pocket_local_indices], ligand_global_indices])) - keep = np.sort(np.concatenate([pocket_global_indices, ligand_global_indices])) cropped = atom_array[keep] return cropped ``` The `cKDTree` is built from the protein coordinates; `query_ball_point` returns indices *local* to `protein_coords`, so we map them back to global indices via `protein_global_indices` before slicing the `AtomArray`. ```` -Confirm the crop runs in your test: +Confirm the crop runs in your test. The crop keeps individual nearby protein atoms; it does not expand the selection to whole residues: ````{dropdown} Click to see the code ```python @@ -407,22 +403,21 @@ print("Cropped atom count:", len(cropped)) ```` ##### Add the `is_ligand` annotation + The featurizer (and later analysis) needs to know which cropped atoms are ligand atoms. Annotate the cropped `AtomArray` with a boolean `is_ligand` array. ```{important} -This annotation is easy to forget: it is computed from `keep` and `ligand_global_indices`, but you must actually attach it to the array with `set_annotation`. If you skip this line, `FeaturizeForDocking` will fail because `atom_array.is_ligand` will not exist. +This annotation is easy to forget: it is computed from `ligand_mask[keep]`, but you must actually attach it to the array with `set_annotation`. If you skip this line, `FeaturizeForDocking` will fail because `atom_array.is_ligand` will not exist. ``` ````{dropdown} Click to see the code -Add these two lines just before returning, so the end of `crop_to_pocket` reads: +Add the annotation just before returning, so the end of `crop_to_pocket` reads: ```{code-block} python -:emphasize-lines: 4-5 +:emphasize-lines: 3 - keep = np.sort(np.concatenate([pocket_global_indices, ligand_global_indices])) + keep = np.sort(np.concatenate([protein_global_indices[pocket_local_indices], ligand_global_indices])) cropped = atom_array[keep] - - is_ligand = np.isin(keep, ligand_global_indices) - cropped.set_annotation("is_ligand", is_ligand) + cropped.set_annotation("is_ligand", ligand_mask[keep]) return cropped ``` ```` @@ -447,6 +442,7 @@ print("CropToPocket sanity checks passed.") ```` ##### Declare which transforms must run first + `CropToPocket` assumes hydrogens and unresolved atoms are already gone. Declare this ordering requirement so AtomWorks can enforce it. ````{dropdown} Click to see the code @@ -454,18 +450,18 @@ print("CropToPocket sanity checks passed.") :emphasize-lines: 2 class CropToPocket(Transform): - requires_previous_transforms = ["RemoveHydrogens", "RemoveUnresolvedAtoms"] + requires_previous_transforms: ClassVar[list[str]] = ["RemoveHydrogens", "RemoveUnresolvedAtoms"] ... ``` ```` At this point you can assemble a full `smoke_test.py`: -````{dropdown} Click to see the full Pyhton file: +````{dropdown} Click to see the full Python file: ```python import pandas as pd from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms from atomworks.ml.transforms.base import Compose from transforms import CropToPocket @@ -482,8 +478,12 @@ dataset = PandasDataset( data=df_train, name="docking_train", id_column="example_id", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader=create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, ), transform=pipe, ) @@ -507,9 +507,11 @@ print("\nCropToPocket smoke test passed.") (aw_build_model_p2_featurize)= #### `FeaturizeForDocking` + We follow the same incremental procedure. `FeaturizeForDocking` reads the cropped `AtomArray` and returns a dictionary of tensors that we merge into the example. ##### Start with a Shell + ````{dropdown} Click to see the code In `transforms.py`: ```python @@ -531,30 +533,38 @@ pipe = Compose([ ```` ##### Declare the dependency on `CropToPocket` + `FeaturizeForDocking` depends on the `is_ligand` annotation created by `CropToPocket`, so declare it as a required previous transform and validate the annotation in `check_input()`. ````{dropdown} Click to see the code ```python -from atomworks.ml.transforms._checks import check_atom_array_annotation +from typing import ClassVar + +from atomworks.ml.transforms._checks import check_atom_array_annotation, check_contains_keys class FeaturizeForDocking(Transform): - requires_previous_transforms = ["CropToPocket"] + """Add atom, bond, and coordinate arrays for the docking model.""" + + requires_previous_transforms: ClassVar[list[str]] = ["CropToPocket"] def check_input(self, data: dict) -> None: + check_contains_keys(data, ["atom_array"]) check_atom_array_annotation(data, ["is_ligand"]) + ``` ```` ##### Write the `featurize_for_docking` Function -Instead of starting with a copy of the `AtomArray`, start with just returning an empty dictionary. -Also update the `forward()` function. Have it update the `data` dictionary with the dictionary features the `featureize_for_docking` function will eventually create. +Start the function by returning an empty dictionary. + +Also update the `forward()` function. Have it update the `data` dictionary with the dictionary features the `featurize_for_docking` function will eventually create. ````{dropdown} Click to see the code ```{code-block} python :emphasize-lines: 1-2,7-8 -def featurize_for_docking(atom_array: AtomArray) -> dict: +def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]: return {} class FeaturizeForDocking(Transform): @@ -567,13 +577,14 @@ class FeaturizeForDocking(Transform): ```` ##### Add the `is_ligand` Mask and Target Coordinates + `target_coords` are the ground-truth coordinates the model must reproduce. ````{dropdown} Click to see the code ```{code-block} python :emphasize-lines: 2-6 -def featurize_for_docking(atom_array: AtomArray) -> dict: +def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]: is_ligand = atom_array.is_ligand.astype(bool) target_coords = atom_array.coord.astype(np.float32) return { @@ -597,13 +608,14 @@ assert example["target_coords"].shape[1] == 3 ```` ##### Add `input_coords` with the ligand zeroed out + This is the heart of the task. We keep the real pocket coordinates but zero out the ligand coordinates: the model must learn to place the ligand. ````{dropdown} Click to see the code ```{code-block} python :emphasize-lines: 5-6,11 -def featurize_for_docking(atom_array: AtomArray) -> dict: +def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]: is_ligand = atom_array.is_ligand.astype(bool) target_coords = atom_array.coord.astype(np.float32) @@ -619,6 +631,7 @@ def featurize_for_docking(atom_array: AtomArray) -> dict: ```` ##### Add `atomic_numbers` + Encode each atom's element as an integer atomic number using the AtomWorks lookup table. These integers will feed an embedding layer in the model. ````{dropdown} Click to see the code @@ -627,7 +640,7 @@ Encode each atom's element as an integer atomic number using the AtomWorks looku from atomworks.constants import ELEMENT_NAME_TO_ATOMIC_NUMBER ... -def featurize_for_docking(atom_array: AtomArray) -> dict: +def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]: is_ligand = atom_array.is_ligand.astype(bool) target_coords = atom_array.coord.astype(np.float32) @@ -649,41 +662,41 @@ def featurize_for_docking(atom_array: AtomArray) -> dict: Elements not found in the lookup table map to `0`, which acts as an "unknown atom" index in the embedding table. ```` -##### Add Bonds Via an `edge_index` +##### Add Bonds Via an `edge_index` + `edge_index` is the bond graph in COO (coordinate) format: a `[2, ]` integer array where each column is a bonded pair of atoms. ````{dropdown} Click to see the code ```{code-block} python -:emphasize-lines: 13-14,20 +:emphasize-lines: 12-14,20 -def featurize_for_docking(atom_array: AtomArray) -> dict: +def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]: + """Build atom, bond, and coordinate arrays from a cropped pocket.""" is_ligand = atom_array.is_ligand.astype(bool) target_coords = atom_array.coord.astype(np.float32) - input_coords = target_coords.copy() input_coords[is_ligand] = 0.0 atomic_numbers = np.array( - [ELEMENT_NAME_TO_ATOMIC_NUMBER.get(e.upper(), 0) for e in atom_array.element], + [ELEMENT_NAME_TO_ATOMIC_NUMBER.get(element.upper(), 0) for element in atom_array.element], dtype=np.int64, ) - - bonds = atom_array.bonds.as_array() - # we only need the first two columns, the last one is the number of bonds - edge_index = bonds[:, :2].T.astype(np.int64) + if atom_array.bonds is None: + raise ValueError("Cropped atom array has no bond list") + edge_index = atom_array.bonds.as_array()[:, :2].T.astype(np.int64) return { "atomic_numbers": atomic_numbers, - "input_coords": input_coords, + "is_ligand": is_ligand, "target_coords": target_coords, "edge_index": edge_index, - "is_ligand": is_ligand, + "input_coords": input_coords, } ``` `atom_array.bonds.as_array()` returns an array whose first two columns are the indices of the bonded atoms; we take those two columns and transpose to get the `[2, E]` shape. ```` -Check the edge graph in your smoke test: +An empty bond list produces an `edge_index` with shape `[2, 0]`. Check the edge graph in your smoke test: ````{dropdown} Click to see the code ```python @@ -692,80 +705,106 @@ print("edge_index shape:", example["edge_index"].shape) print("edge_index dtype:", example["edge_index"].dtype) assert example["edge_index"].ndim == 2 assert example["edge_index"].shape[0] == 2 -assert example["edge_index"].max() < example["atomic_numbers"].shape[0] +assert not example["edge_index"].size or example["edge_index"].max() < example["atomic_numbers"].shape[0] ``` ```` ### The final `FeaturizeForDocking` class + ````{dropdown} Click to see the code ```python class FeaturizeForDocking(Transform): - requires_previous_transforms = ["CropToPocket"] + """Add atom, bond, and coordinate arrays for the docking model.""" + + requires_previous_transforms: ClassVar[list[str]] = ["CropToPocket"] def check_input(self, data: dict) -> None: + check_contains_keys(data, ["atom_array"]) check_atom_array_annotation(data, ["is_ligand"]) def forward(self, data: dict) -> dict: - features = featurize_for_docking(data["atom_array"]) - data.update(features) + data.update(featurize_for_docking(data["atom_array"])) return data ``` ```` (aw_build_model_p2_smoke)= ## The Complete Smoke Test -Your final `smoke_test.py` should build the full pipeline and validate every tensor: + +Your final `smoke_test.py` should build the full pipeline and validate every array. Run it from the repository root with `python docs/how_to_build_a_model/scripts/smoke_test.py splits/train.parquet`. Use `--index` to select a row whose structure exists in a partial mirror: ````{dropdown} Click to see the complete file ```python +"""Load one Part 1 example through the Part 2 transform pipeline.""" + +import argparse +from pathlib import Path + +import numpy as np import pandas as pd +from transforms import CropToPocket, FeaturizeForDocking + from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units -from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.transforms.base import Compose -from transforms import CropToPocket, FeaturizeForDocking +from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms -df_train = pd.read_parquet("splits/train.parquet") -pipe = Compose([ - RemoveHydrogens(), - RemoveUnresolvedAtoms(), - CropToPocket(radius=10.0), - FeaturizeForDocking(), -]) +def build_dataset(split_path: Path) -> PandasDataset: + """Build the tutorial dataset from a Part 1 Parquet split.""" + pipeline = Compose( + [ + RemoveHydrogens(), + RemoveUnresolvedAtoms(), + CropToPocket(radius=10.0), + FeaturizeForDocking(), + ] + ) + return PandasDataset( + data=pd.read_parquet(split_path), + name="docking_train", + id_column="example_id", + loader=create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, + ), + transform=pipeline, + ) -dataset = PandasDataset( - data=df_train, - name="docking_train", - id_column="example_id", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] - ), - transform=pipe, -) -print(f"Dataset size: {len(dataset)}") -example = dataset[0] -print("\nLoaded one sample successfully.") -print("Sample keys:", list(example.keys())) -print("atomic_numbers:", example["atomic_numbers"].shape, example["atomic_numbers"].dtype) -print("input_coords:", example["input_coords"].shape, example["input_coords"].dtype) -print("target_coords:", example["target_coords"].shape, example["target_coords"].dtype) -print("edge_index:", example["edge_index"].shape, example["edge_index"].dtype) -print("is_ligand:", example["is_ligand"].shape, example["is_ligand"].dtype) +def main() -> None: + """Print shapes and check the basic feature contract for one example.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("split", type=Path, nargs="?", default=Path("splits/train.parquet")) + parser.add_argument("--index", type=int, default=0, help="Row to load from the split.") + args = parser.parse_args() + + dataset = build_dataset(args.split) + example = dataset[args.index] + atom_count = len(example["atomic_numbers"]) + assert example["target_coords"].shape == (atom_count, 3) + assert example["input_coords"].shape == (atom_count, 3) + assert example["is_ligand"].shape == (atom_count,) + assert example["edge_index"].shape[0] == 2 + assert not example["edge_index"].size or example["edge_index"].max() < atom_count + assert np.all(example["input_coords"][example["is_ligand"]] == 0) + assert np.array_equal( + example["input_coords"][~example["is_ligand"]], + example["target_coords"][~example["is_ligand"]], + ) + assert example["is_ligand"].any() and (~example["is_ligand"]).any() -assert example["atomic_numbers"].ndim == 1 -assert example["target_coords"].ndim == 2 -assert example["target_coords"].shape[1] == 3 -assert example["input_coords"].shape == example["target_coords"].shape -assert example["edge_index"].ndim == 2 -assert example["edge_index"].shape[0] == 2 -assert example["edge_index"].max() < example["atomic_numbers"].shape[0] -assert (example["input_coords"][example["is_ligand"]] == 0).all(), \ - "Ligand coordinates should be zeroed" -assert (example["input_coords"][~example["is_ligand"]] != 0).any(), \ - "Pocket coordinates should not all be zero" -print("\nFeaturizeForDocking smoke test passed.") + print(f"Loaded {example['example_id']} from {len(dataset)} examples") + for name in ("atomic_numbers", "input_coords", "target_coords", "edge_index", "is_ligand"): + value = example[name] + print(f"{name}: shape={value.shape}, dtype={value.dtype}") + + +if __name__ == "__main__": + main() ``` ```` @@ -773,6 +812,7 @@ When this passes, every example your dataset yields carries five arrays: `atomic (aw_build_model_p2_next)= ## What Next? + With a working transform pipeline, you are ready to write the model that consumes these tensors. Continue to [Part 3: Write a Model](how_to_build_a_model_part3.md). (aw_build_model_p2_glossary)= diff --git a/docs/how_to_build_a_model/how_to_build_a_model_part3.md b/docs/how_to_build_a_model/how_to_build_a_model_part3.md index 0fb90da0..6355b04f 100644 --- a/docs/how_to_build_a_model/how_to_build_a_model_part3.md +++ b/docs/how_to_build_a_model/how_to_build_a_model_part3.md @@ -21,9 +21,9 @@ ## Introduction This is the third tutorial in the **How to Build a Model Using AtomWorks** series. So far you have: -- Parquet files for your `train`/`val`/`test` splits ([Part 1](how_to_build_a_model_part1.md)). -- A loader that turns each parquet row into a protein-ligand structure ([Part 2](how_to_build_a_model_part2.md#wiring-up-the-dataset-and-loader)). -- A transform pipeline that crops the pocket and converts it into tensors ([Part 2](how_to_build_a_model_part2.md#building-the-transform-pipeline)). +- Parquet files for your `train`/`validation`/`test` splits ([Part 1](how_to_build_a_model_part1.md)). +- A loader that turns each parquet row into a protein-ligand structure ([Part 2](how_to_build_a_model_part2.md)). +- A transform pipeline that crops the pocket and produces model features ([Part 2](how_to_build_a_model_part2.md)). **In this installment, you will write the neural network that consumes those tensors and predicts 3D coordinates for every atom.** @@ -46,7 +46,7 @@ Before starting this part of the tutorial series it is assumed that you have: Before writing any code, we have to decide what architecture to use. For this tutorial we use a simple message-passing **graph neural network (GNN)**. A GNN is a natural choice because our data is already a graph: the bond graph is stored in `edge_index`, and each atom is a node with an atomic-number feature. ```{note} -We use a GNN as an example model architecture. This tutorial focuses on using AtomWorks to build and train a model, rather than on the details of how GNNs work. +We use a GNN as an example model architecture. Bond edges connect atoms within the protein and ligand, but do not connect the two. A pooled pocket-coordinate feature therefore gives ligand atoms a simple summary of protein context. This tutorial focuses on the AtomWorks pipeline rather than docking-model design. ``` ```{warning} @@ -63,13 +63,13 @@ To keep the example small and trainable on a single GPU, we use: - **3 GNN layers**: enough to propagate information a few hops through the bond graph. - **128 hidden dimensions**: small enough to train on a single GPU. -- **Mean squared error (MSE)** between predicted and target coordinates. +- **Mean squared error (MSE)** between predicted and target ligand coordinates. (aw_build_model_p3_goal)= ## The Goal of `model.py` We will write the model in `model.py`. It needs to: -- Take atom types, coordinates, and bond edges as input. +- Take atom types, coordinates, bond edges, and the ligand mask as input. - Pass information along the atom graph. - Predict 3D coordinates for each atom. - Expose train/val/test steps through a `LightningModule`. @@ -78,7 +78,7 @@ We will write the model in `model.py`. It needs to: ## Imports and Class Definition Import PyTorch, Lightning, and the basic building blocks. [`torch`](https://pytorch.org/) provides tensors and tensor ops; [`torch.nn`](https://docs.pytorch.org/docs/2.13/nn.html) provides layers like `Linear`, `Embedding`, and `LayerNorm`; [`pytorch_lightning`](https://lightning.ai/docs/pytorch/stable/index) provides `LightningModule`, which packages the model, loss, logging, and optimizer setup into one class. -Lets start off `model.py` by importing these modules and begining our `PocketDockGNN` class that inherits from `Lightning Module`: +Let us start `model.py` by importing these modules and beginning our `PocketDockGNN` class that inherits from `LightningModule`: ````{dropdown} Click to see the code ```python import torch @@ -102,7 +102,7 @@ Write `__init__` and save the hyperparameters so Lightning can restore them from hidden_dim: int = 128, num_layers: int = 3, learning_rate: float = 1e-3, - ): + ) -> None: super().__init__() self.save_hyperparameters() self.learning_rate = learning_rate @@ -114,7 +114,7 @@ These arguments mean: - **`num_atom_types=119`** - one embedding-table entry for each atomic number from 0 to 118. (We are using 0 to denote an unknown atom type.) - **`hidden_dim`** - the width of the learned per-atom representation. - **`num_layers`** - how many rounds of message passing to run. -- **`learning_rate`** - step size for the [Adam optimizer](https://www.geeksforgeeks.org/deep-learning/adam-optimizer/). +- **`learning_rate`** - step size for the [Adam optimizer](https://docs.pytorch.org/docs/stable/generated/torch.optim.Adam.html). (aw_build_model_p3_layers)= ## Building the Layers @@ -130,12 +130,12 @@ Each atom arrives as an integer atomic number. A neural network works better wit ```` ### Combine atom type with input coordinates -For each atom, the model combines its learned atom-type embedding with its three input coordinates. We concatenate these features and project the resulting vector back to hidden_dim. +For each atom, the model combines its learned atom-type embedding with its three input coordinates and the three coordinates of the protein pocket center. We concatenate these features and project the resulting vector back to `hidden_dim`. The pocket center gives ligand atoms protein context even though the bond graph has no protein-ligand edges. ````{dropdown} Click to see the code ```python self.input_proj = nn.Sequential( - nn.Linear(hidden_dim + 3, hidden_dim), + nn.Linear(hidden_dim + 6, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), ) @@ -192,7 +192,7 @@ After message passing, each atom has a learned hidden representation. The output ```` ### Define the loss -For the tutorial we use mean squared error between predicted and target coordinates. +For the tutorial we use mean squared error between predicted and target ligand coordinates. ````{dropdown} Click to see the code ```python @@ -204,8 +204,8 @@ For the tutorial we use mean squared error between predicted and target coordina ## The Forward Pass Now write `forward()`. It does four things in order: -1. Embed the atomic numbers. -2. Concatenate those embeddings with the input coordinates and project. +1. Compute the center of the protein pocket and embed the atomic numbers. +2. Concatenate those embeddings with the input coordinates and pocket center, then project. 3. Run message passing over the bond graph. For each layer, compute a message for every atom, route each source message to its destination atom using `edge_index`, sum the incoming messages per destination, and update each atom with a residual connection. 4. Project to coordinates. @@ -216,13 +216,17 @@ Now write `forward()`. It does four things in order: atomic_numbers: torch.Tensor, input_coords: torch.Tensor, edge_index: torch.Tensor, + is_ligand: torch.Tensor, ) -> torch.Tensor: + if not (~is_ligand).any(): + raise ValueError("Batch has no protein pocket atoms") + pocket_center = input_coords[~is_ligand].mean(dim=0).expand_as(input_coords) x = self.atom_embedding(atomic_numbers) - x = self.input_proj(torch.cat([x, input_coords], dim=-1)) + x = self.input_proj(torch.cat([x, input_coords, pocket_center], dim=-1)) src, dst = edge_index[0], edge_index[1] - for conv, update, norm in zip(self.conv_layers, self.update_layers, self.layer_norms): + for conv, update, norm in zip(self.conv_layers, self.update_layers, self.layer_norms, strict=True): messages = conv(x) agg = torch.zeros_like(x) agg.scatter_add_(0, dst.unsqueeze(-1).expand(-1, x.size(-1)), messages[src]) @@ -242,11 +246,11 @@ Now write `forward()`. It does four things in order: ## One Shared Train/Val/Test Step Lightning calls separate methods for training, validation, and test, but the logic is almost identical, so write it once in a helper. Skip `None` batches (these come from failed examples, which we handle in [Part 4](how_to_build_a_model_part4.md)), remove the leading batch dimension added by the `DataLoader` (we use `batch_size=1`), run the model, and compute the coordinate loss. -The full-coordinate MSE is fine for optimization, but docking quality is best judged on the ligand atoms alone, so we also log a ligand RMSD. +Compute MSE over ligand atoms, the coordinates this model is meant to place. Log ligand RMSD in angstroms as an easier-to-interpret metric. ````{dropdown} Click to see the code ```python - def _shared_step(self, batch: dict, stage: str) -> torch.Tensor: + def _shared_step(self, batch: dict[str, torch.Tensor] | None, stage: str) -> torch.Tensor | None: if batch is None: return None # Remove the batch dimension added by DataLoader (batch_size=1) @@ -256,8 +260,10 @@ The full-coordinate MSE is fine for optimization, but docking quality is best ju edge_index = batch["edge_index"].squeeze(0) # (2, E) is_ligand = batch["is_ligand"].squeeze(0) # (N,) - pred_coords = self(atomic_numbers, input_coords, edge_index) - loss = self.loss_fn(pred_coords, target_coords) + pred_coords = self(atomic_numbers, input_coords, edge_index, is_ligand) + if not is_ligand.any(): + raise ValueError("Batch has no ligand atoms") + loss = self.loss_fn(pred_coords[is_ligand], target_coords[is_ligand]) with torch.no_grad(): ligand_rmsd = torch.sqrt( @@ -278,13 +284,13 @@ The stage-specific methods become tiny wrappers around the shared step. ````{dropdown} Click to see the code ```python - def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor: + def training_step(self, batch: dict[str, torch.Tensor] | None, batch_idx: int) -> torch.Tensor | None: return self._shared_step(batch, "train") - def validation_step(self, batch: dict, batch_idx: int) -> None: + def validation_step(self, batch: dict[str, torch.Tensor] | None, batch_idx: int) -> None: self._shared_step(batch, "val") - def test_step(self, batch: dict, batch_idx: int) -> None: + def test_step(self, batch: dict[str, torch.Tensor] | None, batch_idx: int) -> None: self._shared_step(batch, "test") ``` ```` @@ -295,7 +301,7 @@ Tell Lightning which optimizer to use. Here we use [Adam](https://docs.pytorch.o ````{dropdown} Click to see the code ```python - def configure_optimizers(self): + def configure_optimizers(self) -> torch.optim.Optimizer: return torch.optim.Adam(self.parameters(), lr=self.learning_rate) ``` ```` @@ -341,4 +347,4 @@ With `transforms.py` and `model.py` in place, you are ready to wire everything i **Message passing:** the process of computing a message from each node, aggregating messages at each destination node, and updating node states. -**RMSD (root-mean-square deviation):**: the square root of the mean squared distance between predicted and true atom positions; a standard structural-accuracy metric. +**RMSD (root-mean-square deviation):** the square root of the mean squared distance between predicted and true atom positions; a standard structural-accuracy metric. diff --git a/docs/how_to_build_a_model/how_to_build_a_model_part4.md b/docs/how_to_build_a_model/how_to_build_a_model_part4.md index bbb58c2f..2d47c325 100644 --- a/docs/how_to_build_a_model/how_to_build_a_model_part4.md +++ b/docs/how_to_build_a_model/how_to_build_a_model_part4.md @@ -25,7 +25,7 @@ ## Introduction This is the fourth and final tutorial in the **How to Build a Model Using AtomWorks** series. By now you have: -- Parquet files for your `train`/`val`/`test` splits ([Part 1](how_to_build_a_model_part1.md)). +- Parquet files for your `train`/`validation`/`test` splits ([Part 1](how_to_build_a_model_part1.md)). - A loader that turns each parquet row into a protein-ligand structure ([Part 2](how_to_build_a_model_part2.md)). - A transform pipeline that crops the pocket and converts it into tensors ([Part 2](how_to_build_a_model_part2.md)). - A `PocketDockGNN` model in `model.py` ([Part 3](how_to_build_a_model_part3.md)). @@ -41,7 +41,7 @@ This tutorial completes the training script using the [AtomWorks API](../api_ref Before starting this part it is assumed that you have: - Completed [Parts 1–3](index.rst), with `transforms.py` and `model.py` in place. - A working installation of [PyTorch](https://pytorch.org/) and [PyTorch Lightning](https://lightning.ai/docs/pytorch/stable). -- Access to a GPU (the example trainer is configured for a single GPU, but you can change the accelerator). +- A CPU or GPU; the trainer chooses an available accelerator. (aw_build_model_p4_overview)= ## What the Training Script Does @@ -58,13 +58,13 @@ The model sees a pocket-centered graph with five tensors: - **`input_coords`**: protein pocket coordinates are kept, ligand coordinates are zeroed out. - **`target_coords`**: the true coordinates the model should predict. - **`edge_index`**: bond graph connectivity. -- **`is_ligand`**: a boolean mask for which atoms belong to the ligand. +- **`is_ligand`**: a boolean mask for which atoms belong to the ligand; the model uses its complement to pool protein pocket coordinates. In other words, the task is: *given the protein pocket context and the ligand atoms, predict the ligand's 3D placement.* (aw_build_model_p4_imports)= ## Imports and Global Settings -In a new file, `model.py`, import PyTorch, PyTorch Lightning, the AtomWorks dataset utilities used in the previous scripts, the transforms you wrote in Part 2, and the model class from Part 3. +In a new file, `train.py`, import PyTorch, PyTorch Lightning, the AtomWorks dataset utilities used in the previous scripts, the transforms you wrote in Part 2, and the model class from Part 3. ````{dropdown} Click to see the code ```python @@ -73,11 +73,13 @@ import pandas as pd import pytorch_lightning as pl from pytorch_lightning.callbacks import ModelCheckpoint, EarlyStopping from torch.utils.data import DataLoader, Dataset +from pathlib import Path +import argparse from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms -from atomworks.ml.transforms.base import ConvertToTorch, Compose +from atomworks.ml.transforms.base import Compose, ConvertToTorch from transforms import CropToPocket, FeaturizeForDocking from model import PocketDockGNN @@ -85,14 +87,16 @@ from model import PocketDockGNN Note that we now also import {py:class}`~atomworks.ml.transforms.base.ConvertToTorch`, which turns the NumPy features from `FeaturizeForDocking` into `torch` tensors. ```` -Add two small global settings: -- `torch.set_float32_matmul_precision("medium")`: a practical speed/precision trade-off for training. +Add two small global settings: +- `torch.set_float32_matmul_precision("medium")`: a practical speed/precision trade-off for training. - `pl.seed_everything(42)`: makes runs more reproducible. ````{dropdown} Click to see the code ```python torch.set_float32_matmul_precision("medium") pl.seed_everything(42) + +# Call these in main() before training. ``` ```` @@ -106,7 +110,6 @@ CONFIG = { "hidden_dim": 128, "num_layers": 3, "learning_rate": 1e-3, - "batch_size": 1, "max_epochs": 5, "pocket_radius": 10.0, "num_workers": 0, @@ -123,7 +126,7 @@ The `max_*` values cap how many examples we use so the run stays small and fast (aw_build_model_p4_keys)= ## List the Tensor Keys -Your featurization step produces five tensors. Store those once in a `TENSOR_KEYS` list so the rest of the file can reuse the same list. This list is used both to tell `ConvertToTorch` what to convert and to tell the collate function what to stack. +Your featurization step produces five tensors. Store those once in a `TENSOR_KEYS` list so the rest of the file can reuse the same list. This list tells `ConvertToTorch` what to convert and the collate function what to stack. ````{dropdown} Click to see the code ```python @@ -160,7 +163,8 @@ class RobustDataset(Dataset): def __getitem__(self, idx): try: return self.dataset[idx] - except Exception: + except (OSError, ValueError, KeyError, IndexError) as exc: + print(f"Skipping example {idx}: {exc}") self.failed.append(idx) return None ``` @@ -180,7 +184,6 @@ def collate_fn(batch): return { k: torch.stack([example[k] for example in batch]) for k in TENSOR_KEYS - if k in batch[0] } ``` With `batch_size=1`, this stacks a single example and adds a leading dimension of size 1, which the model's `_shared_step` removes with `.squeeze(0)`. @@ -209,13 +212,17 @@ Write one function that takes a split parquet path and returns a ready-to-use da ````{dropdown} Click to see the code ```python -def build_dataset(parquet_path: str, name: str, radius: float, max_examples: int = None): +def build_dataset(parquet_path: str, name: str, radius: float, max_examples: int | None = None) -> RobustDataset: df = pd.read_parquet(parquet_path) if max_examples is not None: df = df.head(max_examples).reset_index(drop=True) - loader = create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader = create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, ) dataset = PandasDataset( @@ -224,11 +231,10 @@ def build_dataset(parquet_path: str, name: str, radius: float, max_examples: int id_column="example_id", loader=loader, transform=build_pipeline(radius), - save_failed_examples_to_dir="failed_examples/", ) return RobustDataset(dataset) ``` -`save_failed_examples_to_dir` writes any example that fails inside the AtomWorks pipeline to disk so you can inspect it later. +The wrapper prints the failing row index and exception so skipped examples remain visible. Keep at least one valid example in each split. ```` Then write one small helper to build dataloaders consistently. This keeps the script tidy and reuses the same settings for train, validation, and test. @@ -238,7 +244,7 @@ Then write one small helper to build dataloaders consistently. This keeps the sc def build_dataloader(dataset, shuffle: bool) -> DataLoader: return DataLoader( dataset, - batch_size=CONFIG["batch_size"], + batch_size=1, shuffle=shuffle, num_workers=CONFIG["num_workers"], collate_fn=collate_fn, @@ -249,7 +255,7 @@ def build_dataloader(dataset, shuffle: bool) -> DataLoader: (aw_build_model_p4_build)= ## Build the Datasets and Dataloaders -Now the main script body. Start by building the three datasets. +Now the main script body. Start by building the three datasets. The downloadable script accepts `--splits-dir`, `--output-dir`, `--max-epochs`, and example caps. Set `output_dir = Path("tutorial_run")` for the snippets below. ````{dropdown} Click to see the code ```python @@ -258,7 +264,7 @@ train_dataset = build_dataset( "splits/train.parquet", "docking_train", CONFIG["pocket_radius"], CONFIG["max_train"] ) val_dataset = build_dataset( - "splits/val.parquet", "docking_val", CONFIG["pocket_radius"], CONFIG["max_val"] + "splits/validation.parquet", "docking_val", CONFIG["pocket_radius"], CONFIG["max_val"] ) test_dataset = build_dataset( "splits/test.parquet", "docking_test", CONFIG["pocket_radius"], CONFIG["max_test"] @@ -300,12 +306,12 @@ print(f"\nModel parameters: {sum(p.numel() for p in model.parameters()):,}") (aw_build_model_p4_callbacks)= ## Checkpointing and Early Stopping -Before creating the trainer, define the callbacks. Use `periodic_checkpoint` to give recovery points during training, and `best_checkpoint` to keep the best models by `val/loss`. +Before creating the trainer, define the callbacks. Put checkpoints under `output_dir`. Use `periodic_checkpoint` to give recovery points during training, and `best_checkpoint` to keep the best models by `val/loss`. ````{dropdown} Click to see the code ```python periodic_checkpoint = ModelCheckpoint( - dirpath="checkpoints/", + dirpath=output_dir / "checkpoints", filename="pocketdockgnn-{epoch:02d}-{step}", every_n_train_steps=50, save_last=True, @@ -313,8 +319,8 @@ periodic_checkpoint = ModelCheckpoint( ) best_checkpoint = ModelCheckpoint( - dirpath="checkpoints/", - filename="pocketdockgnn-best-{epoch:02d}-{val/loss:.4f}", + dirpath=output_dir / "checkpoints", + filename="pocketdockgnn-best-{epoch:02d}", monitor="val/loss", mode="min", save_top_k=3, @@ -343,22 +349,29 @@ Now define the trainer itself with the [PyTorch Lightning `Trainer`](https://lig ```python trainer = pl.Trainer( max_epochs=CONFIG["max_epochs"], - accelerator="gpu", + accelerator="auto", devices=1, callbacks=callbacks, - log_every_n_steps=10, - val_check_interval=50, + log_every_n_steps=1, + default_root_dir=output_dir, + check_val_every_n_epoch=1, enable_progress_bar=True, ) ``` ```` ```{tip} -If you do not have a GPU available, set `accelerator="cpu"` (and drop `devices=1`) to run the small capped example on CPU. +The small capped example runs on CPU when no GPU is available. ``` (aw_build_model_p4_fit)= ## Train and Evaluate +With `train.parquet`, `validation.parquet`, and `test.parquet` in `splits/`, run the downloaded scripts from one directory: + +```bash +python train.py --splits-dir splits --output-dir tutorial_run +``` + Call `fit()` to train. ````{dropdown} Click to see the code @@ -379,6 +392,8 @@ After training, print some diagnostics: how many examples were skipped, and whic print(f"\nFailed examples during training: {len(train_dataset.failed)}") print(f"Failed examples during val: {len(val_dataset.failed)}") print(f"Best checkpoint: {best_checkpoint.best_model_path}") +if not best_checkpoint.best_model_path: + raise RuntimeError("No validation checkpoint was saved; inspect skipped examples") ``` ```` @@ -407,25 +422,31 @@ You have now built a complete, if simplified, machine-learning pipeline with Ato (aw_build_model_p4_inference)= ## Running Inference on a Trained Model -`trainer.test()` reports metrics, but it does not hand you the predicted poses. To get coordinates for a new pocket-ligand example, reload the best checkpoint and call the model directly. You can find a `inference.py` script in the [tutorial files](./scripts/index.rst). +`trainer.test()` reports metrics, but it does not hand you the predicted poses. To get coordinates for a new pocket-ligand example, reload the best checkpoint and call the model directly. You can find an `inference.py` script in the [tutorial files](./scripts/index.rst). After training, pass the best checkpoint path printed by `train.py`: + +```bash +python inference.py PATH_TO_BEST_CHECKPOINT --split splits/test.parquet +``` ````{dropdown} Click to see the code ```python -model = PocketDockGNN.load_from_checkpoint(best_checkpoint.best_model_path) +model = PocketDockGNN.load_from_checkpoint(best_checkpoint.best_model_path, map_location="cpu") model.eval() -batch = next(iter(test_loader)) +batch = next((example for example in test_loader if example is not None), None) +if batch is None: + raise ValueError("No usable examples in inference split") atomic_numbers = batch["atomic_numbers"].squeeze(0) input_coords = batch["input_coords"].squeeze(0) edge_index = batch["edge_index"].squeeze(0) is_ligand = batch["is_ligand"].squeeze(0) with torch.no_grad(): - pred_coords = model(atomic_numbers, input_coords, edge_index) + pred_coords = model(atomic_numbers, input_coords, edge_index, is_ligand) predicted_ligand_coords = pred_coords[is_ligand] ``` -The `.squeeze(0)` calls undo the batch dimension `DataLoader` adds, matching what `_shared_step` does internally. Only the rows where `is_ligand` is `True` are the coordinates you asked the model to predict. The rest are the (unchanged) protein pocket context. +The `.squeeze(0)` calls undo the batch dimension `DataLoader` adds, matching what `_shared_step` does internally. Only the rows where `is_ligand` is `True` are ligand predictions. Other rows are unconstrained protein predictions and should not be interpreted as a fixed pocket. ```` ```{note} diff --git a/docs/how_to_build_a_model/index.rst b/docs/how_to_build_a_model/index.rst index 2596a6a1..992326b4 100644 --- a/docs/how_to_build_a_model/index.rst +++ b/docs/how_to_build_a_model/index.rst @@ -1,13 +1,13 @@ How to Build a Model with AtomWorks =================================== -In this tutorial you will learn how to use AtomWorks to build a graph neural network that predicts the bound pose of a ligand within a fixed protein pocket. +This tutorial demonstrates how to build a graph neural network model using AtomWorks to predict the bound pose of a ligand within a fixed protein pocket. .. toctree:: - :maxdepth: 1 - :caption: Sections + :maxdepth: 1 - how_to_build_a_model_part1 - how_to_build_a_model_part2 - how_to_build_a_model_part3 - how_to_build_a_model_part4 + how_to_build_a_model_part1 + how_to_build_a_model_part2 + how_to_build_a_model_part3 + how_to_build_a_model_part4 + scripts/index diff --git a/docs/how_to_build_a_model/scripts/data_cleaning_script.py b/docs/how_to_build_a_model/scripts/data_cleaning_script.py index 50622364..e21dc546 100644 --- a/docs/how_to_build_a_model/scripts/data_cleaning_script.py +++ b/docs/how_to_build_a_model/scripts/data_cleaning_script.py @@ -1,157 +1,204 @@ -""" -Script to read in parquet files and clean the data needed for the -"How to Build a Model Using AtomWorks" tutorial. This script is meant to be run -from the command line and takes the path to the parquet files. The PDB_MIRROR_PATH -environment variable needs to be set to the location of your PDB mirror. -It saves the cleaned data and the train/val/test splits as parquet files for -future use in the tutorial. - -Example usage: - python data_cleaning_script.py /path/to/parquet/files +"""Prepare protein-ligand interface metadata for the model-building tutorial. -Last edited: July 9, 2026 +Example: + python data_cleaning_script.py \ + data/pdb_metadata/shared/interfaces_df.parquet \ + --pdb-mirror /path/to/pdb_mirror \ + --output-dir splits """ +import argparse import os -import sys +from pathlib import Path -import numpy as np +import numpy as np import pandas as pd +import pyarrow.parquet as pq + +from atomworks.enums import ChainType + +REQUIRED_INTERFACE_COLUMNS = { + "pdb_id", + "assembly_id", + "altloc_seed", + "pn_unit_1_iid", + "pn_unit_2_iid", + "pn_unit_1_type", + "pn_unit_2_type", + "pn_unit_1_is_polymer", + "pn_unit_2_is_polymer", + "involves_loi", + "is_inter_molecule", + "involves_metal", + "involves_covalent_modification", + "example_id", + "path", +} +PN_UNIT_COLUMNS = { + "pdb_id", + "assembly_id", + "altloc_seed", + "q_pn_unit_iid", + "q_pn_unit_num_resolved_residues", + "cluster", +} +# This tutorial excludes cyclic pseudo-peptides from the broader protein grouping. +PROTEIN_CHAIN_TYPES = {int(ChainType.POLYPEPTIDE_D), int(ChainType.POLYPEPTIDE_L)} +MAX_RESOLVED_RESIDUES = 200 + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("metadata", type=Path, help="Path to the interface metadata Parquet file.") + parser.add_argument( + "--pn-units", type=Path, help="PN-unit metadata Parquet file; inferred from the interface filename by default." + ) + parser.add_argument( + "--pdb-mirror", + type=Path, + default=os.environ.get("PDB_MIRROR_PATH"), + help="PDB mirror root. Defaults to PDB_MIRROR_PATH.", + ) + parser.add_argument("--output-dir", type=Path, default=Path("splits"), help="Directory for output Parquet files.") + parser.add_argument("--seed", type=int, default=42, help="Random seed used to shuffle protein clusters.") + args = parser.parse_args() + if args.pdb_mirror is None: + parser.error("set PDB_MIRROR_PATH or pass --pdb-mirror") + if args.pn_units is None: + if "interfaces" not in args.metadata.name: + parser.error("pass --pn-units when the interface filename does not contain interfaces") + args.pn_units = args.metadata.with_name(args.metadata.name.replace("interfaces", "pn_units", 1)) + return args + + +def load_metadata(interfaces_path: Path, pn_units_path: Path) -> tuple[pd.DataFrame, pd.DataFrame]: + """Load the interfaces and PN-unit tables, checking the interfaces schema first.""" + missing = REQUIRED_INTERFACE_COLUMNS.difference(pq.read_schema(interfaces_path).names) + assert not missing, f"{interfaces_path} is missing required columns: {sorted(missing)}" + + interfaces = pd.read_parquet(interfaces_path) + pn_units = pd.read_parquet(pn_units_path) + return interfaces, pn_units + + +def merge_pn_unit_info(interfaces: pd.DataFrame, pn_units: pd.DataFrame) -> pd.DataFrame: + """Merge resolved-residue counts and cluster labels in for each side of the interface.""" + pn_columns = pn_units[ + ["pdb_id", "assembly_id", "altloc_seed", "q_pn_unit_iid", "q_pn_unit_num_resolved_residues", "cluster"] + ] + + side_1 = pn_columns.rename( + columns={ + "q_pn_unit_iid": "pn_unit_1_iid", + "q_pn_unit_num_resolved_residues": "pn_unit_1_num_resolved_residues", + "cluster": "pn_unit_1_cluster", + } + ) + side_2 = pn_columns.rename( + columns={ + "q_pn_unit_iid": "pn_unit_2_iid", + "q_pn_unit_num_resolved_residues": "pn_unit_2_num_resolved_residues", + "cluster": "pn_unit_2_cluster", + } + ) + + df = interfaces.merge(side_1, on=["pdb_id", "assembly_id", "altloc_seed", "pn_unit_1_iid"], how="left") + df = df.merge(side_2, on=["pdb_id", "assembly_id", "altloc_seed", "pn_unit_2_iid"], how="left") + return df + + +def clean_data(df: pd.DataFrame) -> pd.DataFrame: + """Keep small, noncovalent protein-ligand interfaces and drop duplicates.""" + df = df[df["involves_loi"].eq(True)] + df = df[df["is_inter_molecule"].eq(True)] + df = df[df["involves_metal"].ne(True)] + df = df[df["involves_covalent_modification"].ne(True)] + + exactly_one_polymer = df["pn_unit_1_is_polymer"] ^ df["pn_unit_2_is_polymer"] + df = df[exactly_one_polymer] + + protein_side_type = np.where(df["pn_unit_1_is_polymer"], df["pn_unit_1_type"], df["pn_unit_2_type"]) + df = df[pd.Series(protein_side_type, index=df.index).isin(PROTEIN_CHAIN_TYPES)] + + residue_columns = ["pn_unit_1_num_resolved_residues", "pn_unit_2_num_resolved_residues"] + df = df.dropna(subset=residue_columns) + df = df[df[residue_columns].sum(axis="columns") < MAX_RESOLVED_RESIDUES] + + df = df.drop_duplicates(subset=["example_id"]) + assert df["example_id"].is_unique + return df + + +def resolve_structure_paths(df: pd.DataFrame, pdb_mirror: Path) -> pd.DataFrame: + """Overwrite `path` with each structure's location in the local PDB mirror.""" + pdb_mirror = pdb_mirror.expanduser().resolve() + + def resolve_path(pdb_id: str) -> str: + pdb_id = pdb_id.lower() + return str(pdb_mirror / pdb_id[1:3] / f"{pdb_id}.cif.gz") + + df = df.copy() + df["path"] = df["pdb_id"].map(resolve_path) + return df + + +def assign_cluster_splits(df: pd.DataFrame, *, seed: int) -> pd.DataFrame: + """Add a `protein_cluster` column and assign each cluster to one 80/10/10 split.""" + df = df.copy() + df["protein_cluster"] = np.where(df["pn_unit_1_is_polymer"], df["pn_unit_1_cluster"], df["pn_unit_2_cluster"]) + df = df[df["protein_cluster"].notna()].reset_index(drop=True) + + unique_clusters = df["protein_cluster"].drop_duplicates().to_numpy(copy=True) + rng = np.random.default_rng(seed=seed) + rng.shuffle(unique_clusters) + + n = len(unique_clusters) + n_train = int(0.8 * n) + n_validation = int(0.1 * n) + # test gets the remainder to avoid off-by-one gaps + + train_clusters = set(unique_clusters[:n_train]) + validation_clusters = set(unique_clusters[n_train : n_train + n_validation]) + test_clusters = set(unique_clusters[n_train + n_validation :]) + + def assign_split(cluster: object) -> str: + if cluster in train_clusters: + return "train" + if cluster in validation_clusters: + return "validation" + if cluster in test_clusters: + return "test" + return "unassigned" # rows where cluster was null + + df["split"] = df["protein_cluster"].map(assign_split) + assert df.groupby("protein_cluster")["split"].nunique().eq(1).all() + return df + + +def write_splits(df: pd.DataFrame, output_dir: Path) -> None: + """Write cleaned metadata and individual split files.""" + output_dir.mkdir(parents=True, exist_ok=True) + df.to_parquet(output_dir / "all.parquet", index=False) + for split in ("train", "validation", "test"): + df[df["split"] == split].reset_index(drop=True).to_parquet(output_dir / f"{split}.parquet", index=False) + + +def main() -> None: + """Run the metadata preparation workflow.""" + args = parse_args() + interfaces, pn_units = load_metadata(args.metadata, args.pn_units) + df = merge_pn_unit_info(interfaces, pn_units) + df = clean_data(df) + df = resolve_structure_paths(df, args.pdb_mirror) + df = assign_cluster_splits(df, seed=args.seed) + write_splits(df, args.output_dir) + + counts = df["split"].value_counts() + print(f"Wrote {len(df):,} examples to {args.output_dir}") + for split in ("train", "validation", "test"): + print(f" {split}: {counts.get(split, 0):,}") + - -def read_in_parquet_file(path_to_parquets: str | os.PathLike, parquet_file: str) -> pd.DataFrame: - """ - Create a pandas DataFrame from a parquet file. - :param path_to_parquets: Directory containing the parquet files. - :param parquet_file: Name of the parquet file (without the .parquet extension). - :return: A pandas DataFrame with the parquet contents. - """ - file_path = os.path.join(path_to_parquets, (parquet_file + ".parquet")) - - if not os.path.exists(file_path): - raise FileNotFoundError(f"{file_path} not found") - - return pd.read_parquet(file_path) - - -def assign_split(cluster): - """Assign a protein cluster to a split, using the cluster sets defined below.""" - if cluster in train_clusters: - return "train" - if cluster in val_clusters: - return "val" - if cluster in test_clusters: - return "test" - return "unassigned" # Should already be filtered out; useful as a debug flag. - - -# ── Parse command-line arguments ───────────────────────────────────────────── -if len(sys.argv) > 1: - path_to_parquets = sys.argv[1] -else: - print("To use this script, please provide the path to the parquet files.") - sys.exit(1) - -# ── Read the parquet files ─────────────────────────────────────────────────── -interfaces = read_in_parquet_file(path_to_parquets, "interfaces") -pn_units = read_in_parquet_file(path_to_parquets, "pn_units") - -# ── Merge PN-unit metadata onto each interface (once per side) ─────────────── -# pn_unit_iid is only unique within a (pdb_id, assembly_id) scope, so merge on -# all three keys, twice (once for each PN unit in the interface). -u1_cols = pn_units[["pdb_id", "assembly_id", "pn_unit_iid", "is_polymer", "num_resolved_residues"]].copy() -u2_cols = pn_units[["pdb_id", "assembly_id", "pn_unit_iid", "is_polymer", "num_resolved_residues"]].copy() - -u1_cols = u1_cols.rename(columns={ - "pn_unit_iid": "pn_unit_1_iid", - "is_polymer": "u1_is_polymer", - "num_resolved_residues": "u1_num_resolved_residues", -}) -u2_cols = u2_cols.rename(columns={ - "pn_unit_iid": "pn_unit_2_iid", - "is_polymer": "u2_is_polymer", - "num_resolved_residues": "u2_num_resolved_residues", -}) - -df = interfaces.merge(u1_cols, on=["pdb_id", "assembly_id", "pn_unit_1_iid"], how="inner") -df = df.merge(u2_cols, on=["pdb_id", "assembly_id", "pn_unit_2_iid"], how="inner") - -# ── Filter to drug-like protein-ligand interfaces ──────────────────────────── -df = df[df["involves_loi"]] -df = df[df["is_inter_molecule"]] -df = df[~df["involves_metal"]] -df = df[~df["involves_covalent_modification"]] -df = df[df["u1_is_polymer"] != df["u2_is_polymer"]] -df = df[(df["u1_num_resolved_residues"] + df["u2_num_resolved_residues"]) < 200] - -# Remove duplicate interface rows. -df = df.drop_duplicates(subset=["pdb_id", "assembly_id", "pn_unit_1_iid", "pn_unit_2_iid"]) - -# ── Add a unique example identifier ────────────────────────────────────────── -df["example_id"] = ( - df["pdb_id"] + "_" + - df["assembly_id"].astype(str) + "_" + - df["pn_unit_1_iid"] + "_" + - df["pn_unit_2_iid"] -) -assert df["example_id"].nunique() == len(df), "example_id is not unique!" - -# ── Add the path to each structure in the PDB mirror ───────────────────────── -PDB_MIRROR_PATH = os.environ.get("PDB_MIRROR_PATH", "/PATH/TO/pdb_mirror") -df["path"] = df["pdb_id"].str.lower().map( - lambda x: f"{PDB_MIRROR_PATH}/{x[1:3]}/{x}.cif.gz" -) - -df.to_parquet("cleaned_data.parquet") - -# ── Split into train/val/test by protein cluster ───────────────────────────── -protein_clusters = pn_units[["pdb_id", "assembly_id", "pn_unit_iid", "protein_cluster_30"]].copy() - -df = df.merge( - protein_clusters.rename(columns={ - "pn_unit_iid": "pn_unit_1_iid", - "protein_cluster_30": "u1_cluster", - }), - on=["pdb_id", "assembly_id", "pn_unit_1_iid"], - how="left", -) -df = df.merge( - protein_clusters.rename(columns={ - "pn_unit_iid": "pn_unit_2_iid", - "protein_cluster_30": "u2_cluster", - }), - on=["pdb_id", "assembly_id", "pn_unit_2_iid"], - how="left", -) - -# The polymer side carries the cluster; the ligand side is null. Take whichever -# side is the polymer. -df["protein_cluster"] = np.where(df["u1_is_polymer"], df["u1_cluster"], df["u2_cluster"]) - -# Drop interfaces with no assigned cluster (RNA/DNA, short peptides, low quality). -df = df[df["protein_cluster"].notna()].reset_index(drop=True) - -# Shuffle the unique clusters (seeded for reproducibility) and split 80/10/10. -unique_clusters = df["protein_cluster"].unique() -rng = np.random.default_rng(seed=42) -rng.shuffle(unique_clusters) - -n = len(unique_clusters) -n_train = int(0.8 * n) -n_val = int(0.1 * n) -# test gets the remainder to avoid off-by-one gaps - -train_clusters = set(unique_clusters[:n_train]) -val_clusters = set(unique_clusters[n_train:n_train + n_val]) -test_clusters = set(unique_clusters[n_train + n_val:]) - -df["split"] = df["protein_cluster"].map(assign_split) - -df_train = df[df["split"] == "train"].reset_index(drop=True) -df_val = df[df["split"] == "val"].reset_index(drop=True) -df_test = df[df["split"] == "test"].reset_index(drop=True) - -os.makedirs("splits", exist_ok=True) -df_train.to_parquet("splits/train.parquet", index=False) -df_val.to_parquet("splits/val.parquet", index=False) -df_test.to_parquet("splits/test.parquet", index=False) +if __name__ == "__main__": + main() diff --git a/docs/how_to_build_a_model/scripts/index.rst b/docs/how_to_build_a_model/scripts/index.rst index 05a99c1d..0139021a 100644 --- a/docs/how_to_build_a_model/scripts/index.rst +++ b/docs/how_to_build_a_model/scripts/index.rst @@ -1,11 +1,11 @@ Scripts -======= +======== -Use the links below to download the Python scripts referenced in this tutorial. +Download the complete scripts used in Parts 1 through 4. - :download:`data_cleaning_script.py` - :download:`transforms.py` +- :download:`smoke_test.py` - :download:`model.py` - :download:`train.py` -- :download:`smoke_test.py` - :download:`inference.py` diff --git a/docs/how_to_build_a_model/scripts/inference.py b/docs/how_to_build_a_model/scripts/inference.py index 7538c70b..499b87cd 100644 --- a/docs/how_to_build_a_model/scripts/inference.py +++ b/docs/how_to_build_a_model/scripts/inference.py @@ -1,38 +1,38 @@ -import sys +"""Predict ligand coordinates from a Part 4 checkpoint and test split.""" -import torch - -from docs.how_to_build_a_model.scripts.model import PocketDockGNN -from docs.how_to_build_a_model.scripts.train import CONFIG, build_dataloader, build_dataset - - -def main(): - if len(sys.argv) != 2: - print("Usage: python inference.py /path/to/checkpoint.ckpt") - return - - ckpt_path = sys.argv[1] +import argparse +from pathlib import Path - test_dataset = build_dataset( - "splits/test.parquet", "docking_test", CONFIG["pocket_radius"], CONFIG["max_test"] - ) - test_loader = build_dataloader(test_dataset, shuffle=False) - - model = PocketDockGNN.load_from_checkpoint(ckpt_path) +import torch +from model import PocketDockGNN +from train import CONFIG, build_dataloader, build_dataset + + +def main() -> None: + """Load one usable example and predict its ligand coordinates.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("checkpoint", type=Path) + parser.add_argument("--split", type=Path, default=Path("splits/test.parquet")) + args = parser.parse_args() + dataset = build_dataset(args.split, "docking_inference", CONFIG["pocket_radius"], CONFIG["max_test"]) + loader = build_dataloader(dataset, shuffle=False) + batch = next((example for example in loader if example is not None), None) + if batch is None: + raise ValueError("No usable examples in inference split") + + model = PocketDockGNN.load_from_checkpoint(args.checkpoint, map_location="cpu") model.eval() - - batch = next(iter(test_loader)) - atomic_numbers = batch["atomic_numbers"].squeeze(0) - input_coords = batch["input_coords"].squeeze(0) - edge_index = batch["edge_index"].squeeze(0) - is_ligand = batch["is_ligand"].squeeze(0) - with torch.no_grad(): - pred_coords = model(atomic_numbers, input_coords, edge_index) - - predicted_ligand_coords = pred_coords[is_ligand] - print(f"Predicted coordinates for {predicted_ligand_coords.shape[0]} ligand atoms:") - print(predicted_ligand_coords) + predictions = model( + batch["atomic_numbers"].squeeze(0), + batch["input_coords"].squeeze(0), + batch["edge_index"].squeeze(0), + batch["is_ligand"].squeeze(0), + ) + ligand_coords = predictions[batch["is_ligand"].squeeze(0)] + assert ligand_coords.shape[1] == 3 and torch.isfinite(ligand_coords).all() + print(f"Predicted coordinates for {len(ligand_coords)} ligand atoms:") + print(ligand_coords) if __name__ == "__main__": diff --git a/docs/how_to_build_a_model/scripts/model.py b/docs/how_to_build_a_model/scripts/model.py index b6bcd23a..6e91755f 100644 --- a/docs/how_to_build_a_model/scripts/model.py +++ b/docs/how_to_build_a_model/scripts/model.py @@ -1,9 +1,8 @@ -"""PocketDockGNN model. Created in part 3 of the How to Build a Model with -AtomWorks tutorial series.""" +"""A small message-passing model for tutorial pocket and ligand graphs.""" +import pytorch_lightning as pl import torch import torch.nn as nn -import pytorch_lightning as pl class PocketDockGNN(pl.LightningModule): @@ -13,40 +12,41 @@ def __init__( hidden_dim: int = 128, num_layers: int = 3, learning_rate: float = 1e-3, - ): + ) -> None: super().__init__() self.save_hyperparameters() self.learning_rate = learning_rate self.atom_embedding = nn.Embedding(num_atom_types, hidden_dim) self.input_proj = nn.Sequential( - nn.Linear(hidden_dim + 3, hidden_dim), + nn.Linear(hidden_dim + 6, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), ) - self.conv_layers = nn.ModuleList([ - nn.Sequential( - nn.Linear(hidden_dim, hidden_dim), - nn.ReLU(), - nn.Linear(hidden_dim, hidden_dim), - ) - for _ in range(num_layers) - ]) - - self.update_layers = nn.ModuleList([ - nn.Sequential( - nn.Linear(hidden_dim * 2, hidden_dim), - nn.ReLU(), - nn.Linear(hidden_dim, hidden_dim), - ) - for _ in range(num_layers) - ]) - - self.layer_norms = nn.ModuleList([ - nn.LayerNorm(hidden_dim) - for _ in range(num_layers) - ]) + self.conv_layers = nn.ModuleList( + [ + nn.Sequential( + nn.Linear(hidden_dim, hidden_dim), + nn.ReLU(), + nn.Linear(hidden_dim, hidden_dim), + ) + for _ in range(num_layers) + ] + ) + + self.update_layers = nn.ModuleList( + [ + nn.Sequential( + nn.Linear(hidden_dim * 2, hidden_dim), + nn.ReLU(), + nn.Linear(hidden_dim, hidden_dim), + ) + for _ in range(num_layers) + ] + ) + + self.layer_norms = nn.ModuleList([nn.LayerNorm(hidden_dim) for _ in range(num_layers)]) self.output_proj = nn.Sequential( nn.Linear(hidden_dim, hidden_dim), @@ -61,13 +61,17 @@ def forward( atomic_numbers: torch.Tensor, input_coords: torch.Tensor, edge_index: torch.Tensor, + is_ligand: torch.Tensor, ) -> torch.Tensor: + if not (~is_ligand).any(): + raise ValueError("Batch has no protein pocket atoms") + pocket_center = input_coords[~is_ligand].mean(dim=0).expand_as(input_coords) x = self.atom_embedding(atomic_numbers) - x = self.input_proj(torch.cat([x, input_coords], dim=-1)) + x = self.input_proj(torch.cat([x, input_coords, pocket_center], dim=-1)) src, dst = edge_index[0], edge_index[1] - for conv, update, norm in zip(self.conv_layers, self.update_layers, self.layer_norms): + for conv, update, norm in zip(self.conv_layers, self.update_layers, self.layer_norms, strict=True): messages = conv(x) agg = torch.zeros_like(x) agg.scatter_add_(0, dst.unsqueeze(-1).expand(-1, x.size(-1)), messages[src]) @@ -76,38 +80,37 @@ def forward( pred_coords = self.output_proj(x) return pred_coords - def _shared_step(self, batch: dict, stage: str) -> torch.Tensor: + def _shared_step(self, batch: dict[str, torch.Tensor] | None, stage: str) -> torch.Tensor | None: if batch is None: return None # Remove the batch dimension added by DataLoader (batch_size=1) - atomic_numbers = batch["atomic_numbers"].squeeze(0) # (N,) - input_coords = batch["input_coords"].squeeze(0) # (N, 3) - target_coords = batch["target_coords"].squeeze(0) # (N, 3) - edge_index = batch["edge_index"].squeeze(0) # (2, E) - is_ligand = batch["is_ligand"].squeeze(0) # (N,) + atomic_numbers = batch["atomic_numbers"].squeeze(0) # (N,) + input_coords = batch["input_coords"].squeeze(0) # (N, 3) + target_coords = batch["target_coords"].squeeze(0) # (N, 3) + edge_index = batch["edge_index"].squeeze(0) # (2, E) + is_ligand = batch["is_ligand"].squeeze(0) # (N,) - pred_coords = self(atomic_numbers, input_coords, edge_index) - loss = self.loss_fn(pred_coords, target_coords) + pred_coords = self(atomic_numbers, input_coords, edge_index, is_ligand) + if not is_ligand.any(): + raise ValueError("Batch has no ligand atoms") + loss = self.loss_fn(pred_coords[is_ligand], target_coords[is_ligand]) with torch.no_grad(): - ligand_rmsd = torch.sqrt( - ((pred_coords[is_ligand] - target_coords[is_ligand]) ** 2) - .sum(dim=-1).mean() - ) + ligand_rmsd = torch.sqrt(((pred_coords[is_ligand] - target_coords[is_ligand]) ** 2).sum(dim=-1).mean()) self.log(f"{stage}/loss", loss, prog_bar=True) self.log(f"{stage}/ligand_rmsd", ligand_rmsd, prog_bar=True) return loss - def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor: + def training_step(self, batch: dict[str, torch.Tensor] | None, batch_idx: int) -> torch.Tensor | None: return self._shared_step(batch, "train") - def validation_step(self, batch: dict, batch_idx: int) -> None: + def validation_step(self, batch: dict[str, torch.Tensor] | None, batch_idx: int) -> None: self._shared_step(batch, "val") - def test_step(self, batch: dict, batch_idx: int) -> None: + def test_step(self, batch: dict[str, torch.Tensor] | None, batch_idx: int) -> None: self._shared_step(batch, "test") - def configure_optimizers(self): + def configure_optimizers(self) -> torch.optim.Optimizer: return torch.optim.Adam(self.parameters(), lr=self.learning_rate) diff --git a/docs/how_to_build_a_model/scripts/smoke_test.py b/docs/how_to_build_a_model/scripts/smoke_test.py index 414a6c83..00d0f403 100644 --- a/docs/how_to_build_a_model/scripts/smoke_test.py +++ b/docs/how_to_build_a_model/scripts/smoke_test.py @@ -1,70 +1,70 @@ -""" -Applies a loader and transform pipeline to each example and runs a smoke test. -Created in part 2 of the How to Build a Model with AtomWorks tutorial series. +"""Load one Part 1 example through the Part 2 transform pipeline.""" -Transforms applied: -- RemoveHydrogens -- RemoveUnresolvedAtoms -- CropToPocket -- FeaturizeForDocking -""" +import argparse +from pathlib import Path +import numpy as np import pandas as pd +from transforms import CropToPocket, FeaturizeForDocking + from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units -from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.transforms.base import Compose +from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms -# NOTE: import assumes you run this script from the directory containing transforms.py. - -from transforms import CropToPocket, FeaturizeForDocking - -# Load in the training data as a pandas DataFrame -df_train = pd.read_parquet("splits/train.parquet") -# Define the transform pipeline -transforms_pipeline = Compose([ - RemoveHydrogens(), - RemoveUnresolvedAtoms(), - CropToPocket(radius=10.0), - FeaturizeForDocking(), -]) +def build_dataset(split_path: Path) -> PandasDataset: + """Build the tutorial dataset from a Part 1 Parquet split.""" + pipeline = Compose( + [ + RemoveHydrogens(), + RemoveUnresolvedAtoms(), + CropToPocket(radius=10.0), + FeaturizeForDocking(), + ] + ) + return PandasDataset( + data=pd.read_parquet(split_path), + name="docking_train", + id_column="example_id", + loader=create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, + ), + transform=pipeline, + ) -# Build the AtomWorks PandasDataset -dataset = PandasDataset( - data=df_train, - name="docking_train", - id_column="example_id", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"]), - transform=transforms_pipeline, -) -################################ -# Print statements for testing # -################################ -print(f"Dataset size: {len(dataset)}") +def main() -> None: + """Print shapes and check the basic feature contract for one example.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("split", type=Path, nargs="?", default=Path("splits/train.parquet")) + parser.add_argument("--index", type=int, default=0, help="Row to load from the split.") + args = parser.parse_args() -example = dataset[0] + dataset = build_dataset(args.split) + example = dataset[args.index] + atom_count = len(example["atomic_numbers"]) + assert example["target_coords"].shape == (atom_count, 3) + assert example["input_coords"].shape == (atom_count, 3) + assert example["is_ligand"].shape == (atom_count,) + assert example["edge_index"].shape[0] == 2 + assert not example["edge_index"].size or example["edge_index"].max() < atom_count + assert np.all(example["input_coords"][example["is_ligand"]] == 0) + assert np.array_equal( + example["input_coords"][~example["is_ligand"]], + example["target_coords"][~example["is_ligand"]], + ) + assert example["is_ligand"].any() and (~example["is_ligand"]).any() -print("\nLoaded one sample successfully.") -print("Sample keys:", list(example.keys())) -print("atomic_numbers:", example["atomic_numbers"].shape, example["atomic_numbers"].dtype) -print("input_coords:", example["input_coords"].shape, example["input_coords"].dtype) -print("target_coords:", example["target_coords"].shape, example["target_coords"].dtype) -print("edge_index:", example["edge_index"].shape, example["edge_index"].dtype) -print("is_ligand:", example["is_ligand"].shape, example["is_ligand"].dtype) + print(f"Loaded {example['example_id']} from {len(dataset)} examples") + for name in ("atomic_numbers", "input_coords", "target_coords", "edge_index", "is_ligand"): + value = example[name] + print(f"{name}: shape={value.shape}, dtype={value.dtype}") -assert example["atomic_numbers"].ndim == 1 -assert example["target_coords"].ndim == 2 -assert example["target_coords"].shape[1] == 3 -assert example["input_coords"].shape == example["target_coords"].shape -assert example["edge_index"].ndim == 2 -assert example["edge_index"].shape[0] == 2 -assert example["edge_index"].max() < example["atomic_numbers"].shape[0] -assert (example["input_coords"][example["is_ligand"]] == 0).all(), \ - "Ligand coordinates should be zeroed" -assert (example["input_coords"][~example["is_ligand"]] != 0).any(), \ - "Pocket coordinates should not all be zero" -print("\nFeaturizeForDocking smoke test passed.") +if __name__ == "__main__": + main() diff --git a/docs/how_to_build_a_model/scripts/train.py b/docs/how_to_build_a_model/scripts/train.py index 036eb804..78d200ff 100644 --- a/docs/how_to_build_a_model/scripts/train.py +++ b/docs/how_to_build_a_model/scripts/train.py @@ -2,31 +2,29 @@ binding poses in protein pockets. Created in Part 4 of the How to Build a Model with AtomWorks tutorial.""" -import torch +import argparse +from pathlib import Path + import pandas as pd import pytorch_lightning as pl -from pytorch_lightning.callbacks import ModelCheckpoint, EarlyStopping -from torch.utils.data import DataLoader, Dataset - -from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units -from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms -from atomworks.ml.transforms.base import ConvertToTorch, Compose +import torch # NOTE: These imports match the tutorial text and assume you run train.py from -# the directory that contains transforms.py and model.py. - -from transforms import CropToPocket, FeaturizeForDocking +# the directory that contains transforms.py and model.py. from model import PocketDockGNN +from pytorch_lightning.callbacks import EarlyStopping, ModelCheckpoint +from torch.utils.data import DataLoader, Dataset +from transforms import CropToPocket, FeaturizeForDocking -torch.set_float32_matmul_precision("medium") -pl.seed_everything(42) +from atomworks.ml.datasets import PandasDataset +from atomworks.ml.datasets.loaders import create_structure_loader +from atomworks.ml.transforms.base import Compose, ConvertToTorch +from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveUnresolvedAtoms CONFIG = { "hidden_dim": 128, "num_layers": 3, "learning_rate": 1e-3, - "batch_size": 1, "max_epochs": 5, "pocket_radius": 10.0, "num_workers": 0, @@ -47,48 +45,51 @@ class RobustDataset(Dataset): def __init__(self, dataset: PandasDataset): self.dataset = dataset - self.failed = [] + self.failed: list[int] = [] def __len__(self): return len(self.dataset) - def __getitem__(self, idx): + def __getitem__(self, idx: int) -> dict | None: try: return self.dataset[idx] - except Exception: + except (OSError, ValueError, KeyError, IndexError) as exc: + print(f"Skipping example {idx}: {exc}") self.failed.append(idx) return None -def collate_fn(batch): +def collate_fn(batch: list[dict | None]) -> dict[str, torch.Tensor] | None: batch = [b for b in batch if b is not None] if len(batch) == 0: return None - return { - k: torch.stack([example[k] for example in batch]) - for k in TENSOR_KEYS - if k in batch[0] - } + return {k: torch.stack([example[k] for example in batch]) for k in TENSOR_KEYS} def build_pipeline(radius: float) -> Compose: - return Compose([ - RemoveHydrogens(), - RemoveUnresolvedAtoms(), - CropToPocket(radius=radius), - FeaturizeForDocking(), - ConvertToTorch(keys=TENSOR_KEYS), - ]) + return Compose( + [ + RemoveHydrogens(), + RemoveUnresolvedAtoms(), + CropToPocket(radius=radius), + FeaturizeForDocking(), + ConvertToTorch(keys=TENSOR_KEYS), + ] + ) -def build_dataset(parquet_path: str, name: str, radius: float, max_examples: int = None): +def build_dataset(parquet_path: str, name: str, radius: float, max_examples: int | None = None) -> RobustDataset: df = pd.read_parquet(parquet_path) if max_examples is not None: df = df.head(max_examples).reset_index(drop=True) - loader = create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader = create_structure_loader( + altloc_seed_colname="altloc_seed", + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, ) dataset = PandasDataset( @@ -97,15 +98,14 @@ def build_dataset(parquet_path: str, name: str, radius: float, max_examples: int id_column="example_id", loader=loader, transform=build_pipeline(radius), - save_failed_examples_to_dir="failed_examples/", ) return RobustDataset(dataset) -def build_dataloader(dataset, shuffle: bool) -> DataLoader: +def build_dataloader(dataset: RobustDataset, shuffle: bool) -> DataLoader: return DataLoader( dataset, - batch_size=CONFIG["batch_size"], + batch_size=1, shuffle=shuffle, num_workers=CONFIG["num_workers"], collate_fn=collate_fn, @@ -113,16 +113,27 @@ def build_dataloader(dataset, shuffle: bool) -> DataLoader: ) -def main(): +def main() -> None: + """Train and evaluate on Part 1 split files.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--splits-dir", type=Path, default=Path("splits")) + parser.add_argument("--output-dir", type=Path, default=Path("tutorial_run")) + parser.add_argument("--max-epochs", type=int, default=CONFIG["max_epochs"]) + parser.add_argument("--max-train", type=int, default=CONFIG["max_train"]) + parser.add_argument("--max-val", type=int, default=CONFIG["max_val"]) + parser.add_argument("--max-test", type=int, default=CONFIG["max_test"]) + args = parser.parse_args() + torch.set_float32_matmul_precision("medium") + pl.seed_everything(42) print("Building datasets...") train_dataset = build_dataset( - "splits/train.parquet", "docking_train", CONFIG["pocket_radius"], CONFIG["max_train"] + args.splits_dir / "train.parquet", "docking_train", CONFIG["pocket_radius"], args.max_train ) val_dataset = build_dataset( - "splits/val.parquet", "docking_val", CONFIG["pocket_radius"], CONFIG["max_val"] + args.splits_dir / "validation.parquet", "docking_val", CONFIG["pocket_radius"], args.max_val ) test_dataset = build_dataset( - "splits/test.parquet", "docking_test", CONFIG["pocket_radius"], CONFIG["max_test"] + args.splits_dir / "test.parquet", "docking_test", CONFIG["pocket_radius"], args.max_test ) print(f" Train: {len(train_dataset):,} examples") @@ -141,7 +152,7 @@ def main(): print(f"\nModel parameters: {sum(p.numel() for p in model.parameters()):,}") periodic_checkpoint = ModelCheckpoint( - dirpath="checkpoints/", + dirpath=args.output_dir / "checkpoints", filename="pocketdockgnn-{epoch:02d}-{step}", every_n_train_steps=50, save_last=True, @@ -149,8 +160,8 @@ def main(): ) best_checkpoint = ModelCheckpoint( - dirpath="checkpoints/", - filename="pocketdockgnn-best-{epoch:02d}-{val/loss:.4f}", + dirpath=args.output_dir / "checkpoints", + filename="pocketdockgnn-best-{epoch:02d}", monitor="val/loss", mode="min", save_top_k=3, @@ -169,12 +180,13 @@ def main(): ] trainer = pl.Trainer( - max_epochs=CONFIG["max_epochs"], - accelerator="gpu", + max_epochs=args.max_epochs, + accelerator="auto", devices=1, callbacks=callbacks, - log_every_n_steps=10, - val_check_interval=50, + log_every_n_steps=1, + default_root_dir=args.output_dir, + check_val_every_n_epoch=1, enable_progress_bar=True, ) @@ -188,6 +200,8 @@ def main(): print(f"\nFailed examples during training: {len(train_dataset.failed)}") print(f"Failed examples during val: {len(val_dataset.failed)}") print(f"Best checkpoint: {best_checkpoint.best_model_path}") + if not best_checkpoint.best_model_path: + raise RuntimeError("No validation checkpoint was saved; inspect skipped examples") print("\nEvaluating on test set...") trainer.test( diff --git a/docs/how_to_build_a_model/scripts/transforms.py b/docs/how_to_build_a_model/scripts/transforms.py index 02604fe6..b20f35c1 100644 --- a/docs/how_to_build_a_model/scripts/transforms.py +++ b/docs/how_to_build_a_model/scripts/transforms.py @@ -1,79 +1,72 @@ -"""Script that creates the custom transforms used to train our model. -This script is created in part 2 of the How to Build a Model with AtomWorks -tutorial.""" +"""Pocket cropping and featurization for the model-building tutorial.""" + +from typing import ClassVar -from atomworks.ml.transforms.base import Transform import numpy as np from biotite.structure import AtomArray from scipy.spatial import cKDTree -from atomworks.ml.transforms._checks import check_atom_array_annotation + from atomworks.constants import ELEMENT_NAME_TO_ATOMIC_NUMBER +from atomworks.ml.transforms._checks import check_atom_array_annotation, check_contains_keys +from atomworks.ml.transforms.base import Transform class CropToPocket(Transform): - requires_previous_transforms = ["RemoveHydrogens", "RemoveUnresolvedAtoms"] + """Keep the ligand and nearby protein atoms.""" + + requires_previous_transforms: ClassVar[list[str]] = ["RemoveHydrogens", "RemoveUnresolvedAtoms"] def __init__(self, radius: float = 10.0) -> None: super().__init__() self.radius = radius + def check_input(self, data: dict) -> None: + check_contains_keys(data, ["atom_array", "query_pn_unit_iids", "query_is_polymer"]) + check_atom_array_annotation(data, ["pn_unit_iid"]) + def forward(self, data: dict) -> dict: data["atom_array"] = crop_to_pocket( data["atom_array"], query_pn_unit_iids=data["query_pn_unit_iids"], - chain_info=data["chain_info"], + query_is_polymer=data["query_is_polymer"], radius=self.radius, ) return data - def check_input(self, data: dict) -> None: - assert "atom_array" in data, "Missing atom_array" - assert "query_pn_unit_iids" in data, "Missing query_pn_unit_iids" - assert "chain_info" in data, "Missing chain_info" - class FeaturizeForDocking(Transform): - requires_previous_transforms = ["CropToPocket"] + """Add atom, bond, and coordinate arrays for the docking model.""" + + requires_previous_transforms: ClassVar[list[str]] = ["CropToPocket"] def check_input(self, data: dict) -> None: + check_contains_keys(data, ["atom_array"]) check_atom_array_annotation(data, ["is_ligand"]) def forward(self, data: dict) -> dict: - features = featurize_for_docking(data["atom_array"]) - data.update(features) + data.update(featurize_for_docking(data["atom_array"])) return data def crop_to_pocket( atom_array: AtomArray, - query_pn_unit_iids: list, - chain_info: dict, + query_pn_unit_iids: list[str], + query_is_polymer: list[bool], radius: float = 10.0, ) -> AtomArray: - atom_array = atom_array.copy() - - iid_a, iid_b = query_pn_unit_iids - chain_a = iid_a.split("_")[0] - chain_b = iid_b.split("_")[0] - - a_is_polymer = chain_info.get(chain_a, {}).get("is_polymer", True) - b_is_polymer = chain_info.get(chain_b, {}).get("is_polymer", True) - - if not a_is_polymer: - ligand_iid, protein_iid = iid_a, iid_b - elif not b_is_polymer: - ligand_iid, protein_iid = iid_b, iid_a - else: - mask_a = atom_array.pn_unit_iid == iid_a - mask_b = atom_array.pn_unit_iid == iid_b - if mask_a.sum() <= mask_b.sum(): - ligand_iid, protein_iid = iid_a, iid_b - else: - ligand_iid, protein_iid = iid_b, iid_a + """Keep the complete ligand and protein atoms within ``radius`` angstroms of it.""" + if radius <= 0: + raise ValueError(f"Pocket radius must be positive, got {radius}") + if len(query_pn_unit_iids) != 2 or len(query_is_polymer) != 2 or sum(query_is_polymer) != 1: + raise ValueError("Expected two query PN units with exactly one polymer") + + protein_side = 0 if query_is_polymer[0] else 1 + ligand_side = 1 - protein_side + ligand_iid = query_pn_unit_iids[ligand_side] + protein_iid = query_pn_unit_iids[protein_side] ligand_mask = atom_array.pn_unit_iid == ligand_iid protein_mask = atom_array.pn_unit_iid == protein_iid - ligand_coords = atom_array.coord[ligand_mask] protein_coords = atom_array.coord[protein_mask] @@ -82,41 +75,34 @@ def crop_to_pocket( if len(protein_coords) == 0: raise ValueError(f"Protein {protein_iid} has no atoms") - tree = cKDTree(protein_coords) - neighbor_indices = tree.query_ball_point(ligand_coords, r=radius) - - total_neighbors = sum(len(n) for n in neighbor_indices) - if total_neighbors == 0: - raise ValueError(f"No protein atoms found within {radius}A of ligand {ligand_iid}") + neighbors = cKDTree(protein_coords).query_ball_point(ligand_coords, r=radius) + if not any(neighbors): + raise ValueError(f"No protein atoms found within {radius} Å of ligand {ligand_iid}") - pocket_local_indices = np.unique(np.concatenate(neighbor_indices).astype(int)) - protein_global_indices = np.where(protein_mask)[0] - pocket_global_indices = protein_global_indices[pocket_local_indices] - ligand_global_indices = np.where(ligand_mask)[0] + pocket_local_indices = np.unique(np.concatenate(neighbors).astype(int)) + protein_global_indices = np.flatnonzero(protein_mask) + ligand_global_indices = np.flatnonzero(ligand_mask) + keep = np.sort(np.concatenate([protein_global_indices[pocket_local_indices], ligand_global_indices])) - keep = np.sort(np.concatenate([pocket_global_indices, ligand_global_indices])) cropped = atom_array[keep] - - is_ligand = np.isin(keep, ligand_global_indices) - cropped.set_annotation("is_ligand", is_ligand) - + cropped.set_annotation("is_ligand", ligand_mask[keep]) return cropped -def featurize_for_docking(atom_array: AtomArray) -> dict: +def featurize_for_docking(atom_array: AtomArray) -> dict[str, np.ndarray]: + """Build atom, bond, and coordinate arrays from a cropped pocket.""" is_ligand = atom_array.is_ligand.astype(bool) target_coords = atom_array.coord.astype(np.float32) - input_coords = target_coords.copy() input_coords[is_ligand] = 0.0 atomic_numbers = np.array( - [ELEMENT_NAME_TO_ATOMIC_NUMBER.get(e.upper(), 0) for e in atom_array.element], + [ELEMENT_NAME_TO_ATOMIC_NUMBER.get(element.upper(), 0) for element in atom_array.element], dtype=np.int64, ) - - bonds = atom_array.bonds.as_array() - edge_index = bonds[:, :2].T.astype(np.int64) + if atom_array.bonds is None: + raise ValueError("Cropped atom array has no bond list") + edge_index = atom_array.bonds.as_array()[:, :2].T.astype(np.int64) return { "atomic_numbers": atomic_numbers, diff --git a/docs/index.rst b/docs/index.rst index d98c0596..81aa51c0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,20 +13,18 @@ Welcome to **atomworks** — a toolkit for converting, parsing, and manipulating :alt: atomworks datapipelines -.. toctree:: - :maxdepth: 2 - :caption: Building a Model with AtomWorks - - how_to_build_a_model/index - .. toctree:: :maxdepth: 2 :caption: Navigation tutorial/index + how_to_build_a_model/index + cookbook installation + migration/index glossary api_reference auto_examples/index contributor_guide - mirrors \ No newline at end of file + mirrors + msa diff --git a/docs/installation.rst b/docs/installation.rst index 3afa9e3a..afdb811c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -9,7 +9,7 @@ AtomWorks can be installed in several ways, depending on your workflow and envir Before installing AtomWorks, ensure you have the following prerequisites: * Python 3.11 or higher -* `dotenv `_ +* Pip installs ``python-dotenv`` automatically; Node.js is not required. 1. Installing via pip (recommended) ----------------------------------- @@ -20,7 +20,7 @@ This is the easiest way to get started with AtomWorks. pip install atomworks # base installation version without torch (for only atomworks.io) pip install "atomworks[ml]" # with torch and ML dependencies (for atomworks.io plus atomworks.ml) pip install "atomworks[dev]" # with development dependencies - pip install "atomworks[ml,dev]" # with all dependencies" + pip install "atomworks[ml,dev]" # ML and development tools You can also install AtomWorks with `Open Babel `_, an alternative to RDKit: @@ -28,7 +28,7 @@ You can also install AtomWorks with `Open Babel `_, an a pip install "atomworks[openbabel]" -or for all possible dependencies: +Combine extras as needed: .. code-block:: bash @@ -38,72 +38,33 @@ Open Babel is not automatically installed with AtomWorks due to its larger size 2. Development Installation --------------------------- -For development: +For development, use a virtual environment: .. code-block:: bash - git clone https://github.com/RosettaCommons/atomworks.git + git clone --branch release/atomworks-3-0 https://github.com/RosettaCommons/atomworks.git cd atomworks + python -m venv .venv + source .venv/bin/activate make install # or pip install -e ".[dev]" -To install in a fresh environment: - -.. code-block:: bash - - git clone https://github.com/RosettaCommons/atomworks.git - cd atomworks - make env - 3. Running the Test Suite ------------------------- -To run the AtomWorks test suite, you need to download the test data and configure environment variables. - -**Step 1: Download test data** - -From the repository root, run: - -.. code-block:: bash - - atomworks setup tests - -This downloads and extracts the test pack (~500 MB) to ``tests/data/``, which includes: - -* ``tests/data/pdb/`` — A mini PDB mirror with test structures -* ``tests/data/ccd/`` — A mini CCD mirror with test ligand definitions -* ``tests/data/shared/`` — MSA files, templates, and metadata for ML tests - -**Step 2: Create a .env file** - -Create a ``.env`` file in the repository root with the paths to the test data: - -.. code-block:: bash - - # For running tests with the test pack: - PDB_MIRROR_PATH=tests/data/pdb - CCD_MIRROR_PATH=tests/data/ccd - -You can copy ``.env.sample`` as a starting point: +Public CI runs on GitHub-hosted machines using a versioned bundle containing the +shared fixtures and the PDB subset. To use the same inputs locally: .. code-block:: bash - cp .env.sample .env - # Then edit .env to set the paths above - -**Step 3: Run the tests** - -.. code-block:: bash - - # Run all tests (excluding very slow ones) - pytest tests -m "not very_slow" - - # Run tests in parallel for faster execution - pytest tests -m "not very_slow" -n auto - - # Run a specific test file - pytest tests/io/components/test_parser.py + mkdir -p tests/data + curl --fail --location --retry 3 https://github.com/RosettaCommons/atomworks/releases/download/test-data-3.0.0/atomworks-test-data-3.0.0.tar.gz | tar -xz -C tests/data + pip install -e ".[ml,dev,ase,openbabel]" + pytest tests -n 2 --dist=worksteal -m "not benchmark and not slow and not requires_digs and not requires_pymol_remote and not requires_x3dna" +The bundle includes its public-source provenance. Tests use Biotite's bundled CCD; +a full PDB/CCD mirror is not required. Infrastructure-dependent tests remain excluded. +Run the slow tests separately with one worker when needed to limit memory use. 4. Setting Up Full PDB/CCD Mirrors ---------------------------------- diff --git a/docs/io/transforms.rst b/docs/io/transforms.rst index e38b74e7..c9580bbb 100644 --- a/docs/io/transforms.rst +++ b/docs/io/transforms.rst @@ -5,4 +5,4 @@ Transforms :maxdepth: 2 transforms/atom_array - transforms/categories \ No newline at end of file + transforms/categories diff --git a/docs/io/transforms/atom_array.rst b/docs/io/transforms/atom_array.rst index 16501695..d3690af6 100644 --- a/docs/io/transforms/atom_array.rst +++ b/docs/io/transforms/atom_array.rst @@ -4,4 +4,4 @@ Atom Array Transforms .. automodule:: atomworks.io.transforms.atom_array :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/transforms/categories.rst b/docs/io/transforms/categories.rst index 15b73125..eb763bf9 100644 --- a/docs/io/transforms/categories.rst +++ b/docs/io/transforms/categories.rst @@ -4,4 +4,4 @@ Category Transforms .. automodule:: atomworks.io.transforms.categories :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/utils.rst b/docs/io/utils.rst index f59cd1f8..e857109e 100644 --- a/docs/io/utils.rst +++ b/docs/io/utils.rst @@ -10,6 +10,8 @@ Utilities utils/chain utils/io_utils utils/non_rcsb + utils/selection_syntax + utils/query utils/selection utils/sequence utils/testing diff --git a/docs/io/utils/_render_selection_examples/gen_interactive.py b/docs/io/utils/_render_selection_examples/gen_interactive.py new file mode 100644 index 00000000..83b5304f --- /dev/null +++ b/docs/io/utils/_render_selection_examples/gen_interactive.py @@ -0,0 +1,178 @@ +"""Generate the interactive 3Dmol.js selection widget for the docs. + +Run in the atomworks ``.venv`` (needs ``PDB_MIRROR_PATH`` set): + + python gen_interactive.py docs/_static/selection/interactive.html + +Produces a single self-contained HTML fragment: **one** 3Dmol.js viewer plus a row of buttons +that toggle between selection strings. Each button updates the description text and re-highlights +the same viewer. Highlights are driven by the real ``atom_array.mask(...)`` output mapped to atom +serials, so what you see is exactly what the selection selects - including the coordinate-predicate +example that a viewer's own selection language could not express. + +Interactivity: pick a syntax with the buttons; drag to rotate, scroll to zoom, click a highlighted +atom to label it. +""" + +import json +import sys +from pathlib import Path + +import biotite.structure as struc +import biotite.structure.io.pdb as pdb +import numpy as np +from biotite.structure import AtomArray + +from atomworks.io.utils.io_utils import load_any +from atomworks.io.utils.testing import get_pdb_path + +PDB_ID = "1ycr" # MDM2 (chain A) in complex with the p53 transactivation peptide (chain B) +THREEDMOL_SRC = "https://cdn.jsdelivr.net/npm/3dmol@2.4.2/build/3Dmol-min.js" + + +def presets(atom_array: AtomArray) -> list[dict]: + """Toggle-able selections. ``expr`` is a real query string; ``style`` picks the highlight look.""" + z0 = float(np.median(atom_array.coord[atom_array.atom_name == "CA", 2])) + return [ + { + "label": "Whole chain", + "expr": "sel('B')", + "desc": "A bare chain id selects every atom of that chain - here the p53 peptide (chain B).", + "style": "stick", + }, + { + "label": "One residue", + "expr": "sel('B/TRP')", + "desc": "chain / residue-name: a single tryptophan, small enough to see every atom.", + "style": "stick", + }, + { + "label": "Residue-type list", + "expr": "sel('B/[PHE,TRP,LEU]')", + "desc": "[ ... ] is a within-field list - any of these residue names (the p53 anchor residues).", + "style": "stick", + }, + { + "label": "Residue range", + "expr": "sel('A/*/[25-35]')", + "desc": "[lo-hi] is an inclusive residue-id range. Ranges must be bracketed.", + "style": "stick", + }, + { + "label": "Atom-name list", + "expr": "sel('A/*/*/[N,CA,C,O]')", + "desc": "[ ... ] on the atom field selects just these atoms - the protein backbone.", + "style": "stick", + }, + { + "label": "List and predicate", + "expr": f"sel('[A,B]/*/*/CA') & (z > {z0:.2f})", + "desc": "sel(...) composes inside a query: Ca of both chains, intersected with a coordinate cutoff.", + "style": "sphere", + }, + ] + + +HTML = """ + + +
+
+
+
+
pick a syntax above · drag to rotate · scroll to zoom · click a highlighted atom to label it
+
+ +""" + + +def main(out_path: Path) -> None: + atom_array = load_any(get_pdb_path(PDB_ID), model=1) + + # Write the structure once as a PDB string (serials are sequential 1..N in array order). + plain = struc.AtomArray(atom_array.array_length()) + plain.coord = atom_array.coord + for annot in ("chain_id", "res_id", "res_name", "atom_name", "element"): + if annot in atom_array.get_annotation_categories(): + plain.set_annotation(annot, atom_array.get_annotation(annot)) + tmp = out_path.parent / "_tmp_structure.pdb" + f = pdb.PDBFile() + f.set_structure(plain) + f.write(str(tmp)) + pdb_string = tmp.read_text() + tmp.unlink() + + configs = [] + for ex in presets(atom_array): + mask = atom_array.mask(ex["expr"]) # <-- the actual feature under test + idxs = [int(v) for v in np.where(mask)[0]] + assert idxs, f"{ex['expr']!r} selected no atoms" + configs.append({**ex, "idxs": idxs}) + print(f"{ex['label']:>18}: {len(idxs):>4d} atoms <- {ex['expr']}") + + html = HTML.format(src=THREEDMOL_SRC, pdb_json=json.dumps(pdb_string), presets_json=json.dumps(configs)) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html) + print(f"wrote {out_path} ({len(html) // 1024} KB, {len(configs)} presets, 1 viewer)") + + +if __name__ == "__main__": + main(Path(sys.argv[1])) diff --git a/docs/io/utils/assembly.rst b/docs/io/utils/assembly.rst index 8f707686..c6c91996 100644 --- a/docs/io/utils/assembly.rst +++ b/docs/io/utils/assembly.rst @@ -4,4 +4,4 @@ Assembly Utilities .. automodule:: atomworks.io.utils.assembly :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/utils/bonds.rst b/docs/io/utils/bonds.rst index 91bf7dc4..e73e2e5f 100644 --- a/docs/io/utils/bonds.rst +++ b/docs/io/utils/bonds.rst @@ -4,4 +4,4 @@ Bond Utilities .. automodule:: atomworks.io.utils.bonds :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/utils/chain.rst b/docs/io/utils/chain.rst index cb993611..9cae6f0f 100644 --- a/docs/io/utils/chain.rst +++ b/docs/io/utils/chain.rst @@ -4,4 +4,4 @@ Chain Utilities .. automodule:: atomworks.io.utils.chain :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/utils/non_rcsb.rst b/docs/io/utils/non_rcsb.rst index 771b813b..abbf55e8 100644 --- a/docs/io/utils/non_rcsb.rst +++ b/docs/io/utils/non_rcsb.rst @@ -4,4 +4,4 @@ Non-RCSB Utilities .. automodule:: atomworks.io.utils.non_rcsb :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/utils/query.rst b/docs/io/utils/query.rst new file mode 100644 index 00000000..2908ab77 --- /dev/null +++ b/docs/io/utils/query.rst @@ -0,0 +1,9 @@ +Query & Selection +================= + +See :doc:`selection_syntax` for a narrative guide with rendered examples. + +.. automodule:: atomworks.io.utils.query + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/io/utils/selection.rst b/docs/io/utils/selection.rst index 70f8aee2..a0b5a11b 100644 --- a/docs/io/utils/selection.rst +++ b/docs/io/utils/selection.rst @@ -1,5 +1,9 @@ -Selection Utilities -=================== +Segment & Annotation Utilities +============================== + +Segment slicing (:py:class:`~atomworks.io.utils.selection.ResIdxSlice`, +:py:class:`~atomworks.io.utils.selection.ChainIdxSlice`) and annotation helpers. For the +atom-selection string languages, see :doc:`selection_syntax` and :doc:`query`. .. automodule:: atomworks.io.utils.selection :members: diff --git a/docs/io/utils/selection_syntax.rst b/docs/io/utils/selection_syntax.rst new file mode 100644 index 00000000..bdbbfce3 --- /dev/null +++ b/docs/io/utils/selection_syntax.rst @@ -0,0 +1,154 @@ +Atom Selection Syntax +===================== + +atomworks provides two complementary string languages for selecting atoms, both living in +:py:mod:`atomworks.io.utils.query` and both usable directly on any ``AtomArray`` / +``AtomArrayStack`` via the monkey-patched ``.query()``, ``.mask()`` and ``.idxs()`` methods: + +- **The query language** — pandas-like boolean expressions over annotations and coordinates, + e.g. ``"(chain_id == 'A') & (res_id < 50)"``, ``"res_name in ['ALA','GLY']"``, ``"z > 0"``, + ``"has_bonds()"``. Powerful and composable. +- **The path-selection DSL** — a terse, path-like shorthand over five ordered fields, + e.g. ``"A/ALA/1/CA"``. Compact for the common "chain / residue / atom" case. + +The two are bridged by the ``sel('...')`` function, so a path selection can appear **inside** a +query expression and combine with any predicate — see :ref:`sel-in-query` below. + +.. contents:: + :local: + :depth: 1 + + +The path grammar +---------------- + +A path selection has five ``/``-separated fields, always in this order (trailing fields may be +omitted and default to ``*``):: + + CHAIN_ID / RES_NAME / RES_ID / ATOM_NAME / TRANSFORMATION_ID + +Each field is one of: + +.. list-table:: + :header-rows: 1 + :widths: 22 20 58 + + * - Form + - Example + - Meaning + * - scalar + - ``A``, ``ALA``, ``CA`` + - Exactly that value. + * - wildcard + - ``*`` + - Any value (also the default for omitted trailing fields). + * - list ``[...]`` + - ``[A,B]``, ``[CA,CB]`` + - Any of the listed values (a within-field OR). + * - range (``res_id`` only) + - ``[5-10]``, ``[1-5, 9]`` + - Inclusive integer range(s); must be inside brackets. + +Multiple whole selections separated by top-level commas (or given as a ``list[str]``) are +**unioned**. Commas *inside* ``[...]`` belong to the list, not the union:: + + "A/*/[5-10], B/*/[3-8]" # residues 5-10 of chain A OR residues 3-8 of chain B + +Lists across fields expand as a Cartesian product, then union. For example +``"[A,B]/ALA/1/[CA,CB]"`` is the union of the four selections +``A/ALA/1/CA``, ``A/ALA/1/CB``, ``B/ALA/1/CA``, ``B/ALA/1/CB``. + +.. note:: + **Ranges must be bracketed.** ``A/*/[5-10]`` is a range; a bare ``A/*/5-10`` is an error. + This keeps a single, uniform multi-value mechanism (everything multi-valued lives in ``[...]``) + and lets awkward ids be listed explicitly, e.g. negative residue ids ``A/*/[-5, -3, 0]``. + +.. note:: + ``[...]`` lists are multi-valued and therefore only valid through the multi-selection entry + points — :py:meth:`~atomworks.io.utils.query.AtomSelectionStack.from_query` and the query + ``sel('...')`` function. The single-valued :py:meth:`~atomworks.io.utils.query.AtomSelection.from_selection_str` + rejects brackets. + + +Interactive gallery +------------------- + +Pick a selection syntax with the buttons below to see what it matches on **1YCR** — the MDM2 +domain (chain A) bound to the p53 transactivation peptide (chain B). The viewer highlights exactly +the atoms that ``atom_array.mask()`` returns (the mask is baked into the model as atom +serials), so what you see is what the selection selects — including the coordinate-predicate option +that a viewer's own selection language could not express. **Drag to rotate, scroll to zoom, and +click any highlighted atom to label it.** + +.. raw:: html + :file: ../../_static/selection/interactive.html + +.. note:: + The viewers load `3Dmol.js `_ from a CDN, so an internet connection is + needed to see them; without JavaScript the page still renders the surrounding text. + + +.. _sel-in-query: + +Combining path selection with the query language +------------------------------------------------ + +Inside a query expression, ``sel('')`` evaluates a path selection to a boolean mask, so it +composes with every query operator (``&``, ``|``, ``~``, comparisons, ``in``, ``has_bonds()`` …): + +.. code-block:: python + + # Cα atoms of chain A that are also above a plane + atom_array.query("sel('A/*/*/CA') & (z > 0)") + + # the p53 anchor residues, excluding anything with NaN coordinates + atom_array.mask("sel('B/[PHE,TRP,LEU]') & ~has_nan_coord()") + +.. note:: + ``sel``'s argument must be a **quoted string literal**: the query is ordinary Python-expression + syntax parsed by ``ast``, so an unquoted ``A/*/*/CA`` would parse as arithmetic over undefined + names. Only one nesting level ever arises — the outer query string and the inner ``sel`` argument + — so ``"..."`` outside and ``'...'`` inside is enough (standard Python quote alternation; ``ast`` + handles it). For a pure path selection with no predicate, skip the query layer and call + :py:meth:`~atomworks.io.utils.query.AtomSelectionStack.from_query` directly — no nested quotes. + +The query side is not limited to the five path fields — it exposes **every annotation on the +array** as a variable. ``ensure_annotations`` (from ``atomworks.io.utils.annotator``) computes a +large registry of derived annotations on demand, so rich selections become one-liners that still +compose with ``sel('...')``: + +.. code-block:: python + + from atomworks.io.utils.annotator import ensure_annotations + + # protein backbone atoms + ensure_annotations(atom_array, "is_protein_backbone") + atom_array.query("is_protein_backbone") + + # non-canonical amino acids: protein atoms that are not a standard (or unknown) residue + ensure_annotations(atom_array, "is_protein", "is_standard_or_unknown_aa") + atom_array.query("is_protein & ~is_standard_or_unknown_aa") + +Any boolean or scalar annotation works — e.g. ``is_ligand``, ``is_metal``, ``is_nucleic_acid``, +``is_protein_sidechain``, ``atomic_number``, ``chem_comp_type``, or a custom annotation you set +yourself. + +Unlike a standalone selection, ``sel('...')`` is **non-raising**: if it matches nothing it +contributes an all-``False`` mask instead of raising, so it degrades gracefully inside a larger +boolean expression. + + +Programmatic API +---------------- + +.. code-block:: python + + from atomworks.io.utils.query import AtomSelection, AtomSelectionStack + + # Single, one-valued selection + AtomSelection.from_selection_str("A/ALA/1/CA").get_mask(atom_array) + + # Multi-valued selection (lists, ranges, unions) -> a union of selections + AtomSelectionStack.from_query("[A,B]/*/[25-35]/CA").get_mask(atom_array) + +See :doc:`query` for the full API reference. diff --git a/docs/io/utils/testing.rst b/docs/io/utils/testing.rst index 9b3cb20e..20c2a66c 100644 --- a/docs/io/utils/testing.rst +++ b/docs/io/utils/testing.rst @@ -4,4 +4,4 @@ Testing Utilities .. automodule:: atomworks.io.utils.testing :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/io/utils/visualize.rst b/docs/io/utils/visualize.rst index d66dbccb..00509b21 100644 --- a/docs/io/utils/visualize.rst +++ b/docs/io/utils/visualize.rst @@ -4,4 +4,4 @@ Visualization Utilities .. automodule:: atomworks.io.utils.visualize :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/migration/index.rst b/docs/migration/index.rst new file mode 100644 index 00000000..85770609 --- /dev/null +++ b/docs/migration/index.rst @@ -0,0 +1,11 @@ +Migrating to AtomWorks 3.0 +========================== + +Start with the short guide for the main changes, then use the detailed guide +for examples and compatibility considerations. + +.. toctree:: + :maxdepth: 1 + + migration-short + migration-long diff --git a/docs/migration/migration-long.md b/docs/migration/migration-long.md new file mode 100644 index 00000000..aa2f617d --- /dev/null +++ b/docs/migration/migration-long.md @@ -0,0 +1,226 @@ +# AtomWorks 2.x → 3.0: detailed migration guide + +Start with the I/O changes below: parser configuration, chemical corrections, alternate conformers, CIF round trips and parser caches. The [short guide](migration-short.md) covers the essential steps; additional ML migration notes follow the I/O sections. + +## Parser calls and defaults + +For 2.x: + +```python +from atomworks.io import parse +from atomworks.io.parser import STANDARD_PARSER_ARGS + +options = {**STANDARD_PARSER_ARGS, "hydrogen_policy": "remove"} +result = parse(filename="structure.cif", **options) +``` + +For 3.0: + +```python +from atomworks.io import parse +from atomworks.io.config import ParseConfig + +config = ParseConfig.from_preset("rcsb", hydrogen_policy="remove") +result = parse("structure.cif", config=config) +``` + +`ParseConfig` and `PrepareConfig` are frozen dataclasses. Derive a changed configuration with `config.replace(...)`; serialize it with `to_dict()`. Prefer an explicit constructor when migrating old dictionaries: `from_dict()` deliberately drops unknown keys, which can silently discard obsolete chemistry flags or misspellings. Bare surviving options still work with deprecation warnings; obsolete names passed directly to `parse()` produce `TypeError`. [Configuration](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/config.py), [parser](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/parser.py). + +| 2.x usage or assumption | 3.0 action | +|---|---| +| `filename=path` | Use positional `source` or `source=path`; alias is deprecated. | +| `STANDARD_PARSER_ARGS` | Use `ParseConfig.from_preset("rcsb")`; dictionary remains deprecated. | +| `fix_formal_charges` / `fix_bond_types` | Remove; completing atoms includes integrated sanitization. There is no independent legacy-equivalence switch. | +| `hydrogen_policy="infer"` | Choose `"keep"`/`"remove"`; run explicit protonation separately when needed. | +| `parse_atom_array(atoms, **options)` | Use `parse_atom_array(atoms, config=PrepareConfig(...))` for a result dictionary. | +| Need only prepared atoms | Use `prepare_atom_array(atoms, config=PrepareConfig(...))`; single-model output is squeezed to AtomArray. | +| `remove_ccds=None` as old default sentinel | Omit the option for default crystallization-aid removal; use `remove_ccds=()` to keep them. | +| Assume all multi-model files produce one stack | Same-topology models can stack; variable-topology CIFs can return a **list of result dictionaries**. Handle this explicitly. | + +The ordinary parser result retains `asym_unit`, `assemblies`, `chain_info`, `ligand_info`, `metadata`, and `extra_info`. Do not infer guaranteed rank from the result-key names; choose models deliberately and inspect AtomArray versus AtomArrayStack. Models use Biotite positional conventions (positive positions are one-based); do not mistake a position for an arbitrary deposited model ID. For multi-model inputs, establish the chosen model before calling code requiring a single AtomArray. + +Important defaults remain intentional: all models (`model=None`), all assemblies, missing-atom completion enabled, hydrogen policy `"keep"`, waters removed. Default `ParseConfig()` retains MSE; `"rcsb"` converts MSE to MET. `"lightweight"` disables missing-atom completion; `"minimal"` additionally keeps waters/excluded components and disables several annotations/corrections. These are distinct processing choices, not interchangeable performance settings. `long_bond_policy="warn"` and `struct_conn_distance_policy="filter"` can expose or remove problematic connections. Inspect changed bond sets for unusual inputs. [Presets and policies](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/config.py). + +## Chemical corrections and existing checkpoints + +With missing-atom completion enabled, the pipeline resolves leaving atoms and overvalence, corrects relevant inter-residue formal charges/bond orders, and neutralizes charged amide nitrogens. Hydrogen counts are used in these corrections. With completion disabled, this sanitization is disabled too; selecting that mode solely to suppress changed charges also changes completeness and is not a reproduction of 2.x. [Pipeline](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/_pipeline.py), [link chemistry](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/utils/link_chemistry.py). + +The release tests identify concrete cases worth including in a downstream migration set: + +| Structures | Behavior to compare | +|---|---| +| `1ivo`, `4js1`, `1qfe` | Charges/hydrogen counts at covalent modifications and amide nitrogens. | +| `1a3g` | Schiff-base linkage bond order and carbon charge. | +| `1j8z`, `1a1e`, `1d9d` | Leaving hydrogen handling and resulting charges. | +| `1rcq` | Alternate conformers, `struct_conn`, and lysine charge. | +| `4ndz`, `6h9v` | Multi-residue ligand bonds and beta-peptide connectivity. | +| `1twr`, `6q9t` | Authored component chemistry and unusual atom names. | + +These are documented regression/round-trip cases, not a claim that every structure in a production corpus is chemically validated. [Case list](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/tests/io/utils/test_io.py#L417). + +For an existing model, preserve the old locked environment and a small set of its exact features. Compare by atom identity including assembly instance, not array offset alone. Review atom counts, charge/implicit-hydrogen annotations, bond topology and orders, masks and atomization. Evaluate the checkpoint on both feature sets and relevant scientific metrics. If necessary, retain the versioned 2.x preprocessing path for that checkpoint until retraining/fine-tuning or an explicitly validated adapter is available. Avoid silently mixing newly generated features with old cached training data. + +### Covalent links, reaction states and unsupported chemistry + +Link processing proposes connectivity, resolves the linked product, then validates its valence with RDKit. Recognized explicit `struct_conn` bond orders are retained; missing multiple-bond orders are constrained by both component templates. A candidate single bond is not rejected solely because an isolated CCD reactant is saturated: product formation may displace atoms or change internal bonds and charges. An unsupported explicit order raises instead of silently becoming a single bond. A double bond in one component's leaving group does not by itself authorize a double bond to the other component. + +Sequence adjacency also does not authorize arbitrary backbone chemistry. Polymer linking preserves an existing attachment to the adjacent residue and allows side-chain crosslinks alongside backbone bonds; the resulting product must still pass chemistry validation. Metal coordination is excluded from covalent leaving-group removal and valence accounting; a coordination bond alone should not remove a donor's hydrogens or change its charge. + +Selected alternate-location labels are retained through missing-atom rebuilding so that authored links remain associated with the selected reaction state. In `6dp5`, the B conformer retains an OP3–magnesium coordination contact, while C has the O3′–P covalent link; rebuilding must not combine these states. Distance filtering also recognizes authored glycosylation: links identified by a glycosylation role, or covalent links between carbohydrates, can be retained through 2.4 Å with a warning when above the usual threshold. This includes the 2.211 Å ASN–NAG link in `2odp`; it is not a general relaxation for every covalent link. + +Sanitization handles supported leaving-group substitution, addition across multiple bonds, hydrogen transfer, resonance changes and product charges while preserving observed atoms. Unsupported products still raise `ValueError`; for example, `1vq7` requires an invalid phosphorus bond-order sum of seven. Supply the product's component definitions and explicit connectivity/bond orders when required; do not treat disabling completion or ignoring the exception as a chemical repair. Custom components need consistent `chem_comp_atom` and `chem_comp_bond` definitions. + +Extend the migration set with cases that exercise these decisions: + +| Structures | Behavior to compare | +|---|---| +| `1rcq`, `1dpn` | Inferred link order and the resulting linked product's bond orders and valence. | +| `3n95`, `4aah` | Sequence-defined terminal caps and coexistence of backbone bonds with side-chain crosslinks. | +| `6dp5` | Conformer-specific covalent versus coordination links after rebuilding. | +| `2odp` | Retention of an authored glycosylation link above the usual distance threshold. | +| `1n4e`, `8qia`, `6n0a` | Thymine photodimer formation, addition across C=N with hydrogen transfer, and substitution of an unresolved ASN nitrogen. | +| `6w13`, `1vq7` | Preservation of observed phosphate oxygen and rejection of an invalid phosphorus product. | + +Compare successful outputs and rejected inputs: matching atom counts alone do not establish matching bonds, charges, hydrogen counts or conformer annotations. [Link validation and resolution](https://github.com/RosettaCommons/atomworks/blob/release/atomworks-3-0/src/atomworks/io/utils/link_chemistry.py), [product regressions](https://github.com/RosettaCommons/atomworks/blob/release/atomworks-3-0/tests/io/utils/test_link_product_regressions.py), [rebuilding pipeline](https://github.com/RosettaCommons/atomworks/blob/release/atomworks-3-0/src/atomworks/io/_pipeline.py). + +### Explicit protonation + +Protonation is a separate chemical operation. The experimental API separates pH-dependent +charge/hydrogen assignment from coordinate placement: + +```python +from atomworks.experimental.protonation import assign_hydrogens, place_hydrogens + +atoms = result["asym_unit"][0] # One AtomArray, not an AtomArrayStack +state = assign_hydrogens(atoms, ph=7.4) +protonated = place_hydrogens(state) +``` + +`add_hydrogens(atoms, ph=7.4)` combines these steps. Inputs need bonds with stated +orders, formal `charge`, and `pn_unit_iid` or `pn_unit_id` annotations. Parse metal +coordination with `"metalc"` in `add_bond_types_from_struct_conn` when relevant. +Assignment may change charges and remove hydrogens inconsistent with the chosen +state; `hydrogens=` explicitly declares counts and overrides pH assignment. +Placement keeps heavy-atom coordinates and retained hydrogen coordinates, inserts +new hydrogens by residue, and preserves `atom_id` identity. Recompute positional +masks after atom counts or ordering change. Unresolved atoms and metals marked +`skip_hydrogen_placement` are not completed; unsupported geometry raises an error. +A placement result is not evidence of downstream force-field parameter coverage. + +See the [executed offline example](../auto_examples/plot_protonation) and the +[current API](https://github.com/RosettaCommons/atomworks/tree/release/atomworks-3-0/src/atomworks/experimental/protonation). + +## Alternate conformers + +```python +config = ParseConfig.from_preset( + "rcsb", + altloc="random_clash_aware", + altloc_seed=42, +) +result = parse("structure.cif", config=config) +``` + +`"first"` remains the default; `"random_per_chain"` selects a letter per chain; `"random_clash_aware"` chooses per-residue alternatives while considering nearby clashes and correlations. A specific letter is also accepted. Selection is not exhaustive conformer enumeration and is not occupancy-weighted ensemble sampling. The clash-aware algorithm can reject unresolved conflicts and can fall back to a modified residue's parent component; this can affect atom identities. It uses the first model's coordinates when selecting from a stack. PDB inputs reject altloc choices other than `"first"`. [Configuration](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/config.py), [selection algorithm](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/utils/altloc.py). + +Record the source, policy, seed, parser version and CCD inputs. Unseeded random parsing bypasses the parse cache. Retaining `"first"` makes an initial version comparison easier; switching to random selection should be evaluated separately. A fixed seed establishes reproducibility within the selected implementation, not an eternal cross-version conformer identity guarantee. + +**Candidate edge case to resolve before broad claims:** `load_cif()` fallback paths for variable-topology models and some invalid requested-model reads do not forward the requested `altloc`/`altloc_seed`. Exercise these combinations explicitly before promising consistent random selection for all multi-model CIFs. [Loader source](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/_loaders.py). + +## CIF round trips + +For an already prepared single AtomArray, retaining chemistry and registered annotations: + +```python +from atomworks.io.utils.io_utils import to_cif_file + +# atoms is an already prepared AtomArray/AtomArrayPlus. +to_cif_file( + atoms, + "prepared.cif", + include_entity_categories=True, + include_chem_comp=True, + chain_disambiguation="transformation_id", +) +restored = parse( + "prepared.cif", + config=ParseConfig( + add_missing_atoms=False, + fix_arginines=False, + remove_waters=False, + remove_ccds=(), + build_assembly=None, + extra_fields="all", + return_atom_array_plus=True, + load_standard_annotations=True, + ), +) +``` + +Choose `chain_disambiguation="transformation_id"` to preserve original chain IDs through AtomWorks; repeated chains require the transformation annotation. Choose `"chain_iid"` for more interoperable unique chain names, accepting renamed chains. **Pass this explicitly**: the actual default is `None` and ambiguous identities raise, despite a writer docstring claiming the default is `"chain_iid"`. The writer accepts multiple models as a list of AtomArrays with optional `model_ids`; do not assume every writer accepts a stack directly. [Writer](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/utils/io_utils.py). + +The new implementation emits/reloads richer component and bond chemistry, including authored custom components and standard annotations. Tests cover asymmetric units with and without CCD access and assemblies under both disambiguation modes. Scope the phrase “complete round trip” carefully: + +- Coordinates are rounded to three decimals by the writer. +- Atom order, annotation dtypes, some regenerated IDs and alternate-location labels are not guaranteed identical. Compare structural identity and values after alignment. +- Request additional ordinary annotations explicitly by name when writing; registered StandardAnnotations use their serializer. In this candidate, writer `extra_fields="all"` can fail on parsed structures because `label_entity_id` conflicts with a reserved CIF column (reproduced on 6LYZ). The example uses writer defaults; it does not promise retention of every ordinary annotation. Arbitrary Python attributes are not a general persistence mechanism. +- StandardAnnotation loading requires AtomArrayPlus and forbids missing-atom completion and MSE conversion, which change indices. PDB files cannot carry this path. +- Serialization of two-body standard annotations has a 10,000-row limit. Large pairwise annotations may need another storage design. +- CIF custom chemistry is scoped to the parse; AtomArrayPlus can retain the custom CCD registry for later writing. Supply `ccd_entries` explicitly if working outside that path. +- `cif_ccd_on_mismatch="error_heavy"` rejects heavy-atom name mismatches in authored component templates by default. Investigate malformed/custom templates; `"ignore"` intentionally skips CCD supplementation for mismatching heavy-atom names rather than repairing them. + +[Round-trip tests](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/tests/io/utils/test_io.py), [annotation serialization](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/utils/standard_annotations/serialization.py), [custom CCD handling](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/utils/ccd.py). + +## Parser caching + +The parser now caches complete results as Zstandard-compressed pickles in a `parse-v2` namespace. Settings, package versions and source identity enter the key; random unseeded conformers and active external CCD overrides bypass caching. **Paths are keyed by resolved path, not file-content hash.** Replacing a file in place can reuse a stale result; keep source paths immutable or supply a changed immutable `cache_key`. An explicit key must change with source contents. Isolate old/new cache directories for a controlled migration. Benchmark cached and uncached performance separately. [Cache code](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/parser.py). + +## I/O environment and validation + +Python ≥3.11 remains supported; the candidate pins Biotite exactly to 1.6.0 and checks it at import time (2.2.1 pins 1.4.0). PyArrow minimum rises to 23.0.1, core adds jaxtyping, and Hydride is no longer a core dependency. Optional S3 and catcif integrations have extras. Regenerate a locked environment instead of upgrading Biotite in isolation. [Dependency manifest](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/pyproject.toml). + +Lock both environments, migrate parser calls and configuration, then compare a fixed set of structures covering covalent modifications, alternate conformers and assemblies. Review atom identities, charges, bond orders and hydrogen counts before adopting the new parser or regenerating cached outputs. + +## Additional ML migration notes + +The remaining sections apply when using AtomWorks for model features or datasets. They are not required for a standalone I/O migration. Install the `[ml]` extra for Torch workflows; it now includes numba. + +### Model encodings and conditions + +`UNIFIED_ATOM37_ENCODING` retains 37 atom slots but adds terminal phosphate `OP3` in slot 36 for standard DNA/RNA and a new gap token `` at class 33. The vocabulary therefore grows from 33 to 34 classes relative to 2.2.1. Check embedding/head sizes, saved class tables, labels, loss masks and atom-slot masks. A matching 37-wide coordinate tensor can still carry changed semantics. [Definition](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/encoding_definitions.py). + +`atom_array_to_encoded_resnames()` changes its default `atomize_token` from `""` to `None` (atomic-number tokens). It requires `atomize`; a one-atom residue is no longer automatically classified as atomized merely by length. For a checkpoint using generic atom tokens, explicitly supply `atomize_token=""`, and check the encoding contains that token. `EncodeAtomArray` also exposes `atomize_token`/`atomize_atom_name` for explicitly matching its encoding. Keep the checkpoint's saved encoding definition when reproducing an old model rather than assuming the current global constant is compatible. [Encoding functions](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/transforms/encoding.py). + +Conditions now specialize the StandardAnnotation framework. Sequence and coordinate conditions contain target residue names and coordinates; derive masks through their API rather than treating the annotation itself as a boolean. To mark existing coordinates as ground truth, for example: + +```python +from atomworks.ml.conditions import C_CRD + +# selected is a boolean array of length len(atoms). +C_CRD.set_annotation_from_ground_truth(atoms, selected) +conditioned_mask = C_CRD.mask(atoms) +``` + +For external target values, set explicit condition annotations with the condition API. Calling `set_annotation_from_ground_truth` intentionally replaces targets with values from the current structure. Legacy module aliases and some legacy CIF masks are supported, but this is a specific compatibility list (sequence, coordinate, index), not blanket support for arbitrary historical fields. Custom condition subclasses should follow the new definitions and StandardAnnotation contract. [Condition definitions](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/conditions/definitions.py), [base API](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/conditions/base.py). + +### Datasets and preprocessing + +For a previous loader created with `create_loader_with_query_pn_units(pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"])`: + +```python +from atomworks.ml.datasets.loaders.cif import create_structure_loader + +loader = create_structure_loader( + parser_args=ParseConfig.from_preset("rcsb", hydrogen_policy="remove"), + column_mapping={ + "query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"], + "query_is_polymer": ["pn_unit_1_is_polymer", "pn_unit_2_is_polymer"], + }, +) +``` + +Provide these columns in the metadata table. A string mapping produces a scalar; a list produces an ordered list. Specialized factories remain deprecated wrappers. `storage="filesystem"`, `"bytes"`, and `"blob"` share the factory but have backend-specific arguments. Passing `altloc_seed_colname="altloc_seed"` selects `random_clash_aware` for nonmissing CIF seeds, even if the supplied parser configuration had another altloc policy. Pass it only when the table is meant to define conformer sampling. [Loaders](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/datasets/loaders/cif.py). + +Preprocessing is reorganized around `PreprocessConfig` and assembly/PN-unit/interface records; the old `get_pn_unit_data_from_structure` module is removed. Treat new preprocessing tables as versioned datasets. Audit column names/types and row meaning against the release tutorial and your training pipeline rather than assuming old Parquet files are interchangeable. For multiple conformer rows, preserve seeds and consider the optional altloc weighting in `calculate_weights_for_pdb_dataset_df(..., altloc_seed_column=...)` so extra conformers do not accidentally overweight a structure. This option is explicit, not automatic. [Preprocessing](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/preprocessing/preprocess.py), [samplers](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/samplers.py). + +`PandasDataset` delegates to a metadata index and can use Arrow with `memory_map=True`. Its `.data` is then an Arrow Table, so pandas-only operations must be adapted. Dataset filters are independent row predicates evaluated on the input and combined; filters that calculate aggregate thresholds from the progressively filtered table may change meaning. Use a stable ID column rather than relying on the old optional `id_column=None` pattern. [Dataset](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/ml/datasets/pandas_dataset.py). diff --git a/docs/migration/migration-short.md b/docs/migration/migration-short.md new file mode 100644 index 00000000..87aa1b09 --- /dev/null +++ b/docs/migration/migration-short.md @@ -0,0 +1,41 @@ +# AtomWorks 2.x → 3.0: short migration guide + +This guide covers the main I/O changes when moving from AtomWorks 2.x to 3.0: parser configuration, charges and bonds, alternate conformers, and CIF writing. See the [detailed guide](migration-long.md) for examples and compatibility details. + +## 1. Put parser options in `ParseConfig` + +The result still contains `asym_unit`, `assemblies`, `chain_info`, `ligand_info`, `metadata`, and `extra_info` for ordinary inputs. Use `source` (or the first positional argument), and make processing choices explicit: + +```python +from atomworks.io import parse +from atomworks.io.config import ParseConfig + +config = ParseConfig.from_preset( + "rcsb", + hydrogen_policy="remove", + build_assembly="first", +) +result = parse("structure.cif", config=config) +``` + +Surviving bare keyword options and `filename=` remain accepted with deprecation warnings. Remove `fix_formal_charges` and `fix_bond_types`: these flags no longer exist; completing missing atoms now includes chemistry sanitization. `hydrogen_policy="infer"` is no longer a supported parser policy; use explicit protonation if needed. [Parser and configuration](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/config.py). + +## 2. Check changed charges and covalent chemistry + +Covalent linkage processing corrects leaving atoms, bond orders, hydrogen counts and formal charges, including incorrectly charged amide nitrogens. Link validation also checks whether connections fit the selected conformer and supported chemistry; ambiguous or unsupported products can now raise an error and require explicit chemical definitions. A model trained on 2.x features can be sensitive to these changed inputs. Keep its old preprocessing environment available, compare features on representative covalent complexes, and evaluate the checkpoint before switching production inference. There is no parser switch that reproduces all historical chemistry. See the [detailed chemistry guidance](migration-long.md#covalent-links-reaction-states-and-unsupported-chemistry) for affected cases and how to handle them. + +## 3. Choose alternate conformers deliberately + +CIF parsing supports `altloc="first"` (default), a specific letter, `"random_per_chain"`, or `"random_clash_aware"`. Set `altloc_seed` for reproducible random selection. This selects a conformer; it does not return an exhaustive ensemble. PDB parsing only supports `"first"`. Keep `"first"` for an initial migration comparison; adopt random selection as a separate data change. Record the seed with the parsed data. [Options](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/src/atomworks/io/config.py#L106). + +## 4. Use CIF to preserve structure chemistry and annotations + +The writer and parser now support substantially richer AtomArray → CIF → AtomArray round trips, including custom component chemistry. Use `to_cif_file()`, retain component/entity categories, and reload with `add_missing_atoms=False` when preserving an already prepared structure. For registered StandardAnnotations, also set `return_atom_array_plus=True, load_standard_annotations=True` on reload. Assemblies with repeated chain identities need an explicit `chain_disambiguation` policy. + +Round trips are structural, not byte-for-byte identity: coordinates are rounded to three decimals, order/dtypes and some derived IDs may change, and arbitrary annotations require explicit handling. See the [worked example and limits](migration-long.md#cif-round-trips). + +## 5. Refresh the environment and parser caches + +Install into a fresh environment and regenerate your dependency lock: AtomWorks 3.0 requires exactly `biotite==1.6.0` (2.2.1 used 1.4.0), `pyarrow>=23.0.1`, and Python ≥3.11. Rebuild or separate parser caches when adopting the new chemistry and conformer policies. [Dependencies](https://github.com/RosettaCommons/atomworks/blob/df50559731c0ba43cc29a82b50c87a60d1a0a951/pyproject.toml). + +Before switching, compare a small fixed set of structures under both environments: selected model/assembly, atom identities and counts, bonds, charges and implicit hydrogens. If these structures feed an existing model, check its predictions with the new parser output. diff --git a/docs/mirrors.rst b/docs/mirrors.rst index 681adce5..a5385098 100644 --- a/docs/mirrors.rst +++ b/docs/mirrors.rst @@ -81,8 +81,8 @@ Step 3 — Configure an AF3-style dataset (example: train only on D-polypeptides Next we need to use the metadata to configure a dataset that we would like to sample from. This includes e.g. training cut-off, filters, transforms to apply, etc. Here's a simple example that: -* Filters to D-polypeptide and L-polypeptide chains only (`POLYPEPTIDE_D` and `POLYPEPTIDE_L` -- to include additional chain types, replace the lists with the appropriate IDs (see `mapping `_ in comments). -* Excludes ligands in the AF3 list of excluded ligands, available at `atomworks.constants.AF3_EXCLUDED_LIGANDS_REGEX `_. +* Filters to D-polypeptide and L-polypeptide chains only (`POLYPEPTIDE_D` and `POLYPEPTIDE_L` -- to include additional chain types, replace the lists with the appropriate IDs (see `mapping `_ in comments). +* Excludes ligands in the AF3 list of excluded ligands, available at `atomworks.constants.AF3_EXCLUDED_LIGANDS_REGEX `_. .. code-block:: yaml @@ -99,9 +99,12 @@ Here's a simple example that: _target_: atomworks.ml.datasets.datasets.ConcatDatasetWithID datasets: # Single PN units - - _target_: atomworks.ml.datasets.datasets.StructuralDatasetWrapper - dataset_parser: - _target_: atomworks.ml.datasets.parsers.PNUnitsDFParser + - _target_: atomworks.ml.datasets.datasets.PandasDataset + name: pn_units + id_column: example_id + data: /path/to/metadata/pn_units_df.parquet + loader: + _target_: atomworks.ml.datasets.loaders.create_structure_loader transform: _target_: atomworks.ml.pipelines.af3.build_af3_transform_pipeline is_inference: false @@ -118,28 +121,26 @@ Here's a simple example that: # - { dir: /path/to/msa, extension: .a3m.gz, directory_depth: 2 } # rna_msa_dirs: # - { dir: /path/to/msa, extension: .afa, directory_depth: 0 } - dataset: - _target_: atomworks.ml.datasets.datasets.PandasDataset - name: pn_units - id_column: example_id - data: /path/to/metadata/pn_units_df.parquet - filters: - - "deposition_date < '2022-01-01'" - - "resolution < 5.0 and ~method.str.contains('NMR')" - - "num_polymer_pn_units <= 20" - - "cluster.notnull()" - - "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" - # Train only on D-polypeptides: - - "q_pn_unit_type in [5, 6]" # 5 = POLYPEPTIDE_D, 6 = POLYPEPTIDE_L - # Exclude ligands from AF3 excluded set: - - "~(q_pn_unit_non_polymer_res_names.notnull() and q_pn_unit_non_polymer_res_names.str.contains('${af3_excluded_ligands_regex}', regex=True))" - columns_to_load: null + filters: + - "deposition_date < '2022-01-01'" + - "resolution < 5.0 and ~method.str.contains('NMR')" + - "num_polymer_pn_units <= 20" + - "cluster.notnull()" + - "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" + # Train only on D-polypeptides: + - "q_pn_unit_type in [5, 6]" # 5 = POLYPEPTIDE_D, 6 = POLYPEPTIDE_L + # Exclude ligands from AF3 excluded set: + - "~(q_pn_unit_non_polymer_res_names.notnull() and q_pn_unit_non_polymer_res_names.str.contains('${af3_excluded_ligands_regex}', regex=True))" + columns_to_load: null save_failed_examples_to_dir: null # Binary interfaces - - _target_: atomworks.ml.datasets.datasets.StructuralDatasetWrapper - dataset_parser: - _target_: atomworks.ml.datasets.parsers.InterfacesDFParser + - _target_: atomworks.ml.datasets.datasets.PandasDataset + name: interfaces + id_column: example_id + data: /path/to/metadata/interfaces_df.parquet + loader: + _target_: atomworks.ml.datasets.loaders.create_structure_loader transform: _target_: atomworks.ml.pipelines.af3.build_af3_transform_pipeline is_inference: false @@ -155,25 +156,18 @@ Here's a simple example that: # - { dir: /path/to/msa, extension: .a3m.gz, directory_depth: 2 } # rna_msa_dirs: # - { dir: /path/to/msa, extension: .afa, directory_depth: 0 } - dataset: - _target_: atomworks.ml.datasets.datasets.PandasDataset - name: interfaces - id_column: example_id - data: /path/to/metadata/interfaces_df.parquet - filters: - - "deposition_date < '2022-01-01'" - - "resolution < 5.0 and ~method.str.contains('NMR')" - - "num_polymer_pn_units <= 20" - - "cluster.notnull()" - - "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" - # Train only on D-polypeptide interfaces: - - "pn_unit_1_type in [5, 6]" # 5 = POLYPEPTIDE_D, 6 = POLYPEPTIDE_L - - "pn_unit_2_type in [5, 6]" # 5 = POLYPEPTIDE_D, 6 = POLYPEPTIDE_L - - "~(pn_unit_1_non_polymer_res_names.notnull() and pn_unit_1_non_polymer_res_names.str.contains('${af3_excluded_ligands_regex}', regex=True))" - - "~(pn_unit_2_non_polymer_res_names.notnull() and pn_unit_2_non_polymer_res_names.str.contains('${af3_excluded_ligands_regex}', regex=True))" - columns_to_load: null - cif_parser_args: - cache_dir: null + filters: + - "deposition_date < '2022-01-01'" + - "resolution < 5.0 and ~method.str.contains('NMR')" + - "num_polymer_pn_units <= 20" + - "cluster.notnull()" + - "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" + # Train only on D-polypeptide interfaces: + - "pn_unit_1_type in [5, 6]" # 5 = POLYPEPTIDE_D, 6 = POLYPEPTIDE_L + - "pn_unit_2_type in [5, 6]" # 5 = POLYPEPTIDE_D, 6 = POLYPEPTIDE_L + - "~(pn_unit_1_non_polymer_res_names.notnull() and pn_unit_1_non_polymer_res_names.str.contains('${af3_excluded_ligands_regex}', regex=True))" + - "~(pn_unit_2_non_polymer_res_names.notnull() and pn_unit_2_non_polymer_res_names.str.contains('${af3_excluded_ligands_regex}', regex=True))" + columns_to_load: null save_failed_examples_to_dir: null Step 4 — Train a model @@ -188,7 +182,7 @@ You now have a full fledged dataset that you can use to train models on! If you .. code-block::bash atomworks setup tests # This will download the test pack to `tests/data` and unpack it there (~500 MB). -You will now have a mini PDB at `tests/data/pdb` and a mini custom CCD at `tests/data/ccd`. The distillation and metadata are in `data/ml/af2_distillation`, `data/ml/pdb_pn_units` and `data/ml/pdb_interfaces`. A dataset that uses all of these is for example `here `_. +You will now have a mini PDB at `tests/data/pdb` and a mini custom CCD at `tests/data/ccd`. The distillation and metadata are in `data/ml/af2_distillation`, `data/ml/pdb_pn_units` and `data/ml/pdb_interfaces`. A dataset that uses all of these is for example `here `_. To run the tests for the various datasets, you can run the following command: @@ -196,4 +190,3 @@ To run the tests for the various datasets, you can run the following command: # Make sure you have the correct environment activated, and set your paths correctly in the .env file / shell environment variables (see points above) pytest tests/ml/pipelines/test_data_loading_pipelines.py - diff --git a/docs/ml.rst b/docs/ml.rst index d747f805..010b7835 100644 --- a/docs/ml.rst +++ b/docs/ml.rst @@ -22,5 +22,7 @@ Data Processing Modules ml/transforms/diffusion ml/transforms/dna ml/transforms/feature_aggregation + ml/msa_server ml/transforms/msa - ml/utils \ No newline at end of file + ml/utils + ml/preprocessing diff --git a/docs/ml/datasets.rst b/docs/ml/datasets.rst index 73e4c4c3..496ec8ef 100644 --- a/docs/ml/datasets.rst +++ b/docs/ml/datasets.rst @@ -3,22 +3,6 @@ Datasets This module contains dataset classes and utilities for loading and processing molecular data using a modern, composable architecture. -Core Dataset Classes --------------------- - -.. automodule:: atomworks.ml.datasets - :members: - :undoc-members: - :show-inheritance: - -Functional Loaders ------------------- - -.. automodule:: atomworks.ml.datasets.loaders - :members: - :undoc-members: - :show-inheritance: - Dataset Architecture and Migration Guide ---------------------------------------- @@ -26,4 +10,4 @@ Dataset Architecture and Migration Guide :maxdepth: 2 datasets/datasets - datasets/parsers \ No newline at end of file + datasets/parsers diff --git a/docs/ml/datasets/datasets.rst b/docs/ml/datasets/datasets.rst index c8b61c98..0593db56 100644 --- a/docs/ml/datasets/datasets.rst +++ b/docs/ml/datasets/datasets.rst @@ -61,13 +61,13 @@ Basic Usage Examples .. code-block:: python from atomworks.ml.datasets import PandasDataset - from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units + from atomworks.ml.datasets.loaders import create_structure_loader dataset = PandasDataset( data="metadata.parquet", name="interfaces_dataset", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader=create_structure_loader( + column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]} ) ) @@ -107,9 +107,9 @@ Common Loader Patterns .. code-block:: python - from atomworks.ml.datasets.loaders import create_base_loader + from atomworks.ml.datasets.loaders import create_structure_loader - loader = create_base_loader( + loader = create_structure_loader( example_id_colname="example_id", path_colname="path", assembly_id_colname="assembly_id", @@ -121,10 +121,10 @@ Common Loader Patterns .. code-block:: python - from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units + from atomworks.ml.datasets.loaders import create_structure_loader - loader = create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], + loader = create_structure_loader( + column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]}, base_path="/data/pdb", extension=".cif.gz" ) @@ -133,11 +133,13 @@ Common Loader Patterns .. code-block:: python - from atomworks.ml.datasets.loaders import create_loader_with_interfaces_and_pn_units_to_score + from atomworks.ml.datasets.loaders import create_structure_loader - loader = create_loader_with_interfaces_and_pn_units_to_score( - interfaces_to_score_colname="interfaces_to_score", - pn_units_to_score_colname="pn_units_to_score" + loader = create_structure_loader( + column_mapping={ + "interfaces_to_score": "interfaces_to_score", + "pn_units_to_score": "pn_units_to_score", + } ) Integration with Transform Pipelines @@ -185,3 +187,33 @@ This separation allows for: - **Composable transforms** that can be mixed and matched - **Easy testing** of individual components - **Clear debugging** when issues arise + +Periodic materials +------------------ + +Use the existing ``AseDBDataset`` with the materials loader for ASE LMDB shards:: + + from atomworks.ml.datasets.ase_dataset import AseDBDataset + from atomworks.ml.datasets.loaders import create_ase_materials_loader + + dataset = AseDBDataset( + lmdb_path="materials_shards/", + name="materials", + loader=create_ase_materials_loader(include_atom_array=True), + ) + sample = dataset[0] + +Install the ``ml`` and ``ase`` extras. Each sample includes Cartesian and fractional +coordinates, cell vectors, lengths, angles, volume, and PBC. Available ASE row +energy, forces and stress are retained. Molecular bond inference and preparation +are not applied. Optional AtomArrays preserve the cell and initial charges without +interpreting partial charges as formal charges. + +``space_group`` and ``parent_space_group`` are separate numeric labels supplied by +the data source. Neither is computed from the coordinates, and parent/prototype +labels are never promoted to current symmetry. Other metadata remains in +``extra_info``. A full-rank cell is required; unwrapped fractional positions are +returned by default. Wrapping affects only periodic axes. + +Use the existing metadata ``example_id`` and ``lmdb_idx`` columns to select or +reorder examples. Source ASE row IDs remain available as ``source_row_id``. diff --git a/docs/ml/msa_server.rst b/docs/ml/msa_server.rst new file mode 100644 index 00000000..700e8035 --- /dev/null +++ b/docs/ml/msa_server.rst @@ -0,0 +1,34 @@ +Remote MSA generation +===================== + +Use the ColabFold server without local search binaries or sequence databases. +Install the ``ml`` extra, then call: + +.. code-block:: python + + from atomworks.ml.preprocessing.msa.colabfold_server import make_msas_colabfold_server + + make_msas_colabfold_server( + ["MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQ"], "output_msas/", use_templates=True + ) + +Sequences are sent to the public ColabFold service by default. Set ``host_url`` +to use another compatible server. Results use AtomWorks' hash-sharded MSA +layout. The ``atomworks msa generate`` CLI supports local MMseqs2 and HHblits; +use this Python API for remote generation. + +Paired queries and templates +---------------------------- + +Use ``use_pairing=True`` to submit the sequences of one complex as a paired +query. Templates are available only for unpaired queries. To generate both +unpaired MSAs and paired complex MSAs, use ``make_msas_colabfold_server_batch()`` from +the same module. + +Find template hit tables with ``find_template_alignments()`` from +``atomworks.ml.preprocessing.msa.finding``. To retrieve their structures, use +``fetch_template_structures_from_m8_file(m8_path, output_dir)`` from +``atomworks.ml.preprocessing.msa.template_structures``. It uses the PDB mirror +where available and downloads missing entries from RCSB. Returned files are raw +structures: chain mapping, release-date filtering and template featurization +remain the caller's responsibility. diff --git a/docs/ml/preprocessing.rst b/docs/ml/preprocessing.rst index 1fe05690..e2ff1cbe 100644 --- a/docs/ml/preprocessing.rst +++ b/docs/ml/preprocessing.rst @@ -6,7 +6,7 @@ This module contains utilities for preprocessing molecular structures and data. Core Preprocessing Functions ---------------------------- -.. automodule:: atomworks.ml.preprocessing.get_pn_unit_data_from_structure +.. automodule:: atomworks.ml.preprocessing.preprocess :members: :undoc-members: :show-inheritance: @@ -25,4 +25,43 @@ Utilities .. automodule:: atomworks.ml.preprocessing.utils :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: + + +MSA +--- + +Note that the following functions can be called via the command line. See :doc:`../msa` +for more details. + +Finding +^^^^^^^ + +.. automodule:: atomworks.ml.preprocessing.msa.finding + :members: + :undoc-members: + :show-inheritance: + +Filtering +^^^^^^^^^ + +.. automodule:: atomworks.ml.preprocessing.msa.filtering + :members: + :undoc-members: + :show-inheritance: + +Generating +^^^^^^^^^^ + +.. automodule:: atomworks.ml.preprocessing.msa.generating + :members: + :undoc-members: + :show-inheritance: + +Organizing +^^^^^^^^^^ + +.. automodule:: atomworks.ml.preprocessing.msa.organizing + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/ml/transforms.rst b/docs/ml/transforms.rst index 74ccc7ad..a1b8114c 100644 --- a/docs/ml/transforms.rst +++ b/docs/ml/transforms.rst @@ -136,15 +136,9 @@ Diffusion Transforms Feature Aggregation Transforms ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. automodule:: atomworks.ml.transforms.feature_aggregation - :members: - :undoc-members: - :show-inheritance: +See :doc:`transforms/feature_aggregation`. MSA Transforms ~~~~~~~~~~~~~~ -.. automodule:: atomworks.ml.transforms.msa - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file +See :doc:`transforms/msa`. diff --git a/docs/ml/transforms/feature_aggregation.rst b/docs/ml/transforms/feature_aggregation.rst index ec2fbcf9..5dabe0b5 100644 --- a/docs/ml/transforms/feature_aggregation.rst +++ b/docs/ml/transforms/feature_aggregation.rst @@ -6,4 +6,4 @@ This module contains transformations for feature aggregation tasks. .. automodule:: atomworks.ml.transforms.feature_aggregation :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/ml/transforms/msa.rst b/docs/ml/transforms/msa.rst index aad06b4c..0579055b 100644 --- a/docs/ml/transforms/msa.rst +++ b/docs/ml/transforms/msa.rst @@ -6,4 +6,4 @@ This module contains transformations for multiple sequence alignment (MSA) tasks .. automodule:: atomworks.ml.transforms.msa :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/msa.rst b/docs/msa.rst new file mode 100644 index 00000000..153f7cf2 --- /dev/null +++ b/docs/msa.rst @@ -0,0 +1,38 @@ +Multiple Sequence Alignment in AtomWorks +======================================== + +AtomWorks provides several command-line tools for Multiple Sequence Alignment (MSA) operations. + +Install AtomWorks with the ML extra. Filtering requires hhfilter from HH-suite +on PATH. Local generation requires the selected MMseqs2 or HHblits backend and +configured sequence databases, plus hhfilter for filtering the results. The +ColabFold Python API supports remote generation; see :doc:`ml/msa_server`. +Finding and organizing existing files do not run a sequence search. + +Find +---- + +Provide ``--existing-msa-dirs`` or set ``PROTEIN_MSA_DIRS`` to the directories +containing your MSA files. + +.. typer:: atomworks_cli.find:app + :prog: atomworks msa find + :show-nested: + +Filter +------ +.. typer:: atomworks_cli.filter:app + :prog: atomworks msa filter + :show-nested: + +Generate +-------- +.. typer:: atomworks_cli.generate:app + :prog: atomworks msa generate + :show-nested: + +Organize +-------- +.. typer:: atomworks_cli.organize:app + :prog: atomworks msa organize + :show-nested: diff --git a/docs/sg_execution_times.rst b/docs/sg_execution_times.rst deleted file mode 100644 index 407a5f96..00000000 --- a/docs/sg_execution_times.rst +++ /dev/null @@ -1,52 +0,0 @@ - -:orphan: - -.. _sphx_glr_sg_execution_times: - - -Computation times -================= -**00:03.491** total execution time for 6 files **from all galleries**: - -.. container:: - - .. raw:: html - - - - - - - - .. list-table:: - :header-rows: 1 - :class: table table-striped sg-datatable - - * - Example - - Time - - Mem (MB) - * - :ref:`sphx_glr_auto_examples_plot_basics.py` (``examples/plot_basics.py``) - - 00:03.491 - - 0.0 - * - :ref:`sphx_glr_auto_examples_annotate_and_save_structures.py` (``examples/annotate_and_save_structures.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_basics.py` (``examples/basics.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_load_and_visualize_structures.py` (``examples/load_and_visualize_structures.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_mpnn_pipeline.py` (``examples/mpnn_pipeline.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_pocket_conditioning_transform.py` (``examples/pocket_conditioning_transform.py``) - - 00:00.000 - - 0.0 diff --git a/docs/tutorial/custom_ccd.rst b/docs/tutorial/custom_ccd.rst new file mode 100644 index 00000000..7e893901 --- /dev/null +++ b/docs/tutorial/custom_ccd.rst @@ -0,0 +1,32 @@ +Custom CCDs and CIF round trips +======================================== + +Register your ligand before writing. Here ``ligand`` is one complete LG1 residue +with correct atom names, elements, charges, bonds, and a nonempty chain ID; +``atoms`` is the structure containing it (or just ``ligand``). + +.. code-block:: python + + from atomworks.io import parse + from atomworks.io.config import ParseConfig + from atomworks.io.utils.ccd import custom_ccd_residues + from atomworks.io.utils.io_utils import to_cif_file + + with custom_ccd_residues({"LG1": ligand}): + to_cif_file(atoms, "structure.cif") + + config = ParseConfig.from_preset( + "minimal", build_assembly=None, cif_ccd_on_mismatch="ignore", + return_atom_array_plus=True, + ) + restored = parse("structure.cif", config=config)["asym_unit"][0] + +``register_custom_ccd_entry("LG1", ligand)`` also works, but persists globally. +The writer generates all three ``chem_comp*`` tables. Missing +``chem_comp_type`` is inferred from atom names; override it if the heuristic is +wrong. An existing annotation is used directly as ``chem_comp.type``. + +``"minimal"`` avoids atom rebuilding/filtering; ``"ignore"`` permits intentional +CCD-code collisions. The file is self-contained; no registration is needed to +read it. ``return_atom_array_plus=True`` retains its definitions for rewriting. +Coordinates are rounded to 0.001 Å; this is not exact object serialization. diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst index ed51ea17..f8ad3ca1 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -21,5 +21,6 @@ Quick Install: :caption: Main Concepts parser + custom_ccd transforms - utils \ No newline at end of file + utils diff --git a/docs/tutorial/parser.rst b/docs/tutorial/parser.rst index 08339680..ff75fdd4 100644 --- a/docs/tutorial/parser.rst +++ b/docs/tutorial/parser.rst @@ -3,22 +3,27 @@ Parser The parser is the core entry point for converting structural and sequence files (mmCIF, PDB, FASTA, SMILES, etc.) into Biotite's AtomArray API. It supports extensive options for annotation, filtering, and caching. +For custom ligands and safe read/write workflows, see :doc:`custom_ccd`. + Example Usage ------------- .. code-block:: python from atomworks.io.parser import parse - result = parse(filename="/databases/rcsb/cif/ne/3nez.cif.gz") + result = parse(source="/databases/rcsb/cif/ne/3nez.cif.gz") print(result["chain_info"]) -What's in the Dictionary Returned by the Parser? ------------------------------------------------- + # The file type is also inferred for mmJSON paths, including .json.gz. + json_result = parse(source="/path/to/structure.json.gz") + +Returned Dictionary +------------------- - **chain_info**: Mapping of chain IDs to sequence, type, and metadata -- **ligand_info**: Information about ligands in the structure, if there are any -- **asym_unit**: AtomArrayStack of the asymmetric unit, contains similar information to what is contained in a PDB file +- **ligand_info**: Information about ligands in the structure +- **asym_unit**: AtomArrayStack of the asymmetric unit - **assemblies**: Mapping of assembly IDs to AtomArrayStacks -- **metadata**: Structure-level metadata, such as crystallization details, the method used to determine the structure, resolution, etc. +- **metadata**: Structure-level metadata - **extra_info**: Internal-use information for caching and compatibility Parsing Arguments @@ -31,18 +36,18 @@ Parsing Arguments - Type - Default - Description - * - filename + * - source - PathLike / io.StringIO / io.BytesIO - — - - Path to the structural file. Supports .cif, .cif.gz, .pdb, etc. + - Path to the structural file. Supports .cif, .cif.gz, .pdb, .json, .json.gz, etc. * - add_missing_atoms - bool - True - - Add missing atoms to the structure. Useful for unresolved residues. Also adds intra- and inter-residue bonds. + - Add missing atoms to the structure. Useful for unresolved residues. Also adds intra- and inter-residue bonds, and sanitizes them: removes leaving atoms and fixes bond orders, charges, and amide nitrogens. * - add_id_and_entity_annotations - bool - True - - Add id and entity annotations at chain, pn-unit, and molecule level to the AtomArray. + - **Deprecated.** ID and entity annotations are now always added. This parameter will be removed in a future version. * - add_bond_types_from_struct_conn - list[str] - ["covale"] @@ -63,22 +68,18 @@ Parsing Arguments - bool - True - Fix arginine naming ambiguity. - * - fix_formal_charges - - bool - - True - - Fix formal charges on atoms involved in inter-residue bonds. - * - fix_bond_types - - bool - - True - - Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. * - convert_mse_to_met - bool - False - Convert selenomethionine (MSE) to methionine (MET). + * - remove_hydrogens + - bool or None + - None + - Remove hydrogens from structure. Deprecated; use hydrogen_policy instead. * - hydrogen_policy - - "keep" / "remove" / "infer" + - "keep" / "remove" - "keep" - - Whether to keep, remove, or infer hydrogens. + - Whether to keep or remove hydrogens. * - model - int or None - None @@ -92,8 +93,7 @@ Parsing Arguments - None - Extra fields to include in the AtomArrayStack. - -Wrapper Arguments +Caching Arguments ----------------- .. list-table:: @@ -103,14 +103,6 @@ Wrapper Arguments - Type - Default - Description - * - file_type - - "cif" / "pdb" / "mmjson" / None - - None - - File type to parse. If None, inferred from filename extension. - * - ccd_mirror_path - - PathLike / None - - None - - Path to local mirror of the Chemical Component Dictionary. (Recommended) * - load_from_cache - bool - False @@ -122,4 +114,4 @@ Wrapper Arguments * - cache_dir - PathLike / None - None - - Directory for cached results. Required if caching is enabled. \ No newline at end of file + - Directory for cached results. Required if caching is enabled. diff --git a/docs/tutorial/transforms.rst b/docs/tutorial/transforms.rst index 18068493..8dd9ee82 100644 --- a/docs/tutorial/transforms.rst +++ b/docs/tutorial/transforms.rst @@ -8,4 +8,4 @@ Example Usage .. code-block:: python from atomworks.io.transforms.atom_array import remove_ccd_components - cleaned = remove_ccd_components(atom_array) + cleaned = remove_ccd_components(atom_array, ["HOH"]) diff --git a/pyproject.toml b/pyproject.toml index fa0057c5..2c91e933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "atomworks" -version = "2.2.1" +version = "3.0.0" description = "A research-oriented data toolkit for training biomolecular deep-learning foundation models" readme = "README.md" requires-python = ">=3.11" @@ -25,33 +25,49 @@ dependencies = [ # ... generic tools "cython>=3.0.0", # Cython compiler for C extensions "cytoolz>=0.12.3", # Cython-optimized tools for itertools and functional programming - "tqdm>=4.65.0", # Fast, extensible progress bar for loops and more + "tqdm>=4.66.3", # Fast, extensible progress bar for loops and more # ... CLI & config management "typer>=0.12.5", # Modern CLI framework + "python-dotenv>=1.2.2", # Environment loading for the CLI + "pygments>=2.20.0", # Traceback formatting in core IO # ... linear algebra, maths & ml "numpy>=1.25.0", + "jaxtyping>=0.2.17", # Array annotations used by core IO "scipy>=1.13.1", # ... data tools "pandas>=2.2", # Data manipulation and analysis - "pyarrow>=17.0.0", # Columnar data format for efficient data storage and processing # TODO: Test later versions + "pyarrow>=23.0.1", # Columnar data format for efficient data storage and processing # ... compression "zstandard>=0.21.0", # Zstandard compression for MSA files and other data # ... bioinformatics "py3Dmol>=2.2.1", # Python wrapper for 3Dmol.js "pymol-remote>=0.0.5", # Remote access to PyMOL from Python (has no dependencies) - "biotite==1.4.0", # Pinned — biotite upgrades have broken atomworks in the past - "hydride>=1.2.3", # Biotite-supported tool for hydrogen addition + # Pinned exactly: atomworks monkey-patches biotite internals and ``atomworks/__init__.py`` + # refuses to import against any other version. A ``>=`` floor here silently resolves to a + # newer biotite that then fails at import time. + "biotite==1.6.0", # Biotite is a Python library for bioinformatics # ... small molecule libraries "rdkit>=2024.3.5", + "urllib3>=2.8.0", + "pillow>=12.3.0", + "idna>=3.15", + "requests>=2.33.0", ] [project.optional-dependencies] ml = [ # atomworks-ml dependencies "torch>=2.2.0", + "requests>=2.33.0", # Remote MMseqs2 server client "einops>=0.7.0", - "jaxtyping>=0.2.17", "beartype>=0.18.0", + "numba>=0.60.0", + "filelock>=3.20.3", +] + +s3 = [ + # Reading blob stores / parquet directly from ``s3://`` URLs + "boto3", ] ase = [ @@ -59,6 +75,7 @@ ase = [ "ase>=3.22.0", # Atomic Simulation Environment "ase-db-backends>=0.10.0", # Database backends for ASE (PostgreSQL, MySQL, LMDB) "lmdb>=1.5.0", # LMDB backend for ASE database support (required for .aselmdb files) + "cryptography>=50.0.0", ] openbabel = [ @@ -66,11 +83,21 @@ openbabel = [ "openbabel-wheel>=3.1.1.22", ] +posebusters = [ + # PoseBusters: structural validity checks for docked/predicted poses + "posebusters>=0.6.0", +] + +catcif = [ + # catcif: concatenated CIF archive format support + "catcif-tools", +] + dev = [ # Linters & formatters "ruff==0.8.3", # Testing tools - "pytest>=8.2.0", # testing framework + "pytest>=9.0.3", # testing framework "pytest-testmon>=2.1.1", # run only tests related to changed code "pytest-xdist>=3.6.1", # run tests in parallel "pytest-dotenv>=0.5.2", # load environment variables from .env file @@ -78,21 +105,29 @@ dev = [ "pytest-benchmark>=5.0.0", # benchmark tests for speed # Jupyter notebooks "ipykernel>=6.28.0", # IPython kernel for Jupyter + "tornado>=6.5.9", + # CLI + "fire>=0.5.0", # CLI generation library ] docs = [ + "sphinxcontrib-typer>=0.7.2,<1", "sphinx>=8.0.0", "sphinx-gallery>=0.19.0", "pydata-sphinx-theme>=0.16.1", "matplotlib>=3.10.0", + "myst-parser>=4.0.0", # Support Markdown tutorial pages + "sphinx-design>=0.6.0", # Render collapsible tutorial code examples ] [dependency-groups] # Reference optional dependencies for native uv compatability (in addition to the pip compatability) dev = ["atomworks[dev]"] -ml = ["atomworks[ml]"] +ml = ["atomworks[ml]"] docs = ["atomworks[docs]"] openbabel = ["atomworks[openbabel]"] +posebusters = ["atomworks[posebusters]"] +catcif = ["atomworks[catcif]"] [project.urls] homepage = "https://rosettacommons.github.io/atomworks/latest/index.html" @@ -105,13 +140,15 @@ aw = "atomworks_cli.__main__:main" # Build settings ---------------------------------------------------------------------- [build-system] -requires = ["hatchling", "hatch-vcs"] +requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.build] -sources = ["src"] +[tool.hatch.build.targets.wheel] packages = ["src/atomworks", "src/atomworks_cli"] +[tool.hatch.build.targets.sdist] +include = ["/src", "/README.md", "/LICENSE.md", "/pyproject.toml"] + [tool.hatch.metadata] allow-direct-references = true dynamic = false # optional – this disables dynamic metadata guessing @@ -220,6 +257,7 @@ convention = "google" "docs/**/*.py" = ["E402"] # Ignore forcing imports to be at the top of the file "docs/**/*.ipynb" = ["E402"] # Ignore forcing imports to be at the top of the file "src/atomworks_cli/*" = ["B008"] # Ignore function calls in argument defaults for Typer CLI +"src/atomworks/ml/preprocessing/msa/colabfold_server.py" = ["N803", "N806"] # Keep ColabFold's upstream variable names (ID, REDO, TIME, ...) for easy diffing # Allow one-letter variable names commonly used in scientific code. [tool.ruff.lint.pep8-naming] @@ -238,6 +276,7 @@ testpaths = "tests" # Only run tests in the tests directory (speeds up collecti norecursedirs = "docs *.egg-info .git .tox htmlcov" addopts = "--import-mode=importlib" pythonpath = ["src/atomworks", "tests"] +env_files = ".env" # Automatically load .env file before running tests (via pytest-dotenv) markers = [ "requires_internet: marks tests that require an internet connection", "requires_pymol_remote: marks tests that require a PyMOL remote connection to run", diff --git a/src/atomworks/__init__.py b/src/atomworks/__init__.py index 2f58582a..e52b5458 100644 --- a/src/atomworks/__init__.py +++ b/src/atomworks/__init__.py @@ -16,6 +16,25 @@ except ImportError: __version__ = "unknown" + +# Hard-enforce biotite version compatibility by importing biotite directly. +# Atomworks monkey-patches several private biotite internals that are sensitive to exact version alignment +_REQUIRED_BIOTITE_VERSION = "1.6.0" +try: + import biotite as _biotite_check +except ImportError: + raise ImportError( + f"atomworks requires biotite=={_REQUIRED_BIOTITE_VERSION}, but biotite is not installed.\n" + f" pip install 'biotite=={_REQUIRED_BIOTITE_VERSION}'" + ) from None +if _biotite_check.__version__ != _REQUIRED_BIOTITE_VERSION: + raise ImportError( + f"atomworks requires biotite=={_REQUIRED_BIOTITE_VERSION}, " + f"but biotite {_biotite_check.__version__!r} is installed.\n" + f" pip install 'biotite=={_REQUIRED_BIOTITE_VERSION}'" + ) +del _biotite_check + # Global logging configuration logger = logging.getLogger("atomworks") _log_level = os.environ.get("ATOMWORKS_LOG_LEVEL", "WARNING").upper() @@ -24,15 +43,37 @@ # Ensure that deprecation warnings are not repeated warnings.filterwarnings("once", category=DeprecationWarning) +# Enforce strict biotite version — atomworks patches biotite internals +import biotite # noqa: E402 + +_REQUIRED_BIOTITE_VERSION = "1.6.0" +if biotite.__version__ != _REQUIRED_BIOTITE_VERSION: + raise RuntimeError( + f"atomworks requires biotite=={_REQUIRED_BIOTITE_VERSION}, " + f"but found biotite=={biotite.__version__}. " + f"Install the correct version: pip install biotite=={_REQUIRED_BIOTITE_VERSION}" + ) + # Apply monkey patching to extend AtomArray functionality from atomworks.biotite_patch import monkey_patch_biotite # noqa: E402 monkey_patch_biotite() -# Import version information -# Import subpackages -from . import io, ml # noqa: E402 +# Import subpackages; also import annotators and Conditions to ensure they're registered +from atomworks.io.utils import annotator as _annotator_io # noqa: E402 +from atomworks.io.utils.standard_annotations import definitions as _standard_annotation_definitions # noqa: E402 + +from . import io # noqa: E402 + +# Make atomworks.ml an optional import +try: + from atomworks.ml.conditions import definitions as _condition_definitions + from atomworks.ml.utils import annotator as _annotator_ml + + from . import ml +except ImportError: + pass # Re-export key functionality from subpackages for convenience # This maintains backward compatibility and provides a clean top-level API diff --git a/src/atomworks/biotite_patch.py b/src/atomworks/biotite_patch.py index 28de1faa..7d5c62a4 100644 --- a/src/atomworks/biotite_patch.py +++ b/src/atomworks/biotite_patch.py @@ -8,13 +8,12 @@ `Biotite Structure Module `_ """ -from typing import Callable +import functools +from collections.abc import Callable - -import biotite -from biotite.structure import AtomArray, AtomArrayStack, Atom -import numpy as np import biotite.structure as struc +import numpy as np +from biotite.structure import Atom, AtomArray, AtomArrayStack __all__ = [ "monkey_patch_biotite", @@ -60,9 +59,33 @@ def wrapper(*args, **kwargs): return decorator +def _add_transformation_id_to_struct_conn( + struct_conn: dict[str, np.ndarray], + atom_array: AtomArray, + bond_array: np.ndarray, +) -> None: + """Add transformation_id fields to struct_conn for bond disambiguation. + + Adds ptnr{1,2}_transformation_id custom fields to struct_conn, + enabling correct bond reconstruction in assemblies. + + Args: + struct_conn: Dictionary to add transformation_id fields to (modified in-place). + atom_array: Atom array containing transformation_id annotation. + bond_array: Bond array with shape (n_bonds, 2+) where columns 0,1 are atom indices. + """ + if "transformation_id" not in atom_array.get_annotation_categories(): + raise ValueError("transformation_id annotation required to add transformation IDs to struct_conn") + + transformation_id = atom_array.transformation_id + for i in range(2): + atom_indices = bond_array[:, i] + struct_conn[f"ptnr{i+1}_transformation_id"] = transformation_id[atom_indices].astype(str) + + def _add_query_mask_idxs_methods() -> None: """Add `query`, `mask`, and `idxs` methods to `AtomArray` and `AtomArrayStack`.""" - from atomworks.io.utils.query import query, mask, idxs + from atomworks.io.utils.query import idxs, mask, query def query_method(self: AtomArray | AtomArrayStack, expr: str) -> AtomArray | AtomArrayStack: """ @@ -118,9 +141,9 @@ def lean_atom_array_repr(self: struc.AtomArray) -> str: atoms = atoms + ",\n\t" + self.get_atom(i).__repr__() return f"AtomArray([{atoms}\n])" - setattr(struc.AtomArray, "__repr__", lean_atom_array_repr) - setattr(struc.AtomArray, "_repr_original", original_repr) - setattr(struc.AtomArray, "_repr_lean", True) + struc.AtomArray.__repr__ = lean_atom_array_repr + struc.AtomArray._repr_original = original_repr + struc.AtomArray._repr_lean = True def _enable_segment_slices_in_atom_arrays() -> None: @@ -135,14 +158,14 @@ def getitem_with_segment_slices(self, item): item = item(self) return original_getitem(self, item) - setattr(struc.AtomArray, "__getitem__", getitem_with_segment_slices) - setattr(struc.AtomArray, "_getitem_original", original_getitem) - setattr(struc.AtomArray, "_getitem_new", True) + struc.AtomArray.__getitem__ = getitem_with_segment_slices + struc.AtomArray._getitem_original = original_getitem + struc.AtomArray._getitem_new = True def _update_get_residue_starts() -> None: """Improve the `get_residue_starts` function to disambiguate symmetry copies.""" - from atomworks.io.utils.selection import get_residue_starts # noqa: E402 + from atomworks.io.utils.selection import get_residue_starts struc.get_residue_starts = get_residue_starts @@ -184,7 +207,7 @@ def array(atoms: list[Atom]) -> AtomArray: for i, atom in enumerate(atoms): if sorted(atom._annot.keys()) != names: raise ValueError( - f"The atom at index {i} does not share the same annotation categories as the atom at index 0" + f"The atom at index {i} does not share the same " f"annotation categories as the atom at index 0" ) array = AtomArray(len(atoms)) @@ -203,253 +226,185 @@ def array(atoms: list[Atom]) -> AtomArray: struc.array = array -def _update_pdbx_set_structure() -> None: - """Improve the `set_structure` function to handle altloc atoms.""" +def _concatenate() -> None: + """ + Almost identical to biotite.structure.concatenate, but with a temporary fix + for the UnboundLocalError issue in concat_atoms assignment. - # fmt: off - # ruff: noqa - import biotite.structure.io.pdbx as pdbx - from biotite.structure.io.pdbx.convert import ( - MaskValue, - _check_non_empty, - _determine_entity_id, - _get_or_create_block, - _repeat, - _set_inter_residue_bonds, - _set_intra_residue_bonds, - unitcell_from_vectors, - ) + Once the PR for the fix is merged and released, we can remove this patch. + issue: https://github.com/biotite-dev/biotite/issues/855 + biotite PR for the fix: https://github.com/biotite-dev/biotite/pull/857 + """ + from biotite.sequence import Sequence + from biotite.structure import AtomArray, AtomArrayStack + from biotite.structure.bonds import BondList - def set_structure( - pdbx_file, - array, - data_block=None, - include_bonds=False, - extra_fields=[], - ): + # Ensure that the atoms can be iterated over multiple times + def concatenate(atoms: list[AtomArray | AtomArrayStack]) -> AtomArray | AtomArrayStack: """ - Set the ``atom_site`` category with atom information from an - :class:`AtomArray` or :class:`AtomArrayStack`. - - This will save the coordinates, the mandatory annotation categories - and the optional annotation categories - ``atom_id``, ``b_factor``, ``occupancy`` and ``charge``. - If the atom array (stack) contains the annotation ``'atom_id'``, - these values will be used for atom numbering instead of continuous - numbering. - Furthermore, inter-residue bonds will be written into the - ``struct_conn`` category. + Concatenate multiple :class:`AtomArray` or :class:`AtomArrayStack` objects into + a single :class:`AtomArray` or :class:`AtomArrayStack`, respectively. Parameters ---------- - pdbx_file : CIFFile or CIFBlock or BinaryCIFFile or BinaryCIFBlock - The file object. - array : AtomArray or AtomArrayStack - The structure to be written. If a stack is given, each array in - the stack will be in a separate model. - data_block : str, optional - The name of the data block. - Default is the first (and most times only) data block of the - file. - If the data block object is passed directly to `pdbx_file`, - this parameter is ignored. - If the file is empty, a new data block will be created. - include_bonds : bool, optional - If set to true and `array` has associated ``bonds`` , the - intra-residue bonds will be written into the ``chem_comp_bond`` - category. - Inter-residue bonds will be written into the ``struct_conn`` - independent of this parameter. - extra_fields : list of str, optional - List of additional fields from the ``atom_site`` category - that should be written into the file. - Default is an empty list. + atoms : iterable object of AtomArray or AtomArrayStack + The atoms to be concatenated. + :class:`AtomArray` cannot be mixed with :class:`AtomArrayStack`. + + Returns + ------- + concatenated_atoms : AtomArray or AtomArrayStack + The concatenated atoms, i.e. its ``array_length()`` is the sum of the + ``array_length()`` of the input ``atoms``. Notes ----- - In some cases, the written inter-residue bonds cannot be read again - due to ambiguity to which atoms the bond refers. - This is the case, when two equal residues in the same chain have - the same (or a masked) `res_id`. + The following rules apply: + + - Only the annotation categories that exist in all elements are transferred. + - The box of the first element that has a box is transferred, if any. + - The bonds of all elements are concatenated, if any element has associated bonds. + For elements without a :class:`BondList` an empty :class:`BondList` is assumed. Examples -------- - >>> import os.path - >>> file = CIFFile() - >>> set_structure(file, atom_array) - >>> file.write(os.path.join(path_to_directory, "structure.cif")) - + >>> atoms1 = array( + ... [ + ... Atom([1, 2, 3], res_id=1, atom_name="N"), + ... Atom([4, 5, 6], res_id=1, atom_name="CA"), + ... Atom([7, 8, 9], res_id=1, atom_name="C"), + ... ] + ... ) + >>> atoms2 = array( + ... [ + ... Atom([1, 2, 3], res_id=2, atom_name="N"), + ... Atom([4, 5, 6], res_id=2, atom_name="CA"), + ... Atom([7, 8, 9], res_id=2, atom_name="C"), + ... ] + ... ) + >>> print(concatenate([atoms1, atoms2])) + 1 N 1.000 2.000 3.000 + 1 CA 4.000 5.000 6.000 + 1 C 7.000 8.000 9.000 + 2 N 1.000 2.000 3.000 + 2 CA 4.000 5.000 6.000 + 2 C 7.000 8.000 9.000 """ - _check_non_empty(array) - - block = _get_or_create_block(pdbx_file, data_block) - Category = block.subcomponent_class() - Column = Category.subcomponent_class() - - # Fill PDBx columns from information - # in structures' attribute arrays as good as possible - atom_site = Category() - atom_site["group_PDB"] = np.where(array.hetero, "HETATM", "ATOM") - atom_site["type_symbol"] = np.copy(array.element) - atom_site["label_atom_id"] = np.copy(array.atom_name) - if "altloc_id" in array.get_annotation_categories(): - atom_site["label_alt_id"] = np.copy(array.altloc_id) - else: - atom_site["label_alt_id"] = Column( - # AtomArrays do not store altloc atoms - np.full(array.array_length(), "."), - np.full(array.array_length(), MaskValue.INAPPLICABLE), - ) - atom_site["label_comp_id"] = np.copy(array.res_name) - atom_site["label_asym_id"] = np.copy(array.chain_id) - if "chain_entity" in array.get_annotation_categories(): - atom_site["label_entity_id"] = np.copy(array.chain_entity) + # Ensure that the atoms can be iterated over multiple times + if not isinstance(atoms, Sequence): + atoms = list(atoms) + + length = 0 + depth = None + element_type = None + common_categories = set(atoms[0].get_annotation_categories()) + box = None + has_bonds = False + for element in atoms: + if element_type is None: + if not isinstance(element, (AtomArray, AtomArrayStack)): + raise TypeError("Expected 'AtomArray' or 'AtomArrayStack', " f"but got '{type(element).__name__}'") + element_type = type(element) + else: + if not isinstance(element, element_type): + raise TypeError(f"Cannot concatenate '{type(element).__name__}' " f"with '{element_type.__name__}'") + length += element.array_length() + if isinstance(element, AtomArrayStack): + if depth is None: + depth = element.stack_depth() + else: + if element.stack_depth() != depth: + raise IndexError("The stack depths are not equal") + common_categories &= set(element.get_annotation_categories()) + if element.box is not None and box is None: + box = element.box + if element.bonds is not None: + has_bonds = True + + # Use depth to decide: AtomArrayStack sets depth, AtomArray (and subclasses) leave it None. + # Prefer depth over element_type so subclasses / re-exports of AtomArray still get the correct type. + if depth is None: + concat_atoms = AtomArray(length) else: - atom_site["label_entity_id"] = _determine_entity_id(array.chain_id) - atom_site["label_seq_id"] = np.copy(array.res_id) - atom_site["pdbx_PDB_ins_code"] = Column( - np.copy(array.ins_code), - np.where(array.ins_code == "", MaskValue.INAPPLICABLE, MaskValue.PRESENT), - ) - atom_site["auth_seq_id"] = atom_site["label_seq_id"] - atom_site["auth_comp_id"] = atom_site["label_comp_id"] - atom_site["auth_asym_id"] = atom_site["label_asym_id"] - atom_site["auth_atom_id"] = atom_site["label_atom_id"] - - annot_categories = array.get_annotation_categories() - if "atom_id" in annot_categories: - atom_site["id"] = np.copy(array.atom_id) - if "b_factor" in annot_categories: - atom_site["B_iso_or_equiv"] = np.copy(array.b_factor) - if "occupancy" in annot_categories: - atom_site["occupancy"] = np.copy(array.occupancy) - if "charge" in annot_categories: - atom_site["pdbx_formal_charge"] = Column( - np.array([f"{int(c):+d}" if c != 0 else "?" for c in array.charge]), - np.where(array.charge == 0, MaskValue.MISSING, MaskValue.PRESENT), + concat_atoms = AtomArrayStack(depth, length) + concat_atoms.coord = np.concatenate([element.coord for element in atoms], axis=-2) + for category in common_categories: + concat_atoms.set_annotation( + category, + np.concatenate([element.get_annotation(category) for element in atoms], axis=0), ) - - # Handle all remaining custom fields - if len(extra_fields) > 0: - # ... check to avoid clashes with standard annotations - _standard_annotations = [ - "hetero", - "element", - "atom_name", - "res_name", - "chain_id", - "res_id", - "ins_code", - "atom_id", - "b_factor", - "occupancy", - "charge", - ] - _reserved_annotation_names = list(atom_site.keys()) + _standard_annotations - - for annot in extra_fields: - if annot in _reserved_annotation_names: - raise ValueError( - f"Annotation name '{annot}' is reserved and cannot be written to as extra field. " - "Please choose another name." - ) - atom_site[annot] = np.copy(array.get_annotation(annot)) - - if array.bonds is not None: - struct_conn = _set_inter_residue_bonds(array, atom_site) - if struct_conn is not None: - block["struct_conn"] = struct_conn - if include_bonds: - chem_comp_bond = _set_intra_residue_bonds(array, atom_site) - if chem_comp_bond is not None: - block["chem_comp_bond"] = chem_comp_bond - - # In case of a single model handle each coordinate - # simply like a flattened array - if isinstance(array, AtomArray) or (isinstance(array, AtomArrayStack) and array.stack_depth() == 1): - # 'ravel' flattens coord without copy - # in case of stack with stack_depth = 1 - atom_site["Cartn_x"] = np.copy(np.ravel(array.coord[..., 0])) - atom_site["Cartn_y"] = np.copy(np.ravel(array.coord[..., 1])) - atom_site["Cartn_z"] = np.copy(np.ravel(array.coord[..., 2])) - atom_site["pdbx_PDB_model_num"] = np.ones(array.array_length(), dtype=np.int32) - # In case of multiple models repeat annotations - # and use model-specific coordinates - else: - atom_site = _repeat(atom_site, array.stack_depth()) - coord = np.reshape(array.coord, (array.stack_depth() * array.array_length(), 3)) - atom_site["Cartn_x"] = np.copy(coord[:, 0]) - atom_site["Cartn_y"] = np.copy(coord[:, 1]) - atom_site["Cartn_z"] = np.copy(coord[:, 2]) - atom_site["pdbx_PDB_model_num"] = np.repeat( - np.arange(1, array.stack_depth() + 1, dtype=np.int32), - repeats=array.array_length(), + concat_atoms.box = box + if has_bonds: + # Concatenate bonds of all elements + concat_atoms.bonds = BondList.concatenate( + [element.bonds if element.bonds is not None else BondList(element.array_length()) for element in atoms] ) - if "atom_id" not in annot_categories: - # Count from 1 - atom_site["id"] = np.arange(1, len(atom_site["group_PDB"]) + 1) - block["atom_site"] = atom_site - - # Write box into file - if array.box is not None: - # PDBx files can only store one box for all models - # -> Use first box - if array.box.ndim == 3: - box = array.box[0] - else: - box = array.box - len_a, len_b, len_c, alpha, beta, gamma = unitcell_from_vectors(box) - cell = Category() - cell["length_a"] = len_a - cell["length_b"] = len_b - cell["length_c"] = len_c - cell["angle_alpha"] = np.rad2deg(alpha) - cell["angle_beta"] = np.rad2deg(beta) - cell["angle_gamma"] = np.rad2deg(gamma) - block["cell"] = cell - pdbx.set_structure = set_structure - # fmt: on + return concat_atoms + struc.atoms.concatenate = concatenate + struc.concatenate = concatenate -def _update_coord() -> None: - """Patch the `coord` function to use isinstance (necessary for AtomArrayPlus)""" - def coord(item): - """ - Get the atom coordinates of the given array. +def _update_set_inter_residue_bonds() -> None: + """Patch ``_set_inter_residue_bonds`` to add custom ``transformation_id`` fields to struct_conn.""" + import biotite.structure.io.pdbx.convert as pdbx_convert + from biotite.structure.io.pdbx.convert import ( + _filter_bonds, + _filter_canonical_links, + ) + from biotite.structure.io.pdbx.convert import ( + _set_inter_residue_bonds as _set_inter_residue_bonds_original, + ) - This may be directly and :class:`Atom`, :class:`AtomArray` or - :class:`AtomArrayStack` or - alternatively an (n x 3) or (m x n x 3) :class:`ndarray` - containing the coordinates. + # ruff: noqa + def _set_inter_residue_bonds(array, atom_site): + struct_conn = _set_inter_residue_bonds_original(array, atom_site) + if struct_conn is None: + return None + if "transformation_id" in array.get_annotation_categories(): + bond_array = _filter_bonds(array, "inter") + bond_array = bond_array[~_filter_canonical_links(array, bond_array)] + _add_transformation_id_to_struct_conn(struct_conn, array, bond_array) + return struct_conn - Parameters - ---------- - item : Atom or AtomArray or AtomArrayStack or ndarray - Returns the :attr:`coord` attribute, if `item` is an - :class:`Atom`, :class:`AtomArray` or :class:`AtomArrayStack`. - Directly returns the input, if `item` is a :class:`ndarray`. + pdbx_convert._set_inter_residue_bonds = _set_inter_residue_bonds - Returns - ------- - coord : ndarray - Atom coordinates. - """ - if isinstance(item, (Atom, struc.atoms._AtomArrayBase)): - return item.coord - elif isinstance(item, np.ndarray): - return item.astype(np.float32, copy=False) - else: - return np.array(item, dtype=np.float32) +def _update_cif_loop_parsing() -> None: + """Distribute CIF loop tokens by column without a Python operation per value. - struc.atoms.coord = coord + Biotite still normalizes multiline fields and tokenizes quoted values. The flat + token list temporarily adds one reference per value while columns are sliced out. + """ + from biotite.structure.io.pdbx import cif + + original = cif.CIFCategory._deserialize_looped + + @functools.wraps(original) + def deserialize_looped(lines: list[str]) -> dict[str, list[str]]: + column_names = [] + for line in lines: + if line[0] != "_": + break + column_names.append(line.split(".")[1]) + + n_columns = len(column_names) + if not n_columns or len(set(column_names)) != n_columns: + return original(lines) + + values = [] + for line in lines[n_columns:]: + if line[0] == ";" or "'" in line or '"' in line: + values.extend(cif._split_one_line(line)) + else: + values.extend(line.split()) + if len(values) % n_columns: + raise cif.DeserializationError("Category contains columns with different lengths") + return {name: values[i::n_columns] for i, name in enumerate(column_names)} - # These pyx files also need to be updated with the new version - struc.celllist.to_coord = coord - struc.sasa.CellList = struc.celllist.CellList + cif.CIFCategory._deserialize_looped = staticmethod(deserialize_looped) def monkey_patch_biotite() -> None: @@ -465,7 +420,8 @@ def monkey_patch_biotite() -> None: _enable_segment_slices_in_atom_arrays() _update_get_residue_starts() _update_array() - _update_pdbx_set_structure() - _update_coord() # TODO: Remove once biotite 1.5.0 is released (will contain this fix) + _concatenate() + _update_set_inter_residue_bonds() + _update_cif_loop_parsing() _HAS_BEEN_PATCHED = True diff --git a/src/atomworks/common.py b/src/atomworks/common.py index 1947bd55..458723a3 100644 --- a/src/atomworks/common.py +++ b/src/atomworks/common.py @@ -1,10 +1,21 @@ -"""Common utility functions used throughout the project.""" +""" +Common utility functions used throughout the project. + +Timeout utilities are adapted from https://github.com/pnpnpn/timeout-decorator/blob/master/timeout_decorator/timeout_decorator.py (MIT License) +and from https://github.com/chaidiscovery/chai-lab/blob/main/chai_lab/utils/timeout.py +""" import copy import hashlib +import multiprocessing +import queue as _queue +import signal +import time from collections.abc import Callable +from enum import Enum from functools import lru_cache, wraps -from typing import Any +from multiprocessing import Queue +from typing import Any, Literal, Never import numpy as np from toolz.curried import compose, reduce @@ -160,6 +171,10 @@ def decorator(func: Callable) -> Callable: def wrapper(*args, **kwargs) -> Any: return copy_func(cached_func(*args, **kwargs)) + # Expose cache methods from the underlying lru_cache + wrapper.cache_clear = cached_func.cache_clear + wrapper.cache_info = cached_func.cache_info + return wrapper return decorator @@ -197,3 +212,215 @@ def __call__(self, value: Any) -> int: self.key_to_id[value] = self.next_id self.next_id += 1 return self.key_to_id[value] + + +def timeout(timeout: float | int | None = None, strategy: Literal["signal", "subprocess"] = "subprocess") -> Callable: + """ + Decorator to apply a timeout to a function. + + The `signal` strategy is more efficient and slightly faster, but does not work in all contexts + (e.g. with some C dependencies like RDKit, on certain operating systems). + The `subprocess` strategy is always available, but slightly slower and with a higher overhead. + """ + if timeout is None: + return do_nothing() + match strategy: + case "signal": + # timeout based on signal module + return timeout_using_signal(timeout) + case "subprocess": + # timeout based on subprocess module + return timeout_using_subprocess(timeout) + case _: + raise ValueError(f"Invalid strategy: {strategy}. Must be 'signal' or 'subprocess'.") + + +def do_nothing(*args, **kwargs) -> Callable: + """A decorator that does nothing and simply returns the original function. + + This decorator can be used as a placeholder or for testing purposes when you want + to conditionally apply decorators without changing the code structure. + + Returns: + A decorator function that returns the original function unchanged. + + Example: + .. code-block:: python + + @do_nothing_decorator() + def my_function(): + return "Hello, World!" + + + # or: + do_nothing(bla=123, blub=456)(my_function) + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapped_func(*args: Any, **kwargs: Any) -> Any: + return func(*args, **kwargs) + + return wrapped_func + + return decorator + + +def timeout_using_signal(timeout: float | int | None) -> Callable: + """ + Build a decorator that applies a timeout to a function using the signal module. + + This decorator sets up a signal handler to raise a TimeoutError if the decorated function + exceeds the specified timeout duration. It uses the SIGALRM signal to implement the timeout. + + Use for example as: + ```python + result = timeout_using_signal(timeout=10.0)(my_function)(*args, **kwargs) + ``` + + Args: + timeout (float | int | None): The timeout duration in seconds. + + Returns: + Callable: A decorator function that can be applied to other functions to add timeout functionality. + """ + + def decorate(func: Callable) -> Callable: + @wraps(func) + def wrapped_func(*args, **kwargs): # noqa: ANN202 + _start_time = time.time() + + def _timeout_handler(*_) -> Never: + # ... raise TimeoutError if called + _elapsed_time = time.time() - _start_time + raise TimeoutError(f"Function timed out after {_elapsed_time:.3f} seconds") + + # ... set the timeout handler and record the prior handler to restore later + _prior_handler = signal.signal(signal.SIGALRM, _timeout_handler) + # ... start the timer + signal.setitimer(signal.ITIMER_REAL, timeout) + try: + return func(*args, **kwargs) + finally: + # ... reset the timer + signal.setitimer(signal.ITIMER_REAL, 0) + # ... restore the prior handler + signal.signal(signal.SIGALRM, _prior_handler) + + return wrapped_func + + return decorate + + +def _timeout_handler(queue: Queue, func: Callable, args: Any, kwargs: Any) -> None: + """ + Util function to be used only in `timeout_using_subprocess`. + This util function is in the outer scope to allow pickling during ddp multiprocessing. + """ + try: + result = func(*args, **kwargs) + queue.put((_TimeoutHandlerStatus.SUCCESS, result)) + except Exception as e: + queue.put((_TimeoutHandlerStatus.EXCEPTION, e)) + + +def timeout_using_subprocess(timeout: float | int | None) -> Callable: + """Force function to timeout after specified time. + + The returned decorator uses a subprocess to execute the function, allowing for timeout + functionality even for CPU-bound operations that cannot be interrupted by signals. + + Args: + timeout (float | int | None): The maximum time in seconds allowed for the function to execute. + + Returns: + Callable: A decorator that can be applied to a function. + + Raises: + TimeoutError: If the function does not return before the timeout. + ChildProcessException: If the child process dies unexpectedly. + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapped_func(*args, **kwargs): # noqa: ANN202 + # NOTE: 'fork' context is useful to speed up the timeout handling, + # as using 'spawn' instead will re-trigger imports that are needed to run the function + # and understand the context in which it is used in, which can be slow. + ctx = multiprocessing.get_context("fork") + queue = ctx.Queue() + + # ... create subprocess to run the function + proc = ctx.Process(target=_timeout_handler, args=(queue, func, args, kwargs), daemon=True) + # ... start the subprocess (ensure it is not a daemon to allow doing this in multiprocessing) + with _AllowSubprocessForDeamonicProcess(): + proc.start() + + # ... wait for the subprocess to finish and check if it timed out + proc.join(timeout=float(timeout)) + + # ... if the subprocess is still running, terminate it and raise a TimeoutError + if proc.is_alive(): + proc.terminate() + proc.join() + raise TimeoutError(f"Function {func} timed out after {timeout} seconds") + + # ... try retrieving the result, if available + try: + status, value = queue.get(timeout=0.1) # short timeout to prevent hang + # NOTE: Hang can happen when the child process dies unexpectedly + # and the main process is waiting for the result in the queue. + # See Issue(https://bugs.python.org/issue43805) + except _queue.Empty: + raise ChildProcessError("Child process died unexpectedly") # noqa: B904 + + match status: + case _TimeoutHandlerStatus.SUCCESS: + # ... return the result of the function + return value + case _TimeoutHandlerStatus.EXCEPTION: + # ... raise the exception caught in the child process + raise value + case _: + # ... this code should be unreachable, if reached raise an error + raise ValueError(f"Invalid status: {status}. Must be 'SUCCESS' or 'EXCEPTION'.") + + return wrapped_func + + return decorator + + +# TODO: This is dangerous: revert once the underlying problem in rdkit is fixed +# RDKit Issue(https://github.com/rdkit/rdkit/discussions/7289) +class _AllowSubprocessForDeamonicProcess: + """Context Manager to resolve AssertionError: daemonic processes are not allowed to have children + See https://stackoverflow.com/questions/6974695/python-process-pool-non-daemonic""" + + def __init__(self): + self.conf: dict = multiprocessing.process.current_process()._config # type: ignore + if "daemon" in self.conf: + self.daemon_status_set = True + else: + self.daemon_status_set = False + self.daemon_status_value = self.conf.get("daemon") + + def __enter__(self): + if self.daemon_status_set: + del self.conf["daemon"] + + def __exit__(self, *args, **kwargs): + if self.daemon_status_set: + self.conf["daemon"] = self.daemon_status_value + + +class _TimeoutHandlerStatus(Enum): + """Status of the timeout handler.""" + + SUCCESS = 0 + EXCEPTION = 1 + + +class ChildProcessError(Exception): + """Exception raised when a child process dies unexpectedly.""" + + pass diff --git a/src/atomworks/constants.py b/src/atomworks/constants.py index e07f2b5c..06a5a90e 100644 --- a/src/atomworks/constants.py +++ b/src/atomworks/constants.py @@ -6,33 +6,57 @@ from types import MappingProxyType from typing import Final +import numpy as np from biotite.structure.bonds import BondType from toolz import keymap logger = logging.getLogger(__name__) -def _load_env_var(var_name: str) -> str | None: - """Load an environment variable, returning None if it is not set. +def _load_env_var(var_name: str, default: str | bool | None = None) -> str | bool | None: + """Load an environment variable. Args: var_name: The name of the environment variable to load. + default: The default value if not set. If default is a bool, will parse as bool. Returns: - The value of the environment variable, or None if not set. + - bool if the value is a boolean string (1/0, true/false, yes/no, on/off) or default is bool + - str if the value is a non-boolean string and default is not bool + - None if not set and no default provided """ - try: - return os.environ[var_name] - except KeyError: - logger.warning( - f"Environment variable {var_name} not set. " - "Will not be able to use function requiring this variable. " - "To set it you may:\n" - " (1) add the line 'export VAR_NAME=path/to/variable' to your .bashrc or .zshrc file\n" - " (2) set it in your current shell with 'export VAR_NAME=path/to/variable'\n" - " (3) write it to a .env file in the root of the atomworks.io repository" - ) - return None + value = os.environ.get(var_name) + + # Not set - return default or warn + if value is None: + if default is None: + logger.warning( + f"Environment variable {var_name} not set. " + "Will not be able to use function requiring this variable. " + "To set it you may:\n" + " (1) add the line 'export VAR_NAME=path/to/variable' to your .bashrc or .zshrc file\n" + " (2) set it in your current shell with 'export VAR_NAME=path/to/variable'\n" + " (3) write it to a .env file in the root of the atomworks.io repository" + ) + return default + + # Auto-detect boolean or parse based on default type + value_lower = value.lower().strip() + if isinstance(default, bool) or value_lower in ("1", "true", "yes", "on", "0", "false", "no", "off"): + if value_lower in ("1", "true", "yes", "on"): + return True + elif value_lower in ("0", "false", "no", "off"): + return False + else: + logger.warning( + f"Invalid boolean value for {var_name}: {value!r}. " + f"Expected one of: 1, true, yes, on, 0, false, no, off. " + f"Using default: {default}" + ) + return default if default is not None else False + + # Return as string + return value CCD_MIRROR_PATH: Final[str] = _load_env_var("CCD_MIRROR_PATH") @@ -49,15 +73,42 @@ def _load_env_var(var_name: str) -> str | None: `Protein Data Bank `_ """ +ALLOW_BIOTITE_CCD: Final[bool] = _load_env_var("ALLOW_BIOTITE_CCD", default=True) +"""Whether to allow falling back to Biotite's built-in CCD when no local mirror is available.""" + +# Defualt / unknown values for atoms + UNKNOWN_ELEMENT: Final[str] = "X" """The element name for an unknown element.""" UNKNOWN_ATOMIC_NUMBER: Final[int] = 0 """The atomic number for an unknown element.""" +UNKNOWN_ATOM_NAME: Final[str] = "UNK" +"""The atom name for an unknown atom.""" + +UNKNOWN_COORD_VALUE: Final[float] = np.nan +"""The coordinate value for an unknown atom.""" + +DEFAULT_B_FACTOR: Final[float] = np.nan +"""The default B-factor value for an atom.""" + +DEFAULT_OCCUPANCY: Final[float] = 0.0 +"""The default occupancy value for an atom.""" + +DEFAULT_CHARGE: Final[int] = 0 +"""The default charge value for an atom.""" + +DEFAULT_INS_CODE: Final[str] = "" +"""The default insertion code value for an atom.""" + +DEFAULT_ALTLOC_ID: Final[str] = "." +"""The default altloc ID value for an atom.""" + # fmt: off ELEMENT_NAME_TO_ATOMIC_NUMBER: Final[MappingProxyType[str, int]] = MappingProxyType(keymap(str.upper, { - "H": 1, "He": 2, "Li": 3, "Be": 4, "B": 5, "C": 6, "N": 7, "O": 8, "F": 9, "Ne": 10, + "H": 1, # Hydrogen (isotopes D, T handled via PDB_ISOTOPE_SYMBOL_TO_ELEMENT_SYMBOL) + "He": 2, "Li": 3, "Be": 4, "B": 5, "C": 6, "N": 7, "O": 8, "F": 9, "Ne": 10, "Na": 11, "Mg": 12, "Al": 13, "Si": 14, "P": 15, "S": 16, "Cl": 17, "Ar": 18, "K": 19, "Ca": 20, "Sc": 21, "Ti": 22, "V": 23, "Cr": 24, "Mn": 25, "Fe": 26, "Co": 27, "Ni": 28, "Cu": 29, "Zn": 30, "Ga": 31, "Ge": 32, "As": 33, "Se": 34, "Br": 35, "Kr": 36, "Rb": 37, "Sr": 38, "Y": 39, "Zr": 40, @@ -94,13 +145,14 @@ def _load_env_var(var_name: str) -> str | None: """ METAL_ELEMENTS: Final[frozenset[str]] = frozenset(map(str.upper, [ - "Li", "Na", "K", "Rb", "Cs", "Be", "Mg", "Ca", "Sr", "Ba", - "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", - "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", - "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu", - "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", - "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr", - "Al", "Ga", "In", "Sn", "Tl", "Pb", "Bi", + "Li", "Na", "K", "Rb", "Cs", "Fr", # alkali metals + "Be", "Mg", "Ca", "Sr", "Ba", "Ra", # alkaline earth metals + "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", # 3d transition metals + "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", # 4d transition metals + "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", # 5d transition metals + "Al", "Ga", "In", "Sn", "Tl", "Pb", "Bi", # post-transition metals + "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu", # lanthanides + "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr", # actinides ])) """A set of all metal elements, all *UPPERCASE*. @@ -112,6 +164,23 @@ def _load_env_var(var_name: str) -> str | None: """ # fmt: on +# Common element groups +CHNO_ELEMENTS: Final[frozenset[str]] = frozenset({"C", "H", "N", "O"}) +"""Carbon, Hydrogen, Nitrogen, Oxygen - the four core elements of organic chemistry.""" + +CHNOPS_ELEMENTS: Final[frozenset[str]] = frozenset({"C", "H", "N", "O", "P", "S"}) +"""Carbon, Hydrogen, Nitrogen, Oxygen, Phosphorus, Sulfur - the six most abundant elements in living organisms.""" + +# Bond distance thresholds (in Angstroms) +BOND_DISTANCE_THRESHOLD_CHNO: Final[float] = 1.7 +"""Maximum bond distance for bonds involving only C, H, N, O atoms.""" + +BOND_DISTANCE_THRESHOLD_CHNOPS: Final[float] = 2.4 +"""Maximum bond distance for bonds involving only C, H, N, O, P, S atoms.""" + +BOND_DISTANCE_THRESHOLD_OTHER: Final[float] = 3.6 +"""Maximum bond distance for bonds involving metals or other elements.""" + # fmt: off CHEM_COMP_TYPES: Final[tuple[str, ...]] = tuple([ chemtype.upper() for chemtype in ( @@ -231,6 +300,38 @@ def _load_env_var(var_name: str) -> str | None: ) """A mapping of chemical component types to the atoms that they link when part of a polymer.""" +CCD_POLYMERIZATION_ATOM_OVERRIDES: Final[MappingProxyType[str, tuple[str | None, str | None]]] = MappingProxyType( + { + # Nucleotide analogues where an element other than oxygen sits at the 3' position, so the + # linkage cannot be recovered from the chemical component type's canonical ``O3'``. + "TSP": ("S3'", "P"), # 3'-thio-thymidine + "US1": ("S", "P"), # 2'-deoxy-3'-thiouridine + "C42": ("N", "P"), # 3'-amino-2'-deoxy-cytidine (phosphoramidate) + "TCP": ("O3'", "CP"), # 5'-methylthymidine; a methylene replaces the 5' phosphorus + "TFT": ("O2T", "P"), # threofuranosyl (TNA); the tetrose sugar's 3'-equivalent is O2T + "3DA": ("O2'", "P"), # 3'-deoxyadenosine (cordycepin); no O3', so it links 2'->5' + "8RJ": ("O2G", "P"), # glycol nucleic acid; the free hydroxyl is O2G (5V1K) + "4DG": (None, "P"), # acyclic guanine phosphonate; a 3' terminus, so nothing exits (3KD1/3KD5) + # Backbones that simply use non-canonical atom names, where the CCD entry flags a leaving + # group for only one side and the other side therefore cannot be derived. + "AEA": ("C5", "N1"), + "WCM": ("C14", "N5"), + # 2'-amino-2'-deoxyadenosine. Its sole leaving-atom flag is ``H2`` + # on the purine ``C2``, which would otherwise be derived as a spurious exiting carbon. + "2AD": ("O3'", "N"), + } +) +"""Polymerization atoms for CCD entries whose own annotations cannot yield the right answer. + +Each entry is ``(exiting_atom, entering_atom)``, matching :py:func:`get_polymerization_atoms`, and +every one is confirmed against ``struct_conn`` records in the PDB. Consulted only once a component +is known not to carry the canonical atom names for its chemical component type, so a custom +component registered under one of these codes is unaffected. +""" + +MAX_CHEM_TYPE_LENGTH: Final[int] = max(len(chem_type) for chem_type in CHEM_COMP_TYPES) +"""Maximum length of a valid chem_comp_type identifier. Determines the dtype of the `chem_comp_type` annotation.""" + STRUCT_CONN_BOND_TYPES: Final[frozenset[str]] = frozenset({"covale", "disulf", "metalc"}) """A set of bond types that are considered when adding bonds to the atom array. @@ -264,11 +365,12 @@ def _load_env_var(var_name: str) -> str | None: BondType.AROMATIC_SINGLE: 1, # 5 BondType.AROMATIC_DOUBLE: 2, # 6 BondType.AROMATIC_TRIPLE: 3, # 7 + BondType.COORDINATION: 0, # 8, excluded from covalent valence bookkeeping } ) """Mapping from Biotite bond types to bond orders. -NOTE: We do not include BondType.COORDINATION (8) and BondType.AROMATIC (9) as bond orders are not well-defined; they should be handled separately. +NOTE: BondType.AROMATIC (9) has no well-defined bond order and must be handled separately. Reference: `biotite.structure.BondType `_ @@ -404,6 +506,28 @@ def _load_env_var(var_name: str) -> str | None: STANDARD_DNA_ONE_LETTER: Final[tuple[str, ...]] = tuple(sorted(["A", "C", "G", "T"])) """Tuple of the one-letter symbols for the standard 4 DNA nucleotides.""" +STANDARD_POLYMER_RESIDUES: Final[tuple[str, ...]] = (*STANDARD_AA, *STANDARD_RNA, *STANDARD_DNA) +"""Tuple of all standard polymer residues (20 AA, 4 RNA, 4 DNA).""" + +UNKNOWN_POLYMER_RESIDUES: Final[tuple[str, ...]] = (UNKNOWN_AA, UNKNOWN_RNA, UNKNOWN_DNA) +"""Tuple of unknown polymer residues (UNK, N, DN).""" + + +STANDARD_AND_UNKNOWN_AA: Final[tuple[str, ...]] = (*STANDARD_AA, UNKNOWN_AA) +"""Tuple of the CCD codes for the standard 20 amino acids + unknown (UNK).""" + +STANDARD_AND_UNKNOWN_RNA: Final[tuple[str, ...]] = (*STANDARD_RNA, UNKNOWN_RNA) +"""Tuple of the CCD codes for the standard 4 RNA nucleotides + unknown (N).""" + +STANDARD_AND_UNKNOWN_DNA: Final[tuple[str, ...]] = (*STANDARD_DNA, UNKNOWN_DNA) +"""Tuple of the CCD codes for the standard 4 DNA nucleotides + unknown (DN).""" + +STANDARD_AND_UNKNOWN_POLYMER_RESIDUES: Final[tuple[str, ...]] = ( + *STANDARD_POLYMER_RESIDUES, + *UNKNOWN_POLYMER_RESIDUES, +) +"""Tuple of all standard and unknown polymer residues (AA, RNA, DNA).""" + BIOTITE_DEFAULT_ANNOTATIONS: Final[tuple[str, ...]] = ( "chain_id", "res_id", @@ -414,6 +538,109 @@ def _load_env_var(var_name: str) -> str | None: ) """The default mandatory annotations for Biotite AtomArrays.""" +BIOTITE_COMMON_OPTIONAL_ANNOTATIONS: Final[tuple[str, ...]] = ( + "atom_id", + "charge", + "occupancy", + "b_factor", +) +"""Optional annotations commonly used in Biotite. + +They are saved by biotite `set_structure` by default, and hence by atomworks `to_cif_file`. +They are not loaded by biotite `get_structure` by default, but they ARE loaded by default in atomworks `load_any` +""" + +ALTLOC_DEFAULT_IDS: Final[tuple[str, ...]] = (".", "?", " ", "") +"""Altloc ID values that indicate a default (non-alternate) atom position.""" + +STANDARD_CIF_ANNOTATIONS: Final[frozenset[str]] = frozenset( + { + "chain_id", + "res_id", + "res_name", + "atom_name", + "atom_id", + "element", + "ins_code", + "hetero", + "altloc_id", + "charge", + "occupancy", + "b_factor", + } +) +"""All standard CIF annotations handled by Biotite, mandatory or optional. + +These are reserved names that are excluded from `extra_annotations`. +""" + +ATOM_LEVEL_ANNOTATIONS: Final[tuple[str, ...]] = ( + # +---- Standard mmCIF/Biotite atom-level annotations ----+ + "atom_name", # CCD atom name (e.g., "CA", "N", "O") + "element", # Element symbol (e.g., "C", "N", "O") + "atom_id", # Unique atom identifier + "altloc_id", # Alternate location identifier (mutliple-occupancy models) + "charge", # Atomic charge + "occupancy", # Occupancy value + "b_factor", # B-factor (temperature factor) + # +---- AtomWorks extensions ----+ + "atomic_number", # Atomic number (derived from element) +) +"""Standard atom-level annotations""" + +RESIDUE_LEVEL_ANNOTATIONS: Final[tuple[str, ...]] = ( + # +---- Standard mmCIF/Biotite residue-level annotations ----+ + "res_id", # Residue ID (sequence number) + "res_name", # Residue name (CCD 3-letter code) + "hetero", # Hetero flag (non-standard residue) + "ins_code", # Insertion code (legacy from PDB format, rarely used) + "label_alt_id", # Alternate conformer ID — per-atom in mmCIF, but residue-level after altloc selection +) +"""Residue-level annotations - shared by all atoms in a residue.""" + +CHAIN_LEVEL_ANNOTATIONS: Final[tuple[str, ...]] = ( + # +---- Standard mmCIF/Biotite chain-level annotations ----+ + "chain_id", # Chain identifier (author chain ID) + # +---- AtomWorks extensions ----+ + "chain_iid", # Chain instance identifier (disambiguates across transforms) + "chain_entity", # Chain entity identifier + "chain_type", # Chain type as int enum (polypeptide, DNA, RNA, etc.) + "is_polymer", # Convenience chain-level polymer flag (derived from chain_type) +) +"""Chain-level annotations.""" + +PN_UNIT_LEVEL_ANNOTATIONS: Final[tuple[str, ...]] = ( + "pn_unit_id", # PN Unit identifier (Polymer XOR Non-polymer unit) + "pn_unit_iid", # PN Unit instance identifier (disambiguates across transforms) + "pn_unit_entity", # PN Unit entity identifier +) +"""PN Unit-level annotations - Polymer XOR Non-polymer unit annotations.""" + +MOLECULE_LEVEL_ANNOTATIONS: Final[tuple[str, ...]] = ( + "molecule_id", # Molecule identifier + "molecule_iid", # Molecule instance identifier (disambiguates across transforms) + "molecule_entity", # Molecule entity identifier +) +"""Molecule-level annotations.""" + +TRANSFORMATION_LEVEL_ANNOTATIONS: Final[tuple[str, ...]] = ( + "transformation_id", # Transformation identity in multi-transformation assemblies +) +"""Transformation-level annotations - for bio-assemblies.""" + +ATOMWORKS_COMMON_ANNOTATIONS: Final[tuple[str, ...]] = ( + ATOM_LEVEL_ANNOTATIONS + + RESIDUE_LEVEL_ANNOTATIONS + + CHAIN_LEVEL_ANNOTATIONS + + PN_UNIT_LEVEL_ANNOTATIONS + + MOLECULE_LEVEL_ANNOTATIONS + + TRANSFORMATION_LEVEL_ANNOTATIONS +) +"""All standard annotations used in AtomWorks. + +Includes both standard mmCIF/Biotite annotations and AtomWorks extensions. +""" + STANDARD_PYRIMIDINE_RESIDUES: Final[tuple[str, ...]] = ("C", "U", "DC", "DT") """Tuple of the CCD codes for the 4 standard pyrimidine nucleotides.""" @@ -438,8 +665,23 @@ def _load_env_var(var_name: str) -> str | None: DO_NOT_MATCH_CCD: Final[frozenset[str]] = frozenset((*WATER_LIKE_CCDS, UNKNOWN_LIGAND)) """CCDs that should not be matched to a template for the purpose of adding missing atoms.""" -PEPTIDE_MAX_RESIDUES: Final[int] = 20 -"""The maximum number of residues until which we consider a protein-like sequence to be a peptide.""" +DEFAULT_CCD_ANNOTATIONS: Final[tuple[str, ...]] = ( + "charge", + "stereo", + "is_aromatic", + "is_leaving_atom", + "is_backbone_atom", + "is_n_terminal_atom", + "is_c_terminal_atom", + "nhyd", +) +"""Common annotations derived from Chemical Component Dictionary (CCD) entries.""" + +PEPTIDE_MAX_RESIDUES: Final[int] = 16 +"""The maximum number of residues until which we consider a protein-like sequence to be a peptide. + +NOTE: AF3 uses either 10 or 16 for this number in different places. +""" PDB_ISOTOPE_SYMBOL_TO_ELEMENT_SYMBOL: Final[dict[str, str]] = { "D": "H", diff --git a/src/atomworks/enums.py b/src/atomworks/enums.py index 843d54a1..ae277ab0 100644 --- a/src/atomworks/enums.py +++ b/src/atomworks/enums.py @@ -210,6 +210,23 @@ def to_string(self) -> str: """ return ChainTypeInfo.ENUM_TO_STRING[self] + def to_mmcif_string(self) -> str: + """Convert to mmCIF-format string with correct casing (e.g. ``polypeptide(L)``).""" + return ChainTypeInfo.ENUM_TO_MMCIF_STRING[self] + + def to_entity_type(self) -> str: + """Return the mmCIF ``entity.type`` string for this chain type. + + This differs from :py:meth:`to_mmcif_string`, which returns the + ``entity_poly.type`` string (e.g. ``"polypeptide(L)"``). This method + returns the coarser ``entity.type`` field: ``"polymer"`` for all polymer + types, or the specific non-polymer string (``"branched"``, + ``"macrolide"``, ``"non-polymer"``, ``"water"``) otherwise. + """ + if self in ChainTypeInfo.POLYMERS: + return "polymer" + return ChainTypeInfo.ENUM_TO_MMCIF_STRING[self] + @staticmethod def as_enum(value: Union[str, int, "ChainType"]) -> "ChainType": """Convert a string, int, or ChainType to a ChainType enum. @@ -266,33 +283,37 @@ class ChainTypeInfo: NUCLEIC_ACIDS: Final[tuple[ChainType, ...]] = (ChainType.DNA, ChainType.RNA, ChainType.DNA_RNA_HYBRID) - STRING_TO_ENUM: Final[MappingProxyType[str, ChainType]] = MappingProxyType( - keymap( - str.upper, - { - # Polymers - "CYCLIC-PSEUDO-PEPTIDE": ChainType.CYCLIC_PSEUDO_PEPTIDE, - "OTHER": ChainType.OTHER_POLYMER, # WARNING! Paradoxically, "other" is a polymer type. - "PEPTIDE NUCLEIC ACID": ChainType.PEPTIDE_NUCLEIC_ACID, - "POLYDEOXYRIBONUCLEOTIDE": ChainType.DNA, - "POLYDEOXYRIBONUCLEOTIDE/POLYRIBONUCLEOTIDE HYBRID": ChainType.DNA_RNA_HYBRID, - "POLYPEPTIDE(D)": ChainType.POLYPEPTIDE_D, - "POLYPEPTIDE(L)": ChainType.POLYPEPTIDE_L, - "POLYRIBONUCLEOTIDE": ChainType.RNA, - # Non-polymers - "BRANCHED": ChainType.BRANCHED, - "MACROLIDE": ChainType.MACROLIDE, - "NON-POLYMER": ChainType.NON_POLYMER, - "WATER": ChainType.WATER, - }, - ) - ) + _MMCIF_STRINGS: Final[dict[str, ChainType]] = { + # Polymers + "cyclic-pseudo-peptide": ChainType.CYCLIC_PSEUDO_PEPTIDE, + "other": ChainType.OTHER_POLYMER, # WARNING! Paradoxically, "other" is a polymer type. + "peptide nucleic acid": ChainType.PEPTIDE_NUCLEIC_ACID, + "polydeoxyribonucleotide": ChainType.DNA, + "polydeoxyribonucleotide/polyribonucleotide hybrid": ChainType.DNA_RNA_HYBRID, + "polypeptide(D)": ChainType.POLYPEPTIDE_D, + "polypeptide(L)": ChainType.POLYPEPTIDE_L, + "polyribonucleotide": ChainType.RNA, + # Non-polymers + "branched": ChainType.BRANCHED, + "macrolide": ChainType.MACROLIDE, + "non-polymer": ChainType.NON_POLYMER, + "water": ChainType.WATER, + } + + # Uppercase keys for case-insensitive lookup (existing behavior preserved) + STRING_TO_ENUM: Final[MappingProxyType[str, ChainType]] = MappingProxyType(keymap(str.upper, _MMCIF_STRINGS)) """Mapping from chain_type strings to ChainType enums.""" + # Reverse mappings ENUM_TO_STRING: Final[MappingProxyType[ChainType, str]] = MappingProxyType( {v: k for k, v in STRING_TO_ENUM.items()} ) - """Mapping from ChainType enums to chain_type strings.""" + """Mapping from ChainType enums to UPPERCASE chain_type strings.""" + + ENUM_TO_MMCIF_STRING: Final[MappingProxyType[ChainType, str]] = MappingProxyType( + {v: k for k, v in _MMCIF_STRINGS.items()} + ) + """Mapping from ChainType enums to mmCIF-cased chain_type strings.""" VALID_CHEM_COMP_TYPES: Final[MappingProxyType[ChainType, set[str]]] = MappingProxyType( { @@ -333,6 +354,60 @@ class ChainTypeInfo: """Mapping of chain types to the atoms that they link when part of a polymer.""" +class ChiralType(IntEnum): + """Coordination geometry of a stereogenic center. + + Values match RDKit's ``Chem.rdchem.ChiralType`` int values, so + conversion from an RDKit atom is ``ChiralType(atom.GetChiralTag())``. + """ + + NOT_CHIRAL = 0 # CHI_UNSPECIFIED + TETRAHEDRAL_CW = 1 # CHI_TETRAHEDRAL_CW + TETRAHEDRAL_CCW = 2 # CHI_TETRAHEDRAL_CCW + OTHER = 3 # CHI_OTHER + TETRAHEDRAL = 4 # CHI_TETRAHEDRAL (generic) + ALLENE = 5 # CHI_ALLENE + SQUARE_PLANAR = 6 # CHI_SQUAREPLANAR + TRIGONAL_BIPYRAMIDAL = 7 # CHI_TRIGONALBIPYRAMIDAL + OCTAHEDRAL = 8 # CHI_OCTAHEDRAL + + def to_geometry(self) -> str: + """Return the geometry name (e.g. ``"tetrahedral"``).""" + return _CHIRAL_TYPE_TO_GEOMETRY.get(self, "") + + def is_tetrahedral(self) -> bool: + """Check if this is a tetrahedral chiral center.""" + return self in ( + ChiralType.TETRAHEDRAL_CW, + ChiralType.TETRAHEDRAL_CCW, + ChiralType.TETRAHEDRAL, + ) + + def is_stereogenic(self) -> bool: + """Check if this represents a real stereogenic center with defined geometry.""" + # ALLENE (axial chirality) is intentionally excluded: RDKit's + # ``AssignStereochemistryFrom3D`` does not assign it from 3D coordinates, + # and downstream featurizers do not model axial chirality. OTHER and + # generic TETRAHEDRAL are "unspecified" tags and likewise excluded. + return self in ( + ChiralType.TETRAHEDRAL_CW, + ChiralType.TETRAHEDRAL_CCW, + ChiralType.SQUARE_PLANAR, + ChiralType.TRIGONAL_BIPYRAMIDAL, + ChiralType.OCTAHEDRAL, + ) + + +_CHIRAL_TYPE_TO_GEOMETRY = { + ChiralType.TETRAHEDRAL_CW: "tetrahedral", + ChiralType.TETRAHEDRAL_CCW: "tetrahedral", + ChiralType.TETRAHEDRAL: "tetrahedral", + ChiralType.SQUARE_PLANAR: "square_planar", + ChiralType.TRIGONAL_BIPYRAMIDAL: "trigonal_bipyramidal", + ChiralType.OCTAHEDRAL: "octahedral", +} + + class GroundTruthConformerPolicy(IntEnum): """Enum for ground truth conformer policy. diff --git a/src/atomworks/experimental/__init__.py b/src/atomworks/experimental/__init__.py new file mode 100644 index 00000000..afcdcd0e --- /dev/null +++ b/src/atomworks/experimental/__init__.py @@ -0,0 +1 @@ +"""Beta subsystems whose interfaces and results may change between releases.""" diff --git a/src/atomworks/experimental/protonation/__init__.py b/src/atomworks/experimental/protonation/__init__.py new file mode 100644 index 00000000..a8d3c74b --- /dev/null +++ b/src/atomworks/experimental/protonation/__init__.py @@ -0,0 +1,19 @@ +"""Protonation of a structure: :func:`assign_hydrogens` decides each heavy atom's charge and hydrogen count at a +pH, :func:`place_hydrogens` names, positions and inserts those hydrogens (TMol's internal coordinates where they +apply), and :func:`add_hydrogens` does both. + +Example:: + + from atomworks.experimental.protonation import add_hydrogens + from atomworks.io import parse + from atomworks.io.config import ParseConfig + + config = ParseConfig(add_bond_types_from_struct_conn=("covale", "metalc", "disulf")) + atoms = parse("1abc.cif", config=config)["asym_unit"][0] + protonated = add_hydrogens(atoms, ph=7.4) +""" + +from atomworks.experimental.protonation._assign import assign_hydrogens +from atomworks.experimental.protonation._placement import add_hydrogens, place_hydrogens + +__all__ = ["add_hydrogens", "assign_hydrogens", "place_hydrogens"] diff --git a/src/atomworks/experimental/protonation/_assign.py b/src/atomworks/experimental/protonation/_assign.py new file mode 100644 index 00000000..3d77b15a --- /dev/null +++ b/src/atomworks/experimental/protonation/_assign.py @@ -0,0 +1,731 @@ +"""Hydrogen assignment: every heavy atom's charge and how many hydrogens it carries, from the chemical +component dictionary, the bonds, the metals and Dimorphite-DL.""" + +from __future__ import annotations + +import functools +import itertools +import logging +import threading +from collections.abc import Callable +from typing import Any, TypeVar + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack +from rdkit import Chem, rdBase + +from atomworks.constants import BIOTITE_BOND_TYPE_TO_BOND_ORDER, HYDROGEN_LIKE_SYMBOLS, METAL_ELEMENTS +from atomworks.enums import ChainType +from atomworks.experimental.protonation._titration import ( + bonds_within_components, + imidazole_rings, + partition_atomized_subgraphs, + titrate_component, +) +from atomworks.io.transforms.atomize import compute_standard_atomize_mask +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.atom_array import annotate_hydrogens, chain_identifier +from atomworks.io.utils.ccd import atom_array_from_ccd_code, get_custom_ccd_entries, get_polymerization_atoms + +logger = logging.getLogger(__name__) +T = TypeVar("T") + +# Component titrations shared across calls, least recently used first; the arrays are read-only. +_titrations: dict[tuple, tuple[np.ndarray, np.ndarray, np.ndarray] | None] = {} +_MAX_TITRATIONS = 4096 +_UNTITRATED = object() +_TITRATIONS_LOCK = threading.Lock() + + +def _cache_ccd_lookup(func: Callable[..., T]) -> Callable[..., T]: + """Cache *func* by its arguments, the first a CCD code, except for codes the custom CCD registry defines, + which may change. The bound limits each worker's working set, not supported CCD codes; + evicted entries are recomputed.""" + cached = functools.lru_cache(maxsize=8192)(func) + + @functools.wraps(func) + def wrapper(res_name: str, *args: Any) -> T: + return func(res_name, *args) if res_name.upper() in get_custom_ccd_entries() else cached(res_name, *args) + + return wrapper + + +@_cache_ccd_lookup +def _ccd_template(res_name: str) -> AtomArray | None: + """The dictionary's copy of a component with its bonds (a one-atom component with none); None without one. + + A ligand under a placeholder code is the ordinary case rather than an error. + """ + try: + template = atom_array_from_ccd_code(res_name) + except (AttributeError, ValueError, KeyError): + return None + if template.bonds is None and template.array_length() == 1: + template = template.copy() + template.bonds = struc.BondList(1) + return template if template.bonds is not None else None + + +# What the dictionary gives each atom of a component: formal charge; valence (bond orders, metal bonds left out; +# -1 on a metal); heavy neighbours other than metals; those that do not leave on polymerization; hydrogens; and +# hydrogens that stay on polymerization (-1 where the component flags no leaving atoms or the atom has none). +_COLUMNS = ("charge", "valence", "heavy_degree", "kept_degree", "hydrogens", "linked_hydrogens") + + +@_cache_ccd_lookup +def _ccd_atom_properties(res_name: str) -> dict[str, tuple]: + """Per atom name of a component, its :data:`_COLUMNS` values and its hydrogens' names; empty without one.""" + template = _ccd_template(res_name) + if template is None: + return {} + n = template.array_length() + element = np.char.upper(template.element.astype(str)) + is_h = np.isin(element, HYDROGEN_LIKE_SYMBOLS) + metal = np.isin(element, sorted(METAL_ELEMENTS)) + flagged = "is_leaving_atom" in template.get_annotation_categories() + leaving = template.is_leaving_atom if flagged else np.zeros(n, dtype=bool) + bonds = template.bonds.as_array() + covalent = bonds[~metal[bonds[:, :2]].any(axis=1)] + weights = [BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(t), 1) for t in covalent[:, 2]] + valence = np.where(metal, -1, np.bincount(covalent[:, :2].ravel(), np.repeat(weights, 2), minlength=n)) + counted = ~is_h & ~metal + + def degree(mask: np.ndarray) -> np.ndarray: + return np.bincount(bonds[mask[bonds[:, :2]].all(axis=1), :2].ravel(), minlength=n) + + heavy_degree, kept_degree = degree(counted), degree(counted & ~leaving) + rows = {} + for i, name in enumerate(template.atom_name.tolist()): + partners = template.bonds.get_bonds(i)[0] if not is_h[i] else np.empty(0, dtype=int) + hydrogens = template.atom_name[partners[is_h[partners]]].tolist() + linked = sum(not leaving[j] for j in partners[is_h[partners]]) if flagged and hydrogens else -1 + rows[name] = ( + int(template.charge[i]), + int(valence[i]), + int(heavy_degree[i]), + int(kept_degree[i]), + len(hydrogens), + int(linked), + tuple(hydrogens), + ) + return rows + + +def template_hydrogen_names(res_name: str) -> dict[str, tuple[str, ...]]: + """Each atom of a component that carries hydrogens, mapped to their names; empty without a template.""" + return {name: row[-1] for name, row in _ccd_atom_properties(res_name).items() if row[-1]} + + +def _lookup_ccd_atom_property(codes: np.ndarray, names: np.ndarray, column: str, default: int) -> np.ndarray: + """Each atom's :data:`_COLUMNS` *column* value in its component's template, looked up once per distinct + (code, name); *default* where the template has no such atom.""" + i = _COLUMNS.index(column) + distinct, of_code = np.unique(np.asarray(codes, dtype=str), return_inverse=True) + atom_names, of_name = np.unique(np.asarray(names, dtype=str), return_inverse=True) + pairs, of_pair = np.unique(of_code * len(atom_names) + of_name, return_inverse=True) + templates = {code: _ccd_atom_properties(code) for code in distinct.tolist()} + keys = zip(distinct[pairs // len(atom_names)].tolist(), atom_names[pairs % len(atom_names)].tolist(), strict=True) + values = [templates[code][name][i] if name in templates[code] else default for code, name in keys] + return np.array(values, dtype=int)[of_pair] + + +# pKw, the pKa of a free water on the activity scale that aqua-ion pKa values share. +WATER_PKA: float = 14.0 +# pKa of M(H2O)n by element, or element and formal charge where it differs by oxidation state (Baes & +# Mesmer, The Hydrolysis of Cations, 1976; Smith & Martell, Critical Stability Constants). +AQUA_PKA: dict[str, float] = { + "LI": 13.6, "NA": 14.2, "K": 14.5, "RB": 14.5, "CS": 14.5, # Rb and Cs as K + "MG": 11.4, "CA": 12.8, "SR": 13.3, "BA": 13.5, "MN": 10.6, "FE": 9.5, "FE3": 2.2, "CO": 9.7, "NI": 9.9, + "CU": 8.0, "ZN": 9.0, "CD": 10.1, "HG": 3.4, "PB": 7.7, "AL": 5.0, "GA": 2.6, "IN": 4.0, "CR": 4.0, + "AG": 12.0, "Y": 7.7, "LA": 8.5, "SM": 7.9, "EU": 7.8, "GD": 8.0, "TB": 7.9, "YB": 7.7, "LU": 7.6, +} # fmt: skip +# An unlisted metal takes the median of the listed divalent d-block ions (Mn to Zn, Cd). +DEFAULT_AQUA_PKA: float = 9.7 +# 5CQO THR 59 OG1, 2.98 A from Hg: past the covalent radii (Cordero 2008) by more than this, in A, a metal bond +# carries under 0.15 valence units (s = exp(-excess / 0.37), Brown & Altermatt 1985), a contact, not coordination. +COORDINATION_TOLERANCE: float = 0.7 + + +def is_metal(atom_array: AtomArray, elements: frozenset[str] = METAL_ELEMENTS) -> np.ndarray: + """Mask of the atoms whose element is one of *elements*.""" + symbols, of_symbol = np.unique(atom_array.element.astype(str), return_inverse=True) + return np.isin(np.char.upper(symbols), sorted(elements))[of_symbol] + + +def extract_inter_residue_metal_bonds(atom_array: AtomArray) -> np.ndarray: + """Remove the bonds to a metal in another residue from *atom_array* and return them. + + Such a bond is a coordination bond whatever its type says, as parsing retypes it: it + fills no valence of the donor, so titration runs without it. One inside a component + (HC0's iron-carbon bonds, 1dn8's NCO) is that component's own connectivity. + """ + bond_array = atom_array.bonds.as_array() + metal = is_metal(atom_array) + residue = struc.get_all_residue_positions(atom_array) + left, right = bond_array[:, 0].astype(int), bond_array[:, 1].astype(int) + take = (metal[left] | metal[right]) & (residue[left] != residue[right]) + if take.any(): + atom_array.bonds = struc.BondList(atom_array.array_length(), bond_array[~take]) + return bond_array[take] + + +def atoms_bonded_to(mask: np.ndarray, bonds: np.ndarray) -> np.ndarray: + """Mask of the atoms that a row of *bonds* joins to an atom of *mask*.""" + left, right = bonds[:, 0].astype(int), bonds[:, 1].astype(int) + found = np.zeros(len(mask), dtype=bool) + found[left[mask[right]]] = True + found[right[mask[left]]] = True + return found + + +def _covalent_radius(element: str) -> float: + """RDKit's covalent radius of *element*, in A; NaN for a symbol it does not know.""" + try: + return Chem.GetPeriodicTable().GetRcovalent(element.capitalize()) + except RuntimeError: + return np.nan + + +def effective_donor_ph( + atom_array: AtomArray, coordination_bonds: np.ndarray, metal: np.ndarray, donor: np.ndarray, ph: float +) -> np.ndarray: + """Per atom, the pH at which a *donor* bonded to a *metal* titrates; NaN for the other atoms. + + A metal that lowers a bound water's pKa from :data:`WATER_PKA` to its :data:`AQUA_PKA` stabilises + any donor's conjugate base as much, so a donor titrates at *ph* plus ``WATER_PKA - AQUA_PKA`` summed + over its metals (1HZY's water bridging two Zn is a hydroxide), and never below *ph*. A primary carboxamide N + binds through its O's resonance, so a metal shifts it nothing (7URH ASN ND2). A bond longer than + :data:`COORDINATION_TOLERANCE` past the pair's covalent radii shifts nothing; one without positions, or to + an element without a radius (1JQK UNX's ``X``), counts. + """ + element = np.char.upper(atom_array.element.astype(str)) + covalent = atom_array.bonds.as_array() + carbonyl = atoms_bonded_to(element == "O", covalent[covalent[:, 2] == struc.BondType.DOUBLE]) & (element == "C") + heavy_degree = np.bincount( + covalent[~np.isin(element[covalent[:, :2]], ("H", "D")).any(axis=1), :2].ravel(), minlength=len(element) + ) + donor = donor & ~((element == "N") & (heavy_degree == 1) & atoms_bonded_to(carbonyl, covalent)) + pairs = coordination_bonds[:, :2].astype(int) + pairs = np.vstack([pairs, pairs[:, ::-1]]) + pairs = pairs[metal[pairs[:, 0]] & donor[pairs[:, 1]]] + radius = {e: _covalent_radius(e) for e in np.unique(atom_array.element[pairs.ravel()].astype(str))} + reach = [radius[m] + radius[d] + COORDINATION_TOLERANCE for m, d in atom_array.element[pairs].astype(str)] + length = np.linalg.norm(atom_array.coord[pairs[:, 0]] - atom_array.coord[pairs[:, 1]], axis=-1) + pairs = pairs[~(length > np.asarray(reach, dtype=float))] + symbols = np.char.upper(atom_array.element[pairs[:, 0]].astype(str)) + charges = atom_array.charge[pairs[:, 0]] + pka = [AQUA_PKA.get(f"{s}{q}", AQUA_PKA.get(s, DEFAULT_AQUA_PKA)) for s, q in zip(symbols, charges, strict=True)] + shift = np.zeros(atom_array.array_length()) + np.add.at(shift, pairs[:, 1], WATER_PKA - np.asarray(pka, dtype=float)) + return np.where(donor, ph + np.maximum(shift, 0.0), np.nan) + + +def metal_imidazole_hydrogen_counts( + atom_array: AtomArray, donates_to_metal: np.ndarray, acidified: np.ndarray +) -> np.ndarray: + """Ring-N hydrogen counts for the imidazoles, unsubstituted on N, that coordinate a metal; -1 where unset. + + The nitrogen on the metal gives up its lone pair and carries no hydrogen, so the tautomer moves + to its partner (1VMJ HIS NE2), which no pH rule expresses. A ring on metals through both + nitrogens is the imidazolate where each is *acidified* (its metals lower a water's pKa; 1SPD HIS 63). + """ + targets = np.full(atom_array.array_length(), -1, dtype=np.int8) + if not donates_to_metal.any(): + return targets + rings = imidazole_rings(atom_array) + for pair in rings.n[rings.open]: + if donates_to_metal[pair].any() and (acidified[pair].all() or not donates_to_metal[pair].all()): + targets[pair] = ~donates_to_metal[pair] + elif donates_to_metal[pair].all() and acidified[pair].any(): + # One N on an acidifying metal, the other on one that is not (an alkali): the proton is on the latter. + targets[pair] = ~acidified[pair] + return targets + + +def valence_capacity(element: np.ndarray, charge: np.ndarray, *, lowest: bool = True) -> np.ndarray: + """Bonds, hydrogens included, an atom holds: the lowest (or highest) valence of the element it is + isoelectronic with at its charge (RDKit's; N+ as C, O- as F, B- as C); -1 for a metal or an unknown element. + """ + element = np.char.upper(np.asarray(element).astype(str)) + keys = np.rec.fromarrays([element, np.broadcast_to(np.asarray(charge, dtype=int), element.shape)]) + distinct, of_key = np.unique(keys, return_inverse=True) + table, capacities = Chem.GetPeriodicTable(), [] + for symbol, q in distinct.tolist(): + try: + number = table.GetAtomicNumber(symbol.capitalize()) + except RuntimeError: + number = 0 + if not number or -1 in table.GetValenceList(number): + capacities.append(-1) + continue + valences = sorted(table.GetValenceList(number - q)) if 0 < number - q < 119 else [0] + capacities.append(max(valences[0] if lowest else valences[-1], 0)) + return np.array(capacities, dtype=int)[of_key] + + +def covalent_bond_order_sums(atom_array: AtomArray, metal: np.ndarray) -> np.ndarray: + """Each atom's sum of bond orders to atoms other than a metal, which fills no valence.""" + bonds = atom_array.bonds.as_array() + bonds = bonds[~metal[bonds[:, :2]].any(axis=1)] + weights = [BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(t), 1) for t in bonds[:, 2]] + return np.bincount(bonds[:, :2].ravel(), np.repeat(weights, 2), minlength=atom_array.array_length()).astype(int) + + +def _template_neighbor_deficit(heavy: AtomArray, codes: np.ndarray, include_leaving_atoms: bool = True) -> np.ndarray: + """CCD non-metal neighbour count minus the structure's retained heavy-bond count. + + Call after removing inter-residue metal bonds. Component-internal metal bonds still count as + occupied connections; negative values therefore mean extra covalent or internal metal neighbours. + Without leaving atoms, count only intra-residue bonds: polymer links replace leaving atoms. + """ + template_degree = _lookup_ccd_atom_property( + codes, heavy.atom_name, "heavy_degree" if include_leaving_atoms else "kept_degree", 0 + ) + bonds = heavy.bonds.as_array()[:, :2] + if not include_leaving_atoms: + residue = struc.get_all_residue_positions(heavy) + bonds = bonds[residue[bonds[:, 0]] == residue[bonds[:, 1]]] + observed_degree = np.bincount(bonds.ravel(), minlength=heavy.array_length()) + return template_degree - observed_degree + + +def _polymer_entry_atoms_after_gaps(heavy: AtomArray) -> np.ndarray: + """Entering atoms (N, P) of residues that follow a lower-numbered residue of their chain that no bond + joins them to.""" + starts = struc.get_residue_starts(heavy) + residue = struc.get_all_residue_positions(heavy) + bonds = heavy.bonds.as_array()[:, :2] + joined = np.sort(residue[bonds], axis=1) @ [len(starts), 1] + firsts = heavy[starts] + ensure_annotations(firsts, "chain_type") + entering = np.array( + [ + get_polymerization_atoms(str(code), ChainType.as_enum(chain_type))[1] or "" + for code, chain_type in zip(firsts.res_name, firsts.chain_type, strict=True) + ], + dtype=str, + ) + # 1AWD assembly TYR 1 N: assembly copies share chain IDs; chain_iid tells them apart. + chain_of = chain_identifier(firsts).astype(str) + in_sequence = np.lexsort([firsts.ins_code, firsts.res_id, chain_of]) + continues = np.zeros(len(starts), dtype=bool) + chain = chain_of[in_sequence][None] + neighbours = np.sort(np.c_[in_sequence[:-1], in_sequence[1:]], axis=1) @ [len(starts), 1] + continues[in_sequence[1:]] = (chain[:, 1:] == chain[:, :-1]).all(axis=0) & ~np.isin(neighbours, joined) + return continues[residue] & (heavy.atom_name == entering[residue]) + + +def _format_atom_examples(heavy: AtomArray, indices: np.ndarray, limit: int = 5) -> str: + """``count`` atoms as ``chain:res_id:res_name/atom``, the first *limit* of them named.""" + named = ", ".join( + f"{heavy.chain_id[i]}:{heavy.res_id[i]}:{heavy.res_name[i]}/{heavy.atom_name[i]}" for i in indices[:limit] + ) + return f"{len(indices)} atoms ({named}{', ...' if len(indices) > limit else ''})" + + +def _select_input_hydrogens( + atom_array: AtomArray, is_h: np.ndarray, heavy_index: np.ndarray, count: np.ndarray, protonated: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """The input hydrogens the assignment keeps, and the heavy atom (in heavy numbering) each is on. + + A hydrogen is kept where it has a position, one bond, to a heavy atom being protonated + with a position, and that atom carries at least as many hydrogens as the input states. + """ + finite = np.isfinite(atom_array.coord).all(axis=-1) + bonds = atom_array.bonds.as_array() + left, right = bonds[:, 0].astype(np.intp), bonds[:, 1].astype(np.intp) + n_bonds = np.bincount(bonds[:, :2].ravel(), minlength=len(atom_array)) + pair = np.concatenate([np.stack([left, right], 1), np.stack([right, left], 1)]) + pair = pair[is_h[pair[:, 0]] & ~is_h[pair[:, 1]]] + parent = np.full(len(atom_array), -1, dtype=np.intp) + parent[pair[:, 0]] = pair[:, 1] + candidate = is_h & finite & (n_bonds == 1) & (parent >= 0) + candidate[candidate] &= finite[parent[candidate]] + hydrogens = np.flatnonzero(candidate) + on = heavy_index[parent[hydrogens]] + stated = np.bincount(on, minlength=len(count)) + # Rebuild a smaller target count rather than arbitrarily retaining a subset of input positions. + keep = protonated[on] & (count[on] >= stated[on]) + return hydrogens[keep], on[keep] + + +def _validate_declared_hydrogen_counts(hydrogens: np.ndarray | None, is_input_h: np.ndarray) -> np.ndarray: + """The declared hydrogen counts of the heavy atoms, -1 where none is declared.""" + if hydrogens is None: + return np.full(np.count_nonzero(~is_input_h), -1, dtype=np.int64) + counts = np.asarray(hydrogens) + if counts.shape != is_input_h.shape or not np.issubdtype(counts.dtype, np.integer): + raise ValueError(f"hydrogens must hold one integer per atom ({len(is_input_h)}), got {counts.shape}.") + if (counts < -1).any() or (counts[is_input_h] >= 0).any(): + raise ValueError("hydrogens declares a count below -1 or on a hydrogen; declare it on the heavy atom.") + return counts[~is_input_h].astype(np.int64) + + +def assign_hydrogens( + atom_array: AtomArray, + *, + ph: float = 7.4, + hydrogens: np.ndarray | None = None, +) -> AtomArray: + """Decide every heavy atom's charge and hydrogen count at a pH, without placing hydrogens. + + Standard residues are titrated one at a time with Dimorphite-DL, atomized atoms as connected + bond subgraphs. Boundary atoms keep their input charge and ``nhyd``, metal donors take their + donating state, and stated histidine tautomers and declared counts stand. A residue drawn with + neither hydrogens nor a nonzero ``nhyd`` takes the dictionary's counts, and an input charge that + leaves an atom too little room for its heavy bonds is ignored. Input hydrogens with a position and one bond are kept where their heavy + atom carries at least that many; the rest are dropped. + + For a monatomic component, an undeclared state uses its CCD/custom charge when the input is zero + (also the parser's value for missing charge). Declare ``hydrogens`` to distinguish, e.g., HCl from chloride. + + Args: + atom_array: Structure with bonds and ``charge`` and ``pn_unit_iid`` (or ``pn_unit_id``), as + :func:`~atomworks.io.parse` returns it. A donor is on a metal only through a bond (parse + with ``"metalc"`` in ``add_bond_types_from_struct_conn``). + ph: Target pH. + hydrogens: Declared hydrogen count per input atom, -1 where none; it outranks the input's + hydrogens, a metal and the pH, and one on both ring nitrogens sets a histidine's tautomer. + + Returns: + The input atoms in input order, less the input hydrogens it drops, with ``atom_id`` (numbered + from 0 if absent), ``charge`` (formal), ``nhyd`` (hydrogens each heavy atom still needs), + ``skip_hydrogen_placement`` (input hydrogens, unresolved or unbonded heavy atoms, and metals; + their remaining ``nhyd`` counts are not placed) and + ``tautomer_free`` (histidines nothing decides; a neutral one takes NE2-H). + + Raises: + TypeError: If *atom_array* is an ``AtomArrayStack``. + ValueError: If bonds, ``charge`` or ``pn_unit_iid`` are absent, a bond is ``ANY`` or ``AROMATIC``, + *ph* is not finite, a residue + is partially atomized, or *hydrogens* has invalid entries or exceeds chemical valence. + """ + if isinstance(atom_array, AtomArrayStack): + raise TypeError("assign_hydrogens takes an AtomArray; extract a single model first (e.g. atom_array[0]).") + if not np.isfinite(ph): + raise ValueError(f"ph must be a finite number, got {ph}.") + if atom_array.bonds is None: + raise ValueError("Input atom_array has no bonds. Bonds must be assigned before protonation.") + if "charge" not in atom_array.get_annotation_categories(): + raise ValueError("Input atom_array must have a 'charge' annotation for heavy atoms before protonation.") + if np.isin(atom_array.bonds.as_array()[:, 2], (struc.BondType.ANY, struc.BondType.AROMATIC)).any(): + raise ValueError("Bonds of unstated order (BondType.ANY or AROMATIC) cannot be protonated; state them.") + + is_input_h = np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS) + declared_hydrogens = _validate_declared_hydrogen_counts(hydrogens, is_input_h) + # 3C3G HIS: a ring N's hydrogen with a position states the tautomer (the dictionary's HIS has HD1 and HE2, + # unplaced, so only a placed one says anything). + imidazole_targets = np.full(len(atom_array), -1, dtype=np.int8) + placed_h = is_input_h & np.isfinite(atom_array.coord).all(axis=-1) + has_placed_h = atoms_bonded_to(placed_h, atom_array.bonds.as_array()) + for ring_n in imidazole_rings(atom_array).n: + if has_placed_h[ring_n].any(): + imidazole_targets[ring_n] = has_placed_h[ring_n] + imidazole_targets = imidazole_targets[~is_input_h] + input_tautomer = imidazole_targets >= 0 + has_input_hydrogen_counts = "nhyd" in atom_array.get_annotation_categories() + # 3KS3: a residue with placed hydrogens or nonzero nhyd states its counts; hydrogens in other residues + # say nothing about it. A parse without missing atoms leaves zero counts, so it takes the dictionary's. + input_residue = struc.get_all_residue_positions(atom_array) + residue_has_placed_hydrogens = (np.bincount(input_residue, weights=placed_h) > 0)[input_residue] + residue_has_hydrogen_counts = np.zeros(len(atom_array), dtype=bool) + if has_input_hydrogen_counts: + residue_has_hydrogen_counts = (np.bincount(input_residue, weights=atom_array.nhyd) > 0)[input_residue] + counts_unspecified = ~(residue_has_placed_hydrogens | residue_has_hydrogen_counts)[~is_input_h] + annotated = annotate_hydrogens(atom_array.copy(), increment=True) + heavy = annotated[~is_input_h] + declared_ring = np.zeros(len(heavy), dtype=bool) + for ring_n in imidazole_rings(heavy).n: + declared_ring[ring_n] = (declared_hydrogens[ring_n] >= 0).all() + declared_atom = (declared_hydrogens >= 0) & ~declared_ring + input_tautomer |= declared_ring + ccd_codes = heavy.res_name.astype(str) + needs_ccd_hydrogens = (heavy.nhyd == 0) & (counts_unspecified | (not has_input_hydrogen_counts)) + heavy.nhyd[needs_ccd_hydrogens] = _lookup_ccd_atom_property( + ccd_codes[needs_ccd_hydrogens], heavy.atom_name[needs_ccd_hydrogens], "hydrogens", 0 + ) + if has_input_hydrogen_counts: + # 8HBF NO N, 7YPY PER O1: zero counts do state a residue the dictionary gives no hydrogens. + residue = struc.get_all_residue_positions(heavy) + counts_unspecified &= (np.bincount(residue, weights=heavy.nhyd) > 0)[residue] + hydrogens_stated = ~counts_unspecified + + coordination_bonds = extract_inter_residue_metal_bonds(heavy) + template_neighbor_deficit = _template_neighbor_deficit(heavy, ccd_codes) + # 5ARK LYS C: a leaving atom without density (an unresolved OXT) is missing, not replaced by a hydrogen; the + # atom it leaves keeps the dictionary's hydrogens. + missing_nonleaving_neighbors = np.maximum( + _template_neighbor_deficit(heavy, ccd_codes, include_leaving_atoms=False), 0 + ) + missing_leaving_atoms = template_neighbor_deficit > missing_nonleaving_neighbors + component_bonds = heavy.bonds.as_array() + isolated = np.bincount(component_bonds[:, :2].ravel(), minlength=len(heavy)) == 0 + metal = is_metal(heavy) + # 1FDN SF4 S: valence cannot count the bonds of a metal or of the atoms bonded to one, so they keep their counts. + within_metal_component = metal | atoms_bonded_to(metal, component_bonds) + preserve_metal_component_state = within_metal_component & ~isolated & hydrogens_stated + # 9EWF SIA O1A, 1YTJ PPN N1: an input charge the dictionary does not draw, at which an atom off a metal cannot + # hold its heavy bonds, becomes the nearest to the dictionary's at which it can (a boronate adduct's B -1). + bond_order_sums = covalent_bond_order_sums(heavy, metal) + ccd_charge = _lookup_ccd_atom_property(ccd_codes, heavy.atom_name, "charge", 0) + has_known_valence = valence_capacity(heavy.element, 0) >= 0 + overbonded = ( + (bond_order_sums > valence_capacity(heavy.element, heavy.charge, lowest=False)) + & has_known_valence + & ~within_metal_component + ) + corrected_charge_indices = [] + for index in np.flatnonzero(overbonded & (ccd_charge != heavy.charge)): + ccd_charge_value, element = ccd_charge[index], heavy.element[[index]] + compatible_charges = [ + q + for q in (ccd_charge_value, ccd_charge_value - 1, ccd_charge_value + 1) + if valence_capacity(element, q, lowest=False)[0] >= bond_order_sums[index] + ] + if compatible_charges: + heavy.charge[index] = compatible_charges[0] + corrected_charge_indices.append(index) + if corrected_charge_indices: + logger.warning( + "Replaced incompatible charges of %s with bond-compatible charges using the CCD as reference.", + _format_atom_examples(heavy, np.array(corrected_charge_indices)), + ) + # 6dmz_mod_l CYS SG, 2YAK OSV S16: a bond to a neighbour takes the room of a hydrogen; an atom holds its element's + # lowest valence, or the higher one the dictionary draws it with. + charge_matches_ccd = ccd_charge == heavy.charge + ccd_valences = _lookup_ccd_atom_property(ccd_codes, heavy.atom_name, "valence", -1) + valence_limit = np.maximum( + valence_capacity(heavy.element, heavy.charge), np.where(charge_matches_ccd, ccd_valences, -1) + ) + heavy.nhyd = np.where( + valence_limit >= 0, np.clip(valence_limit - bond_order_sums, 0, heavy.nhyd), heavy.nhyd + ).astype(heavy.nhyd.dtype) + + atomize_mask = compute_standard_atomize_mask(heavy) + missing_coordinates = ~np.isfinite(heavy.coord).all(axis=-1) + sizes = np.diff(struc.get_residue_starts(heavy, add_exclusive_stop=True)) + # 2AJ6 UNL: an atom no bond reaches, in a residue of several, keeps the charge and count it is given. + unbonded_ligand_atoms = isolated & np.repeat(sizes > 1, sizes) & atomize_mask & (declared_hydrogens < 0) + missing_nonleaving_neighbors[unbonded_ligand_atoms] = 0 + + # 4EJK ALA N: a chain continues across a gap, so the atom entering it keeps its linked residue's hydrogens. + after_a_gap = _polymer_entry_atoms_after_gaps(heavy) & ~missing_coordinates & (ccd_codes != "") + linked_hydrogen_counts = _lookup_ccd_atom_property( + ccd_codes[after_a_gap], heavy.atom_name[after_a_gap], "linked_hydrogens", -1 + ) + heavy.nhyd[after_a_gap] = np.where(linked_hydrogen_counts >= 0, linked_hydrogen_counts, heavy.nhyd[after_a_gap]) + missing_nonleaving_neighbors[after_a_gap] = 0 + # 7NO8 PRO N: an atom short of a heavy neighbour the dictionary gives it takes a hydrogen in its place. + capped_atoms = np.flatnonzero(missing_nonleaving_neighbors) + heavy.nhyd[capped_atoms] = ( + _lookup_ccd_atom_property(ccd_codes[capped_atoms], heavy.atom_name[capped_atoms], "hydrogens", 0) + + missing_nonleaving_neighbors[capped_atoms] + ) + if len(capped_atoms): + logger.warning( + "Capped %s with hydrogen: the dictionary gives them a heavy neighbour the structure does " + "not. Supply the missing atoms, or parse with add_missing_atoms=True, for the real chemistry.", + _format_atom_examples(heavy, capped_atoms), + ) + + bonded = heavy.copy() + for left, right, bond_type in coordination_bonds: + bonded.bonds.add_bond(int(left), int(right), int(bond_type)) + + components: list[np.ndarray] = [] + for start, stop in itertools.pairwise(struc.get_residue_starts(heavy, add_exclusive_stop=True)): + res_atomize = atomize_mask[start:stop] + if res_atomize.all(): + continue + if res_atomize.any(): + raise ValueError( + f"Partially-atomized residue detected at {heavy.chain_id[start]}:{heavy.res_id[start]} " + f"({heavy.res_name[start]}): {res_atomize.sum()}/{stop - start} atoms flagged." + ) + components.append(np.arange(start, stop)) + components += partition_atomized_subgraphs(heavy, atomize_mask) + # 1hxq U5P: an unplaced atom ending a branch of its residue at a placed atom holding more covalent bonds than + # the dictionary gives it is a leaving atom that bond displaced; the others are titrated with their component. + of_residue = struc.get_all_residue_positions(heavy) + own_bonds = component_bonds[of_residue[component_bonds[:, 0]] == of_residue[component_bonds[:, 1]]] + ends_a_branch = np.bincount(own_bonds[:, :2].ravel(), minlength=len(heavy)) == 1 + coordination_degree = np.bincount(coordination_bonds[:, :2].ravel(), minlength=len(heavy)) + displaces_leaving_atoms = ( + (template_neighbor_deficit + coordination_degree < 0) & ~within_metal_component & ~missing_coordinates + ) + displaced_leaving_atoms = missing_coordinates & ends_a_branch & atoms_bonded_to(displaces_leaving_atoms, own_bonds) + components = [indices[~displaced_leaving_atoms[indices]] for indices in components] + components = [indices for indices in components if len(indices)] + + metal_donors = atoms_bonded_to(metal, coordination_bonds) & ~metal & (declared_hydrogens < 0) + donor_ph = effective_donor_ph(heavy, coordination_bonds, metal, metal_donors, ph) + metal_targets = metal_imidazole_hydrogen_counts(heavy, metal_donors, donor_ph > ph) + # 8HBF NO N: coordination only takes protons; a donor keeps at most the hydrogens its input states. + max_donor_hydrogens = np.where(metal_donors & hydrogens_stated, heavy.nhyd, -1) + imidazole_targets = np.where(imidazole_targets < 0, metal_targets, imidazole_targets) + imidazole_targets = np.where(declared_ring, declared_hydrogens, imidazole_targets) + + component_local_bonds, on_boundary, links = bonds_within_components(heavy, components) + boundary = ( + on_boundary | (missing_nonleaving_neighbors > 0) | missing_leaving_atoms | within_metal_component | after_a_gap + ) + hydrogen_counts = np.zeros(len(heavy), dtype=np.int64) + ring_bonds: list[np.ndarray] = [] + unbonded_components: list[int] = [] + template_charge_indices: list[int] = [] + preserved_hydrogen_counts = np.where( + declared_atom, declared_hydrogens, np.where(preserve_metal_component_state, heavy.nhyd, -1) + ) + # Each component takes its own bonds, so no slice filters the whole structure's bond list. + heavy.bonds = None + for number, indices in enumerate(components): + if metal[indices].all(): + continue + template = _ccd_template(ccd_codes[indices[0]]) if len(indices) == 1 else None + if template is not None and template.array_length() == 1 and declared_hydrogens[indices[0]] < 0: + # An undeclared monatomic state follows its component definition; explicit counts take precedence. + if bonded.charge[indices[0]] == 0 and template.charge[0] != 0: + bonded.charge[indices] = template.charge[0] + template_charge_indices.append(int(indices[0])) + continue + if unbonded_ligand_atoms[indices].all(): + unbonded_components += indices.tolist() + continue + targets, coordinating, stated = ( + imidazole_targets[indices], + max_donor_hydrogens[indices], + preserved_hydrogen_counts[indices], + ) + inputs = ( + heavy.element[indices], + heavy.charge[indices], + component_local_bonds[number], + heavy.res_id[indices] - heavy.res_id[indices[0]], + targets, + coordinating, + donor_ph[indices], + stated, + links[number], + ) + key = (ph, *((array.dtype.str, array.tobytes()) for array in inputs)) + with _TITRATIONS_LOCK: + titrated = _titrations.pop(key, _UNTITRATED) + if titrated is _UNTITRATED: + sub = heavy[indices] + sub.bonds = struc.BondList(len(indices), component_local_bonds[number]) + with rdBase.BlockLogs(): + titrated = titrate_component(sub, ph, targets, coordinating, stated, donor_ph[indices], links[number]) + for array in titrated or (): + array.setflags(write=False) + with _TITRATIONS_LOCK: + _titrations[key] = titrated + if len(_titrations) > _MAX_TITRATIONS: + _titrations.pop(next(iter(_titrations)), None) + titrated_interior = ~boundary[indices] & (titrated is not None) + if titrated is not None: + charge, hydrogens, rekekulized = titrated + bonded.charge[indices[titrated_interior]] = charge[titrated_interior] + hydrogen_counts[indices[titrated_interior]] = hydrogens[titrated_interior] + if len(rekekulized): + ring_bonds.append(np.column_stack([indices[rekekulized[:, :2]], rekekulized[:, 2]])) + hydrogen_counts[indices[~titrated_interior]] = np.maximum(heavy.nhyd[indices[~titrated_interior]], 0) + if template_charge_indices: + logger.warning( + "Used CCD/custom charges for %s with zero input charge and no declared hydrogen count. " + "Zero also represents missing charge; declare hydrogens or supply a custom component for another state.", + _format_atom_examples(heavy, np.array(template_charge_indices)), + ) + hydrogen_counts = np.where(declared_hydrogens >= 0, declared_hydrogens, hydrogen_counts) + if ring_bonds: + ring, bonds = np.vstack(ring_bonds), bonded.bonds.as_array() + ring[:, :2].sort(axis=1) + keys = bonds[:, 0].astype(np.int64) * len(bonded) + bonds[:, 1] + order = np.argsort(keys) + bonds[order[np.searchsorted(keys, ring[:, 0] * len(bonded) + ring[:, 1], sorter=order)], 2] = ring[:, 2] + bonded.bonds = struc.BondList(len(bonded), bonds) + # Chemical valence limits hydrogen counts; placement geometry must not change the assigned state. + bond_order_sums = covalent_bond_order_sums(bonded, metal) + valence_limit = valence_capacity(bonded.element, bonded.charge, lowest=False) + hydrogen_capacity = np.where(valence_limit < 0, hydrogen_counts, np.maximum(valence_limit - bond_order_sums, 0)) + # 10GS LYS NZ: a declared count stands; the atom takes the charge nearest its own at which it holds its + # bonds and those hydrogens (an NZ declared with three H at charge 0 is the ammonium). + for index in np.flatnonzero((declared_hydrogens >= 0) & (hydrogen_counts > hydrogen_capacity)): + charge, element = int(bonded.charge[index]), bonded.element[[index]] + for q in sorted(range(charge - 2, charge + 3), key=lambda q: abs(q - charge)): + if hydrogen_counts[index] <= valence_capacity(element, q, lowest=False)[0] - bond_order_sums[index]: + bonded.charge[index], hydrogen_capacity[index] = q, hydrogen_counts[index] + break + else: + raise ValueError( + f"Declared hydrogen count exceeds chemical valence for {_format_atom_examples(heavy, [index])}." + ) + overvalent_atoms = np.flatnonzero(hydrogen_counts > hydrogen_capacity) + if len(overvalent_atoms): + logger.warning( + "Gave %s only the hydrogens their bonds leave room for.", _format_atom_examples(heavy, overvalent_atoms) + ) + hydrogen_counts = np.minimum(hydrogen_counts, hydrogen_capacity) + if unbonded_components: + logger.warning( + "No bond is stated for %s, so their charges and hydrogen counts are left as given. Supply the bonds " + "(CONECT records, a struct_conn category, or a chemical component definition) to have them protonated.", + _format_atom_examples(heavy, np.array(unbonded_components)), + ) + + # Metal hydrides are not protons; retain their counts without inventing placement geometry. + skip_placement = missing_coordinates | unbonded_ligand_atoms | metal + metal_hydrides = np.flatnonzero(metal & (hydrogen_counts > 0)) + if len(metal_hydrides): + logger.warning( + "Metal-bound hydrogens on %s remain as nhyd counts: hydride placement is unsupported.", + _format_atom_examples(heavy, metal_hydrides), + ) + heavy_index = np.cumsum(~is_input_h) - 1 + kept_hydrogen_indices, kept_hydrogen_parents = _select_input_hydrogens( + annotated, is_input_h, heavy_index, hydrogen_counts, ~skip_placement + ) + if placed_h.sum() > len(kept_hydrogen_indices): + logger.warning( + "Dropped %d placed input hydrogens that cannot be retained; assigned hydrogen counts are preserved.", + placed_h.sum() - len(kept_hydrogen_indices), + ) + kept_atoms = ~is_input_h + kept_atoms[kept_hydrogen_indices] = True + assigned = annotated[kept_atoms] + output_indices = np.cumsum(kept_atoms) - 1 + heavy_output_indices = output_indices[~is_input_h] + bonds = bonded.bonds.as_array() + bonds[:, :2] = heavy_output_indices[bonds[:, :2]] + h_bonds = np.stack( + [ + heavy_output_indices[kept_hydrogen_parents], + output_indices[kept_hydrogen_indices], + np.full(len(kept_hydrogen_indices), 1), + ], + axis=1, + ) + assigned.bonds = struc.BondList(len(assigned), np.vstack([bonds, h_bonds]).astype(np.int64)) + + nhyd = np.zeros(len(assigned), dtype=heavy.nhyd.dtype) + nhyd[heavy_output_indices] = np.where( + unbonded_ligand_atoms, heavy.nhyd, hydrogen_counts - np.bincount(kept_hydrogen_parents, minlength=len(heavy)) + ) + charge = assigned.charge.copy() + charge[heavy_output_indices] = bonded.charge + skip_hydrogen_placement = np.ones(len(assigned), dtype=bool) + skip_hydrogen_placement[heavy_output_indices] = skip_placement + # A neutral imidazole is free where nothing states its ring hydrogen and neither N bonds a third heavy atom. + free = np.zeros(len(assigned), dtype=bool) + ring_h = np.where(skip_placement, 0, hydrogen_counts) + degree = np.bincount(bonded.bonds.as_array()[:, :2].ravel(), minlength=len(bonded)) + for ring_n in imidazole_rings(bonded).n: + if not input_tautomer[ring_n].any() and ring_h[ring_n].sum() == 1 and (degree[ring_n] == 2).all(): + free[heavy_output_indices[ring_n]] = True + assigned.set_annotation("charge", charge) + assigned.set_annotation("nhyd", nhyd) + assigned.set_annotation("skip_hydrogen_placement", skip_hydrogen_placement) + assigned.set_annotation("tautomer_free", free) + if "atom_id" not in assigned.get_annotation_categories(): + assigned.set_annotation("atom_id", np.arange(len(assigned), dtype=np.intp)) + return assigned diff --git a/src/atomworks/experimental/protonation/_placement.py b/src/atomworks/experimental/protonation/_placement.py new file mode 100644 index 00000000..3c327c4b --- /dev/null +++ b/src/atomworks/experimental/protonation/_placement.py @@ -0,0 +1,829 @@ +"""Hydrogen placement: the plan (TMol's internal coordinates from its table, a rule otherwise), building it on +coordinates, and adding the hydrogens to an AtomArray.""" + +from __future__ import annotations + +import functools +import itertools +import json +import logging +import math +from collections import defaultdict +from collections.abc import Container, Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import NamedTuple + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack + +from atomworks.constants import ( + CHAIN_LEVEL_ANNOTATIONS, + ELEMENT_NAME_TO_ATOMIC_NUMBER, + HYDROGEN_LIKE_SYMBOLS, + RESIDUE_LEVEL_ANNOTATIONS, +) +from atomworks.experimental.protonation._assign import ( + _cache_ccd_lookup, + _ccd_template, + _format_atom_examples, + assign_hydrogens, + is_metal, + template_hydrogen_names, +) +from atomworks.experimental.protonation.geometry import ( + _measure_icoors, + _numbered, + _perpendicular, + _unit, + build_coordinates, + names_from_parent, +) +from atomworks.io.utils.annotator import ANNOTATOR_REGISTRY, ensure_annotations +from atomworks.io.utils.atom_array_plus import AnnotationList2D, AtomArrayPlus, concatenate_atom_array_plus +from atomworks.io.utils.ccd import get_custom_ccd_entries, get_polymerization_atoms +from atomworks.io.utils.selection import get_annotation, get_annotation_categories +from atomworks.io.utils.standard_annotations.base import STANDARD_ANNOTATIONS, Level + +# --- table --- +_TABLE_PATH = Path(__file__).parent / "hydrogen_icoors.json" + +Row = tuple[str, str, str, str, float, float, float] + + +@functools.cache +def entries_by_code() -> dict[str, tuple[tuple[str, frozenset[str], tuple[Row, ...]], ...]]: + """The table's entries grouped by the code they describe, as ``(entry, names, rows)``.""" + table = json.loads(_TABLE_PATH.read_text()) + raw = {k: v for k, v in table.items() if not k.startswith("_")} + for terminus in table["_termini"]: + for entry, body in [(k, v) for k, v in raw.items() if v["code"] in terminus["codes"]]: + patch = {k: terminus[k] for k in ("without", "hydrogens") if k in terminus} + raw[entry + terminus["suffix"]] = {"code": body["code"], "extends": entry, **patch} + + def rows_for(entry: str) -> list[list]: + body = raw[entry] + inherited = rows_for(body["extends"]) if "extends" in body else [] + dropped = set(body.get("without", ())) + return [row for row in inherited if row[0] not in dropped] + (body.get("hydrogens") or []) + + grouped: dict[str, list] = defaultdict(list) + for entry, body in raw.items(): + rows = tuple( + (name, parent, gp, ggp, d, math.radians(theta), math.radians(phi)) + for name, parent, gp, ggp, phi, theta, d in rows_for(entry) + ) + grouped[body["code"]].append((entry, frozenset(row[0] for row in rows), rows)) + return {code: tuple(entries) for code, entries in grouped.items()} + + +@functools.lru_cache(maxsize=1024) +def select_entry(code: str, hydrogen_names: frozenset[str]) -> tuple[Row, ...] | None: + """The entry describing a residue with these hydrogens, if one does. + + An entry applies when it names every hydrogen the table knows for the code (a + protonated aspartate's HD2 is in no entry); the one naming fewest extra wins, which + picks the tautomer built, and a remaining tie goes against the ``_D`` (ND1) histidine. + """ + entries = entries_by_code().get(code, ()) + wanted = hydrogen_names & frozenset().union(*(names for _, names, _ in entries)) + candidates = [(entry, rows) for entry, names, rows in entries if wanted and wanted <= names] + return min(candidates, key=lambda c: (len(c[1]), "_D" in c[0], c[0]))[1] if candidates else None + + +@functools.lru_cache(maxsize=1024) +def names_on_parent(code: str, parent: str, count: int) -> tuple[str, ...] | None: + """The names an entry gives *count* hydrogens on one atom (``H1 H2 H3`` on a terminal N).""" + for _, _, rows in entries_by_code().get(code, ()): + names = tuple(row[0] for row in rows if row[1] == parent) + if len(names) == count: + return names + return None + + +# --- names --- +def residue_hydrogen_names( + res_name: str, heavy_names: frozenset[str], parents: tuple[tuple[str, str, int], ...] +) -> tuple[tuple[str, ...], ...]: + """The names one residue's hydrogens take. + + Args: + res_name: The residue's code. + heavy_names: Names of its heavy atoms. + parents: ``(name, element, count)`` of each heavy atom carrying hydrogens, in order. + + Returns: + Per parent, the names of its hydrogens. + """ + by_parent = template_hydrogen_names(res_name) + taken = set(heavy_names) + # 6Q9T QUK: a generated name avoids every name the component declares, not just those handed out. + declared_names = {name for names in by_parent.values() for name in names} + result = [] + for heavy_name, element, count in parents: + declared = tuple(by_parent.get(heavy_name, ())) + blocked = taken | declared_names.difference(declared) + if not declared: + names = names_from_parent(heavy_name, element, count, blocked) + elif count > len(declared): + # More hydrogens than names: a protonated amino terminus, numbered as such. + names = [name for name in names_on_parent(res_name, heavy_name, count) or () if name not in blocked][:count] + names += _numbered(declared[0].rstrip("0123456789") or "H", count - len(names), blocked | set(names)) + else: + names = [name for name in declared if name not in blocked][:count] + names += itertools.islice(_continued_names(declared, blocked | set(names)), count - len(names)) + taken.update(names) + result.append(tuple(names)) + return tuple(result) + + +def _continued_names(declared: tuple[str, ...], taken: Container[str]) -> Iterator[str]: + """Names continuing a parent's *declared* hydrogen series, less *taken* (``H``, ``H2`` -> ``H3``).""" + prefix = declared[0].rstrip("0123456789") + highest = max(int(name[len(prefix) :]) if name[len(prefix) :].isdigit() else 1 for name in declared) + return (name for number in itertools.count(highest + 1) if (name := f"{prefix}{number}") not in taken) + + +# --- rule --- +_BOND_LENGTH = {"C": 1.09, "N": 1.01, "O": 0.97, "S": 1.34, "P": 1.42, "B": 1.19, "SE": 1.47, "SI": 1.48} +_TETRAHEDRAL = float(np.arccos(-1.0 / 3.0)) +# Cosine to its neighbours below which an apex hydrogen has no side of its own (1/3 when tetrahedral). +_FLAT = 1.0 / 6.0 +_PI_BONDS = np.array([int(struc.BondType[t]) for t in ("DOUBLE", "AROMATIC_SINGLE", "AROMATIC_DOUBLE", "AROMATIC")]) +_TRIPLE_BONDS = np.array([int(struc.BondType.TRIPLE), int(struc.BondType.AROMATIC_TRIPLE)]) +_TETRA, _PLANAR, _LINEAR, _ON_METAL, _SYN, _CONJUGATED_ON_METAL = 0, 1, 2, 3, 4, 5 +# (theta, phi) of each free direction about one known bond, and in a lab frame; an O on a metal is trigonal +# (M-O-H 125.3 deg), and a hydroxyl on a pi-bonded atom lies in its plane, syn to an acid's C=O, away from a metal. +_ONE_KNOWN = { + _TETRA: [(np.pi - _TETRAHEDRAL, np.pi + k * 2 * np.pi / 3) for k in range(3)], + _PLANAR: [(np.pi / 3, np.pi), (np.pi / 3, 0.0)], + _LINEAR: [(0.0, 0.0)], + _ON_METAL: [(_TETRAHEDRAL / 2, np.pi), (_TETRAHEDRAL / 2, 0.0)], + _SYN: [(np.pi - _TETRAHEDRAL, 0.0)], +} +_NONE_KNOWN = { + _TETRA: [(0.0, 0.0)] + [(_TETRAHEDRAL, k * 2 * np.pi / 3) for k in range(3)], + _PLANAR: [(0.0, 0.0), (2 * np.pi / 3, 0.0), (2 * np.pi / 3, np.pi)], + _LINEAR: [(0.0, 0.0), (np.pi, 0.0)], +} +_NONE_KNOWN[_ON_METAL] = _NONE_KNOWN[_CONJUGATED_ON_METAL] = _NONE_KNOWN[_TETRA] +_ONE_KNOWN[_CONJUGATED_ON_METAL] = _ONE_KNOWN[_TETRA] + + +class Topology(NamedTuple): + """The atom array as the rule reads it; *pairs* holds every bond both ways and + *name_rank* orders atoms by name, so ties do not depend on the atom order.""" + + coord: np.ndarray + element: np.ndarray + is_h: np.ndarray + finite: np.ndarray + pairs: np.ndarray + bond_type: np.ndarray + residue: np.ndarray + metal: np.ndarray + name_rank: np.ndarray + + +def _sorted_neighbours(topo: Topology, pairs: np.ndarray, *keys: np.ndarray) -> np.ndarray: + """``[n, k]`` partners of each atom sorted by *keys* (last key most significant), then by + name (5HS6 J3Z HO3), ``-1`` padded.""" + n = len(topo.coord) + pairs = pairs[np.lexsort((pairs[:, 1], topo.name_rank[pairs[:, 1]], *keys, pairs[:, 0]))] + degree = np.bincount(pairs[:, 0], minlength=n) + table = np.full((n, max(int(degree.max(initial=0)), 1)), -1, dtype=np.intp) + table[pairs[:, 0], np.arange(len(pairs)) - np.repeat(np.cumsum(degree) - degree, degree)] = pairs[:, 1] + return table + + +def _first(candidates: np.ndarray, valid: np.ndarray) -> np.ndarray: + """Per row, the first candidate marked *valid*, else -1.""" + return np.where(valid.any(axis=1), candidates[np.arange(len(candidates)), valid.argmax(axis=1)], -1) + + +def _or(v: np.ndarray, fallback: np.ndarray) -> np.ndarray: + return np.where(np.isfinite(v).all(axis=-1, keepdims=True), v, fallback) + + +def _hydrogen_parent_geometries(topo: Topology, atoms: np.ndarray, m: np.ndarray, n_build: np.ndarray) -> np.ndarray: + """Each parent's shape: linear (a triple or two double bonds), planar (a pi bond, or an N + conjugated to one with three neighbours), trigonal about a metal (an O bonded to one, with + three neighbours at most; in the plane of a pi bond it is conjugated to, off the metal), syn + (a hydroxyl conjugated to a pi bond), else tetrahedral; tetrahedral if the shape is full.""" + n, (a, t) = len(topo.coord), (topo.pairs[:, 0], topo.bond_type) + double = np.bincount(a, weights=t == int(struc.BondType.DOUBLE), minlength=n) + linear = (np.bincount(a, weights=np.isin(t, _TRIPLE_BONDS), minlength=n) > 0) | (double >= 2) + planar = np.bincount(a, weights=np.isin(t, _PI_BONDS), minlength=n) > 0 + heavy = ~topo.is_h[topo.pairs[:, 1]] + conjugated = np.bincount(a[heavy], weights=planar[topo.pairs[heavy, 1]], minlength=n) > 0 + on_metal = np.bincount(a, weights=topo.metal[topo.pairs[:, 1]], minlength=n) > 0 + + steric = m + n_build + # 6dmz_mod_d: an N with three neighbours conjugated to a pi bond (an amide N) is planar. + amine = (topo.element[atoms] == "N") & conjugated[atoms] & (steric == 3) + shape = np.where((planar[atoms] | amine) & (steric <= 3), _PLANAR, _TETRA) + oxygen = topo.element[atoms] == "O" + # ACY: a hydroxyl conjugated to a pi bond lies syn to it. + hydroxyl = oxygen & conjugated[atoms] & ~planar[atoms] & (m == 1) & (n_build == 1) + shape = np.where(hydroxyl, _SYN, shape) + # 1HZY: an O on a metal is trigonal. + metal_oxygen = oxygen & on_metal[atoms] & (steric <= 3) + shape = np.where(metal_oxygen, np.where(conjugated[atoms], _CONJUGATED_ON_METAL, _ON_METAL), shape) + shape = np.where(linear[atoms] & (steric <= 2), _LINEAR, shape) + crowded = np.flatnonzero(steric > 4) + if len(crowded): + atom = atoms[crowded[0]] + raise ValueError(f"Atom {atom} ({topo.element[atom]}) has more hydrogens than free directions.") + return shape + + +def _measured_slots( + coord: np.ndarray, + shape: np.ndarray, + m: np.ndarray, + x: np.ndarray, + known: np.ndarray, + d: np.ndarray, + reference: np.ndarray, +) -> np.ndarray: + """``[k, 2, 3]`` ``(d, theta, phi)`` of the free directions of parents with two or more known; + *reference* is the torsion reference of each first known atom (-1 where none).""" + ua, ub, uc = (_unit(coord[known[:, i]] - coord[x]) for i in range(3)) + bisector = _or(-_unit(ua + ub), _perpendicular(ua)) + normal = _or(_unit(np.cross(ua, ub)), _unit(np.cross(ua, bisector))) + half = _TETRAHEDRAL / 2 + first = np.cos(half) * bisector + np.sin(half) * normal + second = np.cos(half) * bisector - np.sin(half) * normal + first = np.where(((shape == _TETRA) & (m == 2))[:, None], first, bisector) + in_plane = _unit(np.cross(np.cross(ua, coord[np.maximum(reference, 0)] - coord[known[:, 0]]), ua)) + syn, anti = (np.cos(_TETRAHEDRAL) * ua + sign * np.sin(_TETRAHEDRAL) * in_plane for sign in (1, -1)) + syn_nearer_metal = np.einsum("ij,ij->i", syn, ub) > np.einsum("ij,ij->i", anti, ub) + off_metal = np.where(syn_nearer_metal[:, None], anti, syn) + first = np.where(((shape == _CONJUGATED_ON_METAL) & (m == 2) & (reference >= 0))[:, None], off_metal, first) + # With three known, the apex is equally far from each; a flat centre offers both sides. + tips = _or(_unit(np.cross(ub - ua, uc - ua)), bisector) + side = np.einsum("ij,ij->i", tips, ua) + apex = np.where((side > 0)[:, None], -tips, tips) + three = (m >= 3)[:, None] + first = np.where(three, apex, first) + second = np.where(three, np.where((np.abs(side) < _FLAT)[:, None], -apex, apex), second) + directions = np.stack([first, second], axis=1) + position = coord[x][:, None] + d[:, None, None] * directions + frame = [coord[v][:, None] for v in (x, known[:, 0], known[:, 1])] + return _measure_icoors(position, *frame) + + +def _torsion_reference(topo: Topology, x: np.ndarray, a: np.ndarray) -> np.ndarray: + """The heaviest heavy atom bonded to each *a* other than *x*, in *a*'s residue first; -1 where none.""" + pairs = topo.pairs + heavy = ~topo.is_h[pairs[:, 1]] & topo.finite[pairs[:, 1]] + symbols, of_symbol = np.unique(topo.element, return_inverse=True) + number = np.array([ELEMENT_NAME_TO_ATOMIC_NUMBER.get(str(e), 0) for e in symbols])[of_symbol] + outside = topo.residue[pairs[:, 1]] != topo.residue[pairs[:, 0]] + table = _sorted_neighbours(topo, pairs[heavy], outside[heavy], -number[pairs[heavy, 1]]) + candidates = table[np.maximum(a, 0)] + return _first(candidates, (candidates >= 0) & (candidates != x[:, None]) & (a[:, None] >= 0)) + + +def _name_by_template( + topo: Topology, + slots: np.ndarray, + anchor: np.ndarray, + n_build: np.ndarray, + m: np.ndarray, + shape: np.ndarray, + template: np.ndarray, + names_at: np.ndarray, +) -> np.ndarray: + """``[n_parents, 4]`` the slot each hydrogen takes, the one nearest its name's dictionary + dihedral about the parent's bond, where the dictionary places the frame in one residue. + + Where a parent's hydrogens fill its free slots this only names them; a lone hydrogen on + a tetrahedral atom with two known neighbours, or three lying flat, takes the side that + keeps the dictionary's chirality (7N5V DT H1'). *names_at* is ``[n_parents, 3, 3]``, the hydrogens at their dictionary positions. + """ + choice = np.tile(np.arange(4), (len(anchor), 1)) + residue = topo.residue + cases = ((1, 2, (m >= 2) & (shape == _TETRA)), (2, 2, (m == 1) | (m == 2)), (3, 3, (m == 1) | (m == 2))) + for count, among, where in cases: + group = np.flatnonzero((n_build == count) & where) + if not len(group): + continue + x, a, ref = anchor[group].T + same = (ref >= 0) & (residue[np.maximum(a, 0)] == residue[x]) & (residue[np.maximum(ref, 0)] == residue[x]) + frame = (template[v][:, None] for v in (x, np.maximum(a, 0), np.maximum(ref, 0))) + wanted = -_measure_icoors(names_at[group, :count], *frame)[..., 2] + ok = same & np.isfinite(wanted).all(axis=1) + if not ok.any(): + continue + perms = np.array(list(itertools.permutations(range(among), count))) + gap = np.abs(np.angle(np.exp(1j * (wanted[ok, None, :] + slots[group[ok]][:, perms, 2])))) + choice[group[ok], :count] = perms[gap.sum(axis=-1).argmin(axis=1)] + return choice + + +def plan_by_rule( + topo: Topology, + rows: np.ndarray, + parent: np.ndarray, + ancestors: np.ndarray, + geometry: np.ndarray, + template: np.ndarray, + template_row: np.ndarray, +) -> None: + """Fill in ``ancestors`` and ``geometry`` of the plan rows marked *rows*. + + Which free direction each hydrogen takes is fixed by the rule; the dictionary's + positions (*template* per atom, *template_row* per row) decide which name goes where + and, at a stereocentre missing a substituent, which side its hydrogen is on. + """ + rows = np.flatnonzero(rows) + if not len(rows): + return + coord, pairs = topo.coord, topo.pairs + rows = rows[np.argsort(parent[rows], kind="stable")] + atoms, first, n_build = np.unique(parent[rows], return_index=True, return_counts=True) + rank = np.arange(len(rows)) - np.repeat(first, n_build) + of_row = np.repeat(np.arange(len(atoms)), n_build) + + within = topo.residue[pairs[:, 1]] == topo.residue[pairs[:, 0]] + kind = np.where(topo.metal[pairs[:, 1]], 3, np.where(topo.is_h[pairs[:, 1]], 2, np.where(within, 0, 1))) + usable = topo.finite[pairs[:, 1]] + known = _sorted_neighbours(topo, pairs[usable], kind[usable])[atoms] + m = (known >= 0).sum(axis=1) + known = np.pad(known, ((0, 0), (0, 3)), constant_values=-1)[:, :3] + shape = _hydrogen_parent_geometries(topo, atoms, m, n_build) + symbols, of_symbol = np.unique(topo.element[atoms], return_inverse=True) + d = np.array([_BOND_LENGTH.get(str(e), 1.0) for e in symbols])[of_symbol] + + slots = np.full((len(atoms), 4, 3), np.nan) + measured = m >= 2 + if measured.any(): + reference = _torsion_reference(topo, atoms[measured], known[measured, 0]) + slots[measured, :2] = _measured_slots( + coord, shape[measured], m[measured], atoms[measured], known[measured], d[measured], reference + ) + for known_count, directions_by_shape in ((1, _ONE_KNOWN), (0, _NONE_KNOWN)): + for shape_kind, directions in directions_by_shape.items(): + select = (m == known_count) & (shape == shape_kind) + for k, (theta, phi) in enumerate(directions): + slots[select, k] = np.stack([d[select], np.full(select.sum(), theta), np.full(select.sum(), phi)], -1) + + anchor = np.stack([atoms, known[:, 0], known[:, 1]], axis=1) + one = m == 1 + anchor[one, 2] = _torsion_reference(topo, atoms[one], known[one, 0]) + anchor[m == 0, 1:] = -1 + + names_at = np.full((len(atoms), 3, 3), np.nan) + listed = rank < 3 + names_at[of_row[listed], rank[listed]] = template_row[rows[listed]] + choice = _name_by_template(topo, slots, anchor, n_build, m, shape, template, names_at) + ancestors[rows] = anchor[of_row] + geometry[rows] = slots[of_row, choice[of_row, rank]] + + +def backups( + topo: Topology, built: np.ndarray, ancestors: np.ndarray, placed: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """A second frame for each built hydrogen, avoiding its great-grandparent, and its geometry there. + + The frame swaps the great-grandparent for the first other heavy atom bonded to the + parent or grandparent; the geometry is measured on the hydrogen as the plan places it. + """ + n = len(topo.coord) + backup = np.full_like(ancestors, -1) + geometry = np.zeros((len(ancestors), 3)) + x, a, b = ancestors.T + rows = np.flatnonzero(built & (a >= 0) & (a < n)) + if not len(rows): + return backup, geometry + pairs = topo.pairs + heavy = ~topo.is_h[pairs[:, 1]] & topo.finite[pairs[:, 1]] + table = _sorted_neighbours(topo, pairs[heavy], topo.residue[pairs[heavy, 1]] != topo.residue[pairs[heavy, 0]]) + candidates = np.concatenate([table[x[rows]], table[a[rows]]], axis=1) + others = (candidates != x[rows, None]) & (candidates != a[rows, None]) & (candidates != b[rows, None]) + c = _first(candidates, (candidates >= 0) & others) + rows, c = rows[c >= 0], c[c >= 0] + backup[rows] = np.stack([x[rows], a[rows], c], axis=1) + geometry[rows] = _measure_icoors(placed[rows], topo.coord[x[rows]], topo.coord[a[rows]], topo.coord[c]) + return backup, geometry + + +# --- place --- +_LAB_X = np.array([1.0, 0.0, 0.0]) +# Rows of one level are independent; building them this many (hydrogens x samples) at a +# time bounds the temporaries without changing any value. +_CHUNK = 1 << 15 + + +def _frame(known: np.ndarray, ancestors: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Ancestor positions ``[..., rows, 3]``; a missing grandparent is a lab axis off the parent, + and a missing great-grandparent the grandparent itself, which leaves the dihedral free.""" + parent = known[..., np.maximum(ancestors[:, 0], 0), :] + grandparent = np.where( + (ancestors[:, 1] >= 0)[:, None], known[..., np.maximum(ancestors[:, 1], 0), :], parent - _LAB_X + ) + great = np.where((ancestors[:, 2] >= 0)[:, None], known[..., np.maximum(ancestors[:, 2], 0), :], grandparent) + return parent, grandparent, great + + +def build_levels( + coord: np.ndarray, + ancestors: np.ndarray, + geometry: np.ndarray, + level: np.ndarray, + kept_index: np.ndarray, + backup_ancestors: np.ndarray | None = None, + backup_geometry: np.ndarray | None = None, +) -> np.ndarray: + """Hydrogen positions ``[..., n_H, 3]`` from atom positions ``[..., n_atoms, 3]``, one dependency level at a + time; a row whose frame lacks a position is built from its backup frame, else left NaN.""" + n = coord.shape[-2] + lead = coord.shape[:-2] + known = np.empty((*lead, n + len(level), 3)) + known[..., :n, :], known[..., n:, :] = coord, np.nan + kept = np.flatnonzero(kept_index >= 0) + known[..., n + kept, :] = coord[..., kept_index[kept], :] + step = max(1, _CHUNK // max(1, int(np.prod(lead)))) + by_level = (np.flatnonzero(level == depth) for depth in range(1, int(level.max(initial=0)) + 1)) + for rows in (at[i : i + step] for at in by_level for i in range(0, len(at), step)): + placed = build_coordinates(*_frame(known, ancestors[rows]), geometry[rows]) + if backup_ancestors is not None: + usable = backup_ancestors[rows, 0] >= 0 + missing = ~np.isfinite(placed).all(axis=-1) & usable + if missing.any(): + backup = build_coordinates(*_frame(known, backup_ancestors[rows]), backup_geometry[rows]) + placed = np.where(missing[..., None], backup, placed) + known[..., n + rows, :] = placed + return known[..., n:, :] + + +# --- plan --- +# A table frame atom is a heavy atom of the residue, one of its hydrogens, or the previous +# residue's connection atom. +_HEAVY, _ROW, _DOWN = 0, 1, 2 + + +@dataclass(frozen=True) +class _Recipe: + """One residue's rows, the same for every residue with its code, heavy atoms and counts.""" + + parent: np.ndarray # [r] heavy position in the residue + name: np.ndarray # [r] + template_heavy: np.ndarray # [n_heavy, 3] dictionary positions, NaN where it has none + template_row: np.ndarray # [r, 3] + table: np.ndarray # [r] the table describes the row's parent as it is + frame: np.ndarray # [r, 2, 2] (kind, index) of grandparent and great-grandparent + geometry: np.ndarray # [r, 3] + + +@_cache_ccd_lookup +def _recipe(res_name: str, heavy_names: tuple, elements: tuple, totals: tuple, use_table: bool) -> _Recipe: + """The rows of a residue whose heavy atom ``i`` is named ``heavy_names[i]`` and carries ``totals[i]``.""" + parents = [(i, n, e, t) for i, (n, e, t) in enumerate(zip(heavy_names, elements, totals, strict=True)) if t] + groups = residue_hydrogen_names(res_name, frozenset(heavy_names), tuple(p[1:] for p in parents)) + parent = np.array([p[0] for p, names in zip(parents, groups, strict=True) for _ in names], dtype=np.intp) + name = [n for names in groups for n in names] + template, nan = _ccd_template(res_name), np.full(3, np.nan) + tpl = {} + if template is not None: + finite = np.isfinite(template.coord).all(axis=-1) + tpl = dict(zip(template.atom_name[finite], template.coord[finite], strict=True)) + r = len(name) + table, frame, geometry = np.zeros(r, bool), np.zeros((r, 2, 2), np.intp), np.zeros((r, 3)) + entry = select_entry(res_name, frozenset(name)) if use_table else None + if entry is not None: + rows = {row[0]: row for row in entry} + heavy_pos = {n: i for i, n in enumerate(heavy_names)} + row_pos = {n: i for i, n in enumerate(name)} + for i, (p, n) in enumerate(zip(parent, name, strict=True)): + row = rows.get(n) + if row is None or row[1] != heavy_names[p]: + continue + refs = [(_HEAVY, heavy_pos[a]) if a in heavy_pos else (_ROW, row_pos.get(a, -1)) for a in row[2:4]] + refs = [(_DOWN, 0) if a == "down" else ref for a, ref in zip(row[2:4], refs, strict=True)] + if all(index >= 0 for _, index in refs): + table[i], frame[i], geometry[i] = True, refs, row[4:7] + return _Recipe( + parent=parent, + name=np.array(name, dtype=str), + template_heavy=np.array([tpl.get(n, nan) for n in heavy_names]).reshape(-1, 3), + template_row=np.array([tpl.get(n, nan) for n in name]).reshape(-1, 3), + table=table, + frame=frame, + geometry=geometry, + ) + + +def _polymer_atoms(atom_array: AtomArray, heavy_idx: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Masks of each residue's entering and leaving polymer atoms (N and C, P and O3').""" + codes, of_code = np.unique(atom_array.res_name, return_inverse=True) + in_heavy = np.isin(codes, atom_array.res_name[heavy_idx]) + names = [get_polymerization_atoms(str(c)) if h else (None, None) for c, h in zip(codes, in_heavy, strict=True)] + leaving, entering = np.array(names, dtype=object).reshape(-1, 2)[of_code].T + return atom_array.atom_name == entering, atom_array.atom_name == leaving + + +def _prepare_hydrogen_coordinates(atom_array: AtomArray) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Every hydrogen of a protonation state: its name, its heavy atom, the input hydrogen it keeps (-1 where it is + built) and its position. + + Each heavy atom not ``skip_hydrogen_placement`` gets ``nhyd`` hydrogens besides those the atom array keeps. A standard + residue takes TMol's table where its frame atoms are present and its parent is drawn as the template + draws it; any other hydrogen takes the free direction its parent's hybridization leaves, a rotatable + group staggered. Names follow the dictionary. + """ + if isinstance(atom_array, AtomArrayStack): + raise TypeError("add_hydrogens takes one protonation state (an AtomArray), e.g. stack[0].") + missing = {"nhyd", "skip_hydrogen_placement"} - set(atom_array.get_annotation_categories()) + if missing: + raise ValueError( + f"Not a protonation state: no {', '.join(sorted(missing))} annotation. Pass the array through " + "assign_hydrogens first, or call add_hydrogens(atom_array)." + ) + n = atom_array.array_length() + coord = atom_array.coord.astype(np.float64) + symbols, of_symbol = np.unique(atom_array.element.astype(str), return_inverse=True) + element = np.char.upper(symbols)[of_symbol] + is_h = np.isin(element, HYDROGEN_LIKE_SYMBOLS) + bonds = atom_array.bonds.as_array().astype(np.intp) + pairs = np.concatenate([bonds[:, :2], bonds[:, 1::-1]]) + + heavy_idx = np.flatnonzero(~is_h) + starts = struc.get_residue_starts(atom_array[heavy_idx], add_exclusive_stop=True) + codes = atom_array.res_name[heavy_idx].astype(str) + residue = np.full(n, -1, dtype=np.intp) + residue[heavy_idx] = np.repeat(np.arange(len(starts) - 1), np.diff(starts)) + kept, kept_on = pairs[is_h[pairs[:, 0]] & ~is_h[pairs[:, 1]]].T + residue[kept] = residue[kept_on] + total = np.where(~is_h & ~atom_array.skip_hydrogen_placement, atom_array.nhyd, 0).astype(np.intp) + total += np.bincount(kept_on, minlength=n) + + # Four covalent neighbours leave no direction for a metal contact. Keep the recorded bond, + # but do not use it to place hydrogens or to disqualify the residue's covalent template. + metal = is_metal(atom_array) + covalent_heavy = (np.tile(bonds[:, 2], 2) != struc.BondType.COORDINATION) & ~is_h[pairs[:, 1]] + covalent_neighbours = np.bincount(pairs[covalent_heavy, 0], minlength=n) + total + saturated = (covalent_neighbours == 4) & (total > 0) + ignore_contact = (bonds[:, 2] == struc.BondType.COORDINATION) & np.any( + saturated[bonds[:, :2]] & metal[bonds[:, 1::-1]], axis=1 + ) + if ignore_contact.any(): + affected = np.unique(bonds[ignore_contact, :2]) + logger.warning( + "Ignored metal contacts as hydrogen-placement constraints for %s: four covalent neighbours " + "already fill their geometry. Assigned hydrogens, charges and recorded bonds are unchanged.", + _format_atom_examples(atom_array, affected[saturated[affected] & ~metal[affected]]), + ) + bonds = bonds[~ignore_contact] + pairs = np.concatenate([bonds[:, :2], bonds[:, 1::-1]]) + + custom = get_custom_ccd_entries() + has_rows = np.bincount(residue[heavy_idx], weights=total[heavy_idx] != 0, minlength=max(len(starts) - 1, 0)) > 0 + with_rows = np.flatnonzero(has_rows) + # A residue's code and the bytes of its (name, element, total) rows identify its recipe. + records = np.rec.fromarrays([atom_array.atom_name[heavy_idx], element[heavy_idx], total[heavy_idx]]).tobytes() + width, bounds = len(records) // max(len(heavy_idx), 1), starts.tolist() + codes = codes[starts[:-1]].tolist() + recipes, by_key = [], {} + for r in with_rows.tolist(): + start, stop = bounds[r], bounds[r + 1] + key = (codes[r], records[start * width : stop * width]) + if key not in by_key: + idx = heavy_idx[start:stop] + names, elements, totals = (tuple(a[idx].tolist()) for a in (atom_array.atom_name, element, total)) + by_key[key] = _recipe(key[0], names, elements, totals, key[0] not in custom) + recipes.append(by_key[key]) + counts = np.array([len(rec.name) for rec in recipes], dtype=np.intp) + row_residue = np.repeat(with_rows, counts) + row_base = np.repeat(np.cumsum(counts) - counts, counts) + + def cat(field: str, width: int = 0) -> np.ndarray: + shape = (0, width) if width else (0,) + return np.concatenate([getattr(rec, field) for rec in recipes]) if recipes else np.zeros(shape) + + first_heavy = starts[row_residue] + parent = heavy_idx[first_heavy + cat("parent").astype(np.intp)] + name = cat("name").astype(str) + template = np.full((n, 3), np.nan) + if recipes: + template[heavy_idx[np.repeat(has_rows, np.diff(starts))]] = cat("template_heavy", 3) + n_rows = len(name) + + kept_index = np.full(n_rows, -1, dtype=np.intp) + # A kept hydrogen takes the row of its name where it can, else the next free row on its atom. + if len(kept): + row_of = {key: i for i, key in enumerate(zip(parent.tolist(), name.tolist(), strict=True))} + free: dict[int, list[int]] = {} + for i, p in enumerate(parent.tolist()): + free.setdefault(p, []).append(i) + pending = [] + for h, p in sorted(zip(kept.tolist(), kept_on.tolist(), strict=True)): + i = row_of.get((p, str(atom_array.atom_name[h]))) + if i is None or kept_index[i] >= 0: + pending.append((h, p)) + continue + kept_index[i] = h + free[p].remove(i) + for h, p in pending: + kept_index[free[p].pop(0)] = h + fixed = kept_index >= 0 + ancestors = np.full((n_rows, 3), -1, dtype=np.intp) + ancestors[:, 0] = parent + + # The previous residue's connection atom is the partner of this residue's entering atom + # that is its own residue's leaving atom, whatever the atom order. + enter, leave = _polymer_atoms(atom_array, heavy_idx) + link = pairs[enter[pairs[:, 0]] & leave[pairs[:, 1]] & (residue[pairs[:, 0]] != residue[pairs[:, 1]])] + down = np.full(len(starts), -1, dtype=np.intp) + down[residue[link[:, 0]]] = link[:, 1] + + # 1CS4 (metalc): a parent bonded to a metal or to another residue other than through the polymer + # link is not the residue the table describes. + heavy_pairs = pairs[~is_h[pairs[:, 0]] & ~is_h[pairs[:, 1]]] + linked = (enter[heavy_pairs[:, 0]] & leave[heavy_pairs[:, 1]]) | ( + leave[heavy_pairs[:, 0]] & enter[heavy_pairs[:, 1]] + ) + foreign = (metal[heavy_pairs[:, 1]] | (residue[heavy_pairs[:, 0]] != residue[heavy_pairs[:, 1]])) & ~linked + has_foreign = np.bincount(heavy_pairs[foreign, 0], minlength=n) > 0 + + # 2CDB: a centre drawn with the other hand than its template's takes the rule, which reads it off the structure. + inner = heavy_pairs[residue[heavy_pairs[:, 0]] == residue[heavy_pairs[:, 1]]] + centre = np.flatnonzero(np.bincount(inner[:, 0], minlength=n) == 3) + arms = inner[np.isin(inner[:, 0], centre)] + arms = arms[np.lexsort((arms[:, 1], arms[:, 0])), 1].reshape(-1, 3) + + def hand(xyz: np.ndarray) -> np.ndarray: + a, b, c = (xyz[arms[:, k]] - xyz[centre] for k in range(3)) + return np.einsum("ij,ij->i", np.cross(a, b), c) + + mirrored = np.zeros(n, dtype=bool) + mirrored[centre] = hand(coord) * hand(template) < 0 + + finite = np.isfinite(coord).all(axis=-1) + table = cat("table").astype(bool) & ~fixed & finite[parent] & ~has_foreign[parent] & ~mirrored[parent] + frame = cat("frame", 2).astype(np.intp).reshape(-1, 2, 2) + for col in range(2): + kind, index = frame[:, col, 0], frame[:, col, 1] + heavy_ref = heavy_idx[np.minimum(first_heavy + index, len(heavy_idx) - 1)] + ref = np.where(kind == _HEAVY, heavy_ref, np.where(kind == _ROW, n + row_base + index, down[row_residue])) + atom_ref = (ref < n) & (kind != _ROW) + table &= ~atom_ref | ((ref >= 0) & finite[np.clip(ref, 0, n - 1)]) + ancestors[:, col + 1] = np.where(table, ref, ancestors[:, col + 1]) + table &= ~(np.bincount(parent[~table], minlength=n) > 0)[parent] + geometry = np.where(table[:, None], cat("geometry", 3).reshape(-1, 3), 0.0) + ancestors[~table & ~fixed, 1:] = -1 + + name_rank = np.unique(atom_array.atom_name.astype(str), return_inverse=True)[1] + topology = Topology(coord, element, is_h, finite, pairs, np.tile(bonds[:, 2], 2), residue, metal, name_rank) + plan_by_rule(topology, ~fixed & ~table, parent, ancestors, geometry, template, cat("template_row", 3)) + + level = np.where(fixed, 0, 1) + for _ in range(n_rows + 1): + refs = ancestors - n + deepest = np.where(refs >= 0, level[np.clip(refs, 0, max(n_rows - 1, 0))], 0).max(axis=1, initial=0) + updated = np.where(fixed, 0, deepest + 1) + if (updated == level).all(): + break + level = updated + else: + raise ValueError("The hydrogen plan has a cycle among its ancestors.") + + placed = build_levels(coord, ancestors, geometry, level, kept_index) + backup_ancestors, backup_geometry = backups(topology, ~fixed, ancestors, placed) + return ( + name, + parent, + kept_index, + build_levels(coord, ancestors, geometry, level, kept_index, backup_ancestors, backup_geometry), + ) + + +# --- insert --- +logger = logging.getLogger(__name__) + +# Values a new hydrogen takes for these atom-level annotations. +_H_DEFAULTS: dict[str, object] = { + "b_factor": np.nan, + "stereo": "N", + "is_aromatic": False, + "is_backbone_atom": False, + "skip_hydrogen_placement": False, + "tautomer_free": False, +} +_RESIDUE_AND_CHAIN = frozenset(RESIDUE_LEVEL_ANNOTATIONS + CHAIN_LEVEL_ANNOTATIONS) + + +def _append_hydrogens(atoms: AtomArray, parent: np.ndarray, names: np.ndarray) -> tuple[AtomArray, list[str]]: + """*atoms* followed by hydrogens singly bonded to ``atoms[parent]``, and the annotations to recompute. + + A hydrogen takes charge 0, a default from :data:`_H_DEFAULTS`, and its parent's value for + any annotation AtomWorks does not compute. Those it computes are left out of the appended. + The hydrogens have no position. + """ + n_atoms, n_h = atoms.array_length(), len(parent) + block = AtomArrayPlus(n_h) + block.element[:] = "H" + block.chain_id[:] = atoms.chain_id[parent] + block.res_id[:] = atoms.res_id[parent] + block.res_name[:] = atoms.res_name[parent] + block.atom_name = names + for annot in atoms.get_annotation_categories(): + values = atoms.get_annotation(annot) + if annot == "charge": + block.set_annotation(annot, np.zeros(n_h, dtype=values.dtype)) + elif annot in _RESIDUE_AND_CHAIN and annot not in ("chain_id", "res_id", "res_name"): + block.set_annotation(annot, values[parent]) + for name in get_annotation_categories(atoms, n_body=2): + annotation = get_annotation(atoms, name, n_body=2) + block.set_annotation(name, AnnotationList2D(n_h, [], annotation.values[:0]), n_body=2) + appended = concatenate_atom_array_plus([atoms.copy(), block], on_annotation_mismatch_policy="drop") + + recompute, inherit = [], [] + for annot in atoms.get_annotation_categories(): + if annot in appended.get_annotation_categories() or annot in ("nhyd", "atom_id"): + continue + standard = next((a for a in STANDARD_ANNOTATIONS if annot in (a.name, a.full_name, *a.aliases)), None) + if standard is not None: + (recompute if standard.level == Level.ATOM else inherit).append(annot) + elif annot in ANNOTATOR_REGISTRY: + recompute.append(annot) + elif annot in _H_DEFAULTS: + values = atoms.get_annotation(annot) + appended.set_annotation(annot, np.concatenate([values, np.full(n_h, _H_DEFAULTS[annot], values.dtype)])) + else: + inherit.append(annot) + new_bonds = np.stack([parent, n_atoms + np.arange(n_h), np.full(n_h, int(struc.BondType.SINGLE))], axis=1) + appended.bonds = struc.BondList(len(appended), np.vstack([appended.bonds.as_array(), new_bonds])) + for annot in inherit: + values = atoms.get_annotation(annot) + appended.set_annotation(annot, np.concatenate([values, values[parent]])) + return appended, recompute + + +def place_hydrogens(atom_array: AtomArray) -> AtomArray: + """Name, position and insert the hydrogens :func:`assign_hydrogens` decided, built on ``atom_array.coord``. + + Args: + atom_array: A protonation state, as :func:`assign_hydrogens` returns it. + + Returns: + The atom array with every hydrogen explicit, named and bonded after its residue's heavy atoms (a + kept hydrogen joins its parent's residue). ``nhyd`` is + zero except on ``skip_hydrogen_placement`` heavy atoms. Its atoms keep their ``atom_id`` and + annotations; added hydrogens are numbered on from the largest ``atom_id`` and take their + parent's value of any annotation AtomWorks does not compute. + + Raises: + TypeError: If *atom_array* is an ``AtomArrayStack``. + ValueError: If *atom_array* is not a protonation state or needs unsupported placement geometry. + """ + names, parents, kept_index, h_coord = _prepare_hydrogen_coordinates(atom_array) + n = atom_array.array_length() + coord = atom_array.coord + built = kept_index < 0 + unbuilt = built & ~np.isfinite(h_coord).all(axis=-1) & np.isfinite(coord[parents]).all(axis=-1) + # 3UQ: coincident frame atoms leave a hydrogen without a position. + if unbuilt.any(): + logger.warning( + "Left the hydrogens of %s without a position: two atoms of their frame coincide or lack a position.", + _format_atom_examples(atom_array, np.unique(parents[unbuilt])), + ) + protonated, recompute = _append_hydrogens(atom_array, parents[built], names[built]) + hydrogen_index = kept_index.copy() + hydrogen_index[built] = np.arange(n, protonated.array_length()) + protonated.atom_name[hydrogen_index] = names + for annot in _RESIDUE_AND_CHAIN.intersection(protonated.get_annotation_categories()): + values = protonated.get_annotation(annot) + values[kept_index[~built]] = values[parents[~built]] + + is_h = np.isin(protonated.element, HYDROGEN_LIKE_SYMBOLS) + nhyd = np.zeros(protonated.array_length(), dtype=atom_array.nhyd.dtype) + nhyd[:n] = np.where(atom_array.skip_hydrogen_placement & ~is_h[:n], atom_array.nhyd, 0) + protonated.set_annotation("nhyd", nhyd) + rank = np.zeros(protonated.array_length(), dtype=np.intp) + rank[~is_h] = struc.get_all_residue_positions(protonated[~is_h]) + rank[hydrogen_index] = rank[parents] + within_residue = np.arange(protonated.array_length()) + within_residue[hydrogen_index] = np.arange(len(parents)) + order = np.lexsort((within_residue, is_h, rank)) + protonated = protonated[order] + from_state = order < n + state_ids = get_annotation(atom_array, "atom_id", default=np.arange(n)) + atom_id = np.zeros(protonated.array_length(), dtype=np.result_type(state_ids, np.intp)) + atom_id[from_state] = state_ids[order[from_state]] + atom_id[~from_state] = state_ids.max(initial=-1) + 1 + np.arange(np.count_nonzero(~from_state)) + protonated.set_annotation("atom_id", atom_id) + protonated.coord = np.concatenate([coord, h_coord[built]])[order] + ensure_annotations(protonated, *recompute) + for annot in recompute: + values = protonated.get_annotation(annot) + values[from_state] = atom_array.get_annotation(annot)[order[from_state]] + protonated.set_annotation(annot, values) + return protonated + + +def add_hydrogens(atom_array: AtomArray, *, ph: float = 7.4) -> AtomArray: + """Protonate *atom_array* at *ph*: :func:`assign_hydrogens`, then :func:`place_hydrogens`.""" + return place_hydrogens(assign_hydrogens(atom_array, ph=ph)) diff --git a/src/atomworks/experimental/protonation/_titration.py b/src/atomworks/experimental/protonation/_titration.py new file mode 100644 index 00000000..4a024b40 --- /dev/null +++ b/src/atomworks/experimental/protonation/_titration.py @@ -0,0 +1,329 @@ +"""Titrating components with Dimorphite-DL: each atom's charge and hydrogen count at a pH.""" + +from __future__ import annotations + +import logging +from typing import NamedTuple + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray +from rdkit import Chem +from scipy.sparse import csr_matrix +from scipy.sparse.csgraph import connected_components + +from atomworks.constants import HYDROGEN_LIKE_SYMBOLS, METAL_ELEMENTS +from atomworks.experimental.protonation.dimorphite import protonate_at_ph +from atomworks.io.tools.rdkit import RDKIT_BOND_TYPE_TO_BIOTITE, atom_array_to_rdkit + +logger = logging.getLogger(__name__) +_METAL_NUMBERS = frozenset(Chem.GetPeriodicTable().GetAtomicNumber(e.capitalize()) for e in METAL_ELEMENTS) +_PHOSPHORYL_AT_VALENCE_3 = Chem.MolFromSmarts("[P+0;v3;$(P=[O,S])]") + + +_MULTIPLE = [int(struc.BondType[t]) for t in ("DOUBLE", "AROMATIC_SINGLE", "AROMATIC_DOUBLE", "AROMATIC")] +# N-C-N-C-C ring, the central C bonded to no third N; matched as (N1, C, N3, C4, C5). +_IMIDAZOLE = Chem.MolFromSmarts("[#7]1~[#6;!$([#6](~[#7])(~[#7])~[#7])]~[#7]~[#6]~[#6]~1") + + +class Imidazoles(NamedTuple): + """The imidazole rings of a structure: ``n`` ``[k, 2]`` ring nitrogens in atom order; ``tele`` ``[k, 2]`` + whether an N's other ring C bears no heavy substituent while its partner's does (histidine's NE2); + ``open`` ``[k]`` whether neither N has a third heavy neighbour other than a metal; ``atoms`` ``[k, 5]``.""" + + n: np.ndarray + tele: np.ndarray + open: np.ndarray + atoms: np.ndarray + + +def imidazole_rings(atom_array: AtomArray) -> Imidazoles: + """The imidazoles of one residue each (HIS, HIC, IMD): N-C-N in a 5-ring whose Kekule form has C=N at that + carbon and C=C across from it, and no ring atom a multiple bond out of the ring (no imidazolone or -thione). + + One SMARTS match on the residues' heavy-atom bond graph, aromatic bonds read as multiple ones. + """ + element = np.char.upper(atom_array.element.astype(str)) + heavy = ~np.isin(element, [*HYDROGEN_LIKE_SYMBOLS, *METAL_ELEMENTS]) + residue = struc.get_all_residue_positions(atom_array) + bonds = atom_array.bonds.as_array() if atom_array.bonds is not None else np.empty((0, 3), dtype=np.int64) + bonds = bonds[heavy[bonds[:, 0]] & heavy[bonds[:, 1]]] + degree = np.bincount(bonds[:, :2].ravel(), minlength=len(atom_array)) + graph = Chem.RWMol() + for symbol in element: + graph.AddAtom(Chem.Atom({"C": 6, "N": 7}.get(symbol, 0))) + for left, right, bond_type in bonds[residue[bonds[:, 0]] == residue[bonds[:, 1]]].tolist(): + graph.AddBond(left, right, Chem.BondType.DOUBLE if bond_type in _MULTIPLE else Chem.BondType.SINGLE) + graph.UpdatePropertyCache(strict=False) + rings, seen = [], set() + for n1, c, n2, b, a in graph.GetSubstructMatches(_IMIDAZOLE, uniquify=False): + cycle = (n1, c, n2, b, a) + multiple = { + (x, y.GetIdx()) + for x in cycle + for y in graph.GetAtomWithIdx(x).GetNeighbors() + if graph.GetBondBetweenAtoms(x, y.GetIdx()).GetBondType() == Chem.BondType.DOUBLE + } + if n1 > n2 or c in seen or any(y not in cycle for _, y in multiple) or (a, b) not in multiple: + continue + if (n1, c) in multiple or (c, n2) in multiple: + seen.add(c) + rings.append((c, cycle)) + rings = [cycle for _, cycle in sorted(rings)] + n = np.array([(r[0], r[2]) for r in rings], dtype=np.intp).reshape(-1, 2) + bare = np.array([(degree[r[4]] == 2, degree[r[3]] == 2) for r in rings], dtype=bool).reshape(-1, 2) + return Imidazoles( + n, bare & ~bare[:, ::-1], (degree[n] == 2).all(axis=1), np.array(rings, dtype=np.intp).reshape(-1, 5) + ) + + +def partition_atomized_subgraphs(heavy: AtomArray, atomize_mask: np.ndarray) -> list[np.ndarray]: + """The connected pieces the bonds among atomized atoms make, as indices into *heavy*.""" + atomized = np.flatnonzero(atomize_mask) + if not len(atomized): + return [] + bonds = heavy.bonds.as_array()[:, :2].astype(np.intp) + local = np.searchsorted(atomized, bonds[atomize_mask[bonds].all(axis=1)]) + graph = csr_matrix((np.ones(len(local), dtype=np.int8), (local[:, 0], local[:, 1])), shape=(len(atomized),) * 2) + n_components, labels = connected_components(graph, directed=False) + order = np.argsort(labels, kind="stable") + return np.split(atomized[order], np.cumsum(np.bincount(labels, minlength=n_components))[:-1]) + + +def bonds_within_components( + heavy: AtomArray, components: list[np.ndarray] +) -> tuple[list[np.ndarray], np.ndarray, list[np.ndarray]]: + """Each component's own bonds, the atoms bonded outside their component, and those links. + + Returns: + ``(local_bonds, on_boundary, links)``: per component its bonds in bond-list order, + indexed as slicing the component out would index them; the mask of atoms with a bond to + another component or to an atom in none; per component ``[k, 3]`` rows of (local atom, + atomic number of the atom it bonds outside, bond type). + """ + n = heavy.array_length() + component_of = np.full(n, -1, dtype=np.intp) + local_of = np.zeros(n, dtype=np.intp) + for number, indices in enumerate(components): + component_of[indices] = number + local_of[indices] = np.arange(len(indices)) + + bond_arr = heavy.bonds.as_array().astype(np.intp) + left, right = component_of[bond_arr[:, 0]], component_of[bond_arr[:, 1]] + crossing = left != right + on_boundary = np.zeros(n, dtype=bool) + on_boundary[bond_arr[crossing, :2].ravel()] = True + on_boundary &= component_of >= 0 + + inner = np.flatnonzero(~crossing & (left >= 0)) + inner = inner[np.argsort(left[inner], kind="stable")] + local = bond_arr[inner] + local[:, :2] = local_of[local[:, :2]] + counts = np.bincount(left[inner], minlength=len(components)) + table = Chem.GetPeriodicTable() + numbers = {} + for element in np.unique(heavy.element.astype(str)): + try: + numbers[element] = table.GetAtomicNumber(element.capitalize()) + except RuntimeError: # 1JQK UNX: element X + numbers[element] = 0 + links: list[list[tuple[int, int, int]]] = [[] for _ in components] + for a, b, bond_type in bond_arr[crossing].tolist(): + for inside, outside in ((a, b), (b, a)): + if component_of[inside] >= 0: + links[component_of[inside]].append((local_of[inside], numbers[str(heavy.element[outside])], bond_type)) + links = [np.array(sorted(rows), dtype=np.int64).reshape(-1, 3) for rows in links] + return np.split(local, np.cumsum(counts)[:-1]), on_boundary, links + + +def _titrate(mol: Chem.Mol, ph: float) -> Chem.Mol | None: + """Dimorphite-DL's single most likely protonation state of *mol* at *ph*, drawn with *mol*'s bonds, or None. + + Dimorphite-DL's neutralisation can redraw a group (A1L's azide as ``N=[N+]=[N-]``) whose bonds the state + keeps, so only its protons carry over: an atom that gains or loses one gains or loses a unit of charge. + """ + # 313 S: Dimorphite-DL's SMILES would bring a non-tetrahedral centre's hydrogens back as atoms. + stripped = Chem.Mol(mol) + Chem.RemoveStereochemistry(stripped) + titrated = protonate_at_ph(stripped, ph, max_variants=1) + if titrated is None or titrated.GetNumAtoms() != mol.GetNumAtoms(): + return titrated + drawn = Chem.RWMol(mol) + for atom, state in zip(drawn.GetAtoms(), titrated.GetAtoms(), strict=True): + gained = state.GetTotalNumHs() - atom.GetTotalNumHs() + if gained: + _set_hydrogens(drawn, atom.GetIdx(), state.GetTotalNumHs(), atom.GetFormalCharge() + gained) + drawn.UpdatePropertyCache(strict=False) + return drawn.GetMol() + + +def _set_hydrogens(mol: Chem.Mol, index: int, count: int, charge: int) -> None: + """Fix atom *index* of *mol* at exactly *count* hydrogens and formal *charge*.""" + atom = mol.GetAtomWithIdx(int(index)) + atom.SetNumExplicitHs(int(count)) + atom.SetNoImplicit(True) + atom.SetFormalCharge(int(charge)) + atom.SetNumRadicalElectrons(0) + + +def _release_ring_bond_orders(mol: Chem.Mol, rings: np.ndarray) -> list[tuple[int, int]]: + """Mark the bonds of each ring (atom indices in ring order), and of each unsaturated ring fused to it (GVI's + pyrimidine), aromatic, so sanitizing re-kekulizes them to follow the hydrogens stated on them rather than + the Kekule form they arrived in; returns their bonds.""" + unsaturated = {a.GetIdx() for a in mol.GetAtoms() if any(b.GetBondTypeAsDouble() > 1 for b in a.GetBonds())} + rings = [list(ring) for ring in rings.tolist()] + for other in Chem.GetSymmSSSR(mol): + other = list(other) + fused = any(len(set(other) & set(ring)) >= 2 for ring in rings) and other not in rings + if fused and set(other) <= unsaturated and not any(set(other) == set(ring) for ring in rings): + rings.append(other) + released = [] + for ring in rings: + for left, right in zip(ring, ring[1:] + ring[:1], strict=True): + mol.GetBondBetweenAtoms(left, right).SetBondType(Chem.BondType.AROMATIC) + mol.GetBondBetweenAtoms(left, right).SetIsAromatic(True) + released.append((left, right)) + for index in ring: + mol.GetAtomWithIdx(index).SetIsAromatic(True) + return released + + +def _kept_as_given(sub: AtomArray, exc: Exception) -> None: + logger.warning( + "RDKit cannot read %s:%s %s, which keeps the charges and hydrogens it is given: %s", + *(sub.chain_id[0], sub.res_id[0], sub.res_name[0]), + " ".join(str(exc).split()[:6]), + ) + return None + + +def _restate_coordinating_donors(mol: Chem.Mol, titrated: Chem.Mol, donor_ph: np.ndarray) -> Chem.Mol: + """Give each metal-coordinating donor of *titrated* its state at its *donor_ph* (NaN: none). + + A metal cation stabilises its donor's conjugate base, so a donor titrates at the higher + pH :func:`~atomworks.experimental.protonation._assign.effective_donor_ph` gives it (1fdn CYS SG). + """ + restated = Chem.RWMol(titrated) + for value in np.unique(donor_ph[np.isfinite(donor_ph)]): + on_metal = _titrate(mol, float(value)) + if on_metal is None or on_metal.GetNumAtoms() != titrated.GetNumAtoms(): + logger.warning( + "Could not titrate a metal-coordinating donor in its donating state; it keeps " + "what the working pH gave it, which may leave a hydrogen pointing at the metal." + ) + return titrated + for index in np.flatnonzero(donor_ph == value): + donor = on_metal.GetAtomWithIdx(int(index)) + _set_hydrogens(restated, index, donor.GetTotalNumHs(), donor.GetFormalCharge()) + try: + Chem.SanitizeMol(restated) + except (Chem.AtomValenceException, Chem.KekulizeException, ValueError) as exc: + logger.warning("Could not put a metal-coordinating donor in its donating state: %s", exc) + return titrated + return restated.GetMol() + + +def titrate_component( + sub: AtomArray, + ph: float, + imidazole_targets: np.ndarray, + coordinating: np.ndarray, + stated_hydrogens: np.ndarray, + donor_ph: np.ndarray, + links: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: + """Titrate one component (a residue or an atomized subgraph). + + Args: + sub: The component's heavy atoms and bonds. + ph: Target pH. + imidazole_targets: Imidazole ring-N hydrogen counts, -1 where unset. + coordinating: Most hydrogens each metal donor may keep, -1 for other atoms; a donor held + to fewer than its state gives keeps its input charge. + stated_hydrogens: Declared counts, -1 where unset; a declared atom keeps its input charge. + donor_ph: The pH at which each metal donor takes its donating state, NaN for other atoms. + links: ``[k, 3]`` (atom, atomic number, bond type) of the component's bonds to other components; + each is titrated as that atom bearing a methyl, so a linked group keeps its substitution. + + Returns: + ``(charge, hydrogens, ring_bonds)``: per atom, and the ``[k, 3]`` imidazole ring bonds + re-kekulized to their targets; ``None`` where RDKit cannot read the component or no Kekule + form gives its rings their targets. + """ + try: + mol = atom_array_to_rdkit( + sub, set_coord=False, hydrogen_policy="remove", sanitize=True, attempt_fixing_corrupted_molecules=False + ) + except RuntimeError as exc: # ASX, GLX: element X + return _kept_as_given(sub, exc) + n = sub.array_length() + # 1TTD DC OP2: a residue titrates with its links capped, so an internal phosphodiester is no monoester. + if links is not None and len(links): + capped = Chem.RWMol(mol) + for index, number, bond_type in links.tolist(): + if not number or number in _METAL_NUMBERS: + continue + partner = capped.AddAtom(Chem.Atom(int(number))) + order = {2: Chem.BondType.DOUBLE, 3: Chem.BondType.TRIPLE}.get(int(bond_type), Chem.BondType.SINGLE) + capped.AddBond(int(index), partner, order) + capped.AddBond(partner, capped.AddAtom(Chem.Atom(6)), Chem.BondType.SINGLE) + capped = capped.GetMol() + if Chem.SanitizeMol(Chem.Mol(capped), catchErrors=True) == Chem.SanitizeFlags.SANITIZE_NONE: + mol = capped + # HP4 P1: RDKit reads a phosphoryl P at its lowest valence, 3; it is P(V), as hypophosphite's PH2. + mol.UpdatePropertyCache(strict=False) + for (index,) in mol.GetSubstructMatches(_PHOSPHORYL_AT_VALENCE_3): + _set_hydrogens(mol, index, mol.GetAtomWithIdx(index).GetTotalNumHs() + 2, 0) + mol.UpdatePropertyCache(strict=False) + # LCP, 1L5: sanitizing draws a nitro or halogen oxoacid charge-separated; charges return as *sub*'s bonds draw. + redrawn = np.array([atom.GetFormalCharge() for atom in mol.GetAtoms()][:n]) - sub.charge + titrated = _titrate(mol, ph) + titrated = mol if titrated is None else titrated + if np.isfinite(donor_ph).any(): + titrated = _restate_coordinating_donors(mol, titrated, donor_ph) + titrated.UpdatePropertyCache(strict=False) + stated_hydrogens = stated_hydrogens.copy() + for index in np.flatnonzero(coordinating >= 0): + if titrated.GetAtomWithIdx(int(index)).GetTotalNumHs() > coordinating[index]: + stated_hydrogens[index] = coordinating[index] + # 6DMZ HIS 8 NE2: a neutral imidazole nothing else decides, neither N substituted, takes its H on the tele N (the + # one whose ring neighbour is unsubstituted, histidine's NE2), whichever Kekule form its bonds draw. + imidazole_targets = imidazole_targets.copy() + rings = imidazole_rings(sub) + for pair, tele_n in zip(rings.n[rings.open], rings.tele[rings.open], strict=True): + free = (imidazole_targets[pair] < 0).all() and (stated_hydrogens[pair] < 0).all() and tele_n.any() + if free and sum(titrated.GetAtomWithIdx(int(n)).GetTotalNumHs() for n in pair) == 1: + imidazole_targets[pair] = tele_n + + ring_bonds = np.zeros((0, 3), dtype=np.int64) + chosen = (imidazole_targets[rings.n] >= 0).all(axis=1) + if chosen.any(): + released = _release_ring_bond_orders(titrated, rings.atoms[chosen]) + for pair in rings.n[chosen]: + # 1QS0 HIS NE2: a nitrogen with a proton or a third heavy bond is substituted; a ring substituted on + # both nitrogens, or on neither, carries its charge on its first one. + substituted = imidazole_targets[pair] + [titrated.GetAtomWithIdx(int(n)).GetDegree() > 2 for n in pair] + for first, index in zip((True, False), pair, strict=True): + _set_hydrogens(titrated, index, imidazole_targets[index], int(substituted.sum()) - 1 if first else 0) + # Other atoms keep the valences the input drew (a borate at charge 0); only the rings are re-derived. + titrated.UpdatePropertyCache(strict=False) + try: + Chem.SanitizeMol(titrated, Chem.SanitizeFlags.SANITIZE_ALL ^ Chem.SanitizeFlags.SANITIZE_PROPERTIES) + kekule = Chem.Mol(titrated) + Chem.Kekulize(kekule) + except Chem.MolSanitizeException as exc: # 1QS0 HIS NE2 read back: no Kekule form fits + return _kept_as_given(sub, exc) + bonds = [kekule.GetBondBetweenAtoms(left, right) for left, right in released] + types = [int(RDKIT_BOND_TYPE_TO_BIOTITE[(bond.GetBondType(), bond.GetIsAromatic())]) for bond in bonds] + ring_bonds = np.column_stack([np.array(released, dtype=np.int64).reshape(-1, 2), types]) + + for index in np.flatnonzero(stated_hydrogens >= 0): + _set_hydrogens(titrated, index, stated_hydrogens[index], sub.charge[index] + redrawn[index]) + titrated.UpdatePropertyCache(strict=False) + + if titrated.GetNumAtoms() != mol.GetNumAtoms(): + raise ValueError("Dimorphite-DL returned a molecule with a different number of atoms.") + atoms = list(titrated.GetAtoms())[:n] + charge = (np.array([atom.GetFormalCharge() for atom in atoms]) - redrawn).astype(sub.charge.dtype) + hydrogens = np.array([atom.GetTotalNumHs() for atom in atoms], dtype=np.int64) + return charge, hydrogens, ring_bonds diff --git a/src/atomworks/experimental/protonation/dimorphite.py b/src/atomworks/experimental/protonation/dimorphite.py new file mode 100644 index 00000000..31353c03 --- /dev/null +++ b/src/atomworks/experimental/protonation/dimorphite.py @@ -0,0 +1,32 @@ +"""Dimorphite-DL's protonation state of a small molecule at one pH, and the site rules that decide it.""" + +from __future__ import annotations + +from rdkit import Chem, rdBase + +from atomworks.experimental.protonation.external.dimorphite_dl.dimorphite_dl import ( + ProtSubstructFuncs, + protonate_mol_variants, +) + +__all__ = ["protonate_at_ph", "site_rules"] + + +def protonate_at_ph(mol: Chem.Mol, ph: float, *, max_variants: int = 128) -> Chem.Mol | None: + """The first Dimorphite-DL state of *mol* at *ph*, its atoms in input order. + + A site whose pKa is within 0.1 standard deviations of *ph* takes both states, and + *max_variants* bounds the states kept after each site; *mol* is not modified. + + Returns: + The protonated molecule, with the input's atom maps, atom properties and conformers (not its + molecule-level properties); ``None`` if *mol* cannot be neutralised. + """ + with rdBase.BlockLogs(): + variants = protonate_mol_variants(mol, min_ph=ph, max_ph=ph, pka_precision=0.1, max_variants=max_variants) + return variants[0] if variants else None + + +def site_rules() -> tuple[tuple[str, str, tuple[tuple[str, float, float], ...]], ...]: + """The site rules in priority order: name, SMARTS, and per site its match index, pKa mean and standard deviation.""" + return tuple((name, smarts, sites) for name, smarts, _, sites in ProtSubstructFuncs._compiled_substructures()) diff --git a/src/atomworks/experimental/protonation/external/__init__.py b/src/atomworks/experimental/protonation/external/__init__.py new file mode 100644 index 00000000..8b6e32e2 --- /dev/null +++ b/src/atomworks/experimental/protonation/external/__init__.py @@ -0,0 +1 @@ +"""Third-party code adapted for AtomWorks; see each package's LICENSE.""" diff --git a/src/atomworks/experimental/protonation/external/dimorphite_dl/LICENSE b/src/atomworks/experimental/protonation/external/dimorphite_dl/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/src/atomworks/experimental/protonation/external/dimorphite_dl/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/atomworks/experimental/protonation/external/dimorphite_dl/__init__.py b/src/atomworks/experimental/protonation/external/dimorphite_dl/__init__.py new file mode 100644 index 00000000..d13b2142 --- /dev/null +++ b/src/atomworks/experimental/protonation/external/dimorphite_dl/__init__.py @@ -0,0 +1,12 @@ +"""Vendorized Dimorphite-DL: enumerate ionization states of drug-like small molecules. + +Source: https://durrantlab.pitt.edu/dimorphite-dl/ (Dimorphite-DL 1.2.4) +License: Apache 2.0 (Copyright 2020 Jacob D. Durrant) + +Reference: + Ropp PJ, Kaminsky JC, Yablonski S, Durrant JD (2019) Dimorphite-DL: An + open-source program for enumerating the ionization states of drug-like + small molecules. J Cheminform 11:14. doi:10.1186/s13321-019-0336-9. + +Callers outside AtomWorks use :mod:`atomworks.experimental.protonation.dimorphite`. +""" diff --git a/src/atomworks/experimental/protonation/external/dimorphite_dl/dimorphite_dl.py b/src/atomworks/experimental/protonation/external/dimorphite_dl/dimorphite_dl.py new file mode 100644 index 00000000..5dc9c7d3 --- /dev/null +++ b/src/atomworks/experimental/protonation/external/dimorphite_dl/dimorphite_dl.py @@ -0,0 +1,393 @@ +# Copyright 2020 Jacob D. Durrant +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dimorphite-DL: enumerate ionization states of drug-like small molecules. + +Limitations: + - Only N, O, S, Se and halogen atoms are ionized. + - pKa predictions are based on substructure SMARTS patterns calibrated + for drug-like molecules; accuracy may degrade for unusual chemistries. + +Identifies and enumerates the possible protonation sites of an RDKit molecule +at a user-specified pH range using pre-calculated pKa distributions. + +Originally authored by Jacob D. Durrant (Dimorphite-DL 1.2.4). Vendored into +atomworks as its molecule API (:func:`protonate_mol_variants`), without the +command-line interface or SMILES-file readers. + +Reference: + Ropp PJ, Kaminsky JC, Yablonski S, Durrant JD (2019) Dimorphite-DL: An + open-source program for enumerating the ionization states of drug-like + small molecules. J Cheminform 11:14. doi:10.1186/s13321-019-0336-9. +""" + +from __future__ import annotations + +import copy +import functools +import os +from typing import Any + +from rdkit import Chem +from rdkit.Chem import AllChem + +_SOURCE_INDEX = "_atomworks_protonation_source_index" + +#: An aromatic nitrogen of formal charge -1. +_AROMATIC_NITRANION = Chem.MolFromSmarts("[n-]") + +#: Reactant queries and reactions that neutralise a molecule, applied in order. +_NEUTRALIZING_REACTIONS = tuple( + (Chem.MolFromSmarts(reactant), AllChem.ReactionFromSmarts(f"{reactant}>>{product}")) + for reactant, product in ( + ("[Ov1-1:1]", "[Ov2+0:1]-[H]"), + ("[#7v4+1:1]-[H]", "[#7v3+0:1]"), + ("[Ov2-:1]", "[Ov2+0:1]"), + ("[#7v3+1:1]", "[#7v3+0:1]"), + ("[#7v2-1;!$([#7]=[#7+]):1]", "[#7+0:1]-[H]"), + ("[H]-[N:1]-[N:2]#[N:3]", "[N:1]=[N+1:2]=[N:3]-[H]"), + ) +) + + +def _neutralize(mol: Chem.Mol) -> Chem.Mol | None: + """Neutralise a molecule (e.g. O-, N+, azides) by applying the SMARTS reactions in turn. + + Args: + mol: The molecule to neutralise; each fragment is neutralised separately. + + Returns: + The neutralised molecule with explicit hydrogens, or None if it does not sanitise. + """ + if len(Chem.GetMolFrags(mol)) > 1: + result = None + for fragment in Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=False): + neutral = _neutralize(fragment) + if neutral is None: + return None + result = neutral if result is None else Chem.CombineMols(result, neutral) + return result + + mol.UpdatePropertyCache(strict=False) + mol = Chem.AddHs(mol) + while True: + reaction = next((rxn for query, rxn in _NEUTRALIZING_REACTIONS if mol.HasSubstructMatch(query)), None) + if reaction is None: + break + reactant = mol + # 1QD7: one product per step; building every product of a reaction to keep the first took over 30 min. + mol = reaction.RunReactants((mol,), maxProducts=1)[0][0] + # Reaction SMARTS use their own map labels. Restore caller + # identity using RDKit's reactant-index provenance instead. + for atom in mol.GetAtoms(): + if atom.HasProp("react_atom_idx"): + source = reactant.GetAtomWithIdx(atom.GetIntProp("react_atom_idx")) + atom.SetAtomMapNum(source.GetAtomMapNum()) + for name, value in source.GetPropsAsDict(includePrivate=True, includeComputed=False).items(): + if isinstance(value, bool): + atom.SetBoolProp(name, value) + elif isinstance(value, int): + atom.SetIntProp(name, value) + elif isinstance(value, float): + atom.SetDoubleProp(name, value) + elif isinstance(value, str): + atom.SetProp(name, value) + mol.UpdatePropertyCache(strict=False) + + sanitized = Chem.SanitizeMol(mol, sanitizeOps=Chem.SanitizeFlags.SANITIZE_ALL, catchErrors=True) + return mol if sanitized == Chem.SanitizeFlags.SANITIZE_NONE else None + + +def _changes_only_its_valence(mols: list[Chem.Mol], index: int, ring_atoms: set[int]) -> bool: + """Whether a new charge and hydrogen count on atom *index* can change no atom's valence but its own. + + Outside a ring and without an aromatic bond, the atom takes no part in aromaticity or a Kekule form, + so sanitizing the molecule again after the change could fail, or change a state, only there. + """ + if index in ring_atoms: + return False + return not any(bond.GetIsAromatic() for mol in mols for bond in mol.GetAtomWithIdx(index).GetBonds()) + + +def _valence_holds(mol: Chem.Mol, index: int) -> bool: + """Whether RDKit accepts atom *index*'s valence, as sanitizing checks every atom's.""" + try: + mol.GetAtomWithIdx(index).UpdatePropertyCache(strict=True) + except Chem.MolSanitizeException: + return False + return True + + +def _parses(smiles: str) -> bool: + """Whether RDKit reads *smiles* back, after spelling legacy azides charge-separated.""" + smiles = smiles.replace("N=N=N", "N=[N+]=N").replace("NN#N", "N=[N+]=N") + return Chem.MolFromSmiles(smiles) is not None + + +class ProtSubstructFuncs: + """Namespace for protonation-substructure matching and site modification.""" + + @staticmethod + @functools.cache + def _compiled_substructures() -> tuple[tuple[str, str, Chem.Mol, tuple[tuple[str, float, float], ...]], ...]: + """The rules of ``site_substructures.smarts`` in priority order, compiled once for every pH. + + The queries are only matched, never modified; no input molecule or pH is retained. + """ + with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "site_substructures.smarts")) as f: + lines = [line.split() for line in f if line.strip() and not line.startswith("#")] + rules = [] + for fields in lines: + name, smart = fields[:2] + sites = tuple((fields[i], float(fields[i + 1]), float(fields[i + 2])) for i in range(2, len(fields) - 1, 3)) + rules.append((name, smart, Chem.MolFromSmarts(smart), sites)) + return tuple(rules) + + @staticmethod + def _substructures_for_ph(min_ph: float, max_ph: float, pka_std_range: float) -> list[dict[str, Any]]: + """Fresh state containers borrowing the compiled, read-only query molecules.""" + return [ + { + "name": name, + "smart": smart, + "mol": query, + "prot_states_for_pH": [ + [site, ProtSubstructFuncs.define_protonation_state(mean, std * pka_std_range, min_ph, max_ph)] + for site, mean, std in sites + ], + } + for name, smart, query, sites in ProtSubstructFuncs._compiled_substructures() + ] + + @staticmethod + def define_protonation_state(mean: float, std: float, min_ph: float, max_ph: float) -> str: + """Determine the protonation state for a site at a given pH range. + + Args: + mean: The mean pKa value. + std: The standard deviation (precision). + min_ph: Minimum pH of the range. + max_ph: Maximum pH of the range. + + Returns: + One of ``"PROTONATED"``, ``"DEPROTONATED"``, or ``"BOTH"``. + """ + if mean - std <= max_ph and min_ph <= mean + std: + return "BOTH" + return "PROTONATED" if mean > max_ph else "DEPROTONATED" + + @staticmethod + def get_prot_sites_and_target_states_from_mol( + mol: Chem.Mol, subs: list[dict[str, Any]] + ) -> tuple[list[tuple[int, str, str]], Chem.Mol | None]: + """Find protonation sites and their target states for a molecule. + + Sites higher in *subs* take priority: once a rule matches, the atoms of + the match cannot be ionized by a later rule, though they may still form + its context. + + Args: + mol: Input molecule. + subs: Substructure definitions in priority order, as :meth:`_substructures_for_ph` gives them. + + Returns: + A tuple of (sites, mol) where sites is a list of + ``(atom_index, target_state, site_name)`` tuples and mol is + the hydrogenated molecule used for indexing, or ``([], None)`` + if hydrogens cannot be added. + """ + try: + mol_used_to_idx_sites = Chem.AddHs(mol) + except Exception: + return [], None + + protonation_sites: list[tuple[int, str, str]] = [] + protected: set[int] = set() + for item in subs: + prot = item["prot_states_for_pH"] + site_indices = [int(site[0]) for site in prot] + matches = [ + match + for match in mol_used_to_idx_sites.GetSubstructMatches(item["mol"]) + if protected.isdisjoint(match[i] for i in site_indices if i < len(match)) + ] + for match in matches: + for site in prot: + new_site = (match[int(site[0])], site[1], item["name"]) + if new_site not in protonation_sites: + protonation_sites.append(new_site) + protected.update(match) + + return protonation_sites, mol_used_to_idx_sites + + @staticmethod + def protonate_site(mols: list[Chem.Mol], site: tuple[int, str, str], *, remove_hs: bool = True) -> list[Chem.Mol]: + """Protonate or deprotonate a single site across a list of molecules. + + Nitrogen, oxygen, sulfur and selenium take the hydrogen count their charge + and bond order give; a site whose name carries ``*`` titrates a + nitrogen between charges -1 and 0 rather than 0 and +1. + + Args: + mols: Input molecule objects. + site: A ``(atom_index, target_state, site_name)`` tuple. + remove_hs: Remove (and so sanitize) each molecule's hydrogens first; without it + each molecule, already hydrogen-free, is copied as it is. + + Returns: + One copy of each molecule per charge the target state allows, with + hydrogens removed; a molecule whose hydrogens cannot be removed is skipped. + """ + idx, target_prot_state, prot_site_name = site + charges = {"DEPROTONATED": [-1], "PROTONATED": [0], "BOTH": [-1, 0]}[target_prot_state] + output: list[Chem.Mol] = [] + for charge in charges: + nitrogen_charge = charge + int("*" not in prot_site_name) + for mol in mols: + try: + mol_copy = Chem.RemoveHs(mol) if remove_hs else Chem.Mol(mol) + except Exception: + continue + atom = mol_copy.GetAtomWithIdx(idx) + bond_order = sum(bond.GetBondTypeAsDouble() for bond in atom.GetBonds()) + element = atom.GetAtomicNum() + if element == 7: + atom.SetFormalCharge(nitrogen_charge) + if bond_order in (0, 1, 2) or (nitrogen_charge == 1 and bond_order == 3): + atom.SetNumExplicitHs(int(3 + nitrogen_charge - bond_order)) + else: + atom.SetFormalCharge(charge) + if element in (8, 16, 34) and bond_order == 1: + atom.SetNumExplicitHs(1 + charge) + + # SMILES spells [nH-] only for an aromatic nitrogen of charge -1, so + # the SMILES is written only where one exists. + if mol_copy.HasSubstructMatch(_AROMATIC_NITRANION) and "[nH-]" in Chem.MolToSmiles(mol_copy): + atom.SetNumExplicitHs(0) + + mol_copy.UpdatePropertyCache(strict=False) + output.append(mol_copy) + return output + + +def protonate_mol_variants( + mol: Chem.Mol, + min_ph: float = 6.4, + max_ph: float = 8.4, + pka_precision: float = 1.0, + max_variants: int = 128, +) -> list[Chem.Mol]: + """Enumerate protonation states while preserving atom identity and order. + + Args: + mol: Input molecule, copied before modification. + min_ph: Lower pH bound. + max_ph: Upper pH bound. + pka_precision: Number of pKa standard deviations in the range. + max_variants: Maximum intermediate variants retained after each site. + + Returns: + Valid molecular states in first-occurrence order, retaining atom maps, + atom properties and the input's conformers; empty if the molecule + cannot be neutralised. + """ + prepared = copy.deepcopy(mol) + for atom in prepared.GetAtoms(): + atom.SetIntProp(_SOURCE_INDEX, atom.GetIdx()) + prepared = _neutralize(prepared) + if prepared is None: + return [] + try: + prepared = Chem.RemoveHs(prepared) + except Exception: + return [] + + subs = ProtSubstructFuncs._substructures_for_ph(min_ph, max_ph, pka_precision) + sites, mol_used_to_idx_sites = ProtSubstructFuncs.get_prot_sites_and_target_states_from_mol(prepared, subs) + if mol_used_to_idx_sites is None: + return [] + + new_mols = [mol_used_to_idx_sites] + properly_formed_mols = [prepared] + if sites: + ring_atoms = {index for ring in Chem.GetSymmSSSR(Chem.Mol(prepared)) for index in ring} + for site in sites: + # 1QD7: once a site has given products the molecules are hydrogen-free and sanitize, so a change + # that can break only its own atom's valence is checked there alone. + local = new_mols[0] is not mol_used_to_idx_sites and _changes_only_its_valence( + new_mols, site[0], ring_atoms + ) + candidates = ( + ProtSubstructFuncs.protonate_site(new_mols, site, remove_hs=False) + if local + else ProtSubstructFuncs.protonate_site(new_mols, site) + ) + products = [ + product + for product in candidates + if ( + _valence_holds(product, site[0]) + if local + else Chem.SanitizeMol(Chem.Mol(product), catchErrors=True) == Chem.SanitizeFlags.SANITIZE_NONE + ) + ] + if not products: + break + new_mols = products[:max_variants] + properly_formed_mols.extend(new_mols) + if len(properly_formed_mols) == 1: + new_mols = properly_formed_mols + else: + mol_used_to_idx_sites = Chem.RemoveHs(mol_used_to_idx_sites) + new_mols = [mol_used_to_idx_sites] + properly_formed_mols.append(mol_used_to_idx_sites) + + seen: set[str] = set() + output_mols: list[Chem.Mol] = [] + for product in new_mols: + smi = Chem.MolToSmiles(product, isomericSmiles=True, canonical=True) + if smi in seen or not _parses(smi): + continue + seen.add(smi) + output_mols.append(product) + if not output_mols: + output_mols = next( + ([m] for m in reversed(properly_formed_mols) if _parses(Chem.MolToSmiles(m, isomericSmiles=True))), [] + ) + + ordered_products = [] + for product in output_mols: + order = sorted( + range(product.GetNumAtoms()), + key=lambda i: ( + product.GetAtomWithIdx(i).GetIntProp(_SOURCE_INDEX) + if product.GetAtomWithIdx(i).HasProp(_SOURCE_INDEX) + else mol.GetNumAtoms() + i + ), + ) + product = Chem.RenumberAtoms(product, order) + Chem.SanitizeMol(product) + product.RemoveAllConformers() + for source_conf in mol.GetConformers(): + conf = Chem.Conformer(product.GetNumAtoms()) + conf.Set3D(source_conf.Is3D()) + for atom in product.GetAtoms(): + if atom.HasProp(_SOURCE_INDEX): + conf.SetAtomPosition(atom.GetIdx(), source_conf.GetAtomPosition(atom.GetIntProp(_SOURCE_INDEX))) + product.AddConformer(conf, assignId=True) + for atom in product.GetAtoms(): + if atom.HasProp(_SOURCE_INDEX): + atom.ClearProp(_SOURCE_INDEX) + ordered_products.append(product) + return ordered_products diff --git a/src/atomworks/experimental/protonation/external/dimorphite_dl/site_substructures.smarts b/src/atomworks/experimental/protonation/external/dimorphite_dl/site_substructures.smarts new file mode 100644 index 00000000..cece1d0a --- /dev/null +++ b/src/atomworks/experimental/protonation/external/dimorphite_dl/site_substructures.smarts @@ -0,0 +1,125 @@ +*Azide [N+0:1]=[N+:2]=[N+0:3]-[H] 2 4.65 0.07071067811865513 +Nitro [C,c,N,n,O,o:1]-[NX3:2](=[O:3])-[O:4]-[H] 3 -1000.0 0 +# LCP, 202: a halogen oxoacid, which RDKit draws charge-separated as [X+n]-[O-] +Halogen_oxoacid [Cl,Br,I;+1,+2,+3:1]-[OX2:2]-[H] 1 -1000.0 0 +# 7PYK ARG 883 NH2 on Zn: guanidinium, arginine's 13.8 (Fitch et al., Protein Sci. 2015, 24, 752) +AmidineGuanidine1 [N:1]-[C:2](-[N:3])=[NX2:4]-[H:5] 3 13.8 1.5941046150769165 +AmidineGuanidine2 [C:1](-[N:2])=[NX2+0:3] 2 10.035538461538462 2.1312826469414716 +# SO4: free sulfate and selenate; the Sulfate rule needs an ester oxygen +Sulfuric_acid [S,Se;X4:1](=[O:2])(=[O:3])(-[OX2:4]-[H])-[OX2:5]-[H] 3 -3.0 0.5 5 1.99 0.5 +Sulfate [SX4:1](=[O:2])(=[O:3])([O:4]-[C,c,N,n:5])-[OX2:6]-[H] 5 -2.36 1.3048043093561141 +Sulfonate [SX4:1](=[O:2])(=[O:3])(-[C,c,N,n:4])-[OX2:5]-[H] 4 -1.8184615384615386 1.4086213481855594 +Sulfinic_acid [SX3:1](=[O:2])-[O:3]-[H] 2 1.7933333333333332 0.4372070447739835 +# BCT: free carbonic acid; ahead of Carboxyl, which would take both hydroxyls at its pKa +Carbonic_acid [CX3:1](=[O:2])(-[OX2:3]-[H])-[OX2:4]-[H] 2 6.35 0.5 4 10.33 0.5 +Phenyl_carboxyl [c,n,o:1]-[C:2](=[O:3])-[O:4]-[H] 3 3.463441968255319 1.2518054407928614 +Carboxyl [C:1](=[O:2])-[O:3]-[H] 2 3.456652971502591 1.2871420886834017 +Thioic_acid [C,c,N,n:1](=[O,S:2])-[SX2,OX2:3]-[H] 2 0.678267 1.497048763660801 +Phenyl_Thiol [c,n:1]-[SX2:2]-[H] 1 4.978235294117647 2.6137000480499806 +Thiol [C,N:1]-[SX2:2]-[H] 1 9.12448275862069 1.3317968158171463 +# SEC SE: a selenol, pKa 5.24 +Selenol [C,c:1]-[SeX2:2]-[H] 1 5.24 0.5 +# ART, CAD, TMO: arsenic acids and N-oxides, which no organic rule matches +Arsenic_acid [AsX4:1](=[O:2])(-[OX2:3]-[H])(-[OX2:4]-[H])-[OX2:5]-[H] 2 2.19 0.5 4 6.94 0.5 6 11.5 0.5 +Arsonic_acid [AsX4:1](=[O:2])(-[OX2:3]-[H])-[OX2:4]-[H] 2 4.1 0.5 4 8.7 0.5 +Arsinic_acid [AsX4:1](=[O:2])-[OX2:3]-[H] 2 6.27 0.5 +Amine_N_oxide [NX4+:1]-[OX2:2]-[H] 1 4.65 0.5 +# 4PO: an O on an sp2 N (N-oxide, nitrone, nitronic acid): pyridine N-oxide's 0.79, a nitrone's predicted 0.8-1.5 +Sp2_N_oxide [$([n+]),$([NX3+]=*):1]-[OX2:2]-[H] 1 0.79 0.5 +# NH4: free ammonia and water (pKw), which no organic rule matches +Water [O;X2,X3;!$(O~[!#1]):1](-[H])-[H] 0 14.0 0.5 +Ammonia [NX3+0:1](-[H])(-[H])-[H] 0 9.25 0.5 + +# PO4: free orthophosphate; ahead of Phosphate, which would leave its third +# hydroxyl to Phosphate_ester_acid +Orthophosphate [PX4:1](=[O,S:2])(-[OX2:3]-[H])(-[OX2:4]-[H])-[OX2:5]-[H] 2 2.15 0.5 4 7.20 0.5 6 12.35 0.5 + +# [*]OP(=O)(O[H])O[H]. Note that this matches terminal phosphate of ATP, ADP, AMP. +Phosphate [PX4:1](=[O:2])(-[OX2:3]-[H])(-[O+0:4])-[OX2:5]-[H] 2 2.4182608695652172 1.1091177991945305 5 6.5055 0.9512787792174668 + +# Note that Internal_phosphate_polyphos_chain and +# Initial_phosphate_like_in_ATP_ADP were added on 6/2/2020 to better detail with +# molecules that have polyphosphate chains (e.g., ATP, ADP, NADH, etc.). Unlike +# the other protonation states, these two were not determined by analyzing a set +# of many compounds with experimentally determined pKa values. + +# For Internal_phosphate_polyphos_chain, we use a mean pKa value of 0.9, per +# DOI: 10.7554/eLife.38821. For the precision value we use 1.0, which is roughly +# the precision of the two ionizable hydroxyls from Phosphate (see above). Note +# that when using recursive SMARTS strings, RDKit considers only the first atom +# to be a match. Subsequent atoms define the environment. +Internal_phosphate_polyphos_chain [$([PX4:1](=O)([OX2][PX4](=O)([OX2])(O[H]))([OX2][PX4](=O)(O[H])([OX2])))][O:2]-[H] 1 0.9 1.0 + +# For Initial_phosphate_like_in_ATP_ADP, we use the same values found for the +# lower-pKa hydroxyl of Phosphate (above). +Initial_phosphate_like_in_ATP_ADP [$([PX4:1]([OX2][C,c,N,n])(=O)([OX2][PX4](=O)([OX2])(O[H])))]O-[H] 1 2.4182608695652172 1.1091177991945305 + +# [*]P(=O)(O[H])O[H]. Cannot match terminal phosphate of ATP because O not among [C,c,N,n] +Phosphonate [PX4:1](=[O:2])(-[OX2:3]-[H])(-[C,c,N,n:4])-[OX2:5]-[H] 2 1.8835714285714287 0.5925999820080644 5 7.247254901960784 0.8511476450801531 +# C2S S2P: a phosphorothioate S-H is an acid +Phosphorothioate_monoester [PX4:1](=[O,S:2])(-[OX2:3]-[C,c,N,n:4])(-[OX2:5]-[H])-[SX2:6]-[H] 4 6.5055 0.9512787792174668 6 2.4182608695652172 1.1091177991945305 +Phosphorothioate_diester [PX4:1](=[O,S:2])(-[OX2:3]-[C,c,N,n:4])(-[OX2:5]-[C,c,N,n:6])-[SX2:7]-[H] 6 2.4182608695652172 1.1091177991945305 + +# Phenol [c,n,o:1]-[O:2]-[H] 1 7.065359866910526 3.277356122295936 +Phenol [c,n,o:1]-[O:2]-[H] 1 10.065359866910526 3.277356122295936 +Peroxide1 [O:1]([$(C=O),$(C[Cl]),$(CF),$(C[Br]),$(CC#N):2])-[O:3]-[H] 2 8.738888888888889 0.7562592839596507 +Peroxide2 [C:1]-[O:2]-[O:3]-[H] 2 11.978235294117647 0.8697645895163075 +O=C-C=C-OH [O:1]=[C;R:2]-[C;R:3]=[C;R:4]-[O:5]-[H] 4 3.554 0.803339458581667 +Vinyl_alcohol [C:1]=[C:2]-[O:3]-[H] 2 8.871850714285713 1.660200255394124 +Alcohol [C:1]-[O:2]-[H] 1 14.780384615384616 2.546464970533435 + +# special treatment for conjugated Amide, has to be before Imide2 to have a higer priority +*Amide_conjugated [NH:1][C:2](=[O,S:3])-[NH:4][C:5](=[O,S:6])[NH:7] 0 12.00611111111111 4.512491341218857 3 12.00611111111111 4.512491341218857 6 12.00611111111111 4.512491341218857 +*Amide_conjugated2 [NH:1][c,n,o:1] 0 12.00 3.0 +*Amide_amine [NH:1][C:2](=[O,S:3])-[NX3+0:4] 0 12.00611111111111 4.512491341218857 3 12.00611111111111 4.512491341218857 + +N-hydroxyamide [C:1](=[O:2])-[N:3]-[O:4]-[H] 3 9.301904761904762 1.2181897185891002 +*Ringed_imide1 [O,S:1]=[C;R:2]([$([#8]),$([#7]),$([#16]),$([#6][Cl]),$([#6]F),$([#6][Br]):3])-[N;R:4]([C;R:5]=[O,S:6])-[H] 3 6.4525 0.5555627777308341 +*Ringed_imide2 [O,S:1]=[C;R:2]-[N;R:3]([C;R:4]=[O,S:5])-[H] 2 8.681666666666667 1.8657779975741713 +*Imide [F,Cl,Br,S,s,P,p:1][#6:2][CX3:3](=[O,S:4])-[NX3+0:5]([CX3:6]=[O,S:7])-[H] 4 2.466666666666667 1.4843629385474877 +*Imide2 [O,S:1]=[CX3:2]-[NX3+0:3]([CX3:4]=[O,S:5])-[H] 2 10.23 1.1198214143335534 +# *Amide_electronegative [C:1](=[O:2])-[N:3](-[Br,Cl,I,F,S,O,N,P:4])-[H] 2 3.4896 2.688124315081677 + + +*Amide [C:1](=[O,S:2])-[N:3]-[H] 2 12.00611111111111 4.512491341218857 +*Amide_tertiary_carbon [C:1](=[O,S:2])-[NX3+0:3]([C:4])[C:5] 2 12.00611111111111 4.512491341218857 +# 34S N17: [!#1] is a heavy neighbour; [!H] matches a hydrogen too +*Amide_tertiary [C:1](=[O,S:2])-[NX3+0:3]([!#1:4])[!#1:5] 2 12.00611111111111 4.512491341218857 +*Sulfonamide [SX4:1](=[O:2])(=[O:3])-[NX3+0:4]-[H] 3 7.9160326086956525 1.9842121316708763 +# 0NH N5, 2F9 N: a tertiary sulfonamide or phosphoramide N is no base +*Sulfonamide_tertiary [SX4:1](=[O:2])(=[O:3])-[NX3+0:4]([!#1:5])[!#1:6] 3 7.9160326086956525 1.9842121316708763 +*Phosphoramide [PX4:1](=[O:2])-[NX3+0:3] 2 12.00611111111111 4.512491341218857 +# 4EJI 0QA N2: a nitrosamine N is no base +*Nitrosamine [NX3+0:1]-[NX2:2]=[O:3] 0 12.00611111111111 4.512491341218857 +Anilines_primary [c:1]-[NX3+0:2]([H:3])[H:4] 1 3.899298673194805 2.068768503987161 +Anilines_secondary [c:1]-[NX3+0:2]([H:3])[!#1:4] 1 4.335408163265306 2.1768842022330843 +Anilines_secondary [c:1]-[NX3+0:2]([!#1:3])[H:4] 1 4.335408163265306 2.1768842022330843 +Anilines_tertiary [c:1]-[NX3+0:2]([!#1:3])[!#1:4] 1 4.16690685045614 2.005865735782679 +Aromatic_nitrogen_unprotonated [n+0&H0:1] 0 4.3535441240733945 2.0714072661859584 +# B2W, 4ZF: vinamidine N-C=C-C=N, central C in a 5-7 ring (no bilin or porphyrin bridge), neither N acylated: +# the imine N takes the proton shared by both N; ahead of Enamine, which would leave it neutral +Vinamidine [NX3+0;!$(N[C,S,P]=[O,S]):1]-[CX3:2]=[CX3;r5,r6,r7:3]-[CX3:4]=[NX2+0;!$(N[C,S,P]=[O,S]):5] 4 10.0 2.0 +# 2R1 N: enamine / dienamine / vinylogous-amide N: a weak base kept neutral, above the amine rule so it wins; +# C=C (not C=N) keeps amidines and guanidines out. +Enamine [NX3+0:1]-[CX3]=[CX3] 0 1.0 1.0 +Amines_primary_secondary_tertiary [C:1]-[NX3+0:2] 1 8.159107682388349 2.5183597445318147 + +# e.g., [*]P(=O)(O[H])[*]. Note that cannot match the internal phosphates of ATP, because +# oxygen is not among [C,c,N,n,F,Cl,Br,I] +Phosphinic_acid [PX4:1](=[O:2])(-[C,c,N,n,F,Cl,Br,I:3])(-[C,c,N,n,F,Cl,Br,I:4])-[OX2:5]-[H] 4 2.9745 0.6867886750744557 + +# e.g., [*]OP(=O)(O[H])O[*]. Cannot match ATP because P not among [C,c,N,n,F,Cl,Br,I] +Phosphate_diester [PX4:1](=[O:2])(-[OX2:3]-[C,c,N,n,F,Cl,Br,I:4])(-[O+0:5]-[C,c,N,n,F,Cl,Br,I:4])-[OX2:6]-[H] 6 2.7280434782608696 2.5437448856908316 + +# e.g., [*]P(=O)(O[H])O[*]. Cannot match ATP because O not among [C,c,N,n,F,Cl,Br,I]. +Phosphonate_ester [PX4:1](=[O:2])(-[OX2:3]-[C,c,N,n,F,Cl,Br,I:4])(-[C,c,N,n,F,Cl,Br,I:5])-[OX2:6]-[H] 5 2.0868 0.4503028610465036 + +# AGS O2B: after the other phosphorus rules +Phosphate_ester_acid [PX4:1](=[O,S:2])-[OX2:3]-[H] 2 6.5055 0.9512787792174668 + +Primary_hydroxyl_amine [C,c:1]-[O:2]-[NH2:3] 2 4.035714285714286 0.8463816543155368 +# YIE N10: a 1H- or 2H-tetrazole N-H: 4.9, a carboxylic acid's (Herr, Bioorg. Med. Chem. 2002, 10, 3379) +*Tetrazole [n;$(n1nnnc1),$(n1ncnn1):1]-[H] 0 4.9 0.5 +*Indole_pyrrole [c;R:1]1[c;R:2][c;R:3][c;R:4][n;R:5]1[H] 4 14.52875 4.06702491591416 +# *Aromatic_nitrogen_protonated [n:1]-[H] 0 7.17 2.94602395490212 +Aromatic_nitrogen_protonated [n:1]-[H] 0 12.075833333333334 5.09761318117877 diff --git a/src/atomworks/experimental/protonation/geometry.py b/src/atomworks/experimental/protonation/geometry.py new file mode 100644 index 00000000..eb868f19 --- /dev/null +++ b/src/atomworks/experimental/protonation/geometry.py @@ -0,0 +1,105 @@ +"""Internal coordinates in the Rosetta convention, and TMol's names for hydrogens no dictionary describes. + +An atom is placed against three placed ancestors (parent, grandparent, +great-grandparent) by ``d``, the distance to its parent, ``theta``, ``180 - +angle(atom, parent, grandparent)``, and ``phi``, the IUPAC dihedral +``atom-parent-grandparent-great_grandparent`` (:func:`biotite.structure.dihedral`). TMol uses the same +convention, so a structure moves between the two libraries without its atoms shifting. +""" + +from __future__ import annotations + +import itertools + +import numpy as np + +__all__ = ["build_coordinates", "names_from_parent"] + + +def _unit(v: np.ndarray) -> np.ndarray: + norm = np.linalg.norm(v, axis=-1, keepdims=True) + with np.errstate(invalid="ignore", divide="ignore"): + return np.where(norm > 1e-8, v / norm, np.nan) + + +def _perpendicular(v: np.ndarray) -> np.ndarray: + """A unit vector perpendicular to each row of *v*: its cross product with the axis it has least of.""" + axis = np.zeros(v.shape) + axis[np.arange(len(v)), np.argmin(np.abs(np.nan_to_num(v, nan=1.0)), axis=-1)] = 1.0 + return _unit(np.cross(v, axis)) + + +def _frame_axes( + parent: np.ndarray, grandparent: np.ndarray, great_grandparent: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """The axes an atom is placed on: x from grandparent to parent, y towards the great-grandparent.""" + parent, grandparent, great_grandparent = np.broadcast_arrays(parent, grandparent, great_grandparent) + x_axis = _unit(parent - grandparent) + second = great_grandparent - grandparent + norm = np.linalg.norm(second, axis=-1, keepdims=True) + # Normalised first, so near-collinearity is judged by an angle rather than by a distance. + with np.errstate(invalid="ignore", divide="ignore"): + y_axis = np.where(norm > 1e-8, second / norm, 0.0) + z_axis = np.cross(x_axis, y_axis) + collinear = np.linalg.norm(z_axis, axis=-1) < 1e-8 + if collinear.any(): + z_axis[collinear] = _perpendicular(x_axis[collinear]) + z_axis = _unit(z_axis) + return x_axis, np.cross(z_axis, x_axis), z_axis + + +def build_coordinates( + parent: np.ndarray, grandparent: np.ndarray, great_grandparent: np.ndarray, geometry: np.ndarray +) -> np.ndarray: + """Place atoms at ``geometry = (d, theta, phi)`` against their ancestors, over any leading dims. + + Collinear ancestors leave the dihedral free; a parent on its grandparent gives NaN. + + Args: + parent: ``[..., 3]`` parent positions. + grandparent: ``[..., 3]``. + great_grandparent: ``[..., 3]``. + geometry: ``[..., 3]`` ``(d, theta, phi)``, radians. + + Returns: + ``[..., 3]`` positions. + """ + x_axis, y_axis, z_axis = _frame_axes(parent, grandparent, great_grandparent) + d, theta, phi = np.moveaxis(np.asarray(geometry, dtype=np.float64), -1, 0) + offset = np.stack([d * np.cos(theta), d * np.sin(theta) * np.cos(phi), d * np.sin(theta) * np.sin(phi)], -1) + return parent + x_axis * offset[..., :1] + y_axis * offset[..., 1:2] + z_axis * offset[..., 2:] + + +def _measure_icoors( + atom: np.ndarray, parent: np.ndarray, grandparent: np.ndarray, great_grandparent: np.ndarray +) -> np.ndarray: + """``(d, theta, phi)`` of each atom on the axes :func:`build_coordinates` places it on, its inverse. + + A great-grandparent without a position leaves ``phi`` NaN rather than measured on a stand-in axis. + """ + offset, axes = atom - parent, _frame_axes(parent, grandparent, great_grandparent) + x, y, z = (np.einsum("...i,...i->...", offset, axis) for axis in axes) + phi = np.where(np.isfinite(great_grandparent).all(axis=-1), np.arctan2(z, y), np.nan) + return np.stack([np.linalg.norm(offset, axis=-1), np.arctan2(np.hypot(y, z), x), phi], axis=-1) + + +def _numbered(prefix: str, count: int, taken: set[str]) -> list[str]: + """The first *count* names ````, ``n`` from 1, that are not *taken*.""" + candidates = (f"{prefix}{n}" for n in itertools.count(1)) + return list(itertools.islice((name for name in candidates if name not in taken), count)) + + +def names_from_parent(heavy_name: str, element: str, count: int, taken: set[str]) -> list[str]: + """TMol's names for hydrogens of a component no dictionary describes. + + One H on X is ``HX``, several ``HX1``, ``HX2``. Past four characters the parent's + element is dropped (``H101`` on C10); still too long or taken, ``H``. + """ + stems = [heavy_name] + if heavy_name.upper().startswith(element.upper()) and len(heavy_name) > len(element): + stems.append(heavy_name[len(element) :]) + for stem in stems: + names = [f"H{stem}"] if count == 1 else [f"H{stem}{i}" for i in range(1, count + 1)] + if all(len(name) <= 4 and name not in taken for name in names): + return names + return _numbered(f"H{element}", count, taken) diff --git a/src/atomworks/experimental/protonation/hydrogen_icoors.json b/src/atomworks/experimental/protonation/hydrogen_icoors.json new file mode 100644 index 00000000..71559535 --- /dev/null +++ b/src/atomworks/experimental/protonation/hydrogen_icoors.json @@ -0,0 +1,329 @@ +{ + "_comment": ["Internal coordinates for the hydrogens of the standard residues.", "", "Each row is ``[name, parent, grand_parent, great_grand_parent, phi, theta, d]``:", "where a hydrogen sits relative to three atoms already placed. ``d`` is the", "distance to its parent, ``theta`` the stored ``180 - angle``, and ``phi`` the", "torsion, all in the Rosetta convention (degrees here, radians in code). Rows", "are in placement order, so a ``great_grand_parent`` naming another hydrogen", "refers to one already placed. ``down`` is the previous residue's connection.", "", "``code`` is the dictionary code the entry applies to; several entries can", "share one, and the entry whose hydrogen names match the residue is the one", "that applies -- which is how a histidine tautomer, a free cysteine or a", "terminus (``_NTERM``, ``_3PRIME``, ``_5PRIME``) selects itself. Each", "``_termini`` patch makes ```` of every entry of its codes.", "", "Derived from TMol (https://github.com/uw-ipd/tmol), Apache-2.0."], + "_termini": [ + {"suffix": "_NTERM", "codes": ["ALA", "ARG", "ASN", "ASP", "CYS", "GLN", "GLU", "GLY", "HIS", "ILE", "LEU", "LYS", "MET", "PHE", "SER", "THR", "TRP", "TYR", "VAL"], "without": ["H"], "hydrogens": [ + ["H1", "N", "CA", "C", 180.0, 70.5, 1.0], + ["H2", "N", "CA", "H1", 120.0, 70.5, 1.0], + ["H3", "N", "CA", "H2", 120.0, 70.5, 1.0] + ]}, + {"suffix": "_5PRIME", "codes": ["DA", "DC", "DG", "DT", "A", "C", "G", "U"], "hydrogens": [["HO5'", "O5'", "C5'", "C4'", 179.890442, 70.479956, 1.000437]]}, + {"suffix": "_3PRIME", "codes": ["DA", "DC", "DG", "DT", "A", "C", "G", "U"], "hydrogens": [["HO3'", "O3'", "C3'", "C4'", 180.0, 70.479956, 1.000437]]} + ], + "ALA": {"code": "ALA", "hydrogens": [ + ["HB1", "CB", "CA", "N", -180.0, 70.5, 1.09004], + ["HB2", "CB", "CA", "HB1", 120.0, 70.5, 1.090069], + ["HB3", "CB", "CA", "HB2", 120.0, 70.5, 1.088803], + ["HA", "CA", "N", "CB", -119.0, 71.9, 1.090078], + ["H", "N", "CA", "down", -180.0, 60.849998, 1.01] + ]}, + "ARG": {"code": "ARG", "hydrogens": [ + ["HH11", "NH1", "CZ", "NE", 0.0, 60.0, 1.010263], + ["HH12", "NH1", "CZ", "HH11", 180.0, 60.0, 1.009685], + ["HH21", "NH2", "CZ", "NE", -0.0, 60.0, 1.010912], + ["HH22", "NH2", "CZ", "HH21", 180.0, 60.0, 1.009116], + ["HE", "NE", "CD", "CZ", 180.0, 62.3, 1.011357], + ["HD2", "CD", "CG", "NE", 120.0, 70.5, 1.089565], + ["HD3", "CD", "CG", "HD2", 120.0, 70.5, 1.089361], + ["HG2", "CG", "CB", "CD", 120.0, 70.5, 1.090245], + ["HG3", "CG", "CB", "HG2", 120.0, 70.5, 1.090619], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.089029], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.090066], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090421], + ["H", "N", "CA", "down", -179.999969, 60.849937, 1.010001] + ]}, + "ASN": {"code": "ASN", "hydrogens": [ + ["HD22", "ND2", "CG", "CB", -180.0, 60.0, 1.000469], + ["HD21", "ND2", "CG", "HD22", 180.0, 60.0, 0.999495], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.090804], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.090088], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.091156], + ["H", "N", "CA", "down", -180.0, 60.849964, 1.009999] + ]}, + "ASP": {"code": "ASP", "hydrogens": [ + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.090238], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.090391], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090385], + ["H", "N", "CA", "down", -180.0, 60.849979, 1.01] + ]}, + "CYD": {"code": "CYS", "extends": "CYS", "without": ["HG"]}, + "CYS": {"code": "CYS", "hydrogens": [ + ["HG", "SG", "CB", "CA", -180.0, 84.011803, 1.329369], + ["HB2", "CB", "CA", "SG", 121.2, 70.5, 1.090249], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089821], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090059], + ["H", "N", "CA", "down", 180.0, 60.849998, 1.01] + ]}, + "DA": {"code": "DA", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -119.112297, 66.662369, 1.100325], + ["H8", "C8", "N7", "C5", 179.981186, 56.923759, 1.099728], + ["H61", "N6", "C6", "N1", 0.038433, 59.995125, 1.000204], + ["H62", "N6", "C6", "H61", 179.9944, 59.980331, 0.99994], + ["H2", "C2", "N3", "N1", -179.996933, 64.638275, 1.099777], + ["H2''", "C2'", "C1'", "C3'", 120.0, 68.0, 1.1], + ["H2'", "C2'", "C1'", "H2''", 120.0, 68.0, 1.1], + ["H1'", "C1'", "O4'", "C2'", 119.303581, 65.49501, 1.099719], + ["H4'", "C4'", "C5'", "O4'", -122.351532, 75.128464, 1.100053], + ["H5''", "C5'", "O5'", "C4'", 121.260681, 70.103035, 1.100023], + ["H5'", "C5'", "O5'", "H5''", 117.505058, 70.07238, 1.09959] + ]}, + "DC": {"code": "DC", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -119.116325, 66.819817, 1.09981], + ["H42", "N4", "C4", "N3", -179.983429, 59.998554, 1.000261], + ["H41", "N4", "C4", "H42", 179.982788, 59.983398, 0.99975], + ["H6", "C6", "C5", "C4", -179.974411, 60.546242, 1.099902], + ["H5", "C5", "C4", "C6", 179.989838, 58.729404, 1.100244], + ["H2''", "C2'", "C1'", "C3'", 120.0, 68.0, 1.1], + ["H2'", "C2'", "C1'", "H2''", 120.0, 68.0, 1.1], + ["H1'", "C1'", "O4'", "C2'", 119.173782, 65.419487, 1.099511], + ["H4'", "C4'", "C5'", "O4'", -122.254921, 75.008286, 1.099893], + ["H5''", "C5'", "O5'", "C4'", 121.260765, 70.10302, 1.100021], + ["H5'", "C5'", "O5'", "H5''", 117.50502, 70.072289, 1.09959] + ]}, + "DG": {"code": "DG", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -119.362885, 66.478569, 1.099893], + ["H8", "C8", "N7", "C5", -179.899933, 56.553242, 1.100078], + ["H1", "N1", "C2", "C6", 179.999619, 62.49205, 1.000128], + ["H22", "N2", "C2", "N3", -0.013213, 60.009777, 1.000051], + ["H21", "N2", "C2", "H22", 179.998093, 59.983944, 1.000217], + ["H2''", "C2'", "C1'", "C3'", 120.0, 68.0, 1.1], + ["H2'", "C2'", "C1'", "H2''", 120.0, 68.0, 1.1], + ["H1'", "C1'", "O4'", "C2'", 119.425476, 65.59964, 1.0999], + ["H4'", "C4'", "C5'", "O4'", -122.424858, 75.549049, 1.100289], + ["H5''", "C5'", "O5'", "C4'", 121.260612, 70.103058, 1.100022], + ["H5'", "C5'", "O5'", "H5''", 117.50499, 70.072197, 1.099588] + ]}, + "DT": {"code": "DT", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -119.443848, 66.50042, 1.099826], + ["H71", "C7", "C5", "C4", 179.998688, 70.502991, 1.100086], + ["H72", "C7", "C5", "H71", 120.016708, 70.485847, 1.099731], + ["H73", "C7", "C5", "H72", 119.979172, 70.50882, 1.10005], + ["H6", "C6", "C5", "C4", -179.945175, 61.848751, 1.100448], + ["H3", "N3", "C2", "C4", 179.994766, 63.632038, 1.000129], + ["H2''", "C2'", "C1'", "C3'", 120.0, 68.0, 1.1], + ["H2'", "C2'", "C1'", "H2''", 120.0, 68.0, 1.1], + ["H1'", "C1'", "O4'", "C2'", 119.082504, 65.314438, 1.099825], + ["H4'", "C4'", "C5'", "O4'", -122.348076, 75.495621, 1.100165], + ["H5''", "C5'", "O5'", "C4'", 121.260803, 70.103081, 1.100024], + ["H5'", "C5'", "O5'", "H5''", 117.504814, 70.07235, 1.099589] + ]}, + "GLN": {"code": "GLN", "hydrogens": [ + ["HE22", "NE2", "CD", "CG", 180.0, 59.97641, 1.000957], + ["HE21", "NE2", "CD", "HE22", 180.0, 59.967007, 1.000084], + ["HG2", "CG", "CB", "CD", 120.0, 71.1, 1.09], + ["HG3", "CG", "CB", "HG2", 120.0, 71.1, 1.09], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.09], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089896], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.08987], + ["H", "N", "CA", "down", -180.0, 60.850025, 1.01] + ]}, + "GLU": {"code": "GLU", "hydrogens": [ + ["HG2", "CG", "CB", "CD", 121.0, 71.1, 1.089692], + ["HG3", "CG", "CB", "HG2", 117.5, 71.1, 1.089106], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.090146], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.09006], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090506], + ["H", "N", "CA", "down", -180.0, 60.850025, 1.01] + ]}, + "GLY": {"code": "GLY", "hydrogens": [ + ["HA2", "CA", "N", "C", 121.4, 70.5, 1.090168], + ["HA3", "CA", "N", "HA2", 117.2, 70.5, 1.089353], + ["H", "N", "CA", "down", 180.0, 60.85004, 1.01] + ]}, + "HIS": {"code": "HIS", "hydrogens": [ + ["HD2", "CD2", "NE2", "CE1", 179.968872, 53.6, 1.090336], + ["HE2", "NE2", "CE1", "CD2", -179.986603, 54.2, 1.010082], + ["HE1", "CE1", "ND1", "NE2", -179.977402, 54.2, 1.089785], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.09058], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089658], + ["HA", "CA", "N", "CB", -119.0, 70.5, 1.089618], + ["H", "N", "CA", "down", 179.999985, 60.850006, 1.01] + ]}, + "HIS_D": {"code": "HIS", "extends": "HIS_POS", "without": ["HE2"]}, + "HIS_POS": {"code": "HIS", "hydrogens": [ + ["HD2", "CD2", "NE2", "CE1", 179.968964, 59.948441, 1.090333], + ["HE1", "CE1", "ND1", "NE2", -179.977829, 59.391884, 1.089789], + ["HD1", "ND1", "CG", "CE1", -174.670059, 53.75705, 1.000244], + ["HE2", "NE2", "CE1", "CD2", -179.986603, 54.2, 1.010082], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.090565], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.08966], + ["HA", "CA", "N", "CB", -119.0, 70.5, 1.089614], + ["H", "N", "CA", "down", 179.999939, 60.850052, 1.010001] + ]}, + "ILE": {"code": "ILE", "hydrogens": [ + ["HD11", "CD1", "CG1", "CB", -179.974518, 70.5, 1.09029], + ["HD12", "CD1", "CG1", "HD11", 119.989571, 70.5, 1.090575], + ["HD13", "CD1", "CG1", "HD12", 120.044632, 70.5, 1.089057], + ["HG12", "CG1", "CB", "CD1", 121.314514, 70.5, 1.089473], + ["HG13", "CG1", "CB", "HG12", 117.5, 70.5, 1.090337], + ["HG21", "CG2", "CB", "CA", 179.963104, 70.5, 1.089758], + ["HG22", "CG2", "CB", "HG21", 120.007439, 70.5, 1.089154], + ["HG23", "CG2", "CB", "HG22", 120.0, 70.5, 1.090153], + ["HB", "CB", "CA", "CG2", -120.0, 70.5, 1.089548], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.089328], + ["H", "N", "CA", "down", -179.999985, 60.849998, 1.01] + ]}, + "LEU": {"code": "LEU", "hydrogens": [ + ["HD11", "CD1", "CG", "CB", -179.98938, 70.5, 1.090099], + ["HD12", "CD1", "CG", "HD11", 119.940079, 70.5, 1.090078], + ["HD13", "CD1", "CG", "HD12", 120.067337, 70.5, 1.089371], + ["HD21", "CD2", "CG", "CB", 179.955276, 70.5, 1.090471], + ["HD22", "CD2", "CG", "HD21", 120.0, 70.5, 1.090214], + ["HD23", "CD2", "CG", "HD22", 120.0, 70.5, 1.090101], + ["HG", "CG", "CB", "CD2", 120.0, 70.5, 1.090331], + ["HB2", "CB", "CA", "CG", 121.2, 71.4, 1.0888], + ["HB3", "CB", "CA", "HB2", 117.6, 71.4, 1.089918], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.089438], + ["H", "N", "CA", "down", 180.0, 60.849979, 1.009999] + ]}, + "LYS": {"code": "LYS", "hydrogens": [ + ["HZ1", "NZ", "CE", "CD", -179.972504, 70.5, 1.010217], + ["HZ2", "NZ", "CE", "HZ1", 120.005402, 70.5, 1.010513], + ["HZ3", "NZ", "CE", "HZ2", 120.002052, 70.5, 1.009906], + ["HE2", "CE", "CD", "NZ", 120.770477, 70.5, 1.088988], + ["HE3", "CE", "CD", "HE2", 118.534203, 70.5, 1.089932], + ["HD2", "CD", "CG", "CE", 120.629311, 70.5, 1.090858], + ["HD3", "CD", "CG", "HD2", 118.840126, 70.5, 1.089962], + ["HG2", "CG", "CB", "CD", 120.57341, 70.5, 1.090654], + ["HG3", "CG", "CB", "HG2", 118.835373, 70.5, 1.089538], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.09033], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.090207], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090528], + ["H", "N", "CA", "down", 179.999985, 60.849998, 1.010001] + ]}, + "MET": {"code": "MET", "hydrogens": [ + ["HE1", "CE", "SD", "CG", 179.996002, 70.5, 1.090273], + ["HE2", "CE", "SD", "HE1", 120.047722, 70.5, 1.090849], + ["HE3", "CE", "SD", "HE2", 119.973984, 70.5, 1.090572], + ["HG2", "CG", "CB", "SD", 120.782417, 70.5, 1.089467], + ["HG3", "CG", "CB", "HG2", 118.488159, 70.5, 1.089808], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.08903], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.090755], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.089824], + ["H", "N", "CA", "down", -179.999985, 60.85004, 1.010001] + ]}, + "PHE": {"code": "PHE", "hydrogens": [ + ["HD2", "CD2", "CE2", "CZ", 179.974045, 60.02375, 1.090602], + ["HE2", "CE2", "CZ", "CD2", 179.948761, 60.038544, 1.08983], + ["HZ", "CZ", "CE1", "CE2", 179.911728, 59.918243, 1.089083], + ["HE1", "CE1", "CD1", "CZ", -179.984573, 60.032631, 1.089717], + ["HD1", "CD1", "CG", "CE1", -179.958023, 60.766548, 1.090326], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.089216], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089189], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090899], + ["H", "N", "CA", "down", 180.0, 60.850014, 1.01] + ]}, + "PRO": {"code": "PRO", "hydrogens": [ + ["HD2", "CD", "CG", "N", 119.0, 70.5, 1.1], + ["HD3", "CD", "CG", "HD2", 121.5, 70.5, 1.1], + ["HG2", "CG", "CB", "CD", 121.2, 69.8, 1.1], + ["HG3", "CG", "CB", "HG2", 117.6, 69.8, 1.1], + ["HB2", "CB", "CA", "CG", 121.2, 70.0, 1.1], + ["HB3", "CB", "CA", "HB2", 117.6, 70.0, 1.1], + ["HA", "CA", "N", "CB", -118.0, 70.8, 1.1] + ]}, + "PRO_NTERM": {"code": "PRO", "extends": "PRO", "hydrogens": [ + ["H2", "N", "CA", "CD", 120.0, 70.5, 1.0], + ["H3", "N", "CA", "CD", -120.0, 70.5, 1.0] + ]}, + "RA": {"code": "A", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -121.624339, 70.859857, 1.1], + ["H8", "C8", "N7", "C5", 179.969884, 56.913803, 1.1], + ["H61", "N6", "C6", "N1", 0.087344, 60.020244, 1.0], + ["H62", "N6", "C6", "H61", 179.897268, 59.957047, 1.0], + ["H2", "C2", "N3", "N1", 179.988203, 64.592452, 1.1], + ["HO2'", "O2'", "C2'", "C3'", -180.0, 71.040035, 1.000437], + ["H2'", "C2'", "C1'", "O2'", 123.786374, 68.568962, 1.1], + ["H1'", "C1'", "O4'", "C2'", 118.834495, 70.550558, 1.1], + ["H4'", "C4'", "C5'", "O4'", -119.18478, 71.115164, 1.1], + ["H5''", "C5'", "O5'", "C4'", 120.560651, 70.892841, 1.1], + ["H5'", "C5'", "O5'", "H5''", 118.971709, 70.940882, 1.1] + ]}, + "RC": {"code": "C", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -121.650268, 70.728443, 1.1], + ["H42", "N4", "C4", "N3", 179.999212, 60.039577, 1.0], + ["H41", "N4", "C4", "H42", 179.97823, 60.002333, 1.0], + ["H6", "C6", "C5", "C4", -179.996478, 61.151615, 1.1], + ["H5", "C5", "C4", "C6", -179.865222, 58.81069, 1.1], + ["HO2'", "O2'", "C2'", "C3'", 180.0, 70.941223, 1.000437], + ["H2'", "C2'", "C1'", "O2'", 123.721247, 68.547585, 1.1], + ["H1'", "C1'", "O4'", "C2'", 118.293283, 71.125441, 1.1], + ["H4'", "C4'", "C5'", "O4'", -119.070325, 71.233185, 1.1], + ["H5''", "C5'", "O5'", "C4'", 120.510417, 70.988906, 1.1], + ["H5'", "C5'", "O5'", "H5''", 118.945587, 70.990285, 1.1] + ]}, + "RG": {"code": "G", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -121.591322, 70.839524, 1.1], + ["H8", "C8", "N7", "C5", -179.970148, 56.654334, 1.1], + ["H1", "N1", "C2", "C6", 179.940668, 62.375426, 1.0], + ["H22", "N2", "C2", "N3", -0.023997, 60.029272, 1.0], + ["H21", "N2", "C2", "H22", -179.98634, 59.811229, 1.0], + ["HO2'", "O2'", "C2'", "C3'", 180.0, 70.999194, 1.000437], + ["H2'", "C2'", "C1'", "O2'", 123.722624, 68.564798, 1.1], + ["H1'", "C1'", "O4'", "C2'", 118.850772, 70.622392, 1.1], + ["H4'", "C4'", "C5'", "O4'", -119.118299, 71.124083, 1.1], + ["H5''", "C5'", "O5'", "C4'", 120.514655, 70.9704, 1.1], + ["H5'", "C5'", "O5'", "H5''", 118.937692, 70.924519, 1.1] + ]}, + "RU": {"code": "U", "hydrogens": [ + ["H3'", "C3'", "C4'", "O3'", -121.689175, 70.778267, 1.1], + ["H5", "C5", "C4", "C6", -179.938492, 59.775992, 1.1], + ["H6", "C6", "C5", "C4", 179.929312, 62.12102, 1.1], + ["H3", "N3", "C2", "C4", 179.906863, 63.572961, 1.0], + ["HO2'", "O2'", "C2'", "C3'", 180.0, 70.997363, 1.000437], + ["H2'", "C2'", "C1'", "O2'", 123.642233, 68.578787, 1.1], + ["H1'", "C1'", "O4'", "C2'", 118.37686, 71.035965, 1.1], + ["H4'", "C4'", "C5'", "O4'", -119.077434, 71.191397, 1.1], + ["H5''", "C5'", "O5'", "C4'", 120.515979, 70.932483, 1.1], + ["H5'", "C5'", "O5'", "H5''", 118.995682, 70.985281, 1.1] + ]}, + "SER": {"code": "SER", "hydrogens": [ + ["HG", "OG", "CB", "CA", -0.0, 74.0, 0.960175], + ["HB2", "CB", "CA", "OG", 121.2, 70.5, 1.089692], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089181], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090924], + ["H", "N", "CA", "down", -179.999969, 60.849979, 1.009999] + ]}, + "THR": {"code": "THR", "hydrogens": [ + ["HG1", "OG1", "CB", "CA", 3.4e-05, 70.573135, 0.960297], + ["HG21", "CG2", "CB", "CA", -179.978256, 70.5, 1.089826], + ["HG22", "CG2", "CB", "HG21", 120.032188, 70.5, 1.089862], + ["HG23", "CG2", "CB", "HG22", 119.987984, 70.5, 1.089241], + ["HB", "CB", "CA", "CG2", -120.292923, 70.5, 1.089822], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090258], + ["H", "N", "CA", "down", 180.0, 60.849979, 1.01] + ]}, + "TRP": {"code": "TRP", "hydrogens": [ + ["HE3", "CE3", "CZ3", "CD2", -179.851593, 59.3, 1.089539], + ["HZ3", "CZ3", "CH2", "CE3", 179.96553, 59.271282, 1.090294], + ["HH2", "CH2", "CZ2", "CZ3", 179.952423, 60.590862, 1.090289], + ["HZ2", "CZ2", "CE2", "CH2", -179.891754, 57.9, 1.090237], + ["HE1", "NE1", "CD1", "CE2", 179.955612, 54.365833, 1.009892], + ["HD1", "CD1", "CG", "NE1", 179.990616, 55.1, 1.088516], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.090167], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089792], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.089883], + ["H", "N", "CA", "down", -179.999878, 60.850006, 1.010001] + ]}, + "TYR": {"code": "TYR", "hydrogens": [ + ["HE1", "CE1", "CD1", "CG", 180.0, 60.0, 1.08966], + ["HD1", "CD1", "CG", "CE1", 180.0, 60.0, 1.090234], + ["HH", "OH", "CZ", "CE2", 0.0, 70.6, 0.960239], + ["HE2", "CE2", "CD2", "CZ", -179.948822, 60.0, 1.090286], + ["HD2", "CD2", "CG", "CE2", -179.936905, 60.0, 1.090021], + ["HB2", "CB", "CA", "CG", 121.2, 70.5, 1.090973], + ["HB3", "CB", "CA", "HB2", 117.6, 70.5, 1.089572], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090319], + ["H", "N", "CA", "down", -179.999924, 60.849972, 1.010001] + ]}, + "VAL": {"code": "VAL", "hydrogens": [ + ["HG11", "CG1", "CB", "CA", -179.982758, 70.5, 1.090355], + ["HG12", "CG1", "CB", "HG11", 119.966087, 70.5, 1.089824], + ["HG13", "CG1", "CB", "HG12", 120.088005, 70.5, 1.08981], + ["HG21", "CG2", "CB", "CA", 179.95784, 70.5, 1.08961], + ["HG22", "CG2", "CB", "HG21", 120.029785, 70.5, 1.090176], + ["HG23", "CG2", "CB", "HG22", 119.992767, 70.5, 1.090169], + ["HB", "CB", "CA", "CG2", 120.0, 71.042519, 1.090106], + ["HA", "CA", "N", "CB", -119.0, 71.5, 1.090646], + ["H", "N", "CA", "down", 180.0, 60.849918, 1.009999] + ]} +} diff --git a/src/atomworks/external/xyz2mol_tm/LICENSE b/src/atomworks/external/xyz2mol_tm/LICENSE new file mode 100644 index 00000000..723af02e --- /dev/null +++ b/src/atomworks/external/xyz2mol_tm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jensen Group + +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/src/atomworks/external/xyz2mol_tm/__init__.py b/src/atomworks/external/xyz2mol_tm/__init__.py new file mode 100644 index 00000000..f27dcb2d --- /dev/null +++ b/src/atomworks/external/xyz2mol_tm/__init__.py @@ -0,0 +1,9 @@ +"""Vendorized code from xyz2mol_tm for converting XYZ files to SMILES for transition metal complexes. + +Source: https://github.com/jensengroup/xyz2mol_tm +License: MIT (Copyright 2024 Jensen Group) +""" + +from atomworks.external.xyz2mol_tm.xyz2mol_tmc import get_tmc_mol + +__all__ = ["get_tmc_mol"] diff --git a/src/atomworks/external/xyz2mol_tm/xyz2mol_local.py b/src/atomworks/external/xyz2mol_tm/xyz2mol_local.py new file mode 100644 index 00000000..75aa0e90 --- /dev/null +++ b/src/atomworks/external/xyz2mol_tm/xyz2mol_local.py @@ -0,0 +1,1108 @@ +"""Module for generating rdkit molobj/smiles/molecular graph from free atoms. + +Main implementation by Jan H. Jensen, based on the paper + + Yeonjoon Kim and Woo Youn Kim + "Universal Structure Conversion Method for Organic Molecules: From Atomic Connectivity + to Three-Dimensional Geometry" + Bull. Korean Chem. Soc. 2015, Vol. 36, 1769-1777 + DOI: 10.1002/bkcs.10334 + +Modified by Maria Harris Rasmussen 2024 +""" + +import copy +import itertools + +try: + from rdkit.Chem import rdEHTTools +except ImportError: + rdEHTTools = None + +import sys +from collections import defaultdict + +import networkx as nx +import numpy as np +from rdkit import Chem + +global __ATOM_LIST__ +__ATOM_LIST__ = [ + "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", +] + +global atomic_valence +global atomic_valence_electrons + +atomic_valence = defaultdict(list) +atomic_valence[1] = [1] +atomic_valence[5] = [3, 4] +atomic_valence[6] = [4, 2] +atomic_valence[7] = [3, 4] +atomic_valence[8] = [2, 1, 3] +atomic_valence[9] = [1] +atomic_valence[13] = [3, 4] +atomic_valence[14] = [4] +atomic_valence[15] = [3, 5] +atomic_valence[16] = [2, 4, 6] +atomic_valence[17] = [1] +atomic_valence[18] = [0] +atomic_valence[32] = [4] +atomic_valence[33] = [5, 3] +atomic_valence[35] = [1] +atomic_valence[34] = [2] +atomic_valence[52] = [2] +atomic_valence[53] = [1] +atomic_valence[21] = [20] +atomic_valence[22] = [20] +atomic_valence[23] = [20] +atomic_valence[24] = [20] +atomic_valence[25] = [20] +atomic_valence[26] = [20] +atomic_valence[27] = [20] +atomic_valence[28] = [20] +atomic_valence[29] = [20] +atomic_valence[30] = [20] +atomic_valence[39] = [20] +atomic_valence[40] = [20] +atomic_valence[41] = [20] +atomic_valence[42] = [20] +atomic_valence[43] = [20] +atomic_valence[44] = [20] +atomic_valence[45] = [20] +atomic_valence[46] = [20] +atomic_valence[47] = [20] +atomic_valence[48] = [20] +atomic_valence[57] = [20] +atomic_valence[72] = [20] +atomic_valence[73] = [20] +atomic_valence[74] = [20] +atomic_valence[75] = [20] +atomic_valence[76] = [20] +atomic_valence[77] = [20] +atomic_valence[78] = [20] +atomic_valence[79] = [20] +atomic_valence[80] = [20] + +atomic_valence_electrons = {} +atomic_valence_electrons[1] = 1 +atomic_valence_electrons[5] = 3 +atomic_valence_electrons[6] = 4 +atomic_valence_electrons[7] = 5 +atomic_valence_electrons[8] = 6 +atomic_valence_electrons[9] = 7 +atomic_valence_electrons[13] = 3 +atomic_valence_electrons[14] = 4 +atomic_valence_electrons[15] = 5 +atomic_valence_electrons[16] = 6 +atomic_valence_electrons[17] = 7 +atomic_valence_electrons[18] = 8 +atomic_valence_electrons[32] = 4 +atomic_valence_electrons[33] = 5 +atomic_valence_electrons[35] = 7 +atomic_valence_electrons[34] = 6 +atomic_valence_electrons[52] = 6 +atomic_valence_electrons[53] = 7 + + +def str_atom(atom): + """Convert integer atom to string atom.""" + global __ATOM_LIST__ + atom = __ATOM_LIST__[atom - 1] + return atom + + +def int_atom(atom): + """Convert str atom to integer atom.""" + global __ATOM_LIST__ + atom = atom.lower() + return __ATOM_LIST__.index(atom) + 1 + + +def get_UA(maxValence_list, valence_list): + """""" + UA = [] + DU = [] + for i, (maxValence, valence) in enumerate(zip(maxValence_list, valence_list)): + if not maxValence - valence > 0: + continue + UA.append(i) + DU.append(maxValence - valence) + return UA, DU + + +def get_BO(AC, UA, DU, valences, UA_pairs, use_graph=True): + """""" + BO = AC.copy() + DU_save = [] + + while DU_save != DU: + for i, j in UA_pairs: + BO[i, j] += 1 + BO[j, i] += 1 + + BO_valence = list(BO.sum(axis=1)) + DU_save = copy.copy(DU) + UA, DU = get_UA(valences, BO_valence) + UA_pairs = get_UA_pairs(UA, AC, DU, use_graph=use_graph)[0] + return BO + + +def valences_not_too_large(BO, valences): + """""" + number_of_bonds_list = BO.sum(axis=1) + for valence, number_of_bonds in zip(valences, number_of_bonds_list): + if number_of_bonds > valence: + return False + return True + + +def charge_is_OK( + BO, + AC, + charge, + DU, + atomic_valence_electrons, + atoms, + valences, + allow_charged_fragments=True, + allow_carbenes=True, +): + Q = 0 + q_list = [] + + if allow_charged_fragments: + BO_valences = list(BO.sum(axis=1)) + for i, atom in enumerate(atoms): + q = get_atomic_charge(atom, atomic_valence_electrons[atom], BO_valences[i]) + Q += q + if atom == 6: + number_of_single_bonds_to_C = list(BO[i, :]).count(1) + if ( + not allow_carbenes + and number_of_single_bonds_to_C == 2 + and BO_valences[i] == 2 + ): + print("found illegal carbene") + Q += 1 + q = 2 + if number_of_single_bonds_to_C == 3 and Q + 1 < charge: + Q += 2 + q = 1 + if q != 0: + q_list.append(q) + return charge == Q + + +def BO_is_OK( + BO, + AC, + charge, + DU, + atomic_valence_electrons, + atoms, + valences, + allow_charged_fragments=True, + allow_carbenes=True, +): + """Sanity of bond-orders. + + args: + BO - + AC - + charge - + DU - + + optional + allow_charges_fragments - + + returns: + boolean - true of molecule is OK, false if not + """ + + if not valences_not_too_large(BO, valences): + return False + + check_sum = (BO - AC).sum() == sum(DU) + check_charge = charge_is_OK( + BO, + AC, + charge, + DU, + atomic_valence_electrons, + atoms, + valences, + allow_charged_fragments, + allow_carbenes=True, + ) + + if check_charge and check_sum: + return True + + return False + + +def get_atomic_charge(atom, atomic_valence_electrons, BO_valence): + """""" + if atom == 1: + charge = 1 - BO_valence + elif atom == 5: + charge = 3 - BO_valence + elif atom == 6 and BO_valence == 2: + charge = 0 + elif atom == 13: + charge = 3 - BO_valence + elif atom == 15 and BO_valence == 5: + charge = 0 + elif atom == 16 and BO_valence == 6: + charge = 0 + elif atom == 16 and BO_valence == 4: + charge = 0 + elif atom == 16 and BO_valence == 5: + charge = 1 + else: + charge = atomic_valence_electrons - 8 + BO_valence + + return charge + + +def BO2mol( + mol, + BO_matrix, + atoms, + atomic_valence_electrons, + mol_charge, + allow_charged_fragments=True, + use_atom_maps=True, +): + """Based on code written by Paolo Toscani. + + From bond order, atoms, valence structure and total charge, generate an + rdkit molecule. + + args: + mol - rdkit molecule + BO_matrix - bond order matrix of molecule + atoms - list of integer atomic symbols + atomic_valence_electrons - + mol_charge - total charge of molecule + + optional: + allow_charged_fragments - bool - allow charged fragments + + returns + mol - updated rdkit molecule with bond connectivity + """ + + length_bo = len(BO_matrix) + length_atoms = len(atoms) + BO_valences = list(BO_matrix.sum(axis=1)) + + if length_bo != length_atoms: + raise RuntimeError( + "sizes of adjMat ({0:d}) and Atoms {1:d} differ".format( + length_bo, length_atoms + ) + ) + + rwMol = Chem.RWMol(mol) + + bondTypeDict = { + 1: Chem.BondType.SINGLE, + 2: Chem.BondType.DOUBLE, + 3: Chem.BondType.TRIPLE, + } + + for i in range(length_bo): + for j in range(i + 1, length_bo): + bo = int(round(BO_matrix[i, j])) + if bo == 0: + continue + bt = bondTypeDict.get(bo, Chem.BondType.SINGLE) + rwMol.RemoveBond(i, j) + rwMol.AddBond(i, j, bt) + + mol = rwMol.GetMol() + + if allow_charged_fragments: + mol = set_atomic_charges( + mol, + atoms, + atomic_valence_electrons, + BO_valences, + BO_matrix, + mol_charge, + use_atom_maps=use_atom_maps, + ) + else: + mol = set_atomic_radicals( + mol, + atoms, + atomic_valence_electrons, + BO_valences, + use_atom_maps=use_atom_maps, + ) + + Chem.SanitizeMol(mol) + + return mol + + +def set_atomic_charges( + mol, + atoms, + atomic_valence_electrons, + BO_valences, + BO_matrix, + mol_charge, + use_atom_maps=True, +): + """""" + q = 0 + for i, atom in enumerate(atoms): + a = mol.GetAtomWithIdx(i) + if use_atom_maps: + a.SetAtomMapNum(i + 1) + charge = get_atomic_charge(atom, atomic_valence_electrons[atom], BO_valences[i]) + q += charge + if atom == 6: + number_of_single_bonds_to_C = list(BO_matrix[i, :]).count(1) + if BO_valences[i] == 2: + a.SetNumRadicalElectrons(2) + charge = 0 + if number_of_single_bonds_to_C == 3 and q + 1 < mol_charge: + q += 2 + charge = 1 + + if abs(charge) > 0: + a.SetFormalCharge(int(charge)) + + return mol + + +def set_atomic_radicals( + mol, atoms, atomic_valence_electrons, BO_valences, use_atom_maps=True +): + """The number of radical electrons = absolute atomic charge.""" + atomic_valence[8] = [2, 1] + atomic_valence[7] = [3, 2] + atomic_valence[6] = [4, 2] + + for i, atom in enumerate(atoms): + a = mol.GetAtomWithIdx(i) + if use_atom_maps: + a.SetAtomMapNum(i + 1) + charge = get_atomic_charge(atom, atomic_valence_electrons[atom], BO_valences[i]) + + if abs(charge) > 0: + a.SetNumRadicalElectrons(abs(int(charge))) + + return mol + + +def get_bonds(UA, AC): + """""" + bonds = [] + + for k, i in enumerate(UA): + for j in UA[k + 1 :]: + if AC[i, j] == 1: + bonds.append(tuple(sorted([i, j]))) + + return bonds + + +def get_UA_pairs(UA, AC, DU, use_graph=True): + """""" + N_UA = 10000 + matching_ids = dict() + matching_ids2 = dict() + for i, du in zip(UA, DU): + if du > 1: + matching_ids[i] = N_UA + matching_ids2[N_UA] = i + N_UA += 1 + + bonds = get_bonds(UA, AC) + for i, j in bonds: + if i in matching_ids: + bonds.append(tuple(sorted([matching_ids[i], j]))) + + elif j in matching_ids: + bonds.append(tuple(sorted([i, matching_ids[j]]))) + + if len(bonds) == 0: + return [()] + + if use_graph: + G = nx.Graph() + G.add_edges_from(bonds) + UA_pairs = [list(nx.max_weight_matching(G))] + UA_pair = UA_pairs[0] + + remove_pairs = [] + add_pairs = [] + for i, j in UA_pair: + if i in matching_ids2 and j in matching_ids2: + remove_pairs.append(tuple([i, j])) + add_pairs.append(tuple([matching_ids2[i], matching_ids2[j]])) + elif i in matching_ids2: + remove_pairs.append(tuple([i, j])) + add_pairs.append(tuple([matching_ids2[i], j])) + elif j in matching_ids2: + remove_pairs.append(tuple([i, j])) + add_pairs.append(tuple([i, matching_ids2[j]])) + for p1, p2 in zip(remove_pairs, add_pairs): + UA_pair.remove(p1) + UA_pair.append(p2) + return [UA_pair] + + max_atoms_in_combo = 0 + UA_pairs = [()] + for combo in list(itertools.combinations(bonds, int(len(UA) / 2))): + flat_list = [item for sublist in combo for item in sublist] + atoms_in_combo = len(set(flat_list)) + if atoms_in_combo > max_atoms_in_combo: + max_atoms_in_combo = atoms_in_combo + UA_pairs = [combo] + + elif atoms_in_combo == max_atoms_in_combo: + UA_pairs.append(combo) + + return UA_pairs + + +def AC2BO( + AC, atoms, charge, allow_charged_fragments=True, use_graph=True, allow_carbenes=True +): + """Implemenation of algorithm shown in Figure 2. + + UA: unsaturated atoms + + DU: degree of unsaturation (u matrix in Figure) + + best_BO: Bcurr in Figure + """ + + global atomic_valence + global atomic_valence_electrons + + valences_list_of_lists = [] + AC_valence = list(AC.sum(axis=1)) + + for i, (atomicNum, valence) in enumerate(zip(atoms, AC_valence)): + possible_valence = [x for x in atomic_valence[atomicNum] if x >= valence] + if atomicNum == 6 and valence == 1: + possible_valence.remove(2) + if atomicNum == 6 and not allow_carbenes and valence == 2: + possible_valence.remove(2) + if atomicNum == 6 and valence == 2: + possible_valence.append(3) + if atomicNum == 16 and valence == 1: + possible_valence = [1, 2] + + if not possible_valence: + print( + "Valence of atom", + i, + "is", + valence, + "which bigger than allowed max", + max(atomic_valence[atomicNum]), + ". Stopping", + ) + sys.exit() + valences_list_of_lists.append(possible_valence) + + valences_list = itertools.product(*valences_list_of_lists) + + best_BO = AC.copy() + + O_valences = [ + v_list + for v_list, atomicNum in zip(valences_list_of_lists, atoms) + if atomicNum == 8 + ] + N_valences = [ + v_list + for v_list, atomicNum in zip(valences_list_of_lists, atoms) + if atomicNum == 7 + ] + C_valences = [ + v_list + for v_list, atomicNum in zip(valences_list_of_lists, atoms) + if atomicNum == 6 + ] + P_valences = [ + v_list + for v_list, atomicNum in zip(valences_list_of_lists, atoms) + if atomicNum == 15 + ] + S_valences = [ + v_list + for v_list, atomicNum in zip(valences_list_of_lists, atoms) + if atomicNum == 16 + ] + + O_sums = [] + for v_list in itertools.product(*O_valences): + O_sums.append(v_list) + + N_sums = [] + for v_list in itertools.product(*N_valences): + N_sums.append(v_list) + + C_sums = [] + for v_list in itertools.product(*C_valences): + C_sums.append(v_list) + + P_sums = [] + for v_list in itertools.product(*P_valences): + P_sums.append(v_list) + + S_sums = [] + for v_list in itertools.product(*S_valences): + S_sums.append(v_list) + + order_dict = dict() + for i, v_list in enumerate( + itertools.product(*[O_sums, N_sums, C_sums, P_sums, S_sums]) + ): + order_dict[v_list] = i + + valence_order_list = [] + for valence_list in valences_list: + C_sum = [] + N_sum = [] + O_sum = [] + P_sum = [] + S_sum = [] + for v, atomicNum in zip(valence_list, atoms): + if atomicNum == 6: + C_sum.append(v) + if atomicNum == 7: + N_sum.append(v) + if atomicNum == 8: + O_sum.append(v) + if atomicNum == 15: + P_sum.append(v) + if atomicNum == 16: + S_sum.append(v) + + order_idx = order_dict[ + (tuple(O_sum), tuple(N_sum), tuple(C_sum), tuple(P_sum), tuple(S_sum)) + ] + valence_order_list.append(order_idx) + + sorted_valences_list = [ + y + for x, y in sorted( + zip(valence_order_list, list(itertools.product(*valences_list_of_lists))) + ) + ] + + for valences in sorted_valences_list: + UA, DU_from_AC = get_UA(valences, AC_valence) + check_len = len(UA) == 0 + if check_len: + check_bo = BO_is_OK( + AC, + AC, + charge, + DU_from_AC, + atomic_valence_electrons, + atoms, + valences, + allow_charged_fragments=allow_charged_fragments, + allow_carbenes=allow_carbenes, + ) + else: + check_bo = None + + if check_len and check_bo: + return AC, atomic_valence_electrons + + UA_pairs_list = get_UA_pairs(UA, AC, DU_from_AC, use_graph=use_graph) + for UA_pairs in UA_pairs_list: + BO = get_BO(AC, UA, DU_from_AC, valences, UA_pairs, use_graph=use_graph) + status = BO_is_OK( + BO, + AC, + charge, + DU_from_AC, + atomic_valence_electrons, + atoms, + valences, + allow_charged_fragments=allow_charged_fragments, + allow_carbenes=allow_carbenes, + ) + charge_OK = charge_is_OK( + BO, + AC, + charge, + DU_from_AC, + atomic_valence_electrons, + atoms, + valences, + allow_charged_fragments=allow_charged_fragments, + allow_carbenes=allow_carbenes, + ) + + if status: + return BO, atomic_valence_electrons + elif ( + BO.sum() >= best_BO.sum() + and valences_not_too_large(BO, valences) + and charge_OK + ): + best_BO = BO.copy() + + return best_BO, atomic_valence_electrons + + +def AC2mol( + mol, + AC, + atoms, + charge, + allow_charged_fragments=True, + use_graph=True, + use_atom_maps=True, + allow_carbenes=True, +): + """""" + + BO, atomic_valence_electrons = AC2BO( + AC, + atoms, + charge, + allow_charged_fragments=allow_charged_fragments, + use_graph=use_graph, + allow_carbenes=allow_carbenes, + ) + + mol = BO2mol( + mol, + BO, + atoms, + atomic_valence_electrons, + charge, + allow_charged_fragments=allow_charged_fragments, + use_atom_maps=use_atom_maps, + ) + + if Chem.GetFormalCharge(mol) != charge: + return None + + return mol + + +def get_proto_mol(atoms): + """""" + mol = Chem.MolFromSmarts("[#" + str(atoms[0]) + "]") + rwMol = Chem.RWMol(mol) + for i in range(1, len(atoms)): + a = Chem.Atom(atoms[i]) + rwMol.AddAtom(a) + + mol = rwMol.GetMol() + + return mol + + +def read_xyz_file(filename, look_for_charge=True): + """""" + atomic_symbols = [] + xyz_coordinates = [] + charge = 0 + + with open(filename, "r") as file: + for line_number, line in enumerate(file): + if line_number == 0: + int(line) + elif line_number == 1: + if "charge=" in line: + charge = int(line.split("=")[1]) + else: + atomic_symbol, x, y, z = line.split() + atomic_symbols.append(atomic_symbol) + xyz_coordinates.append([float(x), float(y), float(z)]) + + atoms = [int_atom(atom) for atom in atomic_symbols] + + return atoms, charge, xyz_coordinates + + +def xyz2AC(atoms, xyz, charge, use_huckel=False, use_obabel=False): + """Atoms and coordinates to atom connectivity (AC) + + args: + atoms - int atom types + xyz - coordinates + charge - molecule charge + + optional: + use_huckel - Use Huckel method for atom connecitivty + use_obabel - Use Opne Babel method for atom connectivity + + returns + ac - atom connectivity matrix + mol - rdkit molecule + """ + if use_huckel: + return xyz2AC_huckel(atoms, xyz, charge) + elif use_obabel: + return xyz2AC_obabel(atoms, xyz) + else: + return xyz2AC_vdW(atoms, xyz) + + +def xyz2AC_vdW(atoms, xyz): + mol = get_proto_mol(atoms) + + conf = Chem.Conformer(mol.GetNumAtoms()) + for i in range(mol.GetNumAtoms()): + conf.SetAtomPosition(i, (xyz[i][0], xyz[i][1], xyz[i][2])) + mol.AddConformer(conf) + + AC = get_AC(mol) + + return AC, mol + + +def get_AC(mol, covalent_factor=1.3): + """Generate adjacent matrix from atoms and coordinates. + + AC is a (num_atoms, num_atoms) matrix with 1 being covalent bond and 0 + is not + + covalent_factor - 1.3 is an arbitrary factor + + args: + mol - rdkit molobj with 3D conformer + + optional + covalent_factor - increase covalent bond length threshold with factor + + returns: + AC - adjacent matrix + """ + dMat = Chem.Get3DDistanceMatrix(mol) + + pt = Chem.GetPeriodicTable() + num_atoms = mol.GetNumAtoms() + AC = np.zeros((num_atoms, num_atoms), dtype=int) + + for i in range(num_atoms): + a_i = mol.GetAtomWithIdx(i) + Rcov_i = pt.GetRcovalent(a_i.GetAtomicNum()) * covalent_factor + for j in range(i + 1, num_atoms): + a_j = mol.GetAtomWithIdx(j) + Rcov_j = pt.GetRcovalent(a_j.GetAtomicNum()) * covalent_factor + if dMat[i, j] <= Rcov_i + Rcov_j: + AC[i, j] = 1 + AC[j, i] = 1 + + return AC + + +def xyz2AC_huckel(atomicNumList, xyz, charge): + """Args. + + atomicNumList - atom type list + xyz - coordinates + charge - molecule charge + + returns + ac - atom connectivity + mol - rdkit molecule + """ + mol = get_proto_mol(atomicNumList) + + conf = Chem.Conformer(mol.GetNumAtoms()) + for i in range(mol.GetNumAtoms()): + conf.SetAtomPosition(i, (xyz[i][0], xyz[i][1], xyz[i][2])) + mol.AddConformer(conf) + + num_atoms = len(atomicNumList) + AC = np.zeros((num_atoms, num_atoms)).astype(int) + + mol_huckel = Chem.Mol(mol) + mol_huckel.GetAtomWithIdx(0).SetFormalCharge(charge) + + passed, result = rdEHTTools.RunMol(mol_huckel) + opop = result.GetReducedOverlapPopulationMatrix() + tri = np.zeros((num_atoms, num_atoms)) + tri[np.tril(np.ones((num_atoms, num_atoms), dtype=bool))] = opop + for i in range(num_atoms): + for j in range(i + 1, num_atoms): + pair_pop = abs(tri[j, i]) + if pair_pop >= 0.2: + AC[i, j] = 1 + AC[j, i] = 1 + + dMat = Chem.Get3DDistanceMatrix(mol) + pt = Chem.GetPeriodicTable() + + for i in range(num_atoms): + a_i = mol.GetAtomWithIdx(i) + N_con = np.sum(AC[i, :]) + while N_con > max(atomic_valence[a_i.GetAtomicNum()]): + AC = remove_weakest_bond(mol, i, AC, dMat, pt) + N_con = np.sum(AC[i, :]) + + return AC, mol + + +def remove_weakest_bond(mol, atom_idx, AC, dMat, pt): + extra_bond_lengths = [] + bond_atoms = np.nonzero(AC[atom_idx, :])[0] + a_i = mol.GetAtomWithIdx(atom_idx) + rcovi = pt.GetRcovalent(a_i.GetAtomicNum()) + for j in bond_atoms: + a_j = mol.GetAtomWithIdx(int(j)) + rcovj = pt.GetRcovalent(a_j.GetAtomicNum()) + extra_bond_length = dMat[atom_idx, j] - rcovj - rcovi + extra_bond_lengths.append(extra_bond_length) + + longest_bond_index = bond_atoms[np.argmax(extra_bond_lengths)] + AC[atom_idx, longest_bond_index] = 0 + AC[longest_bond_index, atom_idx] = 0 + + return AC + + +def xyz2AC_obabel(atoms, xyz, tolerance=0.45): + """Generate adjacent matrix from atoms and coordinates in a way similar to + open babels. + + AC is a (num_atoms, num_atoms) matrix with 1 being covalent bond and 0 + is not + + tolerance - 0.45Å is from the open babel paper + + args: + mol - rdkit molobj with 3D conformer + + optional + tolerance - atoms connected if distance is shorter than sum of atomic + radii + tolerance. If too many bonds to an atom; break longest bond + + returns: + AC - adjacency matrix + """ + global atomic_valence + atomic_valence[6] = [4, 2] + + mol = get_proto_mol(atoms) + + conf = Chem.Conformer(mol.GetNumAtoms()) + for i in range(mol.GetNumAtoms()): + conf.SetAtomPosition(i, (xyz[i][0], xyz[i][1], xyz[i][2])) + mol.AddConformer(conf) + + dMat = Chem.Get3DDistanceMatrix(mol) + + pt = Chem.GetPeriodicTable() + num_atoms = mol.GetNumAtoms() + AC = np.zeros((num_atoms, num_atoms), dtype=int) + + for i in range(num_atoms): + a_i = mol.GetAtomWithIdx(i) + Rcov_i = pt.GetRcovalent(a_i.GetAtomicNum()) + for j in range(i + 1, num_atoms): + a_j = mol.GetAtomWithIdx(j) + Rcov_j = pt.GetRcovalent(a_j.GetAtomicNum()) + if dMat[i, j] <= Rcov_i + Rcov_j + tolerance: + AC[i, j] = 1 + AC[j, i] = 1 + + for i in range(num_atoms): + a_i = mol.GetAtomWithIdx(i) + N_con = np.sum(AC[i, :]) + while N_con > max(atomic_valence[a_i.GetAtomicNum()]): + AC = remove_weakest_bond(mol, i, AC, dMat, pt) + N_con = np.sum(AC[i, :]) + + return AC, mol + + +def chiral_stereo_check(mol): + """Find and embed chiral information into the model based on the + coordinates. + + args: + mol - rdkit molecule, with embeded conformer + """ + Chem.SanitizeMol(mol) + Chem.DetectBondStereochemistry(mol, -1) + Chem.AssignStereochemistry(mol, flagPossibleStereoCenters=True, force=True) + Chem.AssignAtomChiralTagsFromStructure(mol, -1) + + return + + +def xyz2mol( + atoms, + coordinates, + charge=0, + allow_charged_fragments=True, + use_graph=True, + use_huckel=False, + use_obabel=False, + embed_chiral=True, + use_atom_maps=True, +): + """Generate a rdkit molobj from atoms, coordinates and a total_charge. + + args: + atoms - list of atom types (int) + coordinates - 3xN Cartesian coordinates + charge - total charge of the system (default: 0) + + optional: + allow_charged_fragments - alternatively radicals are made + use_graph - use graph (networkx) + use_huckel - Use Huckel method for atom connectivity prediction + embed_chiral - embed chiral information to the molecule + + returns: + mols - list of rdkit molobjects + """ + + AC, mol = xyz2AC( + atoms, coordinates, charge, use_huckel=use_huckel, use_obabel=use_obabel + ) + + new_mol = AC2mol( + mol, + AC, + atoms, + charge, + allow_charged_fragments=allow_charged_fragments, + use_graph=use_graph, + use_atom_maps=use_atom_maps, + ) + + if embed_chiral: + chiral_stereo_check(new_mol) + + return new_mol + + +def canonicalize_smiles(structure_smiles): + """Remove all structural info an atom mapping information.""" + mol = Chem.MolFromSmiles(structure_smiles, sanitize=False) + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + Chem.SanitizeMol(mol) + mol = Chem.RemoveHs(mol) + canonical_smiles = Chem.MolToSmiles(mol) + + return canonical_smiles + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(usage="%(prog)s [options] molecule.xyz") + parser.add_argument("structure", metavar="structure", type=str) + parser.add_argument("-s", "--sdf", action="store_true", help="Dump sdf file") + parser.add_argument( + "--ignore-chiral", action="store_true", help="Ignore chiral centers" + ) + parser.add_argument( + "--no-charged-fragments", action="store_true", help="Allow radicals to be made" + ) + parser.add_argument( + "--no-graph", + action="store_true", + help="Run xyz2mol without networkx dependencies", + ) + parser.add_argument( + "--use-huckel", + action="store_true", + help="Use Huckel method for atom connectivity", + ) + parser.add_argument( + "--use-obabel", + action="store_true", + help="Use Open Babel way of obtaining atom connectivity; recommended for radicals", + ) + parser.add_argument( + "-o", + "--output-format", + action="store", + type=str, + help="Output format [smiles,sdf] (default=sdf)", + ) + parser.add_argument( + "-c", + "--charge", + action="store", + metavar="int", + type=int, + help="Total charge of the system", + ) + parser.add_argument( + "--use-atom-maps", + action="store_true", + help="Label atoms with map numbers according to their order in the .xyz file", + ) + + args = parser.parse_args() + + filename = args.structure + + charged_fragments = not args.no_charged_fragments + + quick = not args.no_graph + + embed_chiral = not args.ignore_chiral + + atoms, charge, xyz_coordinates = read_xyz_file(filename) + + use_huckel = args.use_huckel + + use_obabel = args.use_obabel + + if args.charge is not None: + charge = int(args.charge) + + use_atom_maps = args.use_atom_maps + if not charged_fragments: + atomic_valence[8] = [2, 1] + atomic_valence[7] = [3, 2] + atomic_valence[6] = [4, 2] + + mols = xyz2mol( + atoms, + xyz_coordinates, + charge=charge, + use_graph=quick, + allow_charged_fragments=charged_fragments, + embed_chiral=embed_chiral, + use_huckel=use_huckel, + use_obabel=use_obabel, + use_atom_maps=use_atom_maps, + ) + + for mol in [mols]: + if args.output_format == "sdf": + txt = Chem.MolToMolBlock(mol) + print(txt) + + else: + isomeric_smiles = not args.ignore_chiral + smiles = Chem.MolToSmiles(mol, isomericSmiles=isomeric_smiles) + + smiles = canonicalize_smiles(smiles) diff --git a/src/atomworks/external/xyz2mol_tm/xyz2mol_tmc.py b/src/atomworks/external/xyz2mol_tm/xyz2mol_tmc.py new file mode 100644 index 00000000..f830a6f5 --- /dev/null +++ b/src/atomworks/external/xyz2mol_tm/xyz2mol_tmc.py @@ -0,0 +1,611 @@ +"Module for the xyz2mol functionality for TMCs" + +import argparse +import logging +import signal +import subprocess +from itertools import combinations +from pathlib import Path + +import numpy as np +from rdkit import Chem +from rdkit.Chem import GetPeriodicTable, rdchem, rdEHTTools, rdmolops +from rdkit.Chem.MolStandardize import rdMolStandardize + +from atomworks.external.xyz2mol_tm.xyz2mol_local import ( + AC2mol, + chiral_stereo_check, + read_xyz_file, + xyz2AC_obabel, +) + +# fmt: off +TRANSITION_METALS = ["Sc","Ti","V","Cr","Mn","Fe","Co","La","Ni","Cu","Zn", + "Y","Zr","Nb","Mo","Tc","Ru","Rh","Pd","Ag","Cd","Lu", + "Hf","Ta","W","Re","Os","Ir","Pt","Au","Hg", +] + +TRANSITION_METALS_NUM = [21,22,23,24,25,26,27,57,28,29,30,39,40,41, + 42,43,44,45,46,47,48,71,72,73,74,75,76,77,78,79,80, +] + + +ALLOWED_OXIDATION_STATES = { + "Sc": [3], + "Ti": [3, 4], + "V": [2, 3, 4, 5], + "Cr": [2, 3, 4, 6], + "Mn": [2, 3, 4, 6, 7], + "Fe": [2, 3], + "Co": [2, 3], + "Ni": [2], + "Cu": [1, 2], + "Zn": [2], + "Y": [3], + "Zr": [4], + "Nb": [3, 4, 5], + "Mo": [2, 3, 4, 5, 6], + "Tc": [2, 3, 4, 5, 6, 7], + "Ru": [2, 3, 4, 5, 6, 7, 8], + "Rh": [1, 3], + "Pd": [2, 4], + "Ag": [1], + "Cd": [2], + "La": [3], + "Hf": [4], + "Ta": [3, 4, 5], + "W": [2, 3, 4, 5, 6], + "Re": [2, 3, 4, 5, 6, 7], + "Os": [3, 4, 5, 6, 7, 8], + "Ir": [1, 3], + "Pt": [2, 4], + "Au": [1, 3], + "Hg": [1, 2], +} +# fmt: on + +logger = logging.getLogger(__name__) + +params = Chem.MolStandardize.rdMolStandardize.MetalDisconnectorOptions() +params.splitAromaticC = True +params.splitGrignards = True +params.adjustCharges = False + +MetalNon_Hg = "[#3,#11,#12,#19,#13,#21,#22,#23,#24,#25,#26,#27,#28,#29,#30,#39,#40,#41,#42,#43,#44,#45,#46,#47,#48,#57,#72,#73,#74,#75,#76,#77,#78,#79,#80]~[B,#6,#14,#15,#33,#51,#16,#34,#52,Cl,Br,I,#85]" + +pt = GetPeriodicTable + +global atomic_valence_electrons + +atomic_valence_electrons = {} +atomic_valence_electrons[1] = 1 +atomic_valence_electrons[5] = 3 +atomic_valence_electrons[6] = 4 +atomic_valence_electrons[7] = 5 +atomic_valence_electrons[8] = 6 +atomic_valence_electrons[9] = 7 +atomic_valence_electrons[13] = 3 +atomic_valence_electrons[14] = 4 +atomic_valence_electrons[15] = 5 +atomic_valence_electrons[16] = 6 +atomic_valence_electrons[17] = 7 +atomic_valence_electrons[18] = 8 +atomic_valence_electrons[32] = 4 +atomic_valence_electrons[33] = 5 # As +atomic_valence_electrons[35] = 7 +atomic_valence_electrons[34] = 6 +atomic_valence_electrons[53] = 7 + +# TMs +atomic_valence_electrons[21] = 3 # Sc +atomic_valence_electrons[22] = 4 # Ti +atomic_valence_electrons[23] = 5 # V +atomic_valence_electrons[24] = 6 # Cr +atomic_valence_electrons[25] = 7 # Mn +atomic_valence_electrons[26] = 8 # Fe +atomic_valence_electrons[27] = 9 # Co +atomic_valence_electrons[28] = 10 # Ni +atomic_valence_electrons[29] = 11 # Cu +atomic_valence_electrons[30] = 12 # Zn + +atomic_valence_electrons[39] = 3 # Y +atomic_valence_electrons[40] = 4 # Zr +atomic_valence_electrons[41] = 5 # Nb +atomic_valence_electrons[42] = 6 # Mo +atomic_valence_electrons[43] = 7 # Tc +atomic_valence_electrons[44] = 8 # Ru +atomic_valence_electrons[45] = 9 # Rh +atomic_valence_electrons[46] = 10 # Pd +atomic_valence_electrons[47] = 11 # Ag +atomic_valence_electrons[48] = 12 # Cd + +atomic_valence_electrons[57] = 3 # La +atomic_valence_electrons[72] = 4 # Hf +atomic_valence_electrons[73] = 5 # Ta +atomic_valence_electrons[74] = 6 # W +atomic_valence_electrons[75] = 7 # Re +atomic_valence_electrons[76] = 8 # Os +atomic_valence_electrons[77] = 9 # Ir +atomic_valence_electrons[78] = 10 # Pt +atomic_valence_electrons[79] = 11 # Au +atomic_valence_electrons[80] = 12 # Hg + + +def shell(cmd, shell=False): + if shell: + p = subprocess.Popen( + cmd, + shell=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + else: + cmd = cmd.split() + p = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + output, err = p.communicate() + return output + + +# NOTE: Changing from source code to take as input and return mol object (vs. smiles) +def fix_NO2(m): + """Localizes nitro groups that have been assigned a charge of -2 (neutral + Nitrogen bound to two negatively charged Oxygen atoms). + + These groups are changed to reflect the correct neutral + configuration of a nitro group. The oxidation state on the + transition metal is changed accordingly. + """ + emol = Chem.RWMol(m) + patt = Chem.MolFromSmarts( + "[#8-]-[#7+0]-[#8-].[#21,#22,#23,#24,#25,#26,#27,#28,#29,#30,#39,#40,#41,#42,#43,#44,#45,#46,#47,#48,#57,#72,#73,#74,#75,#76,#77,#78,#79,#80]" + ) + matches = emol.GetSubstructMatches(patt) + for a1, a2, a3, a4 in matches: + if not emol.GetBondBetweenAtoms(a1, a4) and not emol.GetBondBetweenAtoms( + a3, a4 + ): + tm = emol.GetAtomWithIdx(a4) + o1 = emol.GetAtomWithIdx(a1) + n = emol.GetAtomWithIdx(a2) + tm_charge = tm.GetFormalCharge() + new_charge = tm_charge - 2 + tm.SetFormalCharge(new_charge) + n.SetFormalCharge(+1) + o1.SetFormalCharge(0) + emol.RemoveBond(a1, a2) + emol.AddBond(a1, a2, rdchem.BondType.DOUBLE) + + mol = emol.GetMol() + Chem.SanitizeMol(mol) + return mol + + +# NOTE: Changing from source code to take as input and return mol object (vs. smiles) +def fix_equivalent_Os(m): + """Localizes and fixes where a neutral atom is coordinating to the metal + but connected ro a negatively charged atom through resonane. + + The charge is moved to the coordinating atom and charges fixed + accordingly. + """ + emol = Chem.RWMol(m) + + patt = Chem.MolFromSmarts("[#6-,#7-,#8-,#15-,#16-]-[*]=[#6,#7,#8,#15,#16]") + + matches = emol.GetSubstructMatches(patt) + used_atom_ids_1 = [] + used_atom_ids_3 = [] + for atom in emol.GetAtoms(): + if atom.GetAtomicNum() in TRANSITION_METALS_NUM: + neighbor_idxs = [a.GetIdx() for a in atom.GetNeighbors()] + for a1, a2, a3 in matches: + if ( + a3 in neighbor_idxs + and a1 not in neighbor_idxs + and a1 not in used_atom_ids_1 + and a3 not in used_atom_ids_3 + ): + used_atom_ids_1.append(a1) + used_atom_ids_3.append(a3) + + emol.RemoveBond(a1, a2) + emol.AddBond(a1, a2, Chem.rdchem.BondType.DOUBLE) + emol.RemoveBond(a2, a3) + emol.AddBond(a2, a3, Chem.rdchem.BondType.SINGLE) + emol.GetAtomWithIdx(a1).SetFormalCharge(0) + emol.GetAtomWithIdx(a3).SetFormalCharge(-1) + + mol = emol.GetMol() + Chem.SanitizeMol(mol) + return mol + + +def get_proposed_ligand_charge(ligand_mol, cutoff=-10): + """Runs an extended Hückel calculation for the ligand defined in + ligand_mol. + + A suggested charge is found by filling electrons in orbitals <-10eV + and omparing with total number of valence electrons. If charge is >= + 1 (<-1) and the LUMO (HOMO) is low (high) in energy, two additional + electrons are added (removed). The suggested charge is returned. + """ + valence_electrons = 0 + passed, result = rdEHTTools.RunMol(ligand_mol) + for a in ligand_mol.GetAtoms(): + valence_electrons += atomic_valence_electrons[a.GetAtomicNum()] + + passed, result = rdEHTTools.RunMol(ligand_mol) + N_occ_orbs = sum(1 for i in result.GetOrbitalEnergies() if i < cutoff) + charge = valence_electrons - 2 * N_occ_orbs + percieved_homo = result.GetOrbitalEnergies()[N_occ_orbs - 1] + if N_occ_orbs == len(result.GetOrbitalEnergies()): + percieved_lumo = np.nan + else: + percieved_lumo = result.GetOrbitalEnergies()[N_occ_orbs] + while charge >= 1 and percieved_lumo < -9: + N_occ_orbs += 1 + charge += -2 + logger.debug("added two more electrons:", charge, percieved_lumo) + percieved_lumo = result.GetOrbitalEnergies()[N_occ_orbs] + while charge < -1 and percieved_homo > -10.2: + N_occ_orbs -= 1 + charge += 2 + logger.debug("removed two electrons:", charge, percieved_homo) + percieved_homo = result.GetOrbitalEnergies()[N_occ_orbs - 1] + + return charge + + +def get_basic_mol(xyz_file, overall_charge): + """A basic mol-object (that can be usedto do an extended Hückel calculation + is constructed based on the adjacency matrix evaluated from the xyz- + coordinates. + + All bonds are single bonds, and charges are only asigned if + necessary to work with it, i.e. a Nitrogen with four neihbors gets a + +1 charge, Boron with 4 neighbors gets a -1 charge and oxygen with + three neighbors gets a +1 charge. + """ + atoms, _, xyz_coords = read_xyz_file(xyz_file) + + # AC, mol = xyz2AC_huckel(atoms, xyz_coords, overall_charge) + AC, mol = xyz2AC_obabel(atoms, xyz_coords) + tm_indxs = [atoms.index(tm) for tm in TRANSITION_METALS_NUM if tm in atoms] + + rwMol = Chem.RWMol(mol) + length_ac = len(AC) + + bondTypeDict = { + 1: Chem.BondType.SINGLE, + 2: Chem.BondType.DOUBLE, + 3: Chem.BondType.TRIPLE, + } + for i in range(length_ac): + for j in range(i + 1, length_ac): + bo = int(round(AC[i, j])) + if bo == 0: + continue + bt = bondTypeDict.get(bo, Chem.BondType.SINGLE) + rwMol.AddBond(i, j, bt) + + mol = rwMol.GetMol() + + for i, a in enumerate(mol.GetAtoms()): + if a.GetAtomicNum() == 7: + # explicit_valence = np.sum(AC[i]) + explicit_valence = sum( + [ele for idx, ele in enumerate(AC[i]) if idx not in tm_indxs] + ) + if explicit_valence == 4: + a.SetFormalCharge(1) + if a.GetAtomicNum() == 5: + # Boron with 4 explicit bonds should be negative + explicit_valence = sum( + [ele for idx, ele in enumerate(AC[i]) if idx not in tm_indxs] + ) + if explicit_valence == 4: + a.SetFormalCharge(-1) + if a.GetAtomicNum() == 8: + explicit_valence = sum( + [ele for idx, ele in enumerate(AC[i]) if idx not in tm_indxs] + ) + if explicit_valence == 3: + a.SetFormalCharge(1) + + return mol + + +def lig_checks(lig_mol, coordinating_atoms): + """Sending proposed ligand mol object through series of checks. + + - neighbouring coordinating atoms must be connected by pi-bond, aromatic bond (haptic), conjugated system + - If I have two neighbouring identical charges -> fail, I would rather change the charge and make a bond + -> suggest new charge adding/subtracting electrons based on these neighbouring charges + - count partial charges: partial charges that are not negative on ligand coordinating atoms count against this ligand + -> loop through resonance forms to see if any live up to this, then choose that one. + -> partial positive charge on coordinating atom is big red flag + -> If "bad" partial charges still exists suggest a new charge: add/subtract electrons based on the values of the partial charges + """ + res_mols = rdchem.ResonanceMolSupplier(lig_mol) + if len(res_mols) == 0: + res_mols = rdchem.ResonanceMolSupplier( + lig_mol, flags=Chem.ALLOW_INCOMPLETE_OCTETS + ) + # Check for neighbouring coordinating atoms: + possible_lig_mols = [] + + for res_mol in res_mols: + positive_atoms = [] + negative_atoms = [] + N_aromatic = 0 + for a in res_mol.GetAtoms(): + if a.GetIsAromatic(): + N_aromatic += 1 + if a.GetFormalCharge() > 0: + positive_atoms.append(a.GetIdx()) + if a.GetFormalCharge() < 0 and a.GetIdx() not in coordinating_atoms: + negative_atoms.append(a.GetIdx()) + + possible_lig_mols.append( + (res_mol, len(positive_atoms), len(negative_atoms), N_aromatic) + ) + return possible_lig_mols + + +def get_lig_mol(mol, charge, coordinating_atoms): + """A sanitizable mol object is created for the ligand, taking into account + the checks defined in lig_checks. + + We try different charge settings and settings where carbenes are + allowed/not allowed in case no perfect solution (no partial charges + on other than the coordinating atoms) can be found. Finally best + found solution based on criteria in lig_checks is returned. + """ + + atoms = [a.GetAtomicNum() for a in mol.GetAtoms()] + AC = Chem.rdmolops.GetAdjacencyMatrix(mol) + lig_mol = AC2mol( + mol, AC, atoms, charge, allow_charged_fragments=True, use_atom_maps=False + ) + if not lig_mol and charge >= 0: + charge += -2 + lig_mol = AC2mol( + mol, AC, atoms, charge, allow_charged_fragments=True, use_atom_maps=False + ) + if not lig_mol: + return None, charge + if not lig_mol and charge < 0: + charge += 2 + lig_mol = AC2mol( + mol, AC, atoms, charge, allow_charged_fragments=True, use_atom_maps=False + ) + if not lig_mol: + charge += -4 + lig_mol = AC2mol( + mol, + AC, + atoms, + charge, + allow_charged_fragments=True, + use_atom_maps=False, + ) + if not lig_mol: + return None, charge + + possible_res_mols = lig_checks(lig_mol, coordinating_atoms) + best_res_mol, lowest_pos, lowest_neg, highest_aromatic = possible_res_mols[0] + for res_mol, N_pos_atoms, N_neg_atoms, N_aromatic in possible_res_mols: + if N_aromatic > highest_aromatic: + best_res_mol, lowest_pos, lowest_neg, highest_aromatic = ( + res_mol, + N_pos_atoms, + N_neg_atoms, + N_aromatic, + ) + if ( + N_aromatic == highest_aromatic + and N_pos_atoms + N_neg_atoms < lowest_pos + lowest_neg + ): + best_res_mol, lowest_pos, lowest_neg = res_mol, N_pos_atoms, N_neg_atoms + if lowest_pos + lowest_neg == 0: + return best_res_mol, charge + + lig_mol_no_carbene = AC2mol( + mol, + AC, + atoms, + charge, + allow_charged_fragments=True, + use_atom_maps=False, + allow_carbenes=False, + ) + allow_carbenes = True + + if lig_mol_no_carbene: + res_mols_no_carbenes = lig_checks(lig_mol_no_carbene, coordinating_atoms) + for res_mol, N_pos_atoms, N_neg_atoms, N_aromatic in res_mols_no_carbenes: + if ( + N_aromatic > highest_aromatic + and N_pos_atoms + N_neg_atoms <= lowest_pos + lowest_neg + ): + best_res_mol, lowest_pos, lowest_neg, highest_aromatic = ( + res_mol, + N_pos_atoms, + N_neg_atoms, + N_aromatic, + ) + if ( + N_aromatic == highest_aromatic + and N_pos_atoms + N_neg_atoms < lowest_pos + lowest_neg + ): + best_res_mol, lowest_pos, lowest_neg = res_mol, N_pos_atoms, N_neg_atoms + allow_carbenes = False + + if lowest_pos + lowest_neg == 0: + logger.debug("found opt solution without carbenes") + return best_res_mol, charge + + if lowest_pos - lowest_neg + charge < 0: + new_charge = charge + 2 + else: + new_charge = charge - 2 # if 0 maybe I should try both + + new_lig_mol = AC2mol( + mol, + AC, + atoms, + new_charge, + allow_charged_fragments=True, + use_atom_maps=False, + allow_carbenes=allow_carbenes, + ) + if not new_lig_mol: + return best_res_mol, charge + new_possible_res_mols = lig_checks(new_lig_mol, coordinating_atoms) + for res_mol, N_pos_atoms, N_neg_atoms, N_aromatic in new_possible_res_mols: + if N_aromatic > highest_aromatic: + best_res_mol, lowest_pos, lowest_neg, highest_aromatic = ( + res_mol, + N_pos_atoms, + N_neg_atoms, + N_aromatic, + ) + charge = new_charge + if ( + N_aromatic == highest_aromatic + and N_pos_atoms + N_neg_atoms < lowest_pos + lowest_neg + ): + best_res_mol, lowest_pos, lowest_neg = res_mol, N_pos_atoms, N_neg_atoms + charge = new_charge + + return best_res_mol, charge + + +# NOTE: Modified to directly take mol object as input (vs. xyz file path) +def get_tmc_mol(mol, overall_charge, with_stereo=False): + """Get TMC mol object from given xyz file. + + Args: + xyz_file (str) : Path to TMC xyz file + overall_charge (int): Overall charge of TMC + with_stereo (bool): Whether to percieve stereochemistry from the 3D data + + Returns: + tmc_mol (rdkit.Chem.rdchem.Mol): TMC mol object + """ + tmc_idx = None + for a in mol.GetAtoms(): + a.SetIntProp("__origIdx", a.GetIdx()) + if a.GetAtomicNum() in TRANSITION_METALS_NUM: + tmc_idx = a.GetIdx() + + if tmc_idx is None: + raise Exception( + "Found no TM in the input file. Please supply an xyz file with a TM" + ) + + coordinating_atoms = np.nonzero(Chem.rdmolops.GetAdjacencyMatrix(mol)[tmc_idx, :])[ + 0 + ] + + mdis = rdMolStandardize.MetalDisconnector(params) + mdis.SetMetalNon(Chem.MolFromSmarts(MetalNon_Hg)) + frags = mdis.Disconnect(mol) + frag_mols = rdmolops.GetMolFrags(frags, asMols=True) + + total_lig_charge = 0 + tm_idx = None + lig_list = [] + for i, f in enumerate(frag_mols): + m = Chem.Mol(f) + atoms = m.GetAtoms() + for atom in atoms: + if atom.GetAtomicNum() in TRANSITION_METALS_NUM: + tm_idx = i + break + else: + lig_charge = get_proposed_ligand_charge(f) + + lig_coordinating_atoms = [ + a.GetIdx() + for a in m.GetAtoms() + if a.GetIntProp("__origIdx") in coordinating_atoms + ] + lig_mol, lig_charge = get_lig_mol(m, lig_charge, lig_coordinating_atoms) + if not lig_mol: + return None + + total_lig_charge += lig_charge + lig_list.append(lig_mol) + + if tm_idx is None: + raise Exception( + "Found no TM in the input file. Please supply an xyz file with a TM" + ) + + tm = Chem.RWMol(frag_mols[tm_idx]) + tm_ox = overall_charge - total_lig_charge + + len(tm.GetAtoms()) + + for a in tm.GetAtoms(): + if a.GetAtomicNum() in TRANSITION_METALS_NUM: + a.SetFormalCharge(tm_ox) + + for lmol in lig_list: + tm = Chem.CombineMols(tm, lmol) + + emol = Chem.RWMol(tm) + coordinating_atoms_idx = [ + a.GetIdx() + for a in emol.GetAtoms() + if a.GetIntProp("__origIdx") in coordinating_atoms + ] + tm_idx = [ + a.GetIdx() for a in emol.GetAtoms() if a.GetIntProp("__origIdx") == tmc_idx + ][0] + dMat = Chem.Get3DDistanceMatrix(emol) + cut_atoms = [] + for i, j in combinations(coordinating_atoms_idx, 2): + bond = emol.GetBondBetweenAtoms(int(i), int(j)) + if bond and abs(dMat[i, tm_idx] - dMat[j, tm_idx]) >= 0.4: + logger.debug( + "Haptic bond pattern with too great distance:", + dMat[i, tm_idx], + dMat[j, tm_idx], + ) + if dMat[i, tm_idx] > dMat[j, tm_idx] and i in coordinating_atoms_idx: + coordinating_atoms_idx.remove(i) + cut_atoms.append(i) + if dMat[j, tm_idx] > dMat[i, tm_idx] and j in coordinating_atoms_idx: + coordinating_atoms_idx.remove(j) + cut_atoms.append(j) + for j in cut_atoms: + for i in coordinating_atoms_idx: + bond = emol.GetBondBetweenAtoms(int(i), int(j)) + if ( + bond + and dMat[i, tm_idx] - dMat[j, tm_idx] >= -0.1 + and i in coordinating_atoms_idx + ): + coordinating_atoms_idx.remove(i) + + for i in coordinating_atoms_idx: + if emol.GetBondBetweenAtoms(i, tm_idx): + continue + emol.AddBond(i, tm_idx, Chem.BondType.DATIVE) + + # Fix specific cases + # NOTE: Changing from source code to operate on mol object directly + mol = fix_equivalent_Os(emol) + mol = fix_NO2(mol) + + Chem.SanitizeMol(mol) + if with_stereo: + chiral_stereo_check(mol) + + return mol diff --git a/src/atomworks/io/__init__.py b/src/atomworks/io/__init__.py index 504d52bb..4ac7ca9d 100644 --- a/src/atomworks/io/__init__.py +++ b/src/atomworks/io/__init__.py @@ -16,5 +16,15 @@ warnings.filterwarnings("once", category=DeprecationWarning) -# We need to import parse here to ensure that the version string is set -from atomworks.io.parser import parse # noqa: E402 +# Expose loading separately from preparation; importing parse also sets the version string. +from atomworks.io._loaders import load_cif, load_pdb # noqa: E402 +from atomworks.io.parser import get_config, parse, parse_atom_array, prepare_atom_array # noqa: E402 + +__all__ = [ + "get_config", + "load_cif", + "load_pdb", + "parse", + "parse_atom_array", + "prepare_atom_array", +] diff --git a/src/atomworks/io/_loaders.py b/src/atomworks/io/_loaders.py new file mode 100644 index 00000000..57751e6b --- /dev/null +++ b/src/atomworks/io/_loaders.py @@ -0,0 +1,183 @@ +"""Internal loading helpers for the parse pipeline.""" + +import io +import logging +from pathlib import Path +from typing import Literal + +import numpy as np +from biotite.file import InvalidFileError +from biotite.structure import AtomArray, AtomArrayStack +from biotite.structure.io import pdbx + +from atomworks.io.transforms.categories import get_metadata_from_category +from atomworks.io.utils.chain import create_chain_id_generator +from atomworks.io.utils.extra_fields import ExtraFieldsType, merge_extra_fields, normalize_extra_fields +from atomworks.io.utils.io_utils import get_structure, read_any +from atomworks.io.utils.standard_annotations.serialization import ( + _LEGACY_MASK_FIELDS, + STANDARD_ANNOTATIONS, + _detect_standard_annotation_categories, +) + +logger = logging.getLogger(__name__) + +COMMON_EXTRA_FIELDS = { + "label_entity_id": {}, + "atom_id": {}, + "b_factor": {}, + "occupancy": {}, + "charge": {}, + "auth_seq_id": {}, + "nhyd": {"dtype": np.int64, "default": 0}, + "label_alt_id": {}, +} + + +def load_cif( + filename: str | Path | io.StringIO | io.BytesIO, + *, + file_type: Literal["cif", "bcif", "mmjson"] | None = None, + model: int | None, + extra_fields: ExtraFieldsType | None, + load_standard_annotations: bool, + altloc: str = "first", + altloc_seed: int | None = None, +) -> tuple[AtomArrayStack | list[AtomArray], pdbx.CIFFile | pdbx.BinaryCIFFile, pdbx.CIFBlock, dict, list[int] | None]: + """Load a CIF and return atoms, file, block, metadata, and model IDs (None for a whole stack).""" + # ... read the CIF file (bytes or path) + cif_file = read_any(filename, file_type=file_type) + cif_block = cif_file.block + + # +------------ Metadata ------------+ + + # We require an "example_id" field in metadata; assign fallback if not present + if isinstance(filename, io.StringIO | io.BytesIO): + fallback_filename = next(iter(cif_file.keys())) + else: + fallback_filename = Path(filename).stem + + # ... initialize metadata dictionary from CIF categories (CIF-specific) + metadata = get_metadata_from_category(cif_block, fallback_id=fallback_filename) + + # +------------ Annotations (CIF-specific) ------------+ + + # NOTE: transformation_id is auto-injected by get_structure() for CIF files + requested_fields = normalize_extra_fields( + list(cif_block["atom_site"].keys()) if extra_fields == "all" else extra_fields + ) + common_extra_fields = dict(COMMON_EXTRA_FIELDS) + for name, spec in requested_fields.items(): + common_extra_fields[name] = common_extra_fields.get(name, {}) | { + key: value for key, value in spec.items() if value is not None + } + + # Sanity Check: extra_fields must not overlap with SA names saved as CIF extra-categories. + # Scalar 1-body SAs live in atom_site and can coexist with user-provided extra_fields without + # ambiguity. Nonscalar 1-body and 2-body SAs are saved as separate CIF categories, so + # specifying their names in extra_fields is an error. + if load_standard_annotations and extra_fields: + sa_categories = _detect_standard_annotation_categories(cif_block) + user_extra_fields = normalize_extra_fields(extra_fields) + for field_name in user_extra_fields: + if field_name in sa_categories: + annotation_cls = sa_categories[field_name] + raise ValueError( + f"Field '{field_name}' conflicts with StandardAnnotation '{annotation_cls.name}'. " + "When load_standard_annotations=True, StandardAnnotation fields must not appear " + "in extra_fields. Set load_standard_annotations=False to load them manually via extra_fields." + ) + + # When loading StandardAnnotations, extend common_extra_fields with all registered 1-body SA names + # so they are read from atom_site. Fields absent from the file are silently skipped. + if load_standard_annotations: + common_extra_fields = merge_extra_fields( + [*STANDARD_ANNOTATIONS.get_field_names(n_body=1, include_aliases=True), *_LEGACY_MASK_FIELDS.values()], + common_extra_fields, + ) + + # +------------ Structure ------------+ + + model_ids = list(dict.fromkeys(cif_block["atom_site"]["pdbx_PDB_model_num"].as_array(int))) + try: + asym_unit_stack = get_structure( + cif_file, extra_fields=common_extra_fields, model=model, altloc=altloc, altloc_seed=altloc_seed + ) + except InvalidFileError: + if model is not None: + logger.info("Invalid file error encountered; loading with only one model") + asym_unit_stack = get_structure(cif_file, extra_fields=common_extra_fields, model=1) + return asym_unit_stack, cif_file, cif_block, metadata, model_ids[:1] + # Variable-length multi-model: annotations differ between models, so biotite + # cannot stack them. Load each model individually by 1-based position. + atom_site = cif_block.get("atom_site") + if atom_site is not None and "pdbx_PDB_model_num" in atom_site: + arrays = [ + get_structure(cif_file, extra_fields=common_extra_fields, model=i) for i in range(1, len(model_ids) + 1) + ] + return arrays, cif_file, cif_block, metadata, model_ids + logger.info("Invalid file error encountered; loading with only one model") + asym_unit_stack = get_structure( + cif_file, extra_fields=common_extra_fields, model=1, altloc=altloc, altloc_seed=altloc_seed + ) + model = 1 + + selected_ids = None if model is None else [model_ids[model - 1 if model > 0 else model]] + return asym_unit_stack, cif_file, cif_block, metadata, selected_ids + + +def load_pdb( + filename: str | Path | io.StringIO | io.BytesIO, + *, + model: int | None, +) -> tuple[AtomArrayStack, dict]: + """Load a PDB file and return ``(atoms, metadata)``. + + We require that a single chain contains either polymer or non-polymer residues, + but not both. If the PDB file contains a chain with both, the non-polymer residues + will be separated onto a new chain ID. + + LINK and CONECT records have unspecified bond orders (``BondType.ANY``). + Preparation resolves these orders; loading leaves them unchanged. + """ + # ... read the PDB file (bytes or path) + pdb_file = read_any(filename) + + # +------------ Metadata ------------+ + + if isinstance(filename, io.StringIO | io.BytesIO): + fallback_id = "unknown" + else: + fallback_id = Path(filename).stem.lower() + metadata = {"id": fallback_id} + + # +------------ Structure ------------+ + + # NOTE: PDB file support is more limited than CIF since PDB format doesn't support multiple models, extra fields, alternative locations, etc. etc. + atom_array_stack = pdb_file.get_structure( + model=model, + altloc="first", + extra_fields=["b_factor", "occupancy", "charge", "atom_id"], + include_bonds=True, + ) + + # +------------ Normalizing PDB files ------------+ + + # ... if we have polymer and non-polymers on the same chain (as given by the HETATM field), we need to separate them for processing + hetero_atom_mask = atom_array_stack.get_annotation("hetero") + if np.any(hetero_atom_mask): + original_chain_ids = np.unique(atom_array_stack.chain_id) + chain_id_generator = create_chain_id_generator(unavailable_chain_ids=original_chain_ids) + for chain_id in original_chain_ids: + chain_hetero_annotations = atom_array_stack.hetero[atom_array_stack.chain_id == chain_id] + if np.any(chain_hetero_annotations) and np.any(~chain_hetero_annotations): + hetero_chain_id = next(chain_id_generator) + logger.warning( + f"Chain {chain_id} contains both polymer and non-polymer residues; " + f"separating them for processing, naming the non-polymer residues as {hetero_chain_id}." + ) + atom_array_stack.chain_id[(atom_array_stack.chain_id == chain_id) & hetero_atom_mask] = hetero_chain_id + updated_chain_hetero_annotations = atom_array_stack.hetero[atom_array_stack.chain_id == chain_id] + assert np.all(updated_chain_hetero_annotations) or np.all(~updated_chain_hetero_annotations) + + return atom_array_stack, metadata diff --git a/src/atomworks/io/_pipeline.py b/src/atomworks/io/_pipeline.py new file mode 100644 index 00000000..375813d3 --- /dev/null +++ b/src/atomworks/io/_pipeline.py @@ -0,0 +1,489 @@ +"""Internal processing pipeline for structure parsing with AtomWorks. + +Not intended for direct use by external callers; use :py:func:`~atomworks.io.parser.parse`, +:py:func:`~atomworks.io.parser.parse_atom_array`, or :py:func:`~atomworks.io.parser.prepare_atom_array` instead. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack +from biotite.structure.info import standardize_order +from biotite.structure.io import pdbx + +import atomworks.io.transforms.atom_array as ta +from atomworks.common import exists +from atomworks.constants import METAL_ELEMENTS +from atomworks.io import template +from atomworks.io.config import PrepareConfig +from atomworks.io.transforms.categories import ( + category_to_dict, + extract_crystallization_details, + get_ligand_of_interest_info, +) +from atomworks.io.utils.annotator import ensure_annotations, remove_annotations +from atomworks.io.utils.assembly import ( + build_assemblies_from_asym_unit, + get_identity_assembly_gen_category, + get_identity_op_expr_category, +) +from atomworks.io.utils.atom_array import annotate_and_remove_hydrogens, remove_components, remove_hydrogens +from atomworks.io.utils.atom_array_plus import as_atom_array_plus, as_atom_array_plus_stack, stack_any +from atomworks.io.utils.bonds import add_bonds_from_struct_conn, filter_bonds_by_distance +from atomworks.io.utils.ccd import add_annotations_from_ccd +from atomworks.io.utils.chain_info import build_chain_info, update_sequences_from_res_names +from atomworks.io.utils.extra_fields import ExtraFieldsType, normalize_extra_fields +from atomworks.io.utils.selection import get_annotation_categories +from atomworks.io.utils.standardize import standardize_atom_names +from atomworks.io.utils.testing import verify_atom_array_chain_info_consistency + +logger = logging.getLogger(__name__) + + +def _build_assemblies( + atoms: AtomArrayStack, + cif_block: pdbx.CIFBlock | None, + chain_info: dict, + *, + build_assembly: str | list[str] | None, + fix_ligands_at_symmetry_centers: bool, +) -> tuple[AtomArrayStack, dict, dict]: + """Build biological assemblies from the asymmetric unit and extract struct_oper categories.""" + extra_info: dict[str, Any] = { + "assembly_gen_category": None, + "struct_oper_category": None, + } + + # Pre-built assembly: generate annotations, add to assemblies dictionary, and store identity operation info + if "transformation_id" in atoms.get_annotation_categories(): + if exists(build_assembly): + assert not ( + cif_block is not None + and "pdbx_struct_oper_list" in cif_block + and "pdbx_struct_assembly_gen" in cif_block + ), ( + "Both transformation_id annotations and struct_oper categories detected. " + "These are mutually exclusive ways to define assemblies. Please remove one or the other." + ) + atoms = ta.add_iid_annotations(atoms, overwrite=True) + assemblies = {"1": atoms} + return atoms, assemblies, extra_info + + # Validate build_assembly if provided (not None) + if exists(build_assembly): + assert build_assembly in ["first", "all"] or isinstance( + build_assembly, list | tuple + ), "Invalid `build_assembly` option. Must be 'first', 'all', or a list/tuple of assembly IDs as strings." + + # Always read struct_oper categories from file (even if not building assemblies) + # These may be needed later for saving struct_oper info to CIF files + if cif_block is not None and "pdbx_struct_oper_list" in cif_block and "pdbx_struct_assembly_gen" in cif_block: + assembly_gen_category = cif_block["pdbx_struct_assembly_gen"] + struct_oper_category = cif_block["pdbx_struct_oper_list"] + else: + assembly_gen_category = get_identity_assembly_gen_category(list(chain_info.keys())) + struct_oper_category = get_identity_op_expr_category() + + # Store assembly info (always, even if not building) + extra_info["assembly_gen_category"] = assembly_gen_category + extra_info["struct_oper_category"] = struct_oper_category + + # Build assemblies if requested (build_assembly is not None) + if build_assembly is None: + # None means: don't build assemblies, return only asymmetric unit + assemblies = {} + else: + assemblies = build_assemblies_from_asym_unit( + assembly_gen_category=assembly_gen_category, + struct_oper_category=struct_oper_category, + asym_unit_atom_array_stack=atoms, + build_assembly=build_assembly, + fix_symmetry_centers=fix_ligands_at_symmetry_centers, + ) + return atoms, assemblies, extra_info + + +def _standardize_and_complete( + model: AtomArray, + chain_info: dict, + *, + extra_fields: ExtraFieldsType | None, + add_missing_atoms: bool, + ccd_mirror_path: str | None, + hydrogen_policy: str, +) -> AtomArray: + """Standardize atom names and add missing atoms (if configured).""" + if not add_missing_atoms: + return model + + # Normalize and validate extra_fields - require defaults when adding missing atoms + extra_field_specs = None + if extra_fields is not None and extra_fields != "all": + extra_field_specs = normalize_extra_fields(extra_fields, require_defaults=True) + # Preserve selected conformers through rebuilding; links must not cross reaction states (6DP5). + if "label_alt_id" in model.get_annotation_categories(): + extra_field_specs = dict(extra_field_specs or {}) + extra_field_specs["label_alt_id"] = {"default": ".", "dtype": model.label_alt_id.dtype} + + # Step 1: Standardize atom names (convert alt->std, filter non-matching) + # Non-standard atoms will be filtered out (and re-added by add_missing_atoms) + model = standardize_atom_names( + model, + on_mismatch="raise", # Raise if we have a heavy atom mismatch + on_mismatch_non_heavy="filter", # Remove non-standard non-heavy atoms (e.g., deuterium, tritium, etc.) + ccd_mirror_path=ccd_mirror_path, + non_canonical_only=False, # Standardize ALL residues to fix alternate atom names (H5''1->H5'', HB4->HB3, etc.) + ) + + # If removing hydrogens while still adding missing atoms, do so upfront (otherwise we may double-count) + if hydrogen_policy == "remove": + model = remove_hydrogens(model) + + # Step 2: Add missing atoms (includes standardize_order and atomic_number) + model = template.add_missing_atoms( + model, + chain_info_dict=chain_info, + ccd_mirror_path=ccd_mirror_path, + hydrogen_policy=hydrogen_policy, + extra_field_specs=extra_field_specs, + ) + + # Standardize order so that subsequent operations can assume a consistent ordering + model = model[standardize_order(model)] + + # Step 3: Add CCD annotations + # NOTE: We override the nhyd annotation when we build from CCD templates (but preserve if set for non-CCD residues) + assert "charge" in model.get_annotation_categories(), ( + "Charge annotation missing on model before add_annotations_from_ccd(). " + "Structures loaded via parse()/get_structure() always set charge — this assert guards against silent " + "zero-fills that would mask upstream charge-loss bugs." + ) + model = add_annotations_from_ccd( + model, + ccd_mirror_path=ccd_mirror_path, + hydrogen_policy=hydrogen_policy, + overwrite={"nhyd": True}, + ) + return model + + +def _add_bonds( + model: AtomArray, + cif_block: pdbx.CIFBlock | None, + *, + add_bond_types_from_struct_conn: tuple[str, ...], + struct_conn_distance_policy: str, + sanitize: bool, + ccd_mirror_path: str | None, +) -> AtomArray: + """Parse custom bonds, struct_conn bonds, and infer residue bonds.""" + # Parse CIF custom bonds (if CIF file available) + cif_custom_bonds = None + if cif_block is not None and "chem_comp_bond" in cif_block: + cif_custom_bonds = pdbx.convert._parse_intra_residue_bonds(cif_block["chem_comp_bond"]) + + # Add struct_conn bonds (disulfides, metal coordination, etc.) + if cif_block is not None: + model = add_bonds_from_struct_conn( + model, + cif_block, + add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, + struct_conn_distance_policy=struct_conn_distance_policy, + allow_missing_templates=not sanitize, + ) + + # Chemical preparation requires concrete orders; minimal parsing preserves unknown orders. + if sanitize and model.bonds is not None: + model.bonds.convert_bond_type(struc.BondType.ANY, struc.BondType.SINGLE) + + # Add intra- and inter-residue bonds (based on CCD identity and polymer sequence) + model = template.infer_bonds_from_residue_names( + model, + custom_bond_dict=cif_custom_bonds, # Use CIF bonds if available! + sanitize=sanitize, + ccd_mirror_path=ccd_mirror_path, + ) + + # Retype metal-incident bonds to COORDINATION so metals are typed consistently and (via the + # COORDINATION exclusion in add_id_and_entity_annotations) become their own molecule/entity. + model = _retype_metal_bonds_to_coordination(model) + return model + + +def _retype_metal_bonds_to_coordination(model: AtomArray) -> AtomArray: + """Set every bond incident to a metal atom to :py:attr:`BondType.COORDINATION`.""" + if model.bonds is None: + return model + + metal_mask = np.isin(np.char.upper(model.element.astype(str)), list(METAL_ELEMENTS)) + if not metal_mask.any(): + return model + + # A bond qualifies if either of its two endpoint atoms is a metal. + bonds = model.bonds.as_array() # (n_bonds, 3): atom_i, atom_j, bond_type + touches_metal = metal_mask[bonds[:, 0]] | metal_mask[bonds[:, 1]] + + if touches_metal.any(): + bonds[touches_metal, 2] = int(struc.BondType.COORDINATION) + model.bonds = struc.BondList(model.array_length(), bonds) + + return model + + +def _get_crystallization_details(cif_block: pdbx.CIFBlock | None) -> dict: + """Extract crystallization details from CIF.""" + if cif_block is not None and "exptl_crystal_grow" in cif_block: + return extract_crystallization_details(category_to_dict(cif_block, "exptl_crystal_grow")) + return {"pH": None} + + +def _get_ligand_info(cif_block: pdbx.CIFBlock | None) -> dict: + """Extract ligand info from CIF, or return defaults if cif_block is None.""" + if cif_block is not None: + return get_ligand_of_interest_info(cif_block) + return {"has_ligand_of_interest": False, "ligand_of_interest": []} + + +def _get_msa_paths_from_cif(cif_block: pdbx.CIFBlock | None) -> dict[str, Path]: + """Extract per-chain MSA paths from CIF block.""" + if cif_block is None or "msa_paths_by_chain_id" not in cif_block: + return {} + logger.info("MSA paths detected in CIF file. Adding to chain information...") + msa_paths = category_to_dict(cif_block, "msa_paths_by_chain_id") + return {chain_id: Path(msa_path.item()) for chain_id, msa_path in msa_paths.items()} + + +_TMP_ANNOTATIONS = ("leaving_atom_flag", "is_leaving_atom", "is_n_terminal_atom", "is_c_terminal_atom", "index") + + +def _maybe_promote_to_plus(atoms: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: + """Promote to the ``Plus`` variant (or return unchanged if already Plus).""" + if isinstance(atoms, AtomArrayStack): + return as_atom_array_plus_stack(atoms) + if isinstance(atoms, AtomArray): + return as_atom_array_plus(atoms) + return atoms + + +def _assemble_parse_result( + *, + atoms: AtomArrayStack, + chain_info: dict, + cif_block: pdbx.CIFBlock | None, + config: PrepareConfig, + build_assembly: str | list[str] | None, + metadata: dict, + keep_cif_block: bool = False, +) -> dict[str, Any]: + """Build assemblies, gather metadata, clean annotations, and return the result dict. + + This is the post-:py:func:`_prepare_atom_array_or_stack` step shared by + :py:func:`~atomworks.io.parser.parse` and + :py:func:`~atomworks.io.parser.parse_atom_array`. + """ + atoms, assemblies, extra_info = _build_assemblies( + atoms, + cif_block, + chain_info, + build_assembly=build_assembly, + fix_ligands_at_symmetry_centers=config.fix_ligands_at_symmetry_centers, + ) + + metadata["crystallization_details"] = _get_crystallization_details(cif_block) + ligand_info = _get_ligand_info(cif_block) + for chain_id, msa_path in _get_msa_paths_from_cif(cif_block).items(): + chain_info[chain_id]["msa_path"] = msa_path + + if config.add_missing_atoms: + verify_atom_array_chain_info_consistency( + chain_info=chain_info, + atom_array=atoms[0] if isinstance(atoms, AtomArrayStack) else atoms, + verify_sequences=True, + ) + + # Clean temporary annotations + remove_annotations(atoms, *_TMP_ANNOTATIONS) + for assembly in assemblies.values(): + remove_annotations(assembly, *_TMP_ANNOTATIONS) + + if config.return_atom_array_plus: + atoms = _maybe_promote_to_plus(atoms) + assemblies = {k: _maybe_promote_to_plus(v) for k, v in assemblies.items()} + + result: dict[str, Any] = { + "chain_info": chain_info, + "ligand_info": ligand_info, + "asym_unit": atoms, + "assemblies": assemblies, + "metadata": metadata, + "extra_info": extra_info, + } + if keep_cif_block: + result["cif_block"] = cif_block + return result + + +def _check_resid_ordering(atoms: AtomArray | AtomArrayStack) -> None: + """Raise ``ValueError`` if ``res_id`` is not non-decreasing within any chain.""" + arr = atoms[0] if isinstance(atoms, AtomArrayStack) else atoms + + # For assemblies with transformation_id, only check the first transformation + # since res_ids naturally repeat across transformation copies of the same chain. + if "transformation_id" in arr.get_annotation_categories(): + first_tid = arr.transformation_id[0] + arr = arr[arr.transformation_id == first_tid] + + for chain_id in np.unique(arr.chain_id): + res_ids = arr.res_id[arr.chain_id == chain_id] + if np.any(np.diff(res_ids) < 0): + raise ValueError(f"res_id values are not in non-decreasing order for chain '{chain_id}'") + + +def _prepare_atom_array_or_stack( + atoms: AtomArray | AtomArrayStack, + *, + cif_block: pdbx.CIFBlock | None, + config: PrepareConfig, + extra_fields: ExtraFieldsType | None = None, +) -> tuple[AtomArrayStack, dict]: + """Prepare an AtomArray or AtomArrayStack with standardized annotations, bonds, and chain info. + + This is the shared core of :py:func:`~atomworks.io.parser.parse`, + :py:func:`~atomworks.io.parser.parse_atom_array`, and + :py:func:`~atomworks.io.parser.prepare_atom_array`. + """ + has_cif = cif_block is not None + + if get_annotation_categories(atoms, n_body=2) and has_cif: + raise ValueError( + "Providing a CIF file is not supported when parsing an AtomArrayPlus or AtomArrayPlusStack " + "that contains 2-body annotations. Consider using parse() instead, which accepts CIF files directly." + ) + + if exists(extra_fields) and not has_cif: + logger.warning("The `extra_fields` argument will be ignored if there is no CIF file input.") + + # +------ Initialization ------+ + + if atoms.bonds is None: + atoms.bonds = struc.BondList(atoms.array_length()) + + if "label_entity_id" not in atoms.get_annotation_categories(): + # NOTE: int16 (not int8) + if "chain_entity" in atoms.get_annotation_categories(): + atoms.set_annotation("label_entity_id", atoms.chain_entity.astype(np.int16)) + else: + atoms.set_annotation( + "label_entity_id", + pdbx.convert._determine_entity_id(atoms.chain_id).astype(np.int16), + ) + + if "occupancy" not in atoms.get_annotation_categories(): + atoms.set_annotation("occupancy", np.ones(atoms.array_length())) + + atoms = ta.ensure_atom_array_stack(atoms) + + if "transformation_id" in atoms.get_annotation_categories(): + atoms.transformation_id = np.array(atoms.transformation_id, dtype=str) + + atoms = remove_components(atoms, remove_waters=config.remove_waters) + + _check_resid_ordering(atoms) + + # +------ Chain info and structural annotations ------+ + + cif_cats = {} + if cif_block is not None: + for name in ("entity", "entity_poly", "entity_poly_seq"): + if name in cif_block: + cif_cats[name] = category_to_dict(cif_block, name) + + chain_info = build_chain_info( + atoms, + entity=cif_cats.get("entity"), + entity_poly=cif_cats.get("entity_poly"), + entity_poly_seq=cif_cats.get("entity_poly_seq"), + ) + + if "auth_seq_id" in atoms.get_annotation_categories(): + # Replace non-polymeric chain sequence ids with author sequence ids (since the non-polymer sequence ID's are not informative) + # TODO: Regenerate regression tests and remove this function altogether (we don't need it anymore given Biotite upgrade) + atoms = ta.update_nonpoly_seq_ids(atoms, chain_info) + + atoms = ta.add_polymer_annotation(atoms, chain_info) + atoms = ta.add_chain_type_annotation(atoms, chain_info) + + if config.convert_mse_to_met: + atoms = ta.mse_to_met(atoms, chain_info=chain_info) + + # +------ Per-model processing ------+ + + models = [] + for model_idx in range(atoms.stack_depth()): + model = atoms[model_idx] + model = _standardize_and_complete( + model, + chain_info, + extra_fields=extra_fields, + add_missing_atoms=config.add_missing_atoms, + ccd_mirror_path=config.ccd_mirror_path, + hydrogen_policy=config.hydrogen_policy, + ) + + # NOTE: Relies on the custom CCD registry for inferring chemical component types + model = _add_bonds( + model, + cif_block, + add_bond_types_from_struct_conn=config.add_bond_types_from_struct_conn, + struct_conn_distance_policy=config.struct_conn_distance_policy, + sanitize=config.add_missing_atoms, + ccd_mirror_path=config.ccd_mirror_path, + ) + + # Coordinate-dependent: must stay per-model + if config.fix_arginines: + model = ta.resolve_arginine_naming_ambiguity(model, raise_on_error=False) + + if config.long_bond_policy != "keep": + model = filter_bonds_by_distance(model, policy=config.long_bond_policy) + + models.append(model) + + atoms = stack_any(models) + + # Filter candidates only after covalent bonds and distance policies are resolved. + if config.remove_ccds: + n_atoms = atoms.array_length() + atoms = remove_components(atoms, remove_waters=False, remove_ccds=config.remove_ccds) + if atoms.array_length() != n_atoms: + remaining_chains = set(atoms.chain_id) + chain_info = {chain_id: info for chain_id, info in chain_info.items() if chain_id in remaining_chains} + for chain_id, info in chain_info.items(): + # Preserve unresolved sequence entries unless they are explicitly excluded. + keep = ~np.isin(info["res_name"], [ccd.upper() for ccd in config.remove_ccds]) | np.isin( + info["res_id"], atoms.res_id[atoms.chain_id == chain_id] + ) + if not keep.all(): + for key in ("res_id", "res_name"): + info[key] = np.asarray(info[key])[keep].tolist() + update_sequences_from_res_names(info) + + # +------ Stack-wide annotations ------+ + + if config.hydrogen_policy == "remove" and not config.add_missing_atoms: + atoms = annotate_and_remove_hydrogens(atoms, increment=True) + + if config.add_id_and_entity_annotations: + atoms = ta.add_id_and_entity_annotations(atoms) + + ensure_annotations(atoms, "atomic_number", "chem_comp_type") + + # Clean temp annotations so callers get a tidy result + remove_annotations(atoms, *_TMP_ANNOTATIONS) + + return atoms, chain_info diff --git a/src/atomworks/io/config.py b/src/atomworks/io/config.py new file mode 100644 index 00000000..c9da7fba --- /dev/null +++ b/src/atomworks/io/config.py @@ -0,0 +1,257 @@ +"""Configuration dataclasses for AtomWorks structure loading. + +We define two main dataclasses for configuration: +- :py:class:`PrepareConfig`: Options that can be applied to any structure, regardless of input format. This includes things like adding missing atoms, adding bonds, etc. +- :py:class:`ParseConfig`: Configuration for the :py:func:`~atomworks.io.parser.parse` function, which extends :py:class:`PrepareConfig` with file/parse-specific fields. +""" + +from __future__ import annotations + +import dataclasses +import os +import tempfile +import warnings +from typing import Literal, TypeVar + +from atomworks.constants import CCD_MIRROR_PATH, CRYSTALLIZATION_AIDS +from atomworks.io.utils.extra_fields import ExtraFieldsType + +# fmt: off +LongBondPolicyType = Literal["keep", "filter", "warn", "raise", "filter_nonstandard_only", "warn_nonstandard_only", "raise_nonstandard_only"] +# fmt: on + + +@dataclasses.dataclass(frozen=True) +class PrepareConfig: + """Configuration for the AtomWorks processing pipeline. + + Use :py:func:`get_config` or :py:meth:`from_preset` to construct from a named + preset with optional overrides:: + + config = PrepareConfig.from_preset("rcsb") + config = PrepareConfig.from_preset("lightweight", fix_arginines=False) + + Args: + add_missing_atoms: Whether to add missing atoms to the structure (from entirely or + partially unresolved residues). Implies sanitization of bonds and charges after + bond inference (removing leaving atoms, fixing bond orders for nucleophilic + additions, correcting formal charges, and fixing charged amide nitrogens), which + is only well-defined once templates supply the true hydrogen count per atom. + When ``False``, links without CCD templates retain unknown bond orders. + Defaults to ``True``. + add_bond_types_from_struct_conn: Bond types to add from the ``struct_conn`` + CIF category. Defaults to ``("covale",)``, meaning only covalent bonds are + added (excluding metal coordination and disulfide bonds). + remove_ccds: CCD codes (e.g. ``"GOL"``, ``"EDO"``) to remove unless covalently + connected to a non-excluded residue. Defaults to common crystallization aids. + Coordination does not protect candidates; candidate-only molecules are removed. + remove_waters: Whether to remove water molecules. Defaults to ``True``. + fix_ligands_at_symmetry_centers: Whether to patch non-polymer residues at + symmetry centers that clash with themselves when transformed. Defaults to ``True``. + fix_arginines: Whether to fix arginine naming ambiguity (ensuring NH1 is always + closer to CD than NH2); see the AF-3 supplement for details. Defaults to ``True``. + long_bond_policy: How to handle bonds whose length exceeds expected thresholds. + Options: ``"keep"``, ``"filter"``, ``"warn"``, ``"raise"``, + ``"filter_nonstandard_only"``, ``"warn_nonstandard_only"``, + ``"raise_nonstandard_only"``. Defaults to ``"warn"``. + add_id_and_entity_annotations: Whether to add identifier and entity annotations + (``pn_unit_id``, ``molecule_id``, ``chain_entity``, ``pn_unit_entity``, + ``molecule_entity``) to the structure. Defaults to ``True``. + convert_mse_to_met: Whether to convert selenomethionine (MSE) residues to + methionine (MET), as described in the AF-3 supplement. Defaults to ``False``. + hydrogen_policy: Whether to keep or remove hydrogens. Options: ``"keep"``, + ``"remove"``. Defaults to ``"keep"``. + ccd_mirror_path: Path to a local mirror of the Chemical Component Dictionary. + If ``None``, Biotite's built-in CCD will be used (if the environment is configured). + return_atom_array_plus: If ``True``, promote all AtomArray / AtomArrayStack objects in the + result dict to their AtomArrayPlus equivalents. + """ + + add_missing_atoms: bool = True + add_bond_types_from_struct_conn: tuple[str, ...] = ("covale",) + remove_ccds: tuple[str, ...] = tuple(CRYSTALLIZATION_AIDS) + remove_waters: bool = True + fix_ligands_at_symmetry_centers: bool = True + fix_arginines: bool = True + long_bond_policy: LongBondPolicyType = "warn" + struct_conn_distance_policy: LongBondPolicyType = "filter" + add_id_and_entity_annotations: bool = True + convert_mse_to_met: bool = False + hydrogen_policy: Literal["keep", "remove"] = "keep" + ccd_mirror_path: str | None = CCD_MIRROR_PATH + return_atom_array_plus: bool = False + + def __post_init__(self): + if self.ccd_mirror_path and not os.path.exists(self.ccd_mirror_path): + raise FileNotFoundError( + f"Local mirror of the Chemical Component Dictionary provided but does not exist: {self.ccd_mirror_path}. " + "To use Biotite's built-in CCD, set `ccd_mirror_path` to None (and ensure the environment is configured)." + ) + + def replace(self, **kwargs) -> PrepareConfig: + """Return a new config with the given fields replaced.""" + return dataclasses.replace(self, **kwargs) + + @classmethod + def from_dict(cls, d: dict) -> PrepareConfig: + """Construct from a dict, ignoring unknown keys.""" + field_names = {f.name for f in dataclasses.fields(cls)} + return cls(**{k: v for k, v in d.items() if k in field_names}) + + def to_dict(self) -> dict: + """Convert to a plain dict.""" + return dataclasses.asdict(self) + + @classmethod + def from_preset(cls, preset: str = "default", **overrides) -> PrepareConfig: + """Build from a named preset with optional overrides.""" + return get_config(preset, cls=cls, **overrides) + + +@dataclasses.dataclass(frozen=True) +class ParseConfig(PrepareConfig): + """Configuration for :py:func:`~atomworks.io.parser.parse` when loading from files. + + Extends :py:class:`PrepareConfig` with file/parse-specific fields. + Use :py:func:`get_config` or :py:meth:`from_preset` to construct from a named + preset with optional overrides:: + + config = ParseConfig.from_preset("rcsb") + config = ParseConfig.from_preset("rcsb", model=0, build_assembly="first") + + Args: + model: Model number to parse for files with multiple models (e.g., NMR). + ``None`` loads all models. Defaults to ``None``. + build_assembly: Which biological assembly to build. Options: ``None`` + (asymmetric unit only), ``"first"``, ``"all"``, or a list/tuple of + assembly IDs as strings. Defaults to ``"all"``. + extra_fields: Extra CIF fields to include in the AtomArrayStack. ``None`` + includes no extra fields; ``"all"`` includes all fields. Only supported + for CIF files. Defaults to ``None``. + file_type: File type of the structure file. If ``None``, inferred + automatically from the file extension. Defaults to ``None``. + altloc: Which alternate conformer to select. ``"first"`` selects the + alphabetically-first altloc per chain, ``"random_per_chain"`` selects a random altloc per + chain, ``"random_clash_aware"`` selects one altloc per residue while + avoiding steric clashes between nearby residues, or a specific letter + (e.g. ``"A"``). Only supported for CIF files; PDB files always use + ``"first"``. Defaults to ``"first"``. + altloc_seed: Seed for deterministic random altloc selection when + ``altloc="random_per_chain"`` or ``altloc="random_clash_aware"``. + If ``None`` (default), selection is non-deterministic. + load_standard_annotations: Whether to deserialize StandardAnnotations from + the CIF block. Incompatible with ``add_missing_atoms=True`` and PDB files. Requires + ``return_atom_array_plus=True``. + Defaults to ``False``. + keep_cif_block: Whether to include the raw CIF block in the result + dictionary. Defaults to ``False``. + cache_dir: Cache directory; auto-defaults to ``/atomworks_parse_cache`` when caching is enabled. Defaults to ``None``. + save_to_cache: Whether to save results as zstd-3 compressed pickles after parsing. + Defaults to ``False``. + load_from_cache: Whether to load complete parsed results from cache. Keys include parsing + options, package versions, and the file path or buffer contents. Unseeded + random altlocs and active external CCD overrides bypass caching. Defaults to ``False``. + cif_ccd_on_mismatch: How authored ``chem_comp_atom`` templates treat atoms absent + from the available CCD template: ``"error_heavy"`` (default) rejects unmatched + heavy atoms; ``"error"`` rejects any unmatched atom; ``"ignore"`` keeps the + component without CCD supplementation if heavy-atom names mismatch. + Heavy atoms must consistently use standard or alternate CCD names. + """ + + model: int | None = None + build_assembly: Literal["first", "all"] | list[str] | tuple[str] | None = "all" + extra_fields: ExtraFieldsType = None + file_type: Literal["cif", "pdb", "bcif", "mmjson"] | None = None + altloc: Literal["first", "random_per_chain", "random_clash_aware"] | str = "first" + altloc_seed: int | None = None + load_standard_annotations: bool = False + keep_cif_block: bool = False + cache_dir: str | None = None + save_to_cache: bool = False + load_from_cache: bool = False + cif_ccd_on_mismatch: Literal["ignore", "error_heavy", "error"] = "error_heavy" + + def __post_init__(self): + super().__post_init__() + if self.load_standard_annotations and self.add_missing_atoms: + raise ValueError( + "load_standard_annotations=True is incompatible with add_missing_atoms=True. " + "Standard annotations cannot be preserved through add_missing_atoms because atom " + "indices change. Load with add_missing_atoms=False, or set " + "load_standard_annotations=False and reconstruct annotations afterward." + ) + if self.load_standard_annotations and self.convert_mse_to_met: + raise ValueError( + "load_standard_annotations=True is incompatible with convert_mse_to_met=True. " + "MSE to MET conversion changes atom indices, which invalidates standard annotations. " + "Set convert_mse_to_met=False when loading standard annotations." + ) + if self.load_standard_annotations and not self.return_atom_array_plus: + raise ValueError("load_standard_annotations=True requires return_atom_array_plus=True.") + if (self.load_from_cache or self.save_to_cache) and not self.cache_dir: + # Default to node-local temp (fast NVMe on GPU nodes); frozen dataclass -> setattr. + object.__setattr__(self, "cache_dir", os.path.join(tempfile.gettempdir(), "atomworks_parse_cache")) + + +_PRESETS: dict[str, dict] = { + "default": {}, + "rcsb": {"convert_mse_to_met": True}, + "lightweight": { + "add_missing_atoms": False, + }, + "annotations_only": { + "add_missing_atoms": False, + "fix_arginines": False, + "long_bond_policy": "keep", + }, + "minimal": { + "add_missing_atoms": False, + "fix_arginines": False, + "add_id_and_entity_annotations": False, + "long_bond_policy": "keep", + "remove_waters": False, + "remove_ccds": (), + }, +} + +T = TypeVar("T", bound=PrepareConfig) + + +def get_config(preset: str = "default", *, cls: type[T] = PrepareConfig, **overrides) -> T: + """Build a config from a named preset with optional overrides. + + Args: + preset: Preset name. Available: ``"default"``, ``"rcsb"``, ``"lightweight"``, + ``"annotations_only"``, ``"minimal"``. + cls: Config class to instantiate. Defaults to :py:class:`PrepareConfig`. + **overrides: Fields to override on the preset. + """ + if preset not in _PRESETS: + raise ValueError(f"Unknown preset {preset!r}. Available: {sorted(_PRESETS)}") + return cls(**{**_PRESETS[preset], **overrides}) + + +def _resolve_config( + config: str | T | None, + kwargs: dict, + cls: type[T] = PrepareConfig, +) -> tuple[T, dict]: + """Separate config fields from non-config kwargs. Warns on bare config kwargs.""" + config_fields = {f.name for f in dataclasses.fields(cls)} + config_kwargs = {k: kwargs.pop(k) for k in list(kwargs) if k in config_fields} + + if config_kwargs: + warnings.warn( + "Passing processing kwargs directly is deprecated; use config instead.", + DeprecationWarning, + stacklevel=3, + ) + + if isinstance(config, str): + config = get_config(config, cls=cls, **config_kwargs) + elif config is None: + config = cls(**config_kwargs) if config_kwargs else cls() + elif config_kwargs: + config = config.replace(**config_kwargs) + + return config, kwargs diff --git a/src/atomworks/io/parser.py b/src/atomworks/io/parser.py index d57e75fe..cfcba6e8 100644 --- a/src/atomworks/io/parser.py +++ b/src/atomworks/io/parser.py @@ -1,213 +1,187 @@ -"""Entrypoint for parsing atomic-level structure files into Biotite-compatible data structures. +"""Entrypoint for parsing and preparing atomic-level structures with AtomWorks. -This module provides functionality for parsing PDB, CIF, and other structure files -into Biotite-compatible data structures with various processing options. +We provide three public functions to cover the main use cases: -References: - `Biotite Structure I/O `_ - `mmCIF Format Specification `_ -""" +* :py:func:`parse` — load a file and return a full result dictionary + (``chain_info``, ``assemblies``, ``metadata``, etc.). +* :py:func:`parse_atom_array` — process an existing AtomArray and return a + result dictionary matching :py:func:`parse` output. +* :py:func:`prepare_atom_array` — process an existing AtomArray with AtomWorks' common + annotations and return the processed atoms directly, without the surrounding metadata. -from __future__ import annotations +To control the options for parsing and preparing, either: +(a) Use the ``config`` argument to pass a configuration object (e.g. :py:class:`~atomworks.io.config.ParseConfig`) or preset name (e.g. ``"rcsb"``). +(b) (Legacy) Pass bare keyword arguments to :py:func:`parse` (e.g. ``add_missing_atoms=True``) +""" +import contextlib +import hashlib import io +import json import logging import os -from datetime import datetime +import tempfile +import warnings +from collections.abc import Callable from pathlib import Path -from typing import Any, Literal +from typing import Any -import biotite.structure as struc -import numpy as np +import biotite import pandas as pd -from biotite.file import InvalidFileError from biotite.structure import AtomArray, AtomArrayStack -from biotite.structure.io import pdbx -from toolz import keyfilter - -import atomworks.io.transforms.atom_array as ta -from atomworks.common import exists, string_to_md5_hash -from atomworks.constants import CCD_MIRROR_PATH, CRYSTALLIZATION_AIDS, WATER_LIKE_CCDS -from atomworks.io import template -from atomworks.io.transforms.categories import ( - category_to_dict, - extract_crystallization_details, - get_ligand_of_interest_info, - get_metadata_from_category, - initialize_chain_info_from_category, - load_monomer_sequence_information_from_category, -) -from atomworks.io.utils.assembly import build_assemblies_from_asym_unit -from atomworks.io.utils.atom_array_plus import AtomArrayPlus, AtomArrayPlusStack, stack_any -from atomworks.io.utils.bonds import get_struct_conn_dict_from_atom_array -from atomworks.io.utils.ccd import check_ccd_codes_are_available -from atomworks.io.utils.chain import create_chain_id_generator -from atomworks.io.utils.io_utils import ( - apply_sharding_pattern, - build_sharding_pattern, - get_structure, - infer_pdb_file_type, - read_any, + +import atomworks +from atomworks.io._loaders import load_cif, load_pdb +from atomworks.io._pipeline import _assemble_parse_result, _maybe_promote_to_plus, _prepare_atom_array_or_stack +from atomworks.io.config import ParseConfig, PrepareConfig, _resolve_config, get_config +from atomworks.io.utils.atom_array_plus import AtomArrayPlus, AtomArrayPlusStack +from atomworks.io.utils.catcif import resolve_catcif_source +from atomworks.io.utils.ccd import ( + build_ccd_entries_from_cif_block, + custom_ccd_residues, + get_custom_ccd_entries, + snapshot_custom_ccd_registry, ) -from atomworks.io.utils.non_rcsb import ( - get_identity_assembly_gen_category, - get_identity_op_expr_category, - initialize_chain_info_from_atom_array, +from atomworks.io.utils.extra_fields import ExtraFieldsType +from atomworks.io.utils.io_utils import infer_pdb_file_type +from atomworks.io.utils.standard_annotations.serialization import ( + _deserialize_standard_annotations, + _handle_legacy_standard_annotations, ) logger = logging.getLogger("atomworks.io") -__all__ = ["parse"] - -STANDARD_PARSER_ARGS = { - "add_missing_atoms": True, - "add_id_and_entity_annotations": True, - "add_bond_types_from_struct_conn": ("covale",), - "remove_ccds": tuple(CRYSTALLIZATION_AIDS), - "remove_waters": True, - "fix_ligands_at_symmetry_centers": True, - "fix_arginines": True, - "fix_formal_charges": True, - "fix_bond_types": True, - "convert_mse_to_met": True, # Changed from False to True vs. atomworks.io.parser.parse default - "hydrogen_policy": "keep", - "model": None, # all models -} -"""Common cif parser arguments for `atomworks.io.parse` for many biomolecular use cases. - -Similar to the defaults below but additionally converts selenomethionine (MSE) residues to methionine (MET) residues, -which is desirable for many practical applications but would not be appropriate as a universal default. - -This dictionary exists to provide a convenient import for the standard parameters. -""" - -# Cache sharding configuration (internal, not exposed to parse() to avoid complexity) -_CACHE_SHARDING_DEPTH = 2 # Use 2-level sharding by default (e.g., ab/cd/abcdef123456/) -_CACHE_SHARDING_CHARS_PER_DIR = 2 # Number of characters per directory level - - -def _get_atomworks_version() -> str: - """Lazy import of atomworks version to avoid circular imports.""" - try: - from atomworks import __version__ +__all__ = ["ParseConfig", "get_config", "parse", "parse_atom_array", "prepare_atom_array"] - return __version__ - except ImportError: - return "unknown" +STANDARD_PARSER_ARGS = get_config("rcsb").to_dict() +STANDARD_PARSER_ARGS["model"] = None +"""Common parser arguments for many biomolecular use cases (deprecated, use ``get_config("rcsb")``). Will be removed in a future version.""" -def _parse_args_to_hash(parse_arguments: dict[str, Any], truncate: int = 8) -> str: - """Compute hash from parse arguments with sorted keys.""" - args_string = ",".join(str(parse_arguments[k]) for k in sorted(parse_arguments.keys())) - return string_to_md5_hash(args_string, truncate=truncate) +def _restore_legacy_annotations_in_result(result: dict[str, Any]) -> None: + """Restore legacy condition targets in each model's final assembly frame.""" + result["asym_unit"] = _maybe_promote_to_plus(result["asym_unit"]) + _handle_legacy_standard_annotations(result["asym_unit"]) + for assembly_id, assembly in result["assemblies"].items(): + assembly = _maybe_promote_to_plus(assembly) + _handle_legacy_standard_annotations(assembly) + result["assemblies"][assembly_id] = assembly def _build_cache_file_path( cache_dir: Path, - args_hash: str, - filename: os.PathLike, - assembly_info: str, + source: os.PathLike | io.StringIO | io.BytesIO | Callable, + config: ParseConfig, + cache_key: str | None = None, ) -> Path: - """Build sharded cache file path for parsed structure.""" - structure_id = Path(filename).stem + """Key complete parse results by source, parsing options, and package versions. + + ``cache_key`` supplies an immutable source identity without reading or invoking + ``source``; it must change when the source contents change. Otherwise, use the + resolved file path or hash the buffer contents, type, and position. + """ + options = config.to_dict() + for name in ("cache_dir", "load_from_cache", "save_to_cache"): + options.pop(name) + identity = { + "atomworks": atomworks.__version__, + "biotite": biotite.__version__, + "options": options, + } + if cache_key is not None: + identity["cache_key"] = cache_key + # The explicit identity replaces content hashing; updating the digest with empty bytes is a no-op. + content = b"" + elif isinstance(source, io.StringIO | io.BytesIO): + identity.update(buffer_type=type(source).__name__, position=source.tell()) + content = source.getvalue() + content = content.encode("utf-8") if isinstance(content, str) else content + else: + identity["path"] = str(Path(source).resolve()) + content = b"" + digest = hashlib.sha256(json.dumps(identity, sort_keys=True, default=str).encode()) + digest.update(content) + key = digest.hexdigest() + return cache_dir / "parse-v2" / key[:2] / key[2:4] / f"{key}.pkl.zst" + + +def _make_cache_dirs_world_writable(path: Path) -> None: + """Create ``path`` and any missing parents, each world-writable (``0o777``).""" + newly_created = [] + cursor = path + while not cursor.exists(): + newly_created.append(cursor) + cursor = cursor.parent + path.mkdir(parents=True, exist_ok=True) + for directory in newly_created: + with contextlib.suppress(PermissionError, FileNotFoundError): + directory.chmod(0o777) + + +def _atomic_write_pickle(obj: Any, path: Path) -> None: + """Write a zstd-3 pickle atomically so concurrent readers never see a partial file.""" + _make_cache_dirs_world_writable(path.parent) + # Keep the codec suffix; same-directory replacement is atomic. + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f"{path.name}.tmp.", suffix=path.suffix) + os.close(fd) + try: + pd.to_pickle(obj, tmp_name, compression={"method": "zstd", "level": 3}) + os.chmod(tmp_name, 0o666) + os.replace(tmp_name, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # don't leave a partial temp behind on failure + raise - # Pad structure ID to minimum required length for sharding - min_length = _CACHE_SHARDING_DEPTH * _CACHE_SHARDING_CHARS_PER_DIR - structure_id_padded = structure_id.ljust(min_length, "_") - # Build sharded path - sharding_pattern = build_sharding_pattern(depth=_CACHE_SHARDING_DEPTH, chars_per_dir=_CACHE_SHARDING_CHARS_PER_DIR) - sharded_path = apply_sharding_pattern(structure_id_padded, sharding_pattern) +def _attach_live_ccd_registry(results: list[dict[str, Any]]) -> None: + """Snapshot the currently-scoped custom CCD registry and attach it to each result's ``asym_unit``.""" + ccd_registry = snapshot_custom_ccd_registry() - return cache_dir / args_hash / sharded_path / f"{structure_id}_assembly_{assembly_info}.pkl.gz" + if not ccd_registry: + # No custom CCD entries currently registered + return + + # Attach to all AtomArrayPlus-type objects + for r in results: + if isinstance(r["asym_unit"], AtomArrayPlus | AtomArrayPlusStack): + r["asym_unit"]._custom_ccd_registry = ccd_registry + for assembly in r["assemblies"].values(): + if isinstance(assembly, AtomArrayPlus | AtomArrayPlusStack): + assembly._custom_ccd_registry = ccd_registry def parse( - filename: os.PathLike | io.StringIO | io.BytesIO, + source: os.PathLike | io.StringIO | io.BytesIO | Callable[[], io.StringIO | io.BytesIO] | None = None, *, - file_type: Literal["cif", "pdb", "mmjson"] | None = None, - ccd_mirror_path: os.PathLike | None = CCD_MIRROR_PATH, - cache_dir: os.PathLike | None = None, - save_to_cache: bool = False, - load_from_cache: bool = False, - add_missing_atoms: bool = True, - add_id_and_entity_annotations: bool = True, - add_bond_types_from_struct_conn: list[str] = ["covale"], - remove_ccds: list[str] | None = None, - remove_waters: bool = True, - fix_ligands_at_symmetry_centers: bool = True, - fix_arginines: bool = True, - fix_formal_charges: bool = True, - fix_bond_types: bool = True, - convert_mse_to_met: bool = False, - hydrogen_policy: Literal["keep", "remove", "infer"] = "keep", - model: int | None = None, - build_assembly: Literal["first", "all"] | list[str] | tuple[str] | None = "all", - extra_fields: list[str] | Literal["all"] | None = None, - keep_cif_block: bool = False, + config: str | ParseConfig | None = None, + filename: os.PathLike | io.StringIO | io.BytesIO | None = None, + cache_key: str | None = None, + **kwargs, ) -> dict[str, Any]: - """Entrypoint for general parsing of atomic-level structure files. - - Can either: - - Directly load structure from file, using the specified keyword arguments; - - Load the structure from a cached directory, re-building bioassemblies on-the-fly if necessary; or - - Perform analogous cleaning/processing steps on an existing AtomArray or AtomArrayStack. + """Parse structural files into an AtomArrayStack with standardized annotations and metadata. - We categorize arguments into two groups: - - Wrapper arguments: Arguments that are used within the wrapping parse method (e.g., caching) - - CIF parsing arguments: Arguments that control structure parsing and are ultimately are passed - to the _parse_from_atom_array method (regardless of file type, we convert to an AtomArray before parsing) + Processing behaviour is controlled by :py:class:`~atomworks.io.config.ParseConfig`. + Legacy bare keyword arguments (e.g. ``add_missing_atoms=True``) are still accepted + but will emit a :py:class:`DeprecationWarning`; prefer passing a ``config`` object. Args: - filename (PathLike | io.StringIO | io.BytesIO): Either a Path or buffer to the file. This may be any format of - atomic-level structure (e.g. .cif, .bcif, .cif.gz, .pdb), although .cif files are strongly recommended. - - **Wrapper arguments:** - file_type (Literal["cif", "pdb", "mmjson"] | None, optional): The file type of the structure file. - If not provided, the file type will be inferred automatically. - load_from_cache (bool, optional): Whether to load pre-compiled results from cache. Defaults to False. - cache_dir (PathLike, optional): Directory path to save pre-compiled results. Defaults to None. - save_to_cache (bool, optional): Whether to save the results to cache when building the structure. Defaults to False. - - **Parsing arguments:** - ccd_mirror_path (str, optional): Path to the local mirror of the Chemical Component Dictionary (recommended). - If not provided, Biotite's built-in CCD will be used. - add_missing_atoms (bool, optional): Whether to add missing atoms to the - structure (from entirely or partially unresolved residues). Defaults to True. - add_id_and_entity_annotations (bool, optional): Whether to add identifier and entity - annotations to the structure. Defaults to True. - add_bond_types_from_struct_conn (list, optional): A list of bond types to add to the structure - from the `struct_conn` category. Defaults to `["covale"]`. This means that we will only - add covalent bonds to the structure (excluding metal coordination and disulfide bonds). - remove_ccds (list, optional): A list of CCD codes (e.g. `ALA`, `HEM`, ...) to remove from - the structure. Defaults to crystallization aids. NOTE: Exclusion of polymer - residues and common multi-chain ligands must be done with care to avoid sequence gaps. - remove_waters (bool, optional): Whether to remove water molecules from the - structure. Defaults to True. - fix_ligands_at_symmetry_centers (bool, optional): Whether to patch non-polymer residues - at symmetry centers that clash with themselves when transformed. Defaults to True. - fix_arginines (bool, optional): Whether to fix arginine naming ambiguity, see the - AF-3 supplement for details. Defaults to True. - fix_formal_charges (bool, optional): Whether to fix formal charges on atoms involved in inter-residue bonds. - Defaults to True. - fix_bond_types (bool, optional): Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. - Defaults to True. - convert_mse_to_met (bool, optional): Whether to convert selenomethionine (MSE) - residues to methionine (MET) residues. Defaults to False. - hydrogen_policy (Literal, optional): Whether to keep, remove or infer hydrogens using - biotite-hydride (will remove existing hydrogens and infer fresh). - Defaults to "keep". Options: "keep", "remove", "infer". - model (int, optional): The model number to parse for files with multiple models (e.g., NMR). - Defaults to all models (None). - build_assembly (string, list, or tuple, optional): Specifies which assembly to build, if any. Options are None - (e.g., asymmetric unit), "first", "all", or a list or tuple of assembly IDs. Defaults to "all". - extra_fields (list, optional): A list of extra fields to include in the AtomArrayStack. Defaults to None. "all" includes all fields. - Only supports mmCIF files. - keep_cif_block (bool, optional): Whether to keep the CIF block in the result. Defaults to False. + source: Path or buffer to the structure file. May be any format of + atomic-level structure (e.g. .cif, .bcif, .pdb), optionally compressed + with gzip (.gz/.gzip) or Zstandard (.zst), + although .cif files are strongly recommended. A callable returning a fresh buffer + is evaluated only on cache misses and requires ``cache_key`` and ``config.file_type``. + config: Processing configuration. Pass a preset name (``"default"``, + ``"rcsb"``, ``"lightweight"``, ``"minimal"``), a + :py:class:`~atomworks.io.config.ParseConfig` instance, or ``None`` + for defaults. + filename: Deprecated alias for ``source``. Cannot be used together with ``source``. + cache_key: Immutable source identity for deferred reads, replacing content hashing. + Must change whenever source contents change; parser settings and package versions + are included automatically. Cached results still honor the config's cache flags. Returns: dict: A dictionary containing the following keys: - chain_info A dictionary mapping chain ID to sequence, type (as an IntEnum), RCSB entity, EC number, and other information. @@ -223,643 +197,215 @@ def parse( extra_info A dictionary with information for cross-compatibility and caching. Should typically not be used directly. - """ - if ccd_mirror_path and not os.path.exists(ccd_mirror_path): - raise FileNotFoundError( - f"Local mirror of the Chemical Component Dictionary does not exist: {ccd_mirror_path}. " - "To use Biotite's built-in CCD, set `ccd_mirror_path` to None." - ) - - # Set default value for remove_ccds if None - if remove_ccds is None: - remove_ccds = CRYSTALLIZATION_AIDS - - # Argument validation - check_ccd_codes_are_available(remove_ccds, ccd_mirror_path=ccd_mirror_path, mode="warn") - - if load_from_cache and not cache_dir: - raise ValueError("Must provide a cache directory to load from cache") - - if save_to_cache and not cache_dir: - raise ValueError("Must provide a cache directory to save to cache") + config, kwargs = _resolve_config(config, kwargs, cls=ParseConfig) - if fix_formal_charges and not add_missing_atoms: - logger.warning( - "We can't fix formal charges without building from templates, as we need to know the true number of " - "hydrogens bonded to a given atom, not the inferred number. This may lead to occasional inaccuracies " - "after adding inter-residue bonds. To avoid this and fix formal charges, set `add_missing_atoms = True`." + if filename is not None: + if source is not None: + raise TypeError("Cannot pass both 'source' and 'filename'") + warnings.warn( + "The 'filename' parameter is deprecated; use 'source' instead.", + DeprecationWarning, + stacklevel=2, ) - - file_type = file_type or infer_pdb_file_type(filename) - is_buffer = isinstance(filename, io.StringIO | io.BytesIO) - - # Only load from / save to cache if we are not using a buffer - if cache_dir and not is_buffer: - # Build the cache file path, if necessary - cache_dir = Path(cache_dir) - - # Prepare readable arguments dict for metadata - parse_arguments = { - "ccd_mirror_path": ccd_mirror_path, - "add_missing_atoms": add_missing_atoms, - "add_id_and_entity_annotations": add_id_and_entity_annotations, - "add_bond_types_from_struct_conn": add_bond_types_from_struct_conn, - "remove_ccds": remove_ccds, - "remove_waters": remove_waters, - "fix_ligands_at_symmetry_centers": fix_ligands_at_symmetry_centers, - "fix_arginines": fix_arginines, - "fix_formal_charges": fix_formal_charges, - "fix_bond_types": fix_bond_types, - "convert_mse_to_met": convert_mse_to_met, - "hydrogen_policy": hydrogen_policy, - } - args_hash = _parse_args_to_hash(parse_arguments) - - # ... generate assembly info - assembly_info = ",".join(build_assembly) if isinstance(build_assembly, list | tuple) else build_assembly - - # ... construct the full cache file path with sharding - cache_file_path = _build_cache_file_path(cache_dir, args_hash, filename, assembly_info) - - # If we are loading from cache, try to load the result from the cache - if load_from_cache: + source = filename + if source is None: + raise TypeError("parse() requires a source (file path or buffer)") + if kwargs: + raise TypeError(f"Unexpected keyword arguments: {list(kwargs.keys())}") + if callable(source) and (not cache_key or not config.file_type): + raise ValueError("Deferred sources require cache_key and config.file_type") + + # Resolve catcif paths before any file-type inference or caching logic + source, catcif_file_type = resolve_catcif_source(source) + + # Dispatch: file input + file_type = config.file_type or catcif_file_type or infer_pdb_file_type(source) + is_buffer = isinstance(source, io.StringIO | io.BytesIO) + build_assembly = config.build_assembly + extra_fields = config.extra_fields + + cache_file_path = None + if ( + (config.load_from_cache or config.save_to_cache) + and not get_custom_ccd_entries() + and not (config.altloc.startswith("random") and config.altloc_seed is None) + ): + cache_file_path = _build_cache_file_path( + Path(config.cache_dir), source, config.replace(file_type=file_type), cache_key + ) + if config.load_from_cache and cache_file_path.exists(): try: - # Try to load the result from the cache - if cache_file_path.exists(): - # Load the result from the cache - result = pd.read_pickle(cache_file_path) - - # Build assemblies - asym_unit = result["asym_unit"] - extra_info = result["extra_info"] - if "assembly_gen_category" in extra_info: - assemblies = build_assemblies_from_asym_unit( - assembly_gen_category=extra_info["assembly_gen_category"], - struct_oper_category=extra_info["struct_oper_category"], - asym_unit_atom_array_stack=asym_unit, - build_assembly=build_assembly, - fix_symmetry_centers=fix_ligands_at_symmetry_centers, - ) - else: - assemblies = asym_unit - - # Return updated result - result["assemblies"] = assemblies - return result - except Exception as e: - raise RuntimeError(f"Error loading from cache: {e}, tried path: {cache_file_path}") from e - + result, position = pd.read_pickle(cache_file_path) + if is_buffer: + source.seek(position) + if config.load_standard_annotations: + for entry in result if isinstance(result, list) else [result]: + _restore_legacy_annotations_in_result(entry) + return result + except Exception as exc: + raise RuntimeError(f"Error loading parse cache: {cache_file_path}") from exc + + # +------ Uncached route: loading and processing ------+ + if callable(source): + source = source() + is_buffer = isinstance(source, io.StringIO | io.BytesIO) + cif_block = None + model_ids = None if file_type == "pdb": - result = _parse_from_pdb( - filename=filename, - ccd_mirror_path=ccd_mirror_path, - add_missing_atoms=add_missing_atoms, - add_id_and_entity_annotations=add_id_and_entity_annotations, - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - remove_ccds=remove_ccds, - remove_waters=remove_waters, - fix_ligands_at_symmetry_centers=fix_ligands_at_symmetry_centers, - fix_arginines=fix_arginines, - fix_formal_charges=fix_formal_charges, - fix_bond_types=fix_bond_types, - convert_mse_to_met=convert_mse_to_met, - hydrogen_policy=hydrogen_policy, - model=model, - build_assembly=build_assembly, - extra_fields=extra_fields, - ) + if config.load_standard_annotations: + raise ValueError("load_standard_annotations=True is not supported for PDB files. Use CIF format instead.") + if config.altloc != "first": + raise ValueError( + f"altloc='{config.altloc}' is not supported for PDB files. " + "PDB parsing always uses altloc='first'. Use CIF format for altloc selection." + ) + atoms, metadata = load_pdb(source, model=config.model) + extra_fields = None + if build_assembly not in ("all", None): + logger.warning( + "PDB files always build all assemblies; ignoring build_assembly=%r", + build_assembly, + ) + build_assembly = "all" elif file_type in ("cif", "bcif", "mmjson"): - result = _parse_from_cif( - filename=filename, + atoms, _cif_file, cif_block, metadata, model_ids = load_cif( + source, file_type=file_type, - ccd_mirror_path=ccd_mirror_path, - add_missing_atoms=add_missing_atoms, - add_id_and_entity_annotations=add_id_and_entity_annotations, - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - remove_ccds=remove_ccds, - remove_waters=remove_waters, - fix_ligands_at_symmetry_centers=fix_ligands_at_symmetry_centers, - fix_arginines=fix_arginines, - fix_formal_charges=fix_formal_charges, - fix_bond_types=fix_bond_types, - convert_mse_to_met=convert_mse_to_met, - hydrogen_policy=hydrogen_policy, - model=model, - build_assembly=build_assembly, + model=config.model, extra_fields=extra_fields, - keep_cif_block=keep_cif_block, + load_standard_annotations=config.load_standard_annotations, + altloc=config.altloc, + altloc_seed=config.altloc_seed, ) else: - raise ValueError(f"Unsupported file type: {filename}") - - if not is_buffer and save_to_cache and cache_dir and (not cache_file_path.exists()): - # We want our cache to include: - # (1) All keys in `result` except the assemblies; and, - # (2) The information needed to rebuild the assembly(s), which is stored in `result["extra_info"]`; and, - # (3) The parse_arguments and atomworks version + raise ValueError(f"Unsupported file type: {source}") + + # Build CCD entries from the CIF's chem_comp* categories and scope them + # to this parse call so all downstream lookups (types, bonds, templates) + # go through the registry. + cif_ccd_entries = ( + build_ccd_entries_from_cif_block(cif_block, on_mismatch=config.cif_ccd_on_mismatch) + if cif_block is not None + else {} + ) + ctx = custom_ccd_residues(cif_ccd_entries) if cif_ccd_entries else contextlib.nullcontext() - # Add parse_arguments and version to metadata before saving - result.setdefault("metadata", {}).update( - {"parse_arguments": parse_arguments, "atomworks.version": _get_atomworks_version()} + with ctx: + multiple_models = isinstance(atoms, list) + models = ( + zip(atoms, model_ids, strict=False) if multiple_models else [(atoms, model_ids[0] if model_ids else None)] ) - - # Ensure all parent directories exist - cache_file_path.parent.mkdir(parents=True, exist_ok=True) - - # Save the result to the cache, excluding the assemblies - result_to_cache = {k: v for k, v in result.items() if k != "assemblies"} - pd.to_pickle(result_to_cache, cache_file_path) - - return result - - -def parse_atom_array( - atom_array_or_stack: AtomArray | AtomArrayStack | AtomArrayPlus | AtomArrayPlusStack, - data_dict: dict | None = None, - _cif_file: pdbx.CIFFile | pdbx.BinaryCIFFile | None = None, - ccd_mirror_path: os.PathLike | None = CCD_MIRROR_PATH, - add_missing_atoms: bool = True, - add_id_and_entity_annotations: bool = True, - add_bond_types_from_struct_conn: list[str] = ["covale"], - remove_ccds: list[str] | None = CRYSTALLIZATION_AIDS, - remove_waters: bool = True, - fix_ligands_at_symmetry_centers: bool = True, - fix_arginines: bool = True, - fix_formal_charges: bool = True, - fix_bond_types: bool = True, - convert_mse_to_met: bool = False, - hydrogen_policy: Literal["keep", "remove", "infer"] = "keep", - build_assembly: Literal["first", "all"] | list[str] | tuple[str] | None = "all", - extra_fields: list[str] | Literal["all"] | None = None, -) -> dict[str, Any]: - """Parse, clean and augment an AtomArray or AtomArrayStack. - - AtomArrayPlus and AtomArrayPlusStack inputs are also supported, with some restrictions (see Notes). - - Args: - atom_array_or_stack (AtomArray | AtomArrayStack): The AtomArray or AtomArrayStack to parse. - data_dict (dict | None, optional): A dictionary to store the results of the parsing. If None, a new data_dict - will be created. - _cif_file (pdbx.CIFFile | pdbx.BinaryCIFFile | None, optional): The biotite CIF file object to use for parsing. - Intended for internal use only. Defaults to None, corresponding to direct AtomArray parsing. - build_assembly: Specifies which assembly to build. Options: - - ``None``: Creates a single identity assembly (ID "1") with instance ID annotations - (``chain_iid``, ``pn_unit_iid``, ``molecule_iid``). - - ``"first"``: Build only the first assembly defined in the file. - - ``"all"``: Build all assemblies defined in the file. - - ``list | tuple``: Build specific assemblies by their IDs (e.g., ``["1", "2"]``). - **additional_kwargs: See `parse` documentation for details. - - Returns: - Dictionary containing chain information, residue information, atom array, assemblies, and metadata. - This method performs all aspects of ``_parse_from_cif`` that do not require a CIF file input. - - Note: - When using AtomArrayPlus or AtomArrayPlusStack inputs, the following - restrictions apply: - - - add_missing_atoms must be False - - hydrogen_policy cannot be "infer" - - convert_mse_to_met must be False - - _cif_file must be None - - These restrictions ensure that 2D annotations remain aligned with atom indices. - """ - - # TODO: Support more arguments with AtomArrayPlus - if isinstance(atom_array_or_stack, AtomArrayPlus | AtomArrayPlusStack): - if exists(_cif_file): - raise ValueError( - "Providing a CIF file is not supported when parsing an AtomArrayPlus or AtomArrayPlusStack. " - "Consider using parse() instead, which accepts CIF files directly." + results = [] + for arr, model_num in models: + arr, chain_info = _prepare_atom_array_or_stack( + arr, + cif_block=cif_block, + config=config, + extra_fields=extra_fields, ) - if add_missing_atoms: - raise ValueError( - "Adding missing atoms is not supported when parsing an AtomArrayPlus or AtomArrayPlusStack. " - "Convert to AtomArray using atom_array.as_atom_array() first, or pass add_missing_atoms=False." - ) - if hydrogen_policy == "infer": - raise ValueError( - "Hydrogen inference is not supported when parsing an AtomArrayPlus or AtomArrayPlusStack. " - "Convert to AtomArray using atom_array.as_atom_array() first, or use hydrogen_policy='keep' or 'remove'." - ) - if convert_mse_to_met: - raise ValueError( - "MSE to MET conversion is not supported when parsing an AtomArrayPlus or AtomArrayPlusStack. " - "Convert to AtomArray using atom_array.as_atom_array() first, or pass convert_mse_to_met=False." - ) - - if build_assembly == "_spoof": - import warnings - - warnings.warn( - "build_assembly='_spoof' is deprecated. Use build_assembly='all' or None instead.", - DeprecationWarning, - stacklevel=2, - ) - build_assembly = "all" - - # ... ensure that the input AtomArray or AtomArrayStack has a BondList - if atom_array_or_stack.bonds is None: - atom_array_or_stack.bonds = struc.BondList(atom_array_or_stack.array_length()) - - # (Handle default lists to avoid mutable default arguments) - remove_ccds = [] if remove_ccds is None else remove_ccds - - # We must perform argument validation if the function was called directly without a top-level call to `parse` - if _cif_file is None: - # CCD mirror - if ccd_mirror_path and not os.path.exists(ccd_mirror_path): - raise FileNotFoundError( - f"Local mirror of the Chemical Component Dictionary does not exist: {ccd_mirror_path}. " - "To use Biotite's built-in CCD, set `ccd_mirror_path` to None." + if config.load_standard_annotations: + arr = _deserialize_standard_annotations(arr, cif_block, model_num=model_num, restore_legacy=False) + results.append( + _assemble_parse_result( + atoms=arr, + chain_info=chain_info, + cif_block=cif_block, + config=config, + build_assembly=build_assembly, + metadata=metadata, + keep_cif_block=config.keep_cif_block, + ) ) + _attach_live_ccd_registry(results) + result = results if multiple_models else results[0] - # Argument validation - check_ccd_codes_are_available(remove_ccds, ccd_mirror_path=ccd_mirror_path, mode="warn") - - if not exists(data_dict): - # (Default running dictionary, which we will populate through a series of Transforms) - data_dict = {"extra_info": {}} - - if not exists(_cif_file): - # Assemble spoofed metadata for formatting consistency - data_dict["metadata"] = {} - for key in ["method", "deposition_date", "release_date", "resolution", "extra_metadata"]: - data_dict["metadata"][key] = None - - # Add an informative name - data_dict["metadata"]["id"] = f"Parsed from AtomArray on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - - # Initialize unused keys - data_dict["cif_block"] = None - data_dict["assemblies"] = {} - data_dict["ligand_info"] = {"has_ligand_of_interest": False, "ligand_of_interest": []} - - if exists(extra_fields) and not exists(_cif_file): - logger.warning("The `extra_fields` argument will be ignored if there is no CIF file input.") + if config.load_standard_annotations: + for entry in results: + _restore_legacy_annotations_in_result(entry) - if "label_entity_id" not in atom_array_or_stack.get_annotation_categories(): - if "chain_entity" in atom_array_or_stack.get_annotation_categories(): - atom_array_or_stack.set_annotation("label_entity_id", np.copy(atom_array_or_stack.chain_entity)) - else: - atom_array_or_stack.set_annotation( - "label_entity_id", pdbx.convert._determine_entity_id(atom_array_or_stack.chain_id) + if config.save_to_cache and cache_file_path is not None: + for entry in results: + entry.setdefault("metadata", {}).update( + {"parse_arguments": config.to_dict(), "atomworks.version": atomworks.__version__} ) + _atomic_write_pickle((result, source.tell() if is_buffer else None), cache_file_path) - # If occupancy is not an annotation, add it, defaulting to 1.0 - if "occupancy" not in atom_array_or_stack.get_annotation_categories(): - atom_array_or_stack.set_annotation("occupancy", np.ones(atom_array_or_stack.array_length())) - - # ... ensure we have an atom array stack (e.g., if we selected a specific model, we may get an AtomArray) - asym_unit_stack = ta.ensure_atom_array_stack(atom_array_or_stack) - - # ... remove any explicitly excluded residues (e.g., crystallization solvents, waters) - if remove_ccds or remove_waters: - # NOTE: If the excluded residues are part of a polymer chain, or part of a - # multi-chain ligand, this may create sequence gaps! - # ... remove the residues we don't want to keep - remove_ccds = set(map(str.upper, remove_ccds)) - if remove_waters: - remove_ccds.update(WATER_LIKE_CCDS) - - asym_unit_stack = ta.remove_ccd_components(asym_unit_stack, remove_ccds) - - # ... initialize chain information from the first model (uses atom_array to build chain list) - if exists(_cif_file) and "entity" in _cif_file.block and "entity_poly" in _cif_file.block: - # We can get the chain entity-level information directly from the CIF file - data_dict["chain_info"] = initialize_chain_info_from_category(_cif_file.block, asym_unit_stack[0]) - else: - if "auth_seq_id" in asym_unit_stack.get_annotation_categories(): - # ... replace negative res_ids with auth_seq_id (as they are sometimes from AF-3 predictions) - asym_unit_stack = ta.replace_negative_res_ids_with_auth_seq_id(asym_unit_stack) - # ... infer the chain information from the AtomArray residue names (useful for inference; should not be used for RCSB files) - data_dict["chain_info"] = initialize_chain_info_from_atom_array( - asym_unit_stack[0], infer_chain_type=True, infer_chain_sequences=True - ) - - if "auth_seq_id" in asym_unit_stack.get_annotation_categories(): - # ... replace non-polymeric chain sequence ids with author sequence ids (since the non-polymer sequence ID's are not informative) - asym_unit_stack = ta.update_nonpoly_seq_ids(asym_unit_stack, data_dict["chain_info"]) - - if exists(_cif_file) and "entity_poly_seq" in _cif_file.block: - # Use the `entity_poly_seq` category as ground-truth sequence for polymers, and the AtomArray as ground-truth for non-polymers - data_dict["chain_info"] = load_monomer_sequence_information_from_category( - cif_block=_cif_file.block, - chain_info_dict=data_dict["chain_info"], - atom_array=asym_unit_stack, - ccd_mirror_path=ccd_mirror_path, - ) - - # Handle sequence heterogeneity by selecting the residue that appears last - asym_unit_stack = ta.keep_last_residue(asym_unit_stack) + return result - # ... add the is_polymer annotation to the AtomArray - asym_unit_stack = ta.add_polymer_annotation(asym_unit_stack, data_dict["chain_info"]) - # ... add the ChainType annotation to the AtomArray - asym_unit_stack = ta.add_chain_type_annotation(asym_unit_stack, data_dict["chain_info"]) +def prepare_atom_array( + source: AtomArray | AtomArrayStack | AtomArrayPlus | AtomArrayPlusStack, + *, + config: str | PrepareConfig | None = None, + cif_block: Any | None = None, + extra_fields: ExtraFieldsType = None, +) -> AtomArray | AtomArrayStack: + """Perform standard AtomWorks preparation of an AtomArray, returning the processed atoms directly. - # (Most examples, except NMR studies and small molecules, will not have any hydrogens) - if hydrogen_policy == "keep": - pass - elif hydrogen_policy == "remove": - asym_unit_stack = ta.remove_hydrogens(asym_unit_stack) - elif hydrogen_policy == "infer": - # infer hydrogens using biotite-hydride, will replace existing hydrogens - asym_unit_stack = ta.add_hydrogen_atom_positions(asym_unit_stack) - else: - raise ValueError(f"Invalid hydrogen policy: {hydrogen_policy}. Must be 'keep', 'remove', or 'infer'.") - - models = [] - for model_idx in range(asym_unit_stack.stack_depth()): - atom_array = asym_unit_stack[model_idx] - - # ... add any atoms that should be there based on the sequence information - # but may not be resolved. These will have occupancy 0.0 and `nan` coords. - if add_missing_atoms: - if extra_fields is not None: - logger.warning( - "Adding missing atoms will erase extra fields. If you just want to load a structure with the given extra fields, " - "you should probably use the much faster 'load_any' function from atomworks.io.utils.io_utils instead of 'parse'. " - "Parse is meant for cleaning up structures from the RCSB PDB." - ) + Runs the same core processing as :py:func:`parse` (standardize, add missing + atoms, infer bonds, annotate) but returns the processed atoms instead of a + full result dictionary. Single-model results are squeezed to + :py:class:`~biotite.structure.AtomArray` for conciseness. - if exists(_cif_file): - struct_conn_dict = category_to_dict(_cif_file.block, "struct_conn") - else: - struct_conn_dict = get_struct_conn_dict_from_atom_array(atom_array) - - atom_array = template.add_missing_atoms( - atom_array, - chain_info_dict=data_dict["chain_info"], - struct_conn_dict=struct_conn_dict, - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - remove_hydrogens=hydrogen_policy == "remove", - use_ccd_charges=True, - fix_formal_charges=fix_formal_charges, - fix_bond_types=fix_bond_types, - ) + For example, useful to add entity/molecule annotations to an AtomArray that already has a complete set of atoms and bonds. - # ... resolve arginine naming ambiguity - if fix_arginines: - atom_array = ta.resolve_arginine_naming_ambiguity(atom_array, raise_on_error=False) - - # ... convert MSE to MET - if convert_mse_to_met: - atom_array = ta.mse_to_met(atom_array) - - # ... add identifiers and entity annotations - if add_id_and_entity_annotations: - atom_array = ta.add_id_and_entity_annotations(atom_array) - - models.append(atom_array) - - # ... create an AtomArrayStack from the list of AtomArrays - asym_unit_stack = stack_any(models) - - # ... add the atomic number annotation (vs. element, which is a string) - asym_unit_stack = ta.add_atomic_number_annotation(asym_unit_stack) - - if "msa_path" in asym_unit_stack.get_annotation_categories(): - # ... add the MSA information to the chain info dictionary - logger.info("MSA paths attribute detected in AtomArray. Adding to chain information...") - - for chain in data_dict["chain_info"]: - msa_paths_in_chain = asym_unit_stack[asym_unit_stack.chain_id == chain].msa_path - unique_path_in_chain = np.unique(msa_paths_in_chain) - if len(unique_path_in_chain) > 1: - raise ValueError(f"Multiple distinct MSA paths found for chain {chain}.") - msa_path = unique_path_in_chain[0] - if msa_path != "": - data_dict["chain_info"][chain]["msa_path"] = Path(msa_path) - - # ... build assemblies and add assembly-specific annotations (instance IDs like `chain_iid`, `pn_unit_iid`, `molecule_iid`) - if exists(build_assembly): - assert build_assembly in ["first", "all"] or isinstance( - build_assembly, list | tuple - ), "Invalid `build_assembly` option. Must be 'first', 'all', or a list/tuple of assembly IDs as strings." - - # Determine assembly categories: use CIF data if build_assembly is set, otherwise identity operations - if exists(build_assembly) and exists(_cif_file) and "pdbx_struct_assembly" in data_dict["cif_block"]: - assembly_gen_category = data_dict["cif_block"]["pdbx_struct_assembly_gen"] - struct_oper_category = data_dict["cif_block"]["pdbx_struct_oper_list"] - else: - assembly_gen_category = get_identity_assembly_gen_category(list(data_dict["chain_info"].keys())) - struct_oper_category = get_identity_op_expr_category() - - # When build_assembly=None with identity ops, "all" builds the single identity assembly (ID "1") - data_dict["assemblies"] = build_assemblies_from_asym_unit( - assembly_gen_category=assembly_gen_category, - struct_oper_category=struct_oper_category, - asym_unit_atom_array_stack=asym_unit_stack, - build_assembly=build_assembly if build_assembly is not None else "all", - fix_symmetry_centers=fix_ligands_at_symmetry_centers, + Args: + source: The structure to process. + config: Preset name (``"default"``, ``"rcsb"``, ``"lightweight"``), + :py:class:`~atomworks.io.config.PrepareConfig`, or ``None`` for defaults. + cif_block: Optional CIF block for richer processing (struct_conn bonds, + entity categories, custom bonds). + extra_fields: Extra CIF fields to preserve through processing. + """ + config, _ = _resolve_config(config, {}) + atoms, _ = _prepare_atom_array_or_stack( + source, + config=config, + cif_block=cif_block, + extra_fields=extra_fields, ) - # Store the assembly generation and struct oper categories in extra_info for caching and future reference - data_dict["extra_info"]["assembly_gen_category"] = assembly_gen_category - data_dict["extra_info"]["struct_oper_category"] = struct_oper_category + if config.return_atom_array_plus: + atoms = _maybe_promote_to_plus(atoms) - # Handle instances where ph information is included in crystallization conditions - if exists(_cif_file) and "exptl_crystal_grow" in _cif_file.block: - crystal_key = "exptl_crystal_grow" - crystal_dict = category_to_dict(_cif_file.block, crystal_key) - data_dict["metadata"]["crystallization_details"] = extract_crystallization_details(crystal_dict) - else: - # No crystal growth section available in the CIF - data_dict["metadata"]["crystallization_details"] = {"pH": None} - - if not exists(_cif_file): - # Remove temporary annotations from the asym_unit_stack - data_dict = _remove_tmp_annotations(data_dict, asym_unit_stack) - - # ... subset to only the keys we want to return, verbosely for clarity - _keep_keys = {"chain_info", "ligand_info", "asym_unit", "assemblies", "metadata", "extra_info"} - data_dict = keyfilter(lambda k: k in _keep_keys, data_dict) - else: - # If more processing may be performed, we just save the asym_unit_stack temporarily - data_dict["asym_unit"] = asym_unit_stack + if isinstance(atoms, AtomArrayStack) and atoms.stack_depth() == 1: + return atoms[0] - return data_dict + return atoms -def _parse_from_cif( - filename: os.PathLike | io.StringIO | io.BytesIO, file_type: str | None = None, **kwargs +def parse_atom_array( + source: AtomArray | AtomArrayStack | AtomArrayPlus | AtomArrayPlusStack, + *, + config: str | PrepareConfig | None = None, + **kwargs, ) -> dict[str, Any]: - """Parse the CIF file. + """Mimic of :py:func:`parse` that operates on an AtomArray or AtomArrayStack instead of a file input. - Return chain information, residue information, atom array, and metadata. - See `parse` for details on the arguments and return values. + Returns identical result dict to :py:func:`parse`, but takes an AtomArray or AtomArrayStack as input instead of a file path or buffer. - NOTE: This method is not intended to be called directly; use `parse` instead. + Args: + source: The AtomArray or AtomArrayStack to process. + config: Preset name (``"default"``, ``"rcsb"``, ``"lightweight"``), + :py:class:`~atomworks.io.config.PrepareConfig`, or ``None`` for defaults. """ - # (Default running dictionary, which we will populate through a series of Transforms) - data_dict = {"extra_info": {}} - - # ... read the CIF file into the dictionary (we will clean up the dictionary before returning) - cif_file = read_any(filename, file_type=file_type) - data_dict["cif_block"] = cif_file.block + config, kwargs = _resolve_config(config, kwargs) - # ... load metadata into "metadata" key (either from RCSB standard fields, or from the custom `extra_metadata` field) - if isinstance(filename, io.StringIO | io.BytesIO): - fallback_filename = next(iter(cif_file.keys())) - else: - fallback_filename = Path(filename).stem - data_dict["metadata"] = get_metadata_from_category(cif_file.block, fallback_id=fallback_filename) - - # ... load structure into the "asym_unit" key using the RCSB labels for sequence ids, and later update for non-polymers - common_extra_fields = [ - "label_entity_id", - "auth_seq_id", # for non-polymer residue indexing - "atom_id", - "b_factor", - "occupancy", - "charge", - ] - - if kwargs["extra_fields"] is not None: - if kwargs["extra_fields"] != "all": - common_extra_fields += kwargs["extra_fields"] - else: - common_extra_fields = "all" - - try: - asym_unit_stack = get_structure( - cif_file, - extra_fields=common_extra_fields, - model=kwargs["model"], - add_bond_types_from_struct_conn=kwargs["add_bond_types_from_struct_conn"], - fix_bond_types=kwargs["fix_bond_types"], - ) - except InvalidFileError: - logger.info("Invalid file error encountered; loading with only one model") - # Try again, choosing only the first model - asym_unit_stack = get_structure( - cif_file, - extra_fields=common_extra_fields, - model=1, - add_bond_types_from_struct_conn=kwargs["add_bond_types_from_struct_conn"], - fix_bond_types=kwargs["fix_bond_types"], - ) - - # process the asym_unit_stack according to the given keyword arguments - kwargs_to_pass = {k: v for k, v in kwargs.items() if k not in ["model", "file_type", "keep_cif_block"]} - data_dict = parse_atom_array(asym_unit_stack, data_dict=data_dict, _cif_file=cif_file, **kwargs_to_pass) - - # Extract the asym_unit_stack from the returned data_dict - asym_unit_stack = data_dict.pop("asym_unit") - - # ... get ligand of interest information - data_dict["ligand_info"] = get_ligand_of_interest_info(data_dict["cif_block"]) - - if "msa_paths_by_chain_id" in cif_file.block: - # ... add the MSA information to the chain info dictionary - logger.info("MSA paths detected in CIF file. Adding to chain information...") - msa_paths_by_chain_id = category_to_dict(cif_file.block, "msa_paths_by_chain_id") - for chain_id, msa_path in msa_paths_by_chain_id.items(): - data_dict["chain_info"][chain_id]["msa_path"] = Path(msa_path.item()) + if kwargs: + raise TypeError(f"Unexpected keyword arguments: {list(kwargs.keys())}") - # Remove temporary annotations from the asym_unit_stack - data_dict = _remove_tmp_annotations(data_dict, asym_unit_stack) - - # ... subset to only the keys we want to return, verbosely for clarity - _keep_keys = {"chain_info", "ligand_info", "asym_unit", "assemblies", "metadata", "extra_info"} - if kwargs.get("keep_cif_block", False): - _keep_keys.add("cif_block") - data_dict = keyfilter(lambda k: k in _keep_keys, data_dict) - - return data_dict - - -def _parse_from_pdb(filename: os.PathLike, **parse_from_cif_kwargs) -> dict[str, Any]: - """Parse a PDB file and return chain information, residue information, atom array, metadata, and legacy data. - - WARNING: We require that a single chain contains either polymer or non-polymer residues, but not both. Thus, if - the PDB file contains a chain with both polymer and non-polymer residues, the non-polymer - residues will be named with "$" appended to the chain ID (to not conflict with existing chains). - - WARNING: We assume that all residues are resolved (e.g., as is the case for computationally predicted structures). If not, use CIF files. - - NOTE: This method is not intended to be called directly; use `parse` instead. - """ - # ...read the PDB file into a CIF block - pdb_file = read_any(filename) - atom_array_stack = pdb_file.get_structure( - model=parse_from_cif_kwargs["model"], - altloc="first", - extra_fields=["b_factor", "occupancy", "charge", "atom_id"], - include_bonds=True, + atoms, chain_info = _prepare_atom_array_or_stack( + source, + cif_block=None, + config=config, ) - # ...if we have polymer and non-polymers on the same chain (as given by the HETATM field), we need to separate them for processing - assert "hetero" in atom_array_stack.get_annotation_categories() - - hetero_atom_mask = atom_array_stack.get_annotation("hetero") - if np.any(atom_array_stack.get_annotation("hetero")): - # ...loop through chains and ensure the chain contains either polymer or non-polymer residues, but not both (as required by CIF files) - original_chain_ids = np.unique(atom_array_stack.chain_id) - chain_id_generator = create_chain_id_generator(unavailable_chain_ids=original_chain_ids) - chain_ids = list(original_chain_ids) # Creates a copy - for chain_id in original_chain_ids: - # ...check if we have blended `hetero` annotations in the chain - chain_hetero_annotations = atom_array_stack.hetero[atom_array_stack.chain_id == chain_id] - if np.any(chain_hetero_annotations) and np.any(~chain_hetero_annotations): - hetero_chain_id = next(chain_id_generator) - logger.warning( - f"Chain {chain_id} contains both polymer and non-polymer residues; separating them for processing, naming the non-polymer residues as {hetero_chain_id}." - ) - atom_array_stack.chain_id[(atom_array_stack.chain_id == chain_id) & hetero_atom_mask] = hetero_chain_id - - # Add the newly created chain ID to the list to avoid conflicts in future iterations - chain_ids.append(hetero_chain_id) - - # ...ensure we don't have blended `hetero` annotations - updated_chain_hetero_annotations = atom_array_stack.hetero[atom_array_stack.chain_id == chain_id] - assert np.all(updated_chain_hetero_annotations) or np.all(~updated_chain_hetero_annotations) - - # ... parse the CIF block into a dictionary - parse_from_cif_kwargs["file_type"] = "pdb" - parse_from_cif_kwargs["extra_fields"] = None - # PDB files use identity assembly, so "all" builds just the single identity assembly - parse_from_cif_kwargs["build_assembly"] = "all" - - kwargs_to_pass = {k: v for k, v in parse_from_cif_kwargs.items() if k not in ["model", "file_type"]} - data_dict = parse_atom_array(atom_array_stack, _cif_file=None, **kwargs_to_pass) - data_dict["metadata"]["id"] = Path(filename).stem.lower() - - return data_dict - - -# Helper functions -def _remove_annotation_if_exists(atom_array: struc.AtomArray | struc.AtomArrayStack, annotation: str) -> None: - """Safely remove an annotation from an AtomArray or AtomArrayStack if it exists.""" - if annotation in atom_array.get_annotation_categories(): - atom_array.del_annotation(annotation) - - -def _remove_tmp_annotations(data_dict: dict, asym_unit_stack: AtomArrayStack) -> dict: - """Clean the asym_unit_stack by removing unnecessary annotations. - - Also adds the clean asym_unit_stack to the data dictionary. - """ - - # ... remove annotations that are no longer needed to save memory - _remove_annotations = { - "leaving_atom_flag", - "is_leaving_atom", - "is_n_terminal_atom", - "is_c_terminal_atom", - "index", - } - for annotation in _remove_annotations: - _remove_annotation_if_exists(asym_unit_stack, annotation) - if "assemblies" in data_dict: - for assembly in data_dict["assemblies"].values(): - _remove_annotation_if_exists(assembly, annotation) - - # ... add the asym_unit_stack to the data dict - data_dict["asym_unit"] = asym_unit_stack - - return data_dict + return _assemble_parse_result( + atoms=atoms, + chain_info=chain_info, + cif_block=None, + config=config, + build_assembly="all", + metadata={}, + ) diff --git a/src/atomworks/io/template.py b/src/atomworks/io/template.py index d46c3760..54f178b6 100644 --- a/src/atomworks/io/template.py +++ b/src/atomworks/io/template.py @@ -1,33 +1,123 @@ +import functools import logging import os -from typing import Any +from bisect import bisect_left +from collections import Counter +from collections.abc import Iterable +from itertools import pairwise +from typing import Any, Literal import biotite.structure as struc import numpy as np -from biotite.structure import AtomArray, BondList - -import atomworks.io.transforms.atom_array as ta -from atomworks.common import exists -from atomworks.constants import CCD_MIRROR_PATH, DO_NOT_MATCH_CCD +from biotite.structure import AtomArray +from biotite.structure.bonds import connect_via_residue_names + +from atomworks.constants import ( + CCD_MIRROR_PATH, + CHAIN_LEVEL_ANNOTATIONS, + HYDROGEN_LIKE_SYMBOLS, + MOLECULE_LEVEL_ANNOTATIONS, + PN_UNIT_LEVEL_ANNOTATIONS, + RESIDUE_LEVEL_ANNOTATIONS, + TRANSFORMATION_LEVEL_ANNOTATIONS, +) +from atomworks.enums import ChainType, ChainTypeInfo +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.atom_array_plus import insert_atoms from atomworks.io.utils.bonds import ( - correct_bond_types_for_nucleophilic_additions, - correct_formal_charges_for_specified_atoms, - get_inferred_polymer_bonds, - get_struct_conn_bonds, + build_bond_dict_for_atom_array, +) +from atomworks.io.utils.ccd import ( + _get_base_ccd_template, + _standard_ccd_only_cache, + get_available_ccd_codes, +) +from atomworks.io.utils.extra_fields import ( + get_default_array, ) -from atomworks.io.utils.ccd import atom_array_from_ccd_code, check_ccd_codes_are_available -from atomworks.io.utils.non_rcsb import initialize_chain_info_from_atom_array -from atomworks.io.utils.selection import get_annotation -from atomworks.io.utils.testing import has_ambiguous_annotation_set +from atomworks.io.utils.link_chemistry import ( + add_polymer_bonds, + get_chem_comp_leaving_atom_groups, + resolve_link_chemistry, +) +from atomworks.io.utils.scatter import get_segments logger = logging.getLogger(__file__) +@_standard_ccd_only_cache(functools.lru_cache(maxsize=2048)) +def _get_missing_atoms_template( + ccd_code: str, + actual_atom_names: frozenset[str], # Must be hashable for lru_cache + ccd_mirror_path: str, + hydrogen_policy: Literal["keep", "remove"], +) -> AtomArray | None: + """Get template AtomArray for missing atoms in a residue (cached implementation). + + Args: + ccd_code: 3-letter CCD code (e.g., "ALA") + actual_atom_names: Set of atom names currently present + ccd_mirror_path: Path to CCD mirror (as string), or empty string to use Biotite's built-in CCD + hydrogen_policy: Whether to keep or remove hydrogens. + + Returns: + Template with only missing atoms, or None if no atoms are missing + + Raises: + ValueError: If actual_atom_names contains atoms not in CCD template + """ + full_template = _get_base_ccd_template(ccd_code, ccd_mirror_path, "keep") + actual_list = list(actual_atom_names) + + # Validate: no unexpected atoms + unexpected = ~np.isin(actual_list, full_template.atom_name, assume_unique=True) + if np.any(unexpected): + unexpected_names = [actual_list[i] for i in np.where(unexpected)[0]] + raise ValueError(f"CCD {ccd_code}: Unexpected atoms not in CCD template: {unexpected_names}") + + # Find missing atoms + template = _get_base_ccd_template(ccd_code, ccd_mirror_path, hydrogen_policy) + template_atom_names = template.atom_name + missing_mask = ~np.isin(template_atom_names, actual_list) + + if not np.any(missing_mask): + return None + + # Return subset with only missing atoms + return template[missing_mask] + + +@_standard_ccd_only_cache(functools.lru_cache(maxsize=2048)) +def _non_terminal_expected_atom_count( + ccd_code: str, + ccd_mirror_path: str, + hydrogen_policy: str, + chain_type: ChainType, +) -> int | None: + """Expected non-terminal atom count, or None for unknown or ambiguous polymer leaving groups.""" + bond_atoms = ChainTypeInfo.ATOMS_AT_POLYMER_BOND.get(chain_type) + if bond_atoms is None: + return None + + leaving_groups = get_chem_comp_leaving_atom_groups(ccd_code, ccd_mirror_path) + polymer_leaving: set[str] = set() + for bond_atom in bond_atoms: + groups = leaving_groups.get(bond_atom, ()) + if len(groups) > 1: + return None + polymer_leaving.update(*groups) + + template = _get_base_ccd_template(ccd_code, ccd_mirror_path, hydrogen_policy) + # Intersect with template to respect hydrogen_policy (H atoms may already be absent) + return len(template) - len(polymer_leaving & set(template.atom_name)) + + def get_empty_ccd_template( ccd_code: str, *, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, - remove_hydrogens: bool = True, + hydrogen_policy: Literal["keep", "remove"] = "remove", + extra_field_specs: dict[str, dict[str, Any]] | None = None, **res_wise_annotations: int | float | str, ) -> AtomArray: """Get empty CCD template with safe independent copy. @@ -39,7 +129,9 @@ def get_empty_ccd_template( Args: ccd_code: The three-letter code of the chemical component to create a template for. ccd_mirror_path: Path to the local CCD mirror directory. Defaults to CCD_MIRROR_PATH. - remove_hydrogens: Whether to remove hydrogen atoms from the template. Defaults to True. + hydrogen_policy: Whether to keep or remove hydrogen atoms from the template. Defaults to ``"remove"``. + extra_field_specs: Dict mapping field names to spec dicts with "default" and "dtype" keys. If provided, + extra fields will be initialized with their default values for new template atoms. **res_wise_annotations: Additional residue-wise annotations to add to the template. Values can be int, float, or str and will be broadcast to all atoms in the template. @@ -51,486 +143,466 @@ def get_empty_ccd_template( Example: >>> template = get_empty_ccd_template("ALA", chain_id="A", res_id=1, occupancy=1.0) """ - template = atom_array_from_ccd_code(ccd_code, ccd_mirror_path, coords=None) - - if remove_hydrogens: - template = ta.remove_hydrogens(template) + # Get cached base template and make an independent copy for annotation + template = _get_base_ccd_template(ccd_code, str(ccd_mirror_path or ""), hydrogen_policy).copy() n_atoms = len(template) for annot, value in res_wise_annotations.items(): if value is not None: template.set_annotation(annot, np.full(n_atoms, value)) - return template - + # Initialize extra fields with default values + if extra_field_specs: + for field_name, spec in extra_field_specs.items(): + if spec["default"] is not None or spec["dtype"] is not None: + template.set_annotation(field_name, get_default_array(spec, n_atoms)) -def match_residue_to_template( - template: AtomArray, - real: AtomArray, - res_mask: np.ndarray | None = None, - use_ccd_charges: bool = False, -) -> AtomArray: - """ - Matches atoms from a real structure to a template structure, copying over coordinates and annotations while preserving - the template's topology. - - The function attempts to match atoms first by standard atom names, then by alternative atom IDs if available and if - they provide better matching. Coordinates and annotations from matched atoms in the real structure are copied to the - template. Unmatched atoms in the real structure are dropped with a warning. - - Args: - - template (AtomArray): Template structure containing the reference topology and complete set of atoms. - - real (AtomArray): Real structure containing the atoms to be matched to the template. - - res_mask (np.ndarray, optional): A mask of atoms in the real structure to match to the template. Defaults to - None, which matches all atoms in the real structure. - - use_ccd_charges (bool): Whether to keep template charges (True) or copy charges from real structure (False). - - Returns: - - AtomArray: Template structure with coordinates and annotations copied from matched atoms in the real structure. - - Raises: - - ValueError: If multiple atoms in the real structure have the same name. - - Notes: - - Atoms in real structure not found in template are dropped (with warning) - - If multiple template atoms match a real atom, only first match is used (with warning) - - Records whether alternative atom IDs were used for matching in 'uses_alt_atom_id' annotation - """ - if res_mask is None: - res_mask = np.ones(real.array_length(), dtype=bool) - - # Get global indices of relevant entries from real for faster indexing - gidx = np.where(res_mask)[0] - - # ... get information about the residue - ccd_code = real.res_name[gidx[0]] - annotations = set(real.get_annotation_categories()) - - # ... fail if there are multiple atoms with the same name in the `real` array - atom_names = real.atom_name[gidx] - if len(np.unique(atom_names)) != len(gidx): - raise ValueError(f"CCD {ccd_code}: Multiple atoms with the same name in \n{real[gidx]}") - - # ... determine whether to use the standard or alternative atom naming - match_by = "atom_name" - n_matches_std = np.sum(np.isin(atom_names, template.atom_name, assume_unique=True)) - if ("alt_atom_id" in template.get_annotation_categories()) and n_matches_std < len(gidx): - n_matches_alt = np.sum(np.isin(atom_names, template.alt_atom_id, assume_unique=True)) - match_alt = n_matches_alt > n_matches_std - match_by = "alt_atom_id" if match_alt else "atom_name" - if match_alt: - logger.warning(f"CCD {ccd_code}: Having to use alternative atom IDs for matching.") - # ... and record what we used to match - template.set_annotation("uses_alt_atom_id", [(match_by == "alt_atom_id")] * len(template)) - - # ... compute matching indices - _, template_idxs, local_match_idxs = np.intersect1d( - template.get_annotation(match_by), atom_names, assume_unique=True, return_indices=True - ) - - # ... fill the annotations - match_idxs = gidx[local_match_idxs] # global indices of real atoms that matched - template.coord[template_idxs] = real.coord[match_idxs] - template.occupancy[template_idxs] = real.occupancy[match_idxs] if "occupancy" in annotations else 1.0 - if "ins_code" in annotations: - template.ins_code[template_idxs] = real.ins_code[match_idxs] - if "b_factor" in annotations: - template.b_factor[template_idxs] = real.b_factor[match_idxs] - if not use_ccd_charges: - template.charge[template_idxs] = real.charge[match_idxs] - - # ... copy over general residue annotations - template.chain_id = [real.chain_id[gidx[0]]] * len(template) - template.res_id = [real.res_id[gidx[0]]] * len(template) - template.ins_code = [real.ins_code[gidx[0]]] * len(template) - - # ... copy over chain_iid annotation, if present - if "chain_iid" in real.get_annotation_categories(): - template.set_annotation("chain_iid", [real.chain_iid[gidx[0]]] * len(template)) - - # ... return matched array return template -def _find_residue_mask_fast( - residue_keys: np.ndarray, - sorted_keys: np.ndarray, - sort_idx: np.ndarray, - chain_id: str, - res_name: str, - res_id: int, -) -> np.ndarray: - """ - Efficient method of getting a residue mask from a sorted list of residue keys. - - Args: - - residue_keys: Structured np array of residue keys to search through - - sorted_keys: Sorted list of residue keys - - sort_idx: Index of the sorted list (get from doing np.argsort(residue_keys)) - - chain_id: Chain ID of the residue - - res_name: Residue name of the residue - - res_id: Residue ID of the residue +def _residue_keys(res_ids: Iterable[int]) -> list[tuple[int, int]]: + """Key each residue by ``(res_id, n)``, with ``n`` the number of earlier residues sharing the number. - Returns: - - mask: Boolean mask of the residue keys + A PDB chain can repeat a number under insertion codes (``1H``, ``1G``, ..., ``1``). ``chain_info`` + and the atom array list those residues in file order, so ``n`` pairs them up. """ - key = np.array([(chain_id, res_name, res_id)], dtype=residue_keys.dtype) - - # Find start and end indices using binary search - start_idx = np.searchsorted(sorted_keys, key)[0] - end_idx = np.searchsorted(sorted_keys, key, side="right")[0] - - # Create mask - mask = np.zeros(len(residue_keys), dtype=bool) - if start_idx < end_idx: - mask[sort_idx[start_idx:end_idx]] = True + seen = Counter() + keys = [] + for res_id in map(int, res_ids): + keys.append((res_id, seen[res_id])) + seen[res_id] += 1 + return keys - return mask - -def build_template_atom_array( - chain_info_dict: dict[str, dict[str, Any]], +def add_missing_atoms_for_chain( + sequence: list[str], + res_ids: list[int], atom_array: AtomArray | None = None, - remove_hydrogens: bool = True, - use_ccd_charges: bool = True, + chain_mask: np.ndarray | None = None, + chain_type: ChainType | None = None, + chain_id: str | None = None, + *, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, - custom_residues: dict[str, AtomArray] | None = None, -) -> AtomArray: - """ - Builds a template AtomArray by matching residues to CCD templates and copying coordinates/annotations from an existing - structure. + hydrogen_policy: Literal["keep", "remove"] = "keep", + extra_field_specs: dict[str, dict[str, Any]] | None = None, +) -> tuple[list[AtomArray], list[int]]: + """Build templates for missing/incomplete residues and determine insertion positions. - For each residue in chain_info_dict, creates a template from the Chemical Component Dictionary (CCD) and either: - 1. Copies coordinates and annotations from matching atoms in atom_array, or - 2. Leaves coordinates as NaN if no matching atoms exist, or - 3. Copies atoms verbatim from atom_array / custom_residues dictionary if no CCD template exists (e.g., for UNL) or for CCD codes that - we want to ignore for matching (e.g., for water molecules) + Handles two use cases: - In the case where an atom_array is provided, the function is robust to ambiguous (chain_id, res_id, res_name) - combinations, provided that the chain_iid annotation is present and resolves the ambiguity. + 1. **From scratch** (``atom_array=None``) — ``chain_id`` and ``chain_type`` are + required; every residue in ``sequence`` is treated as completely missing. + 2. **Fill gaps** — ``atom_array`` and ``chain_mask`` select an existing chain; + residues absent or partially resolved relative to the CCD template are filled in. Args: - chain_info_dict (dict): Dictionary mapping chain IDs to dicts containing residue information with keys: - - 'res_id': List of residue IDs - - 'res_name': List of residue names (CCD codes) - - 'is_polymer': Boolean indicating if chain is polymeric - - 'chain_type': Chain type enum value - atom_array (AtomArray, optional): Structure containing coordinates and annotations to copy. Defaults to None. - remove_hydrogens (bool, optional): Whether to remove hydrogens from CCD templates. Defaults to True. - use_ccd_charges (bool, optional): Whether to use charges from CCD (True) or atom_array (False). Defaults to True. - ccd_mirror_path (os.PathLike, optional): Path to local CCD mirror. Defaults to CCD_MIRROR_PATH. - custom_residues (dict, optional): Dictionary mapping CCD codes to custom AtomArrays. Can be thought as "expanding" - the CCD to include additional residues (e.g., during inference with custom NCAA). Defaults to None. + sequence: List of 3-letter CCD codes for this chain. + res_ids: List of residue IDs corresponding to ``sequence``. A number repeated under + insertion codes is matched to the chain's residues in order. + atom_array: Full structure (all chains). Pass ``None`` to build from scratch. + chain_mask: Boolean mask over ``atom_array`` selecting atoms in this chain. + Required when ``atom_array`` is provided. + chain_type: Chain type for this chain. Required when ``atom_array`` is ``None``. + chain_id: Chain ID. Required when ``atom_array`` is ``None``; otherwise inferred + from ``atom_array``. + ccd_mirror_path: Path to CCD mirror. + extra_field_specs: Extra field specifications used to initialise additional + annotations in the inserted templates. Returns: - AtomArray: Template structure with coordinates and annotations copied from atom_array where available. - - Raises: - ValueError: If chains in atom_array don't match chains in chain_info_dict. + Tuple of ``(templates_to_insert, insertion_positions)`` where + ``templates_to_insert`` is a list of AtomArrays for missing/incomplete residues + and ``insertion_positions`` is a list of atom indices (insert before index *i*). """ - # ... check if the chain_to_sequence_map is consistent with the atom_array - if exists(atom_array) and (not set(struc.get_chains(atom_array)) == set(chain_info_dict)): - raise ValueError( - "Mismatch between `atom_array` and `chain_to_sequence`! " - f"Atom array contains chains {struc.get_chains(atom_array)} but chain_to_sequence " - f"contains chains {chain_info_dict.keys()}." - ) - - # ... extract the relevant entries from the atom_array if it exists - all_false = lambda n: np.zeros(n, dtype=bool) # noqa: E731, convenience function - chain_ids = atom_array.chain_id if exists(atom_array) else all_false(0) - res_ids = atom_array.res_id if exists(atom_array) else all_false(0) - res_names = atom_array.res_name if exists(atom_array) else all_false(0) - - # Determine if we have ambiguous residue annotations, e.g. if parsing an AtomArray with multiple transformations - use_chain_iids = False - if exists(atom_array): - residue_start_indices = struc.get_residue_starts(atom_array) - residue_start_atoms = atom_array[residue_start_indices] - if has_ambiguous_annotation_set(residue_start_atoms, annotation_set=["chain_id", "res_id", "res_name"]): - if "chain_iid" not in atom_array.get_annotation_categories(): - raise ValueError( - "Ambiguous residue annotations detected. This happens when there are residues that " - "have the same `(chain_id, res_id, res_name)` identifier. " - "This happens for example when you have a bio-assembly with multiple copies " - "of a chain that only differ by `transformation_id`.\n" - "You can fix this for example by re-naming the chains to be named uniquely. " - "For the purposes of this function, you can also add a unambiguous chain_iid annotation instead. " - ) - elif has_ambiguous_annotation_set(residue_start_atoms, annotation_set=["chain_iid", "res_id", "res_name"]): - raise ValueError( - "Ambiguous bond annotations detected. This happens when there are atoms that " - "have the same `(chain_id, res_id, res_name)` identifier. " - "This happens for example when you have a bio-assembly with multiple copies " - "of a chain that only differ by `transformation_id`.\n" - "In this case, falling back to the `chain_iid` annotation was insufficient to resolve the ambiguity." - "You can fix this for example by re-naming the chains to be named uniquely. " - "For the purposes of this function, you can also add a unambiguous chain_iid annotation instead. " - ) - else: - use_chain_iids = True - chain_iids = atom_array.chain_iid - chain_info_dict = initialize_chain_info_from_atom_array(atom_array, use_chain_iids=True) - - # ... extract the relevant entries from the chain_info_dict for readability - chain_identifier_to_res_ids = { - chain_identifier: chain_info_dict[chain_identifier]["res_id"] for chain_identifier in chain_info_dict - } - chain_identifier_to_res_names = { - chain_identifier: chain_info_dict[chain_identifier]["res_name"] for chain_identifier in chain_info_dict - } - chain_identifier_to_is_polymer = { - chain_identifier: chain_info_dict[chain_identifier]["is_polymer"] for chain_identifier in chain_info_dict - } - chain_identifier_to_types = { - chain_identifier: chain_info_dict[chain_identifier]["chain_type"] for chain_identifier in chain_info_dict - } - annotations = set(atom_array.get_annotation_categories()) if exists(atom_array) else set() - - # ... create a list of atoms based on the reference CCD entries - template_residues = [] - chain_identifiers = chain_iids if use_chain_iids else chain_ids - - # ... get the sorted list of residue keys. This will make the residue mask lookup much faster. - residue_keys = np.array( - list(zip(chain_identifiers, res_names, res_ids, strict=True)), - dtype=np.dtype([("chain_id", "object"), ("res_name", "object"), ("res_id", "= len(sorted_existing_res_keys): + # Insert at end of chain (or position 0 when building from scratch) + global_insert_pos = int(chain_indices[-1]) + 1 if len(chain_indices) > 0 else 0 + else: + # Insert before next residue + next_res_key = sorted_existing_res_keys[next_idx] + next_local_start = res_key_to_bounds[next_res_key][0] + global_insert_pos = chain_indices[next_local_start] - # ... concatenate all template residues into a single AtomArray - template_array = struc.concatenate(template_residues) + templates_to_insert.append(atoms_to_insert) + insertion_positions.append(global_insert_pos) - return template_array + return templates_to_insert, insertion_positions -def add_inter_residue_bonds( +def add_missing_atoms( atom_array: AtomArray, - struct_conn_dict: dict, - add_bond_types_from_struct_conn: list[str] = ["covale"], - fix_bond_types: bool = True, - fix_formal_charges: bool = True, + chain_info_dict: dict[str, dict[str, Any]], + *, + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, + hydrogen_policy: Literal["keep", "remove"] = "keep", + extra_field_specs: dict[str, dict[str, Any]] | None = None, ) -> AtomArray: - """ - Adds inter-residue bonds to an AtomArray and correctly handles leaving groups. + """Add atoms that are implied by the sequence but missing from the structure. + + To identify missing atoms, we match based on the CCD template for each residue. - This function performs several steps: - 1. Infers and adds polymer bonds between residues - 2. Adds additional inter-residue bonds from struct_conn records - 3. Removes leaving atoms from bond formation - 4. Fixes formal charges on atoms involved in inter-residue bonds + Fills gaps in a partially-resolved structure by inserting completely or partially + missing residues alongside existing atoms. Args: - atom_array (AtomArray): Input structure to which bonds will be added - struct_conn_dict (dict, optional): Dictionary containing structural connectivity information. Defaults to {}. - add_bond_types_from_struct_conn (list[str], optional): Types of bonds to add from struct_conn. Defaults to - ["covale"]. - fix_formal_charges (bool, optional): Whether to fix formal charges on atoms involved in inter-residue bonds. - fix_bond_types (bool, optional): Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. + atom_array: Existing structure to fill gaps in. + chain_info_dict: Dict mapping chain IDs to residue info with keys: + + - ``"res_name"``: List of 3-letter CCD codes (e.g., ``["ALA", "GLY"]``). + - ``"res_id"``: List of residue IDs (e.g., ``[1, 2, 3]``). + - ``"chain_type"``: Chain type as a :py:class:`ChainType` enum or string. + Inferred from the existing structure if omitted. + ccd_mirror_path: Path to local CCD mirror. + extra_field_specs: Dict mapping field names to spec dicts with + ``"default"``/``"dtype"`` keys for extra annotations. Returns: - AtomArray: Output structure with inter-residue bonds. + AtomArray with missing residues inserted (``occupancy=0.0``, NaN coords, + including hydrogens). """ + if "charge" not in atom_array.get_annotation_categories(): + raise ValueError( + "Input atom_array is missing 'charge' annotation. " + "Ensure the structure was loaded via parse() or get_structure(), which always sets charge." + ) - # ... infer inter-residue polymer bonds - polymer_bonds, polymer_bond_leaving_atom_idxs = get_inferred_polymer_bonds(atom_array) - - # ... create any remaining inter-residue bonds that - # are specified in struct_conn - struct_conn_bonds, struct_conn_leaving_atom_idxs = get_struct_conn_bonds( - atom_array, - struct_conn_dict=struct_conn_dict, - add_bond_types=add_bond_types_from_struct_conn, - ) - - # ... merge all inter-residue bonds - inter_bonds = BondList( - atom_count=atom_array.array_length(), - bonds=np.concatenate((polymer_bonds, struct_conn_bonds)), - ) - - # ... and add them to the atom array - atom_array.bonds = atom_array.bonds.merge(inter_bonds) + # Collect ALL insertions across ALL chains + all_templates = [] + all_positions = [] - # ... and record which atoms make inter-residue bonds - atoms_with_inter_bonds = np.unique(inter_bonds.as_array()[:, :2]) - makes_inter_bond = np.zeros(atom_array.array_length(), dtype=bool) - makes_inter_bond[atoms_with_inter_bonds] = True + # Use chain_iid if available (for bio-assemblies), otherwise chain_id + chain_identifier_key = "chain_id" + if "chain_iid" in atom_array.get_annotation_categories(): + chain_identifier_key = "chain_iid" + elif "transformation_id" in atom_array.get_annotation_categories(): + raise ValueError( + "Structure has transformation_id but no chain_iid annotation. " + "Call add_iid_annotations() first to disambiguate chain instances." + ) - # ... merge all leaving group indices - is_leaving = np.zeros(len(atom_array), dtype=bool) - is_leaving[np.concatenate((polymer_bond_leaving_atom_idxs, struct_conn_leaving_atom_idxs), axis=0)] = True + # Loop over chain identifiers (preserve first-occurrence order) + chain_identifiers = atom_array.get_annotation(chain_identifier_key) + _, _first_occ = np.unique(chain_identifiers, return_index=True) + unique_chain_identifiers = chain_identifiers[np.sort(_first_occ)] + + # Process each unique chain (or chain instance for bio-assemblies) + for chain_identifier in unique_chain_identifiers: + # Create mask for this chain instance + chain_mask = chain_identifiers == chain_identifier + + # Get the actual chain_id for looking up sequence in chain_info_dict + chain_id = atom_array.chain_id[np.where(chain_mask)[0][0]] + assert ( + chain_id in chain_info_dict + ), f"Chain ID {chain_id} (from chain_identifier {chain_identifier}) not found in chain_info_dict!" + + # Extract sequence info + chain_info = chain_info_dict[chain_id] + sequence = chain_info["res_name"] + res_ids = chain_info["res_id"] + + # Get the template atom arrays and indices of where to insert these atom arrays for this chain + chain_type = ChainType.as_enum(chain_info["chain_type"]) + templates, positions = add_missing_atoms_for_chain( + atom_array=atom_array, + chain_mask=chain_mask, + sequence=sequence, + res_ids=res_ids, + chain_type=chain_type, + ccd_mirror_path=ccd_mirror_path, + hydrogen_policy=hydrogen_policy, + extra_field_specs=extra_field_specs, + ) - # ... and remove them from the atom array - atom_array = atom_array[~is_leaving] - makes_inter_bond = makes_inter_bond[~is_leaving] + all_templates.extend(templates) + all_positions.extend(positions) - # ... fix bond types of newly bonded atoms, where needed - # (We must fix bonds before fixing formal charges, since the bond degree is used to infer the formal charge) - if fix_bond_types and np.any(makes_inter_bond): - atom_array = correct_bond_types_for_nucleophilic_additions(atom_array, to_update=makes_inter_bond) + if all_templates: + atom_array = insert_atoms(atom_array, all_templates, all_positions) - # ... fix charges of newly bonded atoms, where needed - if fix_formal_charges: - if hasattr(atom_array, "charge"): - atom_array = correct_formal_charges_for_specified_atoms(atom_array, to_update=makes_inter_bond) - else: - logger.warning("Cannot fix formal charges: AtomArray has no 'charge' annotation.") + ensure_annotations(atom_array, "atomic_number", "chem_comp_type") return atom_array -def add_missing_atoms( +def infer_bonds_from_residue_names( atom_array: AtomArray, - chain_info_dict: dict[str, dict[str, Any]], - struct_conn_dict: dict = {}, - add_bond_types_from_struct_conn: list[str] = ["covale"], - remove_hydrogens: bool = True, - use_ccd_charges: bool = True, - fix_formal_charges: bool = True, - fix_bond_types: bool = True, + custom_bond_dict: dict[str, dict[tuple[str, str], int]] | None = None, + sanitize: bool = True, + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, + preserve_atom_mask: np.ndarray | None = None, ) -> AtomArray: - """ - Adds missing atoms to an AtomArray by matching residues to CCD templates and handling inter-residue bonds. + """Add bonds to an AtomArray, using the CCD as ground-truth. - This function performs several steps: - 1. Matches residues to CCD templates to add missing atoms and intra-residue bonds - 2. Infers and adds polymer bonds between residues - 3. Adds additional inter-residue bonds from struct_conn records - 4. Removes leaving atoms from bond formation - 5. Fixes formal charges on atoms involved in inter-residue bonds + Adds two types of bonds: + 1. Intra-residue bonds based on the CCD template for each residue. + 2. Inter-residue bonds inferred from the sequence (e.g., peptide bonds between consecutive amino acids). + + Does NOT add inter-chain and/or `struct_conn` bonds (which are assumed to have been added separately). + + When ``sanitize=True``, cleans the resultant structure by: + - Removing leaving atoms and fixing bond orders for nucleophilic additions + - Fixing formal charges on atoms involved in inter-residue bonds + - Correcting charged amide nitrogens Args: - atom_array (AtomArray): Input structure containing atoms to be completed. - chain_info_dict (dict): Dictionary mapping chain IDs to dicts containing 'res_id', 'res_name', 'is_polymer', and - 'chain_type' info. - struct_conn_dict (dict, optional): Dictionary containing structural connectivity information. Defaults to {}. - add_bond_types_from_struct_conn (list[str], optional): Types of bonds to add from struct_conn. Defaults to - ["covale"]. - remove_hydrogens (bool, optional): Whether to remove hydrogen atoms from templates. Defaults to True. - use_ccd_charges (bool, optional): Whether to use charges from CCD or input structure. Defaults to True. - fix_formal_charges (bool, optional): Whether to fix formal charges on atoms involved in inter-residue bonds. - fix_bond_types (bool, optional): Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. + atom_array: Structure to add bonds to. Requires a ``charge`` annotation, even when ``sanitize=False``. + custom_bond_dict: Optional custom bonds. Maps residue names to + ``{(atom1_name, atom2_name): bond_type_int}``. Overrides CCD for + those residues. + sanitize: If ``True``, resolves leaving atoms, fixes bond orders, + formal charges, and amide nitrogens. Requires hydrogens or nhyd + annotation to be present. Defaults to ``True``. + ccd_mirror_path: Path to local CCD mirror. + preserve_atom_mask: Atoms whose coordinates came from the input structure + and must not be removed as leaving groups. Finite coordinates are used + when omitted. Returns: - AtomArray: Completed structure with missing atoms added and proper bonding. - - Raises: - ValueError: If chain_info_dict is inconsistent with atom_array chains. + AtomArray with bonds added and processed (modified in-place). """ - # ... match all residues to a CCD template - # (unless no CCD template esits, in which case we copy over) - # this also creates the intra-residue bonds from the CCD - atoms = build_template_atom_array( - chain_info_dict=chain_info_dict, - atom_array=atom_array, - use_ccd_charges=use_ccd_charges, - remove_hydrogens=False - if fix_formal_charges - else remove_hydrogens, # we keep hydrogens here, to allow fixing formal charges - ) - # Add inter-residue bonds and remove leaving groups - atoms = add_inter_residue_bonds( - atom_array=atoms, - struct_conn_dict=struct_conn_dict, - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - fix_formal_charges=fix_formal_charges, - fix_bond_types=fix_bond_types, - ) + assert "charge" in atom_array.get_annotation_categories(), "Bond inference requires a 'charge' annotation." - # ... remove hydrogens - if remove_hydrogens: - atoms = ta.remove_hydrogens(atoms) + # Edge case: empty array + if len(atom_array) == 0: + return atom_array + + # Build complete bond dictionary: custom bonds (CIF) + CCD bonds + custom_bond_dict = build_bond_dict_for_atom_array( + atom_array, + custom_bond_dict=custom_bond_dict, + ccd_mirror_path=ccd_mirror_path, + ) - return atoms + # Reuse layouts only within this call, including repeated names from alternate locations. + residue_starts = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + atom_names = atom_array.atom_name.tolist() + res_names = atom_array.res_name + bond_templates, bond_chunks = {}, [] + for start, stop in pairwise(residue_starts): + key = res_names[start], tuple(atom_names[start:stop]) + if key not in bond_templates: + bond_templates[key] = connect_via_residue_names( + atom_array[start:stop], inter_residue=False, custom_bond_dict=custom_bond_dict + ).as_array() + residue_bonds = bond_templates[key].copy() + residue_bonds[:, :2] += int(start) + bond_chunks.append(residue_bonds) + bonds = struc.BondList(len(atom_array), np.concatenate(bond_chunks)) + + # Merge with existing bonds if present; otherwise, set new bonds + if atom_array.bonds is not None: + atom_array.bonds = atom_array.bonds.merge(bonds) + else: + atom_array.bonds = bonds + + # Add polymer inter-residue bonds, skipping pairs already bonded (e.g. via struct_conn) + atom_array = add_polymer_bonds(atom_array) + + return resolve_link_chemistry(atom_array, preserve_atom_mask) if sanitize else atom_array diff --git a/src/atomworks/io/tools/fasta.py b/src/atomworks/io/tools/fasta.py index 4a5ae1a7..1af42124 100644 --- a/src/atomworks/io/tools/fasta.py +++ b/src/atomworks/io/tools/fasta.py @@ -1,12 +1,17 @@ -""" -Convenience utils for working with (generalized) FASTA files. -""" +"""Convenience utils for working with (generalized) FASTA files.""" import logging import os import re - -from atomworks.constants import CCD_MIRROR_PATH +from collections import Counter + +from atomworks.constants import ( + CCD_MIRROR_PATH, + STANDARD_AA_ONE_LETTER, + STANDARD_DNA, + STANDARD_DNA_ONE_LETTER, + STANDARD_RNA, +) from atomworks.enums import ChainType from atomworks.io.utils.ccd import ( check_ccd_codes_are_available, @@ -81,3 +86,72 @@ def one_letter_to_ccd_code( seq_with_ccd_ids.append(chem_comp_id) return seq_with_ccd_ids + + +def infer_chain_type_from_one_letter(seq: str | list[str]) -> ChainType: + """Infer chain type from one-letter sequence notation. + + Supports all common sequence input formats: + + - Simple one-letter: ``"ACDEFG"`` + - Parenthesized notation: ``"(DA)(DT)(DG)"`` (PDB format for DNA/RNA disambiguation) + - Mixed with non-canonical amino acids: ``"ACDE(SEP)FG"`` + + Args: + seq: Sequence as string or list (supports all notation types). + + Returns: + Inferred chain type (POLYPEPTIDE_L, DNA, or RNA). + + Raises: + ValueError: If chain type cannot be inferred from the sequence. + + See Also: + :py:func:`~atomworks.io.utils.non_rcsb.infer_chain_type_from_three_letter` - + For CCD code arrays from parsed structures. + """ + # Convert string to list if necessary + if isinstance(seq, str): + seq = split_generalized_fasta_sequence(seq) + + # Define one-letter code sets + protein_codes = set(STANDARD_AA_ONE_LETTER) + dna_codes = set(STANDARD_DNA_ONE_LETTER) + rna_codes = set(STANDARD_RNA) + + hits = Counter() + for letter in seq: + if letter.startswith("("): + # Parenthesized notation - strip and check against CCD codes + ccd_code = letter.strip("()") + if ccd_code in STANDARD_DNA: + # DNA is commonly provided like (DA), (DT), etc. + hits["dna"] += 1 + elif ccd_code in STANDARD_RNA: + # We also support RNA codes like (A), (U), etc; though these are less common + hits["rna"] += 1 + else: + hits["unknown"] += 1 + else: + # Single-letter code checks + if letter in protein_codes: + hits["protein"] += 1 + if letter in dna_codes: + hits["dna"] += 1 + if letter in rna_codes: + hits["rna"] += 1 + + # Heuristics: + # If the sequence contains more protein hits than DNA or RNA hits, it's probably a protein + if hits["protein"] > hits["dna"] and hits["protein"] > hits["rna"]: + return ChainType.POLYPEPTIDE_L + + # Else, if the sequence is all RNA hits, it's probably RNA + elif hits["rna"] == len(seq): + return ChainType.RNA + + # Else, if the sequence is all DNA hits, it's probably DNA + elif hits["dna"] == len(seq): + return ChainType.DNA + + raise ValueError(f"Could not infer chain type from sequence: {seq=}") diff --git a/src/atomworks/io/tools/inference.py b/src/atomworks/io/tools/inference.py index 313bbf5d..76693586 100644 --- a/src/atomworks/io/tools/inference.py +++ b/src/atomworks/io/tools/inference.py @@ -25,16 +25,20 @@ ) from atomworks.enums import ChainType, ChainTypeInfo from atomworks.io import parse +from atomworks.io.config import ParseConfig from atomworks.io.parser import STANDARD_PARSER_ARGS -from atomworks.io.template import build_template_atom_array -from atomworks.io.tools.fasta import one_letter_to_ccd_code, split_generalized_fasta_sequence +from atomworks.io.template import add_missing_atoms_for_chain, infer_bonds_from_residue_names +from atomworks.io.tools.fasta import ( + infer_chain_type_from_one_letter, + one_letter_to_ccd_code, + split_generalized_fasta_sequence, +) +from atomworks.io.tools.rdkit import atom_array_from_rdkit, inchi_to_rdkit +from atomworks.io.utils.annotator import ensure_annotations from atomworks.io.utils.bonds import ( - correct_bond_types_for_nucleophilic_additions, - correct_formal_charges_for_specified_atoms, - get_inferred_polymer_bonds, - get_struct_conn_bonds, + get_coarse_graph_as_nodes_and_edges, + get_connected_nodes, hash_atom_array, - spoof_struct_conn_dict_from_string, ) from atomworks.io.utils.ccd import ( atom_array_from_ccd_code, @@ -45,6 +49,7 @@ ) from atomworks.io.utils.chain import create_chain_id_generator from atomworks.io.utils.io_utils import CIF_LIKE_EXTENSIONS, read_any +from atomworks.io.utils.query import AtomSelection logger = logging.getLogger("atomworks.io") @@ -56,10 +61,15 @@ def as_dict(self) -> dict: @staticmethod def from_dict(args_dict: dict) -> "ChemicalComponent": if "seq" in args_dict: + args_dict = {k: v for k, v in args_dict.items() if k != "is_polymer"} return SequenceComponent(**args_dict) elif "smiles" in args_dict: return SmilesComponent(**args_dict) - elif "path" in args_dict and args_dict["path"].endswith(".sdf"): + elif "inchi" in args_dict: + return InChIComponent(**args_dict) + elif "path" in args_dict and str(args_dict["path"]).lower().endswith( + (".sdf", ".sdf.gz", ".sdf.gzip", ".sdf.zst") + ): return SDFComponent(**args_dict) elif "path" in args_dict and any(extension in args_dict["path"] for extension in CIF_LIKE_EXTENSIONS): return CIFOrPDBFileComponent(**args_dict) @@ -73,40 +83,14 @@ def from_dict(args_dict: dict) -> "ChemicalComponent": class SequenceComponent(ChemicalComponent): seq: str | list[str] chain_type: ChainType | None = None - is_polymer: bool | None = None chain_id: str | None = None + include_bonds: bool = True msa_path: os.PathLike | None = None @staticmethod - def infer_chain_type(seq: str) -> ChainType: - if isinstance(seq, str): - seq = split_generalized_fasta_sequence(seq) - - hits = Counter() - for letter in seq: - if letter in Protein._valid_one_letter_codes(): - hits["protein"] += 1 - if letter in DNA._valid_one_letter_codes(): - hits["dna"] += 1 - if letter in RNA._valid_one_letter_codes(): - hits["rna"] += 1 - if letter.startswith("("): - hits["unknown"] += 1 - - # Heuristics: - # If the sequence contains more protein hits than DNA or RNA hits, it's probably a protein - if hits["protein"] > hits["dna"] and hits["protein"] > hits["rna"]: - return ChainType.POLYPEPTIDE_L - - # Else, if the sequence is all RNA hits, it's probably RNA - elif hits["rna"] == len(seq): - return ChainType.RNA - - # Else, if the sequence is all DNA hits, it's probably DNA - elif hits["dna"] == len(seq): - return ChainType.DNA - - raise ValueError(f"Could not infer chain type from sequence: {seq=}") + def infer_chain_type(seq: str | list[str]) -> ChainType: + """Infer chain type from sequence notation.""" + return infer_chain_type_from_one_letter(seq) @staticmethod def assert_valid_chain_type(seq: list[str], chain_type: ChainType, allow_other: bool = False) -> bool: @@ -133,25 +117,25 @@ def from_seq( seq: str | list[str], *, chain_type: ChainType | str = None, is_polymer: bool | None = None ) -> "SequenceComponent": chain_type = chain_type or SequenceComponent.infer_chain_type(seq) - is_polymer = is_polymer or chain_type in ChainType.get_polymers() if chain_type in ChainTypeInfo.PROTEINS: - return Protein(seq=seq, chain_type=chain_type, is_polymer=is_polymer) + return Protein(seq=seq, chain_type=chain_type) elif chain_type == ChainType.RNA: - return RNA(seq=seq, chain_type=chain_type, is_polymer=is_polymer) + return RNA(seq=seq, chain_type=chain_type) elif chain_type == ChainType.DNA: - return DNA(seq=seq, chain_type=chain_type, is_polymer=is_polymer) + return DNA(seq=seq, chain_type=chain_type) else: - return SequenceComponent(seq=seq, chain_type=chain_type, is_polymer=is_polymer) + return SequenceComponent(seq=seq, chain_type=chain_type) + + @property + def is_polymer(self) -> bool: + return self.chain_type.is_polymer() def __post_init__(self): # If the chain type is not provided, infer it from the sequence self.chain_type = self.chain_type or SequenceComponent.infer_chain_type(self.seq) self.chain_type = ChainType.as_enum(self.chain_type) - # If the is_polymer is not provided, infer it from the sequence - self.is_polymer = self.is_polymer or self.chain_type.is_polymer() - # If the sequence is a string, split it into a list of one-letter codes if isinstance(self.seq, str): self.seq = split_generalized_fasta_sequence(self.seq) @@ -195,6 +179,15 @@ class SmilesComponent(LigandComponent): res_name: str = UNKNOWN_LIGAND +@dataclass +class InChIComponent(LigandComponent): + inchi: str + chain_type: ChainType | str = "non-polymer" + is_polymer: bool = False + chain_id: str | None = None + res_name: str = UNKNOWN_LIGAND + + @dataclass class SDFComponent(LigandComponent): path: os.PathLike | io.StringIO @@ -209,6 +202,7 @@ class CIFOrPDBFileComponent(ChemicalComponent): path: os.PathLike | io.StringIO msa_paths: dict[str, os.PathLike] | None = None custom_parse_kwargs: dict[str, Any] | None = None + chain_id: str | None = None def __post_init__(self): """Initialize the component by parsing the structure file.""" @@ -217,6 +211,9 @@ def __post_init__(self): else: self._parse_standard_pdb_or_cif() + # Validate and apply chain_id if provided + self._validate_and_apply_chain_id() + def _is_ccd_cif_file(self) -> bool: """Check if we are given a CCD CIF file, which by convention includes the _chem_comp_atom field but not the atom_site field""" # If not a CIF file, return False @@ -245,6 +242,7 @@ def _parse_ccd_style_cif(self) -> None: ) self.atom_array = parse_ccd_cif(read_any(self.path)) + self.preserve_atom_mask = np.zeros(len(self.atom_array), dtype=bool) self.atom_array.set_annotation("is_polymer", np.full(len(self.atom_array), False)) self.chain_ids = np.unique(self.atom_array.chain_id) @@ -256,8 +254,12 @@ def _parse_standard_pdb_or_cif(self) -> None: if self.custom_parse_kwargs is None: self.custom_parse_kwargs = {} - # We add missing atoms later to the fully-concatenated inference AtomArray - parse_kwargs = {**STANDARD_PARSER_ARGS, "add_missing_atoms": False} | self.custom_parse_kwargs + # We add missing atoms later to the fully-concatenated inference AtomArray. + parse_kwargs = { + **STANDARD_PARSER_ARGS, + "add_missing_atoms": False, + "return_atom_array_plus": False, + } | self.custom_parse_kwargs if parse_kwargs["add_missing_atoms"]: logger.warning( @@ -265,7 +267,7 @@ def _parse_standard_pdb_or_cif(self) -> None: "It is recommended to set this argument to False in initial CIFOrPDBFileComponent parsing." ) - parsing_results = parse(self.path, **parse_kwargs) + parsing_results = parse(self.path, config=ParseConfig(**parse_kwargs)) if "assemblies" in parsing_results: assemblies = parsing_results["assemblies"] @@ -290,9 +292,35 @@ def _parse_standard_pdb_or_cif(self) -> None: ) structure_file_atom_array = atom_array_stack[0] + self.preserve_atom_mask = np.isfinite(structure_file_atom_array.coord).all(axis=-1) self.chain_ids = np.unique(structure_file_atom_array.chain_id) self.atom_array = structure_file_atom_array + def _validate_and_apply_chain_id(self) -> None: + """Validate chain_id can be applied and assign it if provided. + + Raises: + ValueError: If chain_id is provided but structure has multiple molecules. + """ + if self.chain_id is None: + return + + # Check if structure is a single connected molecule using bond connectivity + # Use chain_id level for the coarse graph + nodes, edges = get_coarse_graph_as_nodes_and_edges(self.atom_array, "chain_id") + connected_components = get_connected_nodes(nodes, edges) + + if len(connected_components) > 1: + raise ValueError( + f"Cannot assign chain_id '{self.chain_id}' to CIF file '{self.path}': " + f"structure contains {len(connected_components)} disconnected molecules. " + f"chain_id can only be assigned when all atoms form a single connected molecule." + ) + + # Assign the chain_id to all atoms + self.atom_array.chain_id = np.full(len(self.atom_array), self.chain_id) + self.chain_ids = np.array([self.chain_id]) + @dataclass class Polymer(SequenceComponent): @@ -358,27 +386,70 @@ def read_chai_fasta(fasta_path: Path) -> list[ChemicalComponent]: return components +def build_chain_atom_array( + seq: list[str], + chain_id: str, + chain_type: ChainType, + *, + include_bonds: bool = False, + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, +) -> AtomArray: + """Build an AtomArray from a sequence of CCD codes for a single chain. + + Calls :py:func:`add_missing_atoms_for_chain` directly to build complete chains + from CCD templates, then patches ``occupancy`` to ``1.0`` since every atom in + the result is conceptually present (not unresolved). + + Args: + seq: Sequence of three-letter CCD codes. + chain_id: Chain ID for all residues. + chain_type: Chain type (determines ``is_polymer`` and other annotations). + include_bonds: If ``True``, infer bonds, remove leaving atoms, and fix charges/bond types + via :py:func:`infer_bonds_from_residue_names`. Defaults to ``False``. + ccd_mirror_path: Path to local CCD mirror. + + Returns: + AtomArray with residue IDs ``1..len(seq)``, ``occupancy=1.0``, and ``b_factor=nan``. + """ + chain_type = ChainType.as_enum(chain_type) + templates, _ = add_missing_atoms_for_chain( + sequence=list(seq), + res_ids=list(range(1, len(seq) + 1)), + chain_id=chain_id, + chain_type=chain_type, + ccd_mirror_path=ccd_mirror_path, + ) + + atom_array = struc.concatenate(templates) + atom_array.set_annotation("occupancy", np.ones(len(atom_array))) + + if include_bonds: + atom_array = infer_bonds_from_residue_names( + atom_array, + sanitize=True, + ccd_mirror_path=ccd_mirror_path, + preserve_atom_mask=np.zeros(len(atom_array), dtype=bool), + ) + + return atom_array + + def sequence_to_annotated_atom_array( seq: list[str], chain_id: str, *, chain_type: ChainType | str = None, - is_polymer: bool | None = None, + include_bonds: bool = True, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, - custom_residues: dict[str, AtomArray] | None = None, **kwargs, ) -> AtomArray: - if isinstance(seq, str) and is_polymer: + if isinstance(seq, str): seq = one_letter_to_ccd_code( split_generalized_fasta_sequence(seq), chain_type=chain_type, check_ccd_codes=False ) - # Turn the sequence into a numpy array seq = np.asarray(seq) - - chain_type = chain_type or SequenceComponent.infer_chain_type(seq) - chain_type = ChainType.as_enum(chain_type) - is_polymer = is_polymer or chain_type.is_polymer() + chain_type = ChainType.as_enum(chain_type or SequenceComponent.infer_chain_type(seq)) # Ensure that the sequence is a valid combination of existing 3-letter CCD codes ccd_codes_in_seq = set(seq) @@ -388,54 +459,15 @@ def sequence_to_annotated_atom_array( f"is not in the CCD, use a SMILES string or SDF file instead." ) - codes_to_check = ccd_codes_in_seq - set(custom_residues.keys()) if custom_residues else ccd_codes_in_seq - check_ccd_codes_are_available(codes_to_check, ccd_mirror_path=ccd_mirror_path, mode="raise") - - # ... create a list of atoms based on the reference CCD entries - atom_array = build_template_atom_array( - chain_info_dict={ - chain_id: { - "res_name": seq, - "res_id": np.arange(1, len(seq) + 1), - "chain_type": chain_type, - "is_polymer": is_polymer, - } - }, - atom_array=None, - remove_hydrogens=False, # we keep hydrogens here, to allow fixing formal charges - use_ccd_charges=True, - ccd_mirror_path=ccd_mirror_path, - custom_residues=custom_residues, - ) + check_ccd_codes_are_available(ccd_codes_in_seq, ccd_mirror_path=ccd_mirror_path, mode="raise") - # ... add the atomic number annotation (vs. element, which is a string) - atom_array = ta.add_atomic_number_annotation(atom_array) - - # Compute bonds and leaving groups - n_atoms = atom_array.array_length() - polymer_bonds, polymer_bonds_leaving_atoms = get_inferred_polymer_bonds(atom_array) - polymer_bonds = struc.BondList(n_atoms, polymer_bonds) - # ... add bonds to the atom array - atom_array.bonds = atom_array.bonds.merge(polymer_bonds) - # ... remove the leaving groups - atom_array = atom_array[np.setdiff1d(np.arange(n_atoms), polymer_bonds_leaving_atoms)] - - # ... remove index annotation and leaving group annotations - _annotations_to_remove = ( - "is_n_terminal_atom", - "is_c_terminal_atom", - "is_leaving_atom", + return build_chain_atom_array( + seq, + chain_id, + chain_type, + include_bonds=include_bonds, + ccd_mirror_path=ccd_mirror_path, ) - for annotation in _annotations_to_remove: - atom_array.del_annotation(annotation) - - # Add custom annotations - atom_array.set_annotation("occupancy", np.ones(atom_array.array_length())) - atom_array.set_annotation("is_polymer", np.full(atom_array.array_length(), is_polymer)) - atom_array.set_annotation("chain_type", np.full(atom_array.array_length(), chain_type)) - atom_array.set_annotation("b_factor", np.full(atom_array.array_length(), np.nan)) - - return atom_array def smiles_to_annotated_atom_array( @@ -474,7 +506,53 @@ def smiles_to_annotated_atom_array( array.set_annotation("res_name", np.full(array.array_length(), res_name)) array.set_annotation("chain_id", np.full(array.array_length(), chain_id)) array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer)) - array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type))) + array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8)) + array.set_annotation("b_factor", np.full(array.array_length(), np.nan)) + array.set_annotation("stereo", np.full(array.array_length(), "N")) + array.set_annotation("is_backbone_atom", np.full(array.array_length(), False)) + + return array + + +def inchi_to_annotated_atom_array( + inchi: str, + chain_id: str, + *, + chain_type: ChainType | str = "non-polymer", + is_polymer: bool = False, + res_name: str = UNKNOWN_LIGAND, +) -> AtomArray: + """Convert an InChI string to an annotated AtomArray. + + Args: + inchi: The InChI string representing the molecule. + chain_id: The chain ID to assign. + chain_type: The chain type. Defaults to "non-polymer". + is_polymer: Whether the component is a polymer. Defaults to False. + res_name: The residue name. Defaults to UNKNOWN_LIGAND. + + Returns: + AtomArray with standard ligand annotations. + """ + mol = inchi_to_rdkit(inchi) + try: + # ... generate a conformer + mol = Chem.AddHs(mol) + params = AllChem.ETKDGv3() + params.maxAttempts = 1 + AllChem.EmbedMultipleConfs(mol, numConfs=1, params=params) + except Exception: + pass + + array = atom_array_from_rdkit(mol) + + # Update annotations + array.set_annotation("occupancy", np.ones(array.array_length())) + array.set_annotation("hetero", np.full(array.array_length(), True)) + array.set_annotation("res_name", np.full(array.array_length(), res_name)) + array.set_annotation("chain_id", np.full(array.array_length(), chain_id)) + array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer)) + array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8)) array.set_annotation("b_factor", np.full(array.array_length(), np.nan)) array.set_annotation("stereo", np.full(array.array_length(), "N")) array.set_annotation("is_backbone_atom", np.full(array.array_length(), False)) @@ -490,11 +568,12 @@ def sdf_to_annotated_atom_array( is_polymer: bool = False, res_name: str = UNKNOWN_LIGAND, backend: Literal["openbabel", "rdkit"] = "rdkit", + remove_hydrogens: bool = True, ) -> AtomArray: if backend == "rdkit": from atomworks.io.tools.rdkit import atom_array_from_rdkit, sdf_to_rdkit - mol = sdf_to_rdkit(path) + mol = sdf_to_rdkit(path, remove_hydrogens=remove_hydrogens) array = atom_array_from_rdkit(mol) elif backend == "openbabel": raise NotImplementedError("Openbabel backend not yet implemented.") @@ -507,7 +586,7 @@ def sdf_to_annotated_atom_array( array.set_annotation("res_name", np.full(array.array_length(), res_name)) array.set_annotation("chain_id", np.full(array.array_length(), chain_id)) array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer)) - array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type))) + array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8)) array.set_annotation("b_factor", np.full(array.array_length(), np.nan)) array.set_annotation("stereo", np.full(array.array_length(), "N")) array.set_annotation("is_backbone_atom", np.full(array.array_length(), False)) @@ -537,7 +616,7 @@ def ccd_code_to_annotated_atom_array( array.set_annotation("res_name", np.full(array.array_length(), ccd_code)) array.set_annotation("chain_id", np.full(array.array_length(), chain_id)) array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer)) - array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type))) + array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8)) return array @@ -597,13 +676,58 @@ def build_msa_paths_by_chain_id_from_component_list(components: list[ChemicalCom return msa_paths_by_chain_id +def _finalize_inference_atom_array(atom_array: AtomArray) -> AtomArray: + """Add standard post-concat annotations to a concatenated inference atom array.""" + ensure_annotations(atom_array, "chem_comp_type", "atomic_number") + + if "transformation_id" not in atom_array.get_annotation_categories(): + atom_array.set_annotation("transformation_id", np.full(len(atom_array), "1")) + + # Entity + ID annotations must come before IIDs (iid = id + "_" + transformation_id) + atom_array = ta.add_id_and_entity_annotations(atom_array) + atom_array = ta.add_iid_annotations(atom_array) + + return atom_array + + +def _add_bonds_from_strings( + atom_array: AtomArray, + bond_strings: list[tuple[str, str]], +) -> AtomArray: + """Add explicit covalent bonds from CHAIN/RESNAME/RESID/ATOMNAME string pairs. + + Bonds are added as SINGLE bonds. Post-processing (leaving-atom removal, charge and + bond-type correction) is handled by a subsequent call to + :py:func:`~atomworks.io.template.infer_bonds_from_residue_names`. + + Uses :py:class:`~atomworks.io.utils.query.AtomSelection` to parse bond strings, + supporting wildcards (``"*"``) and the same syntax as the rest of the selection API. + """ + if not bond_strings: + return atom_array + + raw_bonds = [] + for atom1_str, atom2_str in bond_strings: + idx1 = AtomSelection.from_selection_str(atom1_str).get_idxs(atom_array) + idx2 = AtomSelection.from_selection_str(atom2_str).get_idxs(atom_array) + if len(idx1) != 1 or len(idx2) != 1: + raise ValueError( + f"Bond specification must resolve to exactly one atom each: " + f"{atom1_str!r} → {len(idx1)} atoms, {atom2_str!r} → {len(idx2)} atoms" + ) + raw_bonds.append((int(idx1[0]), int(idx2[0]), struc.BondType.SINGLE)) + + new_bonds = struc.BondList(atom_array.array_length(), np.array(raw_bonds, dtype=np.uint32)) + atom_array.bonds = atom_array.bonds.merge(new_bonds) if atom_array.bonds is not None else new_bonds + return atom_array + + def components_to_atom_array( components: list[ChemicalComponent | dict], bonds: list[str] | None = None, return_components: bool = False, - custom_residues: dict[str, AtomArray | SDFComponent | dict] | None = None, ) -> AtomArray | list[ChemicalComponent]: - """Build an AtomArray from a list of ChemicalComponent objects and supporting details (bonds, custom residues). + """Build an AtomArray from a list of ChemicalComponent objects and supporting details (bonds). Args: components (list[ChemicalComponent | dict]): List of ChemicalComponent objects or dictionaries that can be @@ -616,11 +740,13 @@ def components_to_atom_array( e.g., [("A/THR/4/CG", "D/L:1/0/O13"), ("A/CYS/5/SG", "A/CYS/137/SG")] return_components (bool): If True, return the components list as well as the AtomArray. Useful for e.g., mapping components to generated chain IDs or inferred chain types. - custom_residues: A dictionary of custom residues to be used as "spoof" CCD entries. Can be given either as - AtomArrays directly or as dictionary specifying paths to CIF files (must include atom names). - NOTE: If manually specifying bonds, we recommend visualizing the bond graph with `matplotlib` to ensure that the bonds are correctly - NOTE: The res_id numbering follows the RCSB convention (1-indexed) + Note: + If manually specifying bonds, we recommend visualizing the bond graph with `matplotlib` + to ensure that the bonds are correctly specified. + The res_id numbering follows the RCSB convention (1-indexed). + Custom CCD entries can be registered using :py:func:`~atomworks.io.utils.ccd.register_custom_ccd_entry` + to override standard CCD definitions before calling this function. Returns: AtomArray: The assembled AtomArray, used for visualization or inference. @@ -680,19 +806,8 @@ def components_to_atom_array( # Instantiate a chain id generator chain_id_generator = create_chain_id_generator(chain_ids) - # Convert the custom_residues to a dictionary mapping strings to AtomArrays, if given - if custom_residues: - for key, value in custom_residues.items(): - if isinstance(value, dict): - chemical_component = ChemicalComponent.from_dict(value) - atom_array = chemical_component.atom_array - - # Delete the res_id annotation (otherwise users must set it correctly) - atom_array.del_annotation("res_id") - - custom_residues[key] = atom_array - atom_arrays = [] + preserve_atom_masks = [] ligand_hash_to_id = KeyToIntMapper() # ... to keep track of identical ligands for component in components: # CIFOrPDBFileComponents already have parsed AtomArrays @@ -705,17 +820,24 @@ def components_to_atom_array( f"The next available chain ID was assigned, assuming that this is a single-chain structure: {atom_array.chain_id[0]}" ) atom_arrays.append(component.atom_array) + preserve_atom_masks.append(component.preserve_atom_mask) continue component.chain_id = component.chain_id or next(chain_id_generator) if isinstance(component, SequenceComponent): - atom_arrays.append(sequence_to_annotated_atom_array(**component.as_dict(), custom_residues=custom_residues)) + # include_bonds=False: global bond inference runs after all chains are concatenated + atom_arrays.append(sequence_to_annotated_atom_array(**{**component.as_dict(), "include_bonds": False})) elif isinstance(component, SmilesComponent): ligand_array = smiles_to_annotated_atom_array(**component.as_dict()) if component.res_name == UNKNOWN_LIGAND: ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id) atom_arrays.append(ligand_array) + elif isinstance(component, InChIComponent): + ligand_array = inchi_to_annotated_atom_array(**component.as_dict()) + if component.res_name == UNKNOWN_LIGAND: + ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id) + atom_arrays.append(ligand_array) elif isinstance(component, CCDComponent): atom_arrays.append(ccd_code_to_annotated_atom_array(**component.as_dict())) elif isinstance(component, SDFComponent): @@ -725,66 +847,38 @@ def components_to_atom_array( atom_arrays.append(ligand_array) else: raise ValueError(f"Unknown chemical component type: {type(component)}") + preserve_atom_masks.append(np.zeros(len(atom_arrays[-1]), dtype=bool)) - # ... add (possibly spoofed) annotations to each AtomArray - for atom_array in atom_arrays: - if "transformation_id" not in atom_array.get_annotation_categories(): - atom_array.set_annotation("transformation_id", np.full(atom_array.array_length(), "1")) - if "charge" not in atom_array.get_annotation_categories(): - atom_array.set_annotation("charge", np.zeros(atom_array.array_length(), dtype=int)) - if "b_factor" not in atom_array.get_annotation_categories(): - atom_array.set_annotation("b_factor", np.full(atom_array.array_length(), np.nan)) - if "occupancy" not in atom_array.get_annotation_categories(): - atom_array.set_annotation("occupancy", np.ones(atom_array.array_length(), dtype=float)) - if "atom_id" not in atom_array.get_annotation_categories(): - # This is 1-indexed for consistency with the PDB. However, biotite 0-indexes it if not present in the CIF. - atom_array.set_annotation("atom_id", np.arange(1, atom_array.array_length() + 1)) + # add required per-array annotations before concatenation so biotite does not fill + # missing annotations with defaults when arrays have heterogeneous annotation sets + for arr in atom_arrays: + if "b_factor" not in arr.get_annotation_categories(): + arr.set_annotation("b_factor", np.full(arr.array_length(), np.nan)) + if "transformation_id" not in arr.get_annotation_categories(): + arr.set_annotation("transformation_id", np.full(arr.array_length(), "1")) # ... concatenate all atom arrays into a single AtomArray atom_array = struc.concatenate(atom_arrays) - # TODO: We may be able to simplify by casting to a buffer and running `parse` - - # ... add the chain_iid annotation - ta.add_chain_iid_annotation(atom_array) - + # ... add explicit struct-conn bonds before infer_bonds_from_residue_names so they + # participate in leaving-atom removal and charge/bond-type correction if bonds: - # ... spoof the struct_conn CIFCategory - struct_conn_dict = spoof_struct_conn_dict_from_string(bonds) - - # ... get the bonds and leaving atoms - struct_conn_bonds, struct_conn_leaving_atom_idxs = get_struct_conn_bonds( - atom_array=atom_array, struct_conn_dict=struct_conn_dict, add_bond_types=["covale"], raise_on_failure=True - ) - struct_conn_bonds = struc.BondList(atom_array.array_length(), struct_conn_bonds) - - # ... add the bonds to the AtomArray - atom_array.bonds = atom_array.bonds.merge(struct_conn_bonds) - - # ... record which atoms make inter-residue bonds - atoms_with_inter_bonds = np.unique(struct_conn_bonds.as_array()[:, :2]) - makes_inter_bond = np.zeros(len(atom_array), dtype=bool) - makes_inter_bond[atoms_with_inter_bonds] = True - - # ... and remove the leaving atoms - is_leaving = np.zeros(len(atom_array), dtype=bool) - is_leaving[struct_conn_leaving_atom_idxs] = True - atom_array = atom_array[~is_leaving] - makes_inter_bond = makes_inter_bond[~is_leaving] - - # ... fix charges of newly bonded atoms, where needed - atom_array = correct_formal_charges_for_specified_atoms(atom_array, to_update=makes_inter_bond) - - # ... fix bond orders of newly bonded atoms, where needed (e.g., convert double bonds to single bonds during nucleophilic additions) - atom_array = correct_bond_types_for_nucleophilic_additions(atom_array, to_update=makes_inter_bond) + atom_array = _add_bonds_from_strings(atom_array, bonds) + + # ... infer CCD bonds, remove leaving atoms, fix charges and bond types. + # Merges with existing bonds (including struct-conn bonds added above). + atom_array = infer_bonds_from_residue_names( + atom_array, + sanitize=True, + preserve_atom_mask=np.concatenate(preserve_atom_masks), + ) # ... remove hydrogens atom_array = ta.remove_hydrogens(atom_array) - # ... add (pn_unit, molecule) x (id, iid) entity annotations - atom_array = ta.add_id_and_entity_annotations(atom_array) - atom_array = ta.add_pn_unit_iid_annotation(atom_array) - atom_array = ta.add_molecule_iid_annotation(atom_array) + # ... add atomic_number, transformation_id, and all IID/entity/ID annotations. + # Must come AFTER bond inference so molecule_id reflects full bond connectivity. + atom_array = _finalize_inference_atom_array(atom_array) # Raise an error if chain_ids with the same name correspond to different entities for chain_id in np.unique(atom_array.chain_id): diff --git a/src/atomworks/io/tools/posebusters.py b/src/atomworks/io/tools/posebusters.py new file mode 100644 index 00000000..9cde62a6 --- /dev/null +++ b/src/atomworks/io/tools/posebusters.py @@ -0,0 +1,161 @@ +"""Tools for running PoseBusters validation on RDKit molecules or AtomArrays.""" + +import contextlib +import logging +from functools import lru_cache +from typing import Any + +from biotite.structure import AtomArray +from rdkit import Chem +from rdkit.Chem import Mol + +from atomworks.constants import METAL_ELEMENTS +from atomworks.io.tools.rdkit import atom_array_to_rdkit + +try: + from posebusters import PoseBusters +except ImportError: + PoseBusters = None # type: ignore[assignment, misc] + +logger = logging.getLogger(__name__) + +DEFAULT_EXCLUDED_CHECKS: frozenset[str] = frozenset({"pb_all_atoms_connected", "pb_inchi_convertible"}) + + +def _ensure_mol(mol_or_atom_array: Mol | AtomArray) -> Mol: + """Convert an AtomArray to an RDKit Mol if needed.""" + if isinstance(mol_or_atom_array, AtomArray): + return atom_array_to_rdkit( + mol_or_atom_array, + set_coord=True, + hydrogen_policy="keep", + infer_bonds=False, + sanitize=True, + assume_metal_bonds_are_dative=True, + ) + return mol_or_atom_array + + +def _check_posebusters_available() -> None: + if PoseBusters is None: + raise ImportError( + "posebusters is required for PoseBusters validation. " "Install it with: pip install posebusters" + ) + + +@lru_cache(maxsize=1) +def _get_default_posebusters() -> Any: + """Return a lazily-cached ``PoseBusters(config="mol")`` instance.""" + _check_posebusters_available() + return PoseBusters(config="mol") + + +def _merge_check_results(results: list[dict[str, bool]]) -> dict[str, bool]: + """Merge per-fragment check results via AND across all fragments.""" + all_keys = {k for d in results for k in d} + return {k: all(d[k] for d in results if k in d) for k in all_keys} + + +def run_posebusters( + mol: Mol | AtomArray, + pb: Any | None = None, + excluded_checks: frozenset[str] = DEFAULT_EXCLUDED_CHECKS, +) -> tuple[bool, dict[str, bool]]: + """Run PoseBusters on a single molecule. + + Args: + mol: RDKit molecule or AtomArray to validate. + pb: Optional pre-built PoseBusters instance for reuse across calls. + excluded_checks: Check keys to exclude from pass/fail determination. + + Returns: + Tuple of ``(failed, check_results)`` where *failed* is ``True`` if PB + raised an exception or any non-excluded check failed, and *check_results* + maps ``pb_`` to ``bool`` (empty dict on crash). + """ + mol = _ensure_mol(mol) + if pb is None: + pb = _get_default_posebusters() + + try: + results_df = pb.bust(mol_pred=mol, full_report=False) + check_results = { + f"pb_{k}": bool(v) + for k, v in results_df.iloc[0].to_dict().items() + if isinstance(v, bool | int) and k != "file_path" + } + relevant = {k: v for k, v in check_results.items() if k not in excluded_checks} + failed = not (all(relevant.values()) if relevant else True) + return failed, check_results + except Exception: + logger.debug("PoseBusters raised an exception", exc_info=True) + return True, {} + + +def validate_organic_fragments( + mol: Mol | AtomArray, + pb: Any | None = None, + excluded_checks: frozenset[str] = DEFAULT_EXCLUDED_CHECKS, +) -> tuple[bool, dict[str, bool]]: + """Run PoseBusters on each organic fragment of a molecule separately. + + Dative bonds are broken so that metal centres and ligands become separate + fragments. Only organic (non-metal) fragments are validated. Results are + merged across fragments; the function exits early on the first + failure, returning only the fragments checked so far. + + Returns: + Tuple of ``(failed, check_results)``. For metal-only molecules the + result is ``(False, {})``. + """ + mol = _ensure_mol(mol) + mol_copy = Chem.RWMol(Chem.Mol(mol)) + metal_indices = {a.GetIdx() for a in mol_copy.GetAtoms() if a.GetSymbol().upper() in METAL_ELEMENTS} + metal_bonds = [ + (b.GetBeginAtomIdx(), b.GetEndAtomIdx()) + for b in mol_copy.GetBonds() + if b.GetBeginAtomIdx() in metal_indices or b.GetEndAtomIdx() in metal_indices + ] + + for i, j in sorted(metal_bonds, reverse=True): + mol_copy.RemoveBond(i, j) + + with contextlib.suppress(Exception): # Best-effort sanitization after bond removal + Chem.SanitizeMol(mol_copy) + + frags = Chem.GetMolFrags(Chem.Mol(mol_copy), asMols=True, sanitizeFrags=False) + organic_frags = [f for f in frags if not any(a.GetSymbol().upper() in METAL_ELEMENTS for a in f.GetAtoms())] + + if not organic_frags: + return False, {} + + all_check_results: list[dict[str, bool]] = [] + for frag in organic_frags: + failed, frag_results = run_posebusters(frag, pb=pb, excluded_checks=excluded_checks) + all_check_results.append(frag_results) + if failed: + return True, _merge_check_results(all_check_results) + + return False, _merge_check_results(all_check_results) + + +def validate_molecule( + mol: Mol | AtomArray, + pb: Any | None = None, + excluded_checks: frozenset[str] = DEFAULT_EXCLUDED_CHECKS, +) -> tuple[bool, dict[str, bool]]: + """Validate a molecule with PoseBusters, falling back to per-fragment validation. + + Tries whole-molecule validation first. If PB crashes or fails on a + metal-containing molecule, falls back to validating each organic fragment + independently. + + Returns: + Tuple of ``(failed, check_results)``. + """ + mol = _ensure_mol(mol) + has_metal = any(a.GetSymbol().upper() in METAL_ELEMENTS for a in mol.GetAtoms()) + failed, check_results = run_posebusters(mol, pb=pb, excluded_checks=excluded_checks) + if not check_results or (failed and has_metal): + failed, check_results = validate_organic_fragments(mol, pb=pb, excluded_checks=excluded_checks) + return failed, check_results diff --git a/src/atomworks/io/tools/rdkit.py b/src/atomworks/io/tools/rdkit.py index 03ef2e07..b5644cc8 100644 --- a/src/atomworks/io/tools/rdkit.py +++ b/src/atomworks/io/tools/rdkit.py @@ -1,10 +1,12 @@ """Tools for using RDKit with AtomArray objects.""" +import contextlib import copy import io import logging +import os from collections import Counter -from collections.abc import Callable +from collections.abc import Callable, Generator from functools import cache, wraps from os import PathLike from pathlib import Path @@ -15,7 +17,8 @@ import toolz from biotite.structure import AtomArray from rdkit import Chem -from rdkit.Chem import AllChem, Mol, rdFingerprintGenerator +from rdkit.Chem import AllChem, Mol, rdDetermineBonds, rdFingerprintGenerator +from rdkit.Chem.inchi import MolFromInchi from rdkit.Chem.MolStandardize import rdMolStandardize from rdkit.DataStructs import ExplicitBitVect @@ -29,11 +32,45 @@ PDB_ISOTOPE_SYMBOL_TO_ELEMENT_SYMBOL, UNKNOWN_LIGAND, ) -from atomworks.io.utils.ccd import atom_array_from_ccd_code +from atomworks.external.xyz2mol_tm import get_tmc_mol +from atomworks.io.utils.ccd import _standard_ccd_only_cache, atom_array_from_ccd_code +from atomworks.io.utils.compression import open_compressed +from atomworks.ml.utils import timer logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _suppress_stderr_fd() -> Generator[None, None, None]: + """Context manager to suppress stderr at file descriptor level. + + Suppresses YAeHMOP warnings from rdDetermineBonds that write directly + to the stderr file descriptor. + """ + stderr_fd = 2 + saved_stderr = os.dup(stderr_fd) + devnull_fd = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull_fd, stderr_fd) + yield + finally: + os.dup2(saved_stderr, stderr_fd) + os.close(devnull_fd) + os.close(saved_stderr) + + +@contextlib.contextmanager +def suppress_rdkit_warnings() -> Generator[None, None, None]: + """Temporarily silence Python-level RDKit logger warnings.""" + rdkit_log = logging.getLogger("rdkit") + old_level = rdkit_log.level + rdkit_log.setLevel(logging.ERROR) + try: + yield + finally: + rdkit_log.setLevel(old_level) + + # Set default pickle properties to all properties, otherwise # annotations get lost when pickling/unpickling molecules # https://github.com/rdkit/rdkit/issues/1320 @@ -88,6 +125,7 @@ # because the PDB specified bond-order (from a kekulized form of the molecule) # is lost when we map to aromatic, which can lead to incorrect bond-order # perception in RDKit. + struc.bonds.BondType.AROMATIC: (Chem.BondType.AROMATIC, True), struc.bonds.BondType.AROMATIC_SINGLE: (Chem.BondType.SINGLE, True), struc.bonds.BondType.AROMATIC_DOUBLE: (Chem.BondType.DOUBLE, True), struc.bonds.BondType.AROMATIC_TRIPLE: (Chem.BondType.TRIPLE, True), @@ -111,7 +149,7 @@ class ChEMBLNormalizer: alone. Reference: - `ChEMBL Structure Pipeline `_ + `ChEMBL Structure Pipeline `__ """ def __init__(self): @@ -220,16 +258,22 @@ def _calc_formal_charge_from_valence(rdatom: Chem.Atom) -> int: def fix_charge_based_on_valence(mol: Mol) -> Mol: """ - Attempt to fix the formal charge of an RDKit molecule by making it compatible with its valence state. + Repair invalid atomic valences in-place without changing valid atoms or bonds. + + Failed repairs restore the original charges. Valid molecules require no copy. """ - # ... record previous mol to revert if changing charges does not fix the valence - previous_mol = copy.deepcopy(mol) + if _has_correct_valence(mol): + return mol - if not _has_correct_valence(mol): - for rdatom in mol.GetAtoms(): - rdatom.SetFormalCharge(_calc_formal_charge_from_valence(rdatom)) + changed = [(atom, atom.GetFormalCharge()) for atom in mol.GetAtoms() if atom.HasValenceViolation()] + for atom, _ in changed: + atom.SetFormalCharge(_calc_formal_charge_from_valence(atom)) - return mol if _has_correct_valence(mol) else previous_mol + if not _has_correct_valence(mol): + for atom, charge in changed: + atom.SetFormalCharge(charge) + mol.UpdatePropertyCache(strict=False) + return mol def change_metal_bonds_to_dative( @@ -291,7 +335,7 @@ def fix_mol( References: `RDKit Molecular Sanitization `_ - `ChEMBL Structure Pipeline `_ + `ChEMBL Structure Pipeline `__ `datamol mol.py `_ """ @@ -363,9 +407,9 @@ def remove_hydrogens(mol: Mol) -> Mol: def get_morgan_fingerprint_from_rdkit_mol(mol: Chem.Mol, *, radius: int = 2, n_bits: int = 2048) -> ExplicitBitVect: - """ - Generates the Morgan fingerprint for an RDKit molecule. Useful for calculating Tanimoto - similarity between molecules, e.g. for similarity searches. + """Generates the Morgan fingerprint for an RDKit molecule. + + Useful for calculating Tanimoto similarity between molecules, e.g. for similarity searches. Default parameters are based on the AF-3 supplementary material: > We measure ligand Tanimoto similarity using RDKit v.2023_03_3 Morgan fingerprints (radius 2, 2048 bits) @@ -446,7 +490,63 @@ def smiles_to_rdkit(smiles: str, *, sanitize: bool = True, timeout: int = 5, gen return mol -def sdf_to_rdkit(sdf_path_or_buffer: io.StringIO | PathLike, *, sanitize: bool = True) -> Mol: +def inchi_to_rdkit(inchi: str, *, sanitize: bool = True, timeout: int = 5, generate_conformers: bool = True) -> Mol: + """Generate an RDKit molecule from an InChI string. + + Args: + inchi: The InChI string representing the molecule. + sanitize: Whether to sanitize the molecule. + timeout: The timeout for the conformer generation. + generate_conformers: Whether to generate and minimize conformers. + If False, returns the molecule immediately after InChI parsing. + + Returns: + The RDKit molecule generated from the InChI string. + + Note: + The returned molecule is sanitized and has aromaticity perceived. + If generate_conformers=True, the molecule will also have (implicit) hydrogens added + and conformers generated with UFF minimization. + """ + mol = MolFromInchi(inchi, sanitize=sanitize) + if mol is None: + raise Chem.MolSanitizeException( + f"Failed to create molecule from InChI string: {inchi}. Try setting `sanitize=False`." + ) + + if not generate_conformers: + return mol + + # Conformer generation parameters (same as smiles_to_rdkit) + _optimizer_force_tol = 1e-3 + _max_its = 500 + _energy_tol = 1e-7 + + # ... add hydrogens (needed for accurate conformer generation) + mol = Chem.AddHs(mol) + + # ... generate a conformer + etkdg = AllChem.ETKDGv3() + etkdg.useRandomCoords = True + etkdg.optimizerForceTol = float(_optimizer_force_tol) + etkdg.timeout = timeout + AllChem.EmbedMolecule(mol, params=etkdg) + + if mol.GetNumConformers() > 0: + # (Extra step to ensure we get the best conformer) + ff = AllChem.UFFGetMoleculeForceField(mol) + ff.Initialize() + ff.Minimize(energyTol=_energy_tol, maxIts=_max_its) + + # ... remove hydrogens again, since we no longer need them + mol = remove_hydrogens(mol) + + return mol + + +def sdf_to_rdkit( + sdf_path_or_buffer: io.StringIO | PathLike, *, sanitize: bool = True, remove_hydrogens: bool = True +) -> Mol: """ Generate an RDKit molecule from an SDF file or buffer. @@ -454,6 +554,9 @@ def sdf_to_rdkit(sdf_path_or_buffer: io.StringIO | PathLike, *, sanitize: bool = - sdf_path_or_buffer (io.StringIO | PathLike): Either a path to an SDF file or a StringIO buffer containing SDF-formatted molecule data - sanitize (bool): Whether to sanitize the molecule during parsing. Default is True. + - remove_hydrogens (bool): Whether to drop the hydrogens the file states, as RDKit does by + default. ``False`` keeps them, for a caller reading an SDF as the structure it describes. + Default is True. Returns: - Mol: The RDKit molecule generated from the SDF data @@ -463,12 +566,15 @@ def sdf_to_rdkit(sdf_path_or_buffer: io.StringIO | PathLike, *, sanitize: bool = - TypeError: If the input is neither a StringIO buffer nor a valid path """ if isinstance(sdf_path_or_buffer, str | PathLike): - supplier = Chem.SDMolSupplier(str(sdf_path_or_buffer), sanitize=sanitize) + with open_compressed(sdf_path_or_buffer, "rt") as stream: + sdf_data = stream.read() elif isinstance(sdf_path_or_buffer, io.StringIO): - supplier = Chem.SDMolSupplier(sdf_path_or_buffer, sanitize=sanitize) + sdf_data = sdf_path_or_buffer.getvalue() else: raise TypeError("Input must be either a path or a StringIO buffer") + supplier = Chem.SDMolSupplier() + supplier.SetData(sdf_data, sanitize=sanitize, removeHs=remove_hydrogens) try: mol = next(supplier) except StopIteration: @@ -498,8 +604,8 @@ def atom_array_from_rdkit( - mol: The RDKit molecule to convert. - set_coord_if_available: Whether to set the coordinates from the RDKit molecule if a conformer is available. - - conformer_id: The conformer ID to use for coordinates. If None, the first - conformer is used. + - conformer_id: The RDKit ID of the conformer to use for coordinates, not its position in the + conformer list. If None, the first conformer is used. An unknown ID raises ``ValueError``. - remove_hydrogens: Whether to remove any explicit hydrogen atoms. - remove_inferred_atoms: Whether to remove any atoms that do not carry the `rdkit_atom_id` annotation. @@ -521,8 +627,6 @@ def atom_array_from_rdkit( n_conformers = mol.GetNumConformers() if set_coord_if_available and n_conformers > 0: conformer_id = conformer_id if conformer_id is not None else -1 - if conformer_id >= n_conformers: - raise ValueError(f"Conformer ID {conformer_id} out of range for molecule with {n_conformers} conformers") coords = mol.GetConformer(conformer_id).GetPositions() # Set atoms @@ -538,8 +642,7 @@ def atom_array_from_rdkit( coord=coords[idx], charge=rdatom.GetFormalCharge(), hyb=RDKIT_HYBRIDIZATION_TO_INT[rdatom.GetHybridization()], - nhyd=rdatom.GetTotalNumHs(), - hvydeg=rdatom.GetDegree() - rdatom.GetTotalNumHs(), + **({"nhyd": rdatom.GetTotalNumHs(includeNeighbors=True)} if remove_hydrogens else {}), rdkit_atom_id=rdatom.GetIntProp("rdkit_atom_id") if rdatom.HasProp("rdkit_atom_id") else -1, hetero=True, # per default, set all atoms to be hetero atoms atom_name=f"{rdatom.GetSymbol().upper()}{element_occurence}", # per default, set atom name to be element symbol + index @@ -574,7 +677,7 @@ def atom_array_from_rdkit( # Set extra annotations annotations = mol._annotations if hasattr(mol, "_annotations") else {} if len(annotations) > 0: - # Create mapping of array idx <> annotation idx via the openbabel atom id: + # Create mapping of array idx <> annotation idx via the rdkit atom id: _rdkit_id_to_annotation_idx = { rdkit_atom_id: idx for idx, rdkit_atom_id in enumerate(annotations["rdkit_atom_id"]) } @@ -585,14 +688,14 @@ def atom_array_from_rdkit( array_idx_to_annotation_idx.append((idx, _rdkit_id_to_annotation_idx[atom.rdkit_atom_id])) array_idx_to_annotation_idx = np.array(array_idx_to_annotation_idx) - # Exit if there are no annotations to set + # Transfer nothing if no atom matches, but still apply the output filters below if len(array_idx_to_annotation_idx) == 0: logger.warning( "No rdkit atoms match any annotation. You may want to check that you are " "using the @preserve_annotations decorator correctly. And set the " - "rdkit pickle options to preserve properties. Returning." + "rdkit pickle options to preserve properties. Skipping annotation transfer." ) - return atom_array + annotations = {} for key, val in annotations.items(): if key in ["coord", "charge"]: @@ -618,7 +721,12 @@ def atom_array_from_rdkit( logger.warning(f"Unsupported annotation dtype: {val.dtype} for annotation: {key}. Skipping.") continue - vals = np.full(len(atom_array), default, dtype=val.dtype) + # For positions not matched to any pre-existing atom (e.g. new atoms added by RDKit) + # preserve the values set by the struc.Atom() constructor above + if key in atom_array.get_annotation_categories(): + vals = atom_array.get_annotation(key).copy() + else: + vals = np.full(len(atom_array), default, dtype=val.dtype) vals[array_idx_to_annotation_idx[:, 0]] = annotations[key][array_idx_to_annotation_idx[:, 1]] atom_array.set_annotation(key, vals) @@ -640,6 +748,9 @@ def atom_array_to_rdkit( sanitize: bool = True, attempt_fixing_corrupted_molecules: bool = True, assume_metal_bonds_are_dative: bool = False, + infer_bonds: bool = False, + system_charge: int | None = None, + timeout_seconds: int = 1, ) -> Mol: """Generate an RDKit molecule from a Biotite AtomArray object. @@ -653,6 +764,13 @@ def atom_array_to_rdkit( - attempt_fixing_corrupted_molecules (bool): Whether to attempt fixing corrupted molecules during conversion. Default is True. - assume_metal_bonds_are_dative (bool): Whether to assume that all bonds with metals are dative bonds. Default is False. WARNING: This messes up RDKit conformer generation. + - infer_bonds (bool): If True, infer bonds from 3D coordinates using rdDetermineBonds, + ignoring any existing bonds in atom_array. If False, use bonds from atom_array.bonds. + Defaults to False. + - system_charge (int): Overall charge of the system for bond order determination (used when infer_bonds=True). + Defaults to 0. + - timeout_seconds (int): Timeout in seconds for bond inference when using xyz2mol_tm for transition metal complexes. + Defaults to 1 second. Returns: - rdkit.Chem.Mol: RDKit Molecule generated from the AtomArray. @@ -661,8 +779,7 @@ def atom_array_to_rdkit( Aromaticity, hybridization states, and other properties are automatically perceived by RDKit's SanitizeMol during the conversion process. """ - # Initialize the RDKit molecule; copy AtomArray to avoid modifying the original - atom_array = atom_array.copy() + # Read the input directly; copy only annotations retained by the output. mol = Chem.RWMol() # Set atoms @@ -678,34 +795,88 @@ def atom_array_to_rdkit( else: raise ValueError(f"Invalid hydrogen policy: {hydrogen_policy}. Must be 'infer', 'remove', or 'keep'.") - for atom_id, atom in enumerate(atom_array): - atomic_number = element_to_atomic_number(atom.element) + charges = atom_array.charge if "charge" in atom_array.get_annotation_categories() else None + for atom_id, (element, atom_name) in enumerate(zip(atom_array.element, atom_array.atom_name, strict=True)): + atomic_number = element_to_atomic_number(element) rdatom = Chem.Atom(atomic_number) - if hasattr(atom, "charge"): + if charges is not None: # ... set formal charge if available (otherwise RDKit will assume it is 0 # and assign a charge state in SanitizeMol if it is required to satisfy # valence constraints) - rdatom.SetFormalCharge(int(atom.charge)) + rdatom.SetFormalCharge(int(charges[atom_id])) rdatom.SetIntProp("rdkit_atom_id", atom_id) - rdatom.SetProp("atom_name", atom.atom_name) + rdatom.SetProp("atom_name", atom_name) rdkit_atom_ids.append(atom_id) mol.AddAtom(rdatom) # Set coordinates first - set_coord = set_coord or not np.any(np.isnan(atom_array.coord)) + set_coord = not np.any(np.isnan(atom_array.coord)) if set_coord is None else set_coord if set_coord: # ... add conformer (at id 0) conf_id = mol.AddConformer(Chem.Conformer(len(atom_array)), assignId=True) - # ... fill in coordinates - for atom_id, atom_coord in enumerate(atom_array.coord): - mol.GetConformer(conf_id).SetAtomPosition(atom_id, atom_coord.tolist()) + mol.GetConformer(conf_id).SetPositions(np.ascontiguousarray(atom_array.coord, dtype=np.float64)) - # Set bonds from existing bonds in atom_array + # Set bonds (either infer from 3D coordinates or use existing bonds) _should_be_aromatic = set() - if exists(atom_array.bonds): + if infer_bonds: + assert mol.GetNumAtoms() > 0, "Cannot infer bonds for empty molecule" + assert ( + "charge" in atom_array.get_annotation_categories() or system_charge is not None + ), "System charge must be provided when inferring bonds if atom_array has no 'charge' annotation." + + system_charge = system_charge if system_charge is not None else int(np.nansum(atom_array.charge)) + + try: + # (Fast) Try standard rdDetermineBonds first + # Suppress YAeHMOP warnings that write directly to stderr + with _suppress_stderr_fd(): + rdDetermineBonds.DetermineBonds(mol, useHueckel=True, charge=system_charge, maxIterations=10_000) + except Exception as err_rdkit: + # (Slow) Transitionmetal complexes (TMC) - fall back to xyz2mol_tm + try: + + @timer.timeout(timeout=timeout_seconds, strategy="signal") + def _get_tmc_mol_with_timeout( + mol: Mol, + overall_charge: int, + with_stereo: bool, + ) -> Mol: + with _suppress_stderr_fd(): + return get_tmc_mol( + mol, + overall_charge=overall_charge, + with_stereo=with_stereo, + ) + + mol = _get_tmc_mol_with_timeout( + mol, + overall_charge=system_charge, + with_stereo=True, + ) + + # xyz2mol_tm preserves rdkit_atom_id but reorders atoms + # Build mapping: rdkit_atom_id -> current index in mol_with_bonds + rdkit_id_to_current_idx = {} + for current_idx, atom in enumerate(mol.GetAtoms()): + rdkit_id = atom.GetIntProp("rdkit_atom_id") + rdkit_id_to_current_idx[rdkit_id] = current_idx + + # Restore original atom order (so order of atom_array is preserved) + new_order = [rdkit_id_to_current_idx[i] for i in range(len(rdkit_id_to_current_idx))] + mol = Chem.RenumberAtoms(mol, new_order) + + except Exception as err_xyz2mol: + # Both methods failed - raise comprehensive error + raise RuntimeError( + f"Bond inference failed with both methods:\n" + f" rdDetermineBonds: {err_rdkit}\n" + f" xyz2mol_tm: {err_xyz2mol}" + ) from err_xyz2mol + + elif exists(atom_array.bonds): # Use existing bonds from atom_array for bond in atom_array.bonds.as_array(): atom1, atom2, bond_type = list(map(int, bond)) @@ -726,7 +897,7 @@ def atom_array_to_rdkit( if mol.GetNumConformers() > 0: try: Chem.AssignStereochemistryFrom3D(mol) - except ValueError: + except (ValueError, RuntimeError): logger.warning("Failed to assign stereochemistry to molecule.") pass @@ -755,9 +926,11 @@ def atom_array_to_rdkit( # ... verify validity of the molecule (according to Lewis octet rule) try: Chem.SanitizeMol(mol) - except Chem.MolSanitizeException as e: + except (Chem.rdchem.AtomValenceException, Chem.rdchem.KekulizeException, Chem.MolSanitizeException) as e: logger.warning( - f"Failed final sanitzation of molecule with error: {e}! It may not satisfy the octet rule, for example. Catching error and ignoring..." + f"Sanitization failed: {type(e).__name__}: {e}. " + f"Molecule will be used without sanitization. " + f"This may affect aromaticity perception and formal charges." ) # ... verify that atoms that are labelled as `_should_be_aromatic` are aromatic @@ -773,7 +946,7 @@ def atom_array_to_rdkit( mol._annotations = {"rdkit_atom_id": np.array(rdkit_atom_ids)} for annotation in annotations_to_keep: if annotation in atom_array.get_annotation_categories(): - mol._annotations[annotation] = atom_array._annot[annotation] + mol._annotations[annotation] = atom_array.get_annotation(annotation).copy() if hydrogen_policy == "infer": mol = add_hydrogens(mol, add_coords=set_coord) @@ -781,7 +954,7 @@ def atom_array_to_rdkit( return mol -@immutable_lru_cache(maxsize=1000) +@_standard_ccd_only_cache(immutable_lru_cache(maxsize=1000)) def ccd_code_to_rdkit( ccd_code: str, *, @@ -789,8 +962,7 @@ def ccd_code_to_rdkit( hydrogen_policy: Literal["infer", "remove", "keep"] = "keep", **atom_array_to_rdkit_kwargs, ) -> Mol: - """ - Convert a CCD residue name to an RDKit molecule. + """Convert a CCD residue name to an RDKit molecule. This function retrieves an RDKit molecule corresponding to a given CCD residue name. If `ccd_dir` is not provided, Biotite's internal CCD is used. Otherwise, the specified local CCD directory is used. @@ -817,7 +989,7 @@ def ccd_code_to_rdkit( # ... assign stereochemistry try: Chem.AssignStereochemistryFrom3D(mol) - except ValueError: + except (ValueError, RuntimeError): logger.warning(f"Failed to assign stereochemistry to {ccd_code}. Returning unstereochem molecule.") pass diff --git a/src/atomworks/io/transforms/atom_array.py b/src/atomworks/io/transforms/atom_array.py index 1938a77c..8537a4d6 100644 --- a/src/atomworks/io/transforms/atom_array.py +++ b/src/atomworks/io/transforms/atom_array.py @@ -9,11 +9,13 @@ import biotite.structure as struc import networkx as nx import numpy as np -import pandas as pd from biotite.structure import AtomArray, AtomArrayStack -from atomworks.common import listmap, not_isin, sum_string_arrays -from atomworks.constants import ELEMENT_NAME_TO_ATOMIC_NUMBER, HYDROGEN_LIKE_SYMBOLS, WATER_LIKE_CCDS +from atomworks.common import exists, not_isin, sum_string_arrays +from atomworks.constants import ( + WATER_LIKE_CCDS, +) +from atomworks.io.utils.atom_array import remove_hydrogens # noqa: F401 from atomworks.io.utils.atom_array_plus import stack_any from atomworks.io.utils.bonds import ( generate_inter_level_bond_hash, @@ -21,16 +23,11 @@ get_connected_nodes, hash_graph, ) -from atomworks.io.utils.ccd import atom_array_from_ccd_code +from atomworks.io.utils.chain_info import update_sequences_from_res_names from atomworks.io.utils.selection import annot_start_stop_idxs logger = logging.getLogger("atomworks.io") -try: - import hydride -except ImportError: - logger.warning("Hydride library not found, hydrogens cannot be inferred. Pip install hydride to enable.") - def subset_atom_array(atom_array: AtomArray | AtomArrayStack, keep: np.ndarray) -> AtomArray | AtomArrayStack: """Subsets an AtomArray or AtomArrayStack by a boolean mask. @@ -85,31 +82,25 @@ def remove_ccd_components( return subset_atom_array(atom_array, not_isin(atom_array.res_name, ccd_codes_to_remove)) -def remove_hydrogens(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: - """Removes hydrogens from the AtomArray or AtomArrayStack.""" - keep = not_isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS) - return subset_atom_array(atom_array, keep) - - def remove_waters(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: """Removes waters from the AtomArray or AtomArrayStack.""" return remove_ccd_components(atom_array, WATER_LIKE_CCDS) -def ensure_atom_array_stack(atom_array_or_stack: AtomArray | AtomArrayStack) -> AtomArrayStack: - """Ensures that the input is an AtomArrayStack. If it is an AtomArray, it is converted to a stack.""" - if isinstance(atom_array_or_stack, AtomArray): +def ensure_atom_array_stack(atom_array_or_stack: AtomArray | AtomArrayStack | list[AtomArray]) -> AtomArrayStack: + """Ensures that the input is an AtomArrayStack. If it is an AtomArray or list, it is converted to a stack.""" + if isinstance(atom_array_or_stack, list): + return stack_any(atom_array_or_stack) + elif isinstance(atom_array_or_stack, AtomArray): return stack_any([atom_array_or_stack]) elif isinstance(atom_array_or_stack, AtomArrayStack): return atom_array_or_stack else: - raise TypeError(f"Expected AtomArray or AtomArrayStack, got {type(atom_array_or_stack)}") + raise TypeError(f"Expected AtomArray, AtomArrayStack, or list[AtomArray], got {type(atom_array_or_stack)}") def resolve_arginine_naming_ambiguity(atom_array: AtomArray, raise_on_error: bool = True) -> AtomArray: - """ - Arginine naming ambiguities are fixed (ensuring NH1 is always closer to CD than NH2) - """ + """Arginine naming ambiguities are fixed (ensuring NH1 is always closer to CD than NH2)""" # TODO: Generalize to AtomArrayStack arg_mask = atom_array.res_name == "ARG" arg_nh1_mask = (atom_array.atom_name == "NH1") & arg_mask @@ -146,68 +137,73 @@ def resolve_arginine_naming_ambiguity(atom_array: AtomArray, raise_on_error: boo return atom_array -def mse_to_met(atom_array: AtomArray) -> AtomArray: - """Convert MSE residues (selenomethionine) to MET (methionine).""" +def mse_to_met( + atom_array: AtomArray | AtomArrayStack, + *, + chain_info: dict[str, dict] | None = None, +) -> AtomArray | AtomArrayStack: + """Convert MSE residues (selenomethionine) to MET (methionine). + + Within crystal structures, selenomethionine (MSE) are often used to solve the phase problem. + + Args: + atom_array: Atom array or stack to modify. + chain_info: Optional chain information dictionary to update. If provided, + will update ``res_name``, ``processed_entity_canonical_sequence``, and + ``processed_entity_non_canonical_sequence`` fields for chains containing MSE. + + Returns: + The modified atom array (same object, modified in-place). + Modifies the chain_info dictionary in-place if provided. + """ + # Unify handling by converting to stack + is_single_model = isinstance(atom_array, AtomArray) + atom_array = ensure_atom_array_stack(atom_array) + mse_mask = atom_array.res_name == "MSE" if np.any(mse_mask): + # Create a mask for the selenium atom within MSE, which we will convert to sulfur (SD) se_mask = (atom_array.atom_name == "SE") & mse_mask logger.debug(f"Converting {np.sum(se_mask)} MSE residues to MET.") - # Update residue name, hetero flag, and element + # Update residue name, hetero flag, and element (broadcasts across all models) atom_array.res_name[mse_mask] = "MET" atom_array.hetero[mse_mask] = False atom_array.atom_name[se_mask] = "SD" + atom_array.element[se_mask] = "S" - # ... handle cases for integer or string representations of element - _elt_prev = atom_array.element[se_mask][0] - if _elt_prev == "SE": - atom_array.element[se_mask] = "S" - elif _elt_prev == ELEMENT_NAME_TO_ATOMIC_NUMBER["SE"]: - atom_array.element[se_mask] = ELEMENT_NAME_TO_ATOMIC_NUMBER["S"] - elif _elt_prev == str(ELEMENT_NAME_TO_ATOMIC_NUMBER["SE"]): - atom_array.element[se_mask] = str(ELEMENT_NAME_TO_ATOMIC_NUMBER["S"]) - - # Reorder atoms for canonical MET ordering - atom_array_mse = atom_array[mse_mask] - atom_array_mse = atom_array_mse[struc.info.standardize_order(atom_array_mse)] - atom_array[mse_mask] = atom_array_mse - - return atom_array - + # Reorder atoms to canonical MET ordering (all models at once) + mse_indices = np.where(mse_mask)[0] + mse_atoms = atom_array[0][mse_mask] # Get MSE atoms from first model + mse_order = struc.info.standardize_order(mse_atoms) -def keep_last_residue(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: - """ - Removes duplicate residues in the atom array, keeping only the last occurrence. + # Build full reordering index: identity for non-MSE, reordered for MSE + full_index = np.arange(atom_array.array_length()) + full_index[mse_indices] = mse_indices[mse_order] - Args: - atom_array (AtomArray): The atom array containing the chain information. - - Returns: - AtomArray: The atom array with duplicate residues removed. - """ - atom_df = pd.DataFrame( - { - "chain_id": atom_array.chain_id, - "res_id": atom_array.res_id, - "res_name": atom_array.res_name, - } - ) + # Reorder entire stack at once + atom_array = atom_array[:, full_index] - # Get the mask of duplicates based on the combination of chain_id, res_id, and res_name - collapsed_df = atom_df.drop_duplicates(subset=["chain_id", "res_id", "res_name"]) + # Update chain_info if provided + if exists(chain_info): + for chain_data in chain_info.values(): + if "res_name" not in chain_data: + continue - # Get duplicates based on res_id, keeping the last - duplicate_mask = collapsed_df.duplicated(subset=["chain_id", "res_id"], keep="last") - duplicates_df = collapsed_df[duplicate_mask] + # Ensure res_name is a list and check for MSE as an element (not substring) + res_names = chain_data.get("res_name", []) + has_mse = isinstance(res_names, list) and "MSE" in res_names + if not has_mse: + continue - # Perform a left merge to find rows in atom_df that are also in duplicates_df - merged_df = atom_df.merge(duplicates_df, on=["chain_id", "res_id", "res_name"], how="left", indicator=True) + # Convert MSE to MET in three-letter sequence + chain_data["res_name"] = ["MET" if res_name == "MSE" else res_name for res_name in chain_data["res_name"]] - # Create a mask where True indicates the row is not in duplicates_df - keep = merged_df["_merge"] == "left_only" + # Recompute one-letter sequences after conversion + update_sequences_from_res_names(chain_data) - # Remove rows from atom_array with the deletion mask - return subset_atom_array(atom_array, keep) + # Return same type as input + return atom_array[0] if is_single_model else atom_array def maybe_fix_non_polymer_at_symmetry_center( @@ -323,28 +319,37 @@ def add_polymer_annotation(atom_array: AtomArray | AtomArrayStack, chain_info_di AtomArray: The updated atom array with the polymer annotation added. """ chain_ids = atom_array.get_annotation("chain_id") - is_polymer = np.array([chain_info_dict[chain_id]["is_polymer"] for chain_id in chain_ids]) + is_polymer = np.array([chain_info_dict[chain_id]["is_polymer"] for chain_id in chain_ids], dtype=bool) atom_array.set_annotation("is_polymer", is_polymer) return atom_array -def update_nonpoly_seq_ids(atom_array: AtomArray, chain_info_dict: dict) -> AtomArray: - """ - Updates the sequence IDs of non-polymeric chains in the atom array to the author sequence IDs. +def update_nonpoly_seq_ids(atom_array: AtomArray | AtomArrayStack, chain_info_dict: dict) -> AtomArray | AtomArrayStack: + """Updates the sequence IDs of non-polymeric chains in the atom array to the author sequence IDs. Args: - atom_array (AtomArray): The atom array containing the chain information. - chain_info_dict (dict): Dictionary containing the sequence details of each chain. + atom_array (AtomArray | AtomArrayStack): The atom array containing the chain information. Returns: - AtomArray: The updated atom array with the sequence IDs updated for non-polymeric chains. + AtomArray | AtomArrayStack: The updated atom array with the sequence IDs updated for non-polymeric chains. + + TODO: Delete this function when we update regression tests so we remove reliance on `auth_seq_id` altogether """ - # For non-polymeric chains, we use the author sequence ids - author_seq_ids = atom_array.get_annotation("auth_seq_id") chain_ids = atom_array.get_annotation("chain_id") - - # Create mask based on the is_polymer column - non_polymer_mask = ~np.array([chain_info_dict[chain_id]["is_polymer"] for chain_id in chain_ids]) + author_seq_ids = atom_array.get_annotation("auth_seq_id") + non_polymer_mask = ~np.array([chain_info_dict[chain_id]["is_polymer"] for chain_id in chain_ids], dtype=bool) + + # ... update the chain_info dictionary + for chain_id in np.unique(atom_array.chain_id[non_polymer_mask]): + original_res_ids = chain_info_dict[chain_id]["res_id"] + chain_mask = atom_array.chain_id == chain_id + new_res_ids = [] + for res_id in original_res_ids: + res_id_mask = atom_array.res_id == res_id + new_res_ids.append( + author_seq_ids[res_id_mask & chain_mask][0] + ) # Assuming all atoms in the residue have the same author seq id + chain_info_dict[chain_id]["res_id"] = new_res_ids # Update the atom_array_label with the (1-indexed) author sequence ids atom_array.res_id[non_polymer_mask] = author_seq_ids[non_polymer_mask] @@ -390,174 +395,48 @@ def replace_negative_res_ids_with_auth_seq_id(atom_array: AtomArray) -> AtomArra return atom_array -def add_charge_from_ccd_codes(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: - """ - Adds charge annotations to an atom array based on the Chemical Component Dictionary (CCD) codes. - - Retrieves charge information from the CCD for each residue and assigns it to matching atoms. - If a residue or atom is not found in the CCD, a charge of 0 is assigned. If a charge annotation - is already present, it is overwritten. - - Args: - atom_array: The atom array to which charge annotations will be added. - Can be either an AtomArray or AtomArrayStack. - - Returns: - The input atom array with added charge annotations. - - - WARNING: This function will assume that each residue in the atom array is exactly as in the CCD. - Therefore the charges will be incorrect if it is in a different protonation state, or has been - ionized or else in the original structure. Use this function with caution! - - NOTE: If you want to add charges to canonical amino acids based on the pH, take a look at the - 'hydride.estimate_amino_acid_charges' function instead. - - Example: - >>> atom_array = load_any("6lyz.cif", model=1) - >>> atom_array_with_charges = add_charge_from_ccd_codes(atom_array) - """ - # Warn if a charge annotation is already present - if "charge" in atom_array.get_annotation_categories(): - logger.info("Charge annotation already present in atom array. It will be overwritten.") - - # Build up a lookup table (res_name, atom_name) -> charge for each res_name that appears in the atom_array - unique_res_names = np.unique(atom_array.res_name) - charge_lookup_table: dict[tuple[str, str], float] = {} - - for res_name in unique_res_names: - try: - ccd_array = atom_array_from_ccd_code(res_name) - # Use dictionary comprehension to build lookup entries for this residue - charge_lookup_table.update( - { - (res_name, atom_name): charge - for atom_name, charge in zip(ccd_array.atom_name, ccd_array.charge, strict=False) - } - ) - except ValueError: - logger.info(f"CCD charge look-up failed for {res_name}. Assuming charge is 0 for all atoms.") - continue - - # Create the charge annotations for the atom array - res_names = atom_array.res_name - atom_names = atom_array.atom_name - charges = np.array( - [charge_lookup_table.get((res, atom), 0) for res, atom in zip(res_names, atom_names, strict=False)] +def add_charge_from_ccd_codes(*args, **kwargs) -> AtomArray: + """Removed. Use :py:func:`atomworks.io.utils.ccd.add_annotations_from_ccd` instead.""" + raise DeprecationWarning( + "add_charge_from_ccd_codes() has been deprecated!" + "Use `add_annotations_from_ccd(atom_array, annotations=['charge'])` from " + "`atomworks.io.utils.ccd` for an equivalent CCD-based charge lookup. " + "If you want bond-aware charge inference (not just a CCD lookup), consider " + "`prepare_atom_array(..., add_missing_atoms=True)` instead." ) - # Set the charges annotation - atom_array.set_annotation("charge", charges) - - return atom_array - -def add_hydrogen_atom_positions( +def add_pn_unit_id_annotation( atom_array: AtomArray | AtomArrayStack, - residue_level_annots_to_copy_to_hydrogens: list[str] = [], + overwrite: bool = True, + exclude_bond_types: set[struc.BondType] | None = None, ) -> AtomArray | AtomArrayStack: - """Add hydrogens using biotite supported hydride library. + """Adds the polymer/non-polymer unit ID (pn_unit_id) annotation to the AtomArray. - Removes any existing hydrogens first, then adds new hydrogens using the hydride library. - - Args: - atom_array: The atom array to which hydrogens will be added. - residue_level_annots_to_copy_to_hydrogens (list[str]): A list of residue-level annotations that will be copied - over to the newly-added hydrogens for each residue. - - Returns: - The updated atom array with hydrogens added, preserving the input type. - """ - # Remove existing hydrogens - atom_array = remove_hydrogens(atom_array) - - # Determine which fields to copy from the original array to the new hydrogens - fields_to_copy_from_residue_if_present = ["auth_seq_id", "label_entity_id"] - fields_to_copy_from_residue_if_present.extend(residue_level_annots_to_copy_to_hydrogens) - fields_to_copy_from_residue_if_present = list( - set(fields_to_copy_from_residue_if_present).intersection(set(atom_array.get_annotation_categories())) - ) - - # Ensure charge annotation exists - if "charge" not in atom_array.get_annotation_categories(): - atom_array = add_charge_from_ccd_codes(atom_array) - - # Helper function to copy annotations from one array to another - def _copy_missing_annotations_residue_wise( - from_array: AtomArray, to_array: AtomArray, fields_to_copy: list[str] - ) -> AtomArray: - """Copy specified annotations residue-wise from one AtomArray to another. Updates annotations in-place.""" - residue_starts = struc.get_residue_starts(from_array) - residue_starts_atom_array = from_array[residue_starts] - annot = {item: getattr(residue_starts_atom_array, item) for item in fields_to_copy_from_residue_if_present} - for field in fields_to_copy: - updated_field = struc.spread_residue_wise(to_array, annot[field]) - to_array.set_annotation(field, updated_field) - return to_array - - def _add_hydrogens_nan_tolerant(atom_array: AtomArray) -> AtomArray: - """Adds hydrogens to the input AtomArray, safely handling the case in which some atoms have NaN coordinates""" - original_nan_coords_mask = np.any(np.isnan(atom_array.coord), axis=1) - if np.any(original_nan_coords_mask): - # Temporarily set NaN coordinates to zero so that hydride doesn't error - atom_array.coord[original_nan_coords_mask] = np.zeros((np.sum(original_nan_coords_mask), 3)) - - # Add hydrogens using hydride - result_atom_array, original_atoms_mask = hydride.add_hydrogen(atom_array) - - # Reset the coordinates of atoms that originally had at least one NaN coordinate to be fully NaN - originally_nan_inds = np.arange(result_atom_array.array_length())[original_atoms_mask][ - original_nan_coords_mask - ] - result_atom_array.coord[originally_nan_inds, :] = np.nan - - # For any newly-added hydrogens bonded to heavy atoms with NaN coordinates, set their coordinates to NaN as well - result_nan_coords_mask = np.any(np.isnan(result_atom_array.coord), axis=1) - heavy_atom_nan_idces = np.where(result_nan_coords_mask & ~(result_atom_array.element == "H"))[0] - for idx in heavy_atom_nan_idces: - bonded_atoms = result_atom_array.bonds.get_bonds(idx)[0] - bonded_h_atoms = bonded_atoms[result_atom_array[bonded_atoms].element == "H"] - new_bonded_h_atoms = bonded_h_atoms[~original_atoms_mask[bonded_h_atoms]] - result_atom_array.coord[new_bonded_h_atoms, :] = np.nan - else: - result_atom_array, original_atoms_mask = hydride.add_hydrogen(atom_array) - - return result_atom_array - - if isinstance(atom_array, AtomArrayStack): - updated_arrays = [] - for old_arr in atom_array: - arr = _add_hydrogens_nan_tolerant(old_arr) - arr = _copy_missing_annotations_residue_wise(old_arr, arr, fields_to_copy_from_residue_if_present) - updated_arrays.append(arr) - - ret_array = struc.stack(updated_arrays) - - elif isinstance(atom_array, AtomArray): - arr = _add_hydrogens_nan_tolerant(atom_array) - ret_array = _copy_missing_annotations_residue_wise(atom_array, arr, fields_to_copy_from_residue_if_present) - - return ret_array - - -def add_pn_unit_id_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: - """ - Adds the polymer/non-polymer unit ID (pn_unit_id) annotation to the AtomArray. Two covalently bonded ligands are considered one PN unit, but a ligand bonded to a protein is considered two PN units. See the README glossary for more details on how we define `chains`, `pn_units`, and `molecules` within this codebase. Args: - atom_array (AtomArray): The AtomArray to process. + atom_array: The AtomArray to process. + overwrite: If ``True``, recompute and replace annotation if it exists. + If ``False``, skip if annotation already exists. Defaults to ``True``. + exclude_bond_types: Bond types to exclude when determining connectivity. Returns: - atom_array (AtomArray): The AtomArray including the `pn_unit_id` annotation. + The AtomArray including the ``pn_unit_id`` annotation. """ + # Check if annotation exists and skip if not overwriting + if not overwrite and "pn_unit_id" in atom_array.get_annotation_categories(): + return atom_array + # ...initialize the pn_unit_id to chain_id (we will later update for multi-chain non-polymer PN units) pn_unit_id_annotation = atom_array.chain_id.astype(object) # ...make the NetworkX graph for non-polymer chains non_polymer_atom_array = atom_array[~atom_array.is_polymer] - connected_chains = get_connected_nodes(*get_coarse_graph_as_nodes_and_edges(non_polymer_atom_array, "chain_id")) + connected_chains = get_connected_nodes( + *get_coarse_graph_as_nodes_and_edges(non_polymer_atom_array, "chain_id", exclude_bond_types=exclude_bond_types) + ) for connected_chain in connected_chains: # ...set the same the pn_unit_id for each chain in the connected chain @@ -570,12 +449,26 @@ def add_pn_unit_id_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomArr return atom_array -def add_pn_unit_iid_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: +def add_pn_unit_iid_annotation( + atom_array: AtomArray | AtomArrayStack, overwrite: bool = True +) -> AtomArray | AtomArrayStack: """Adds the polymer/non-polymer unit instance ID (pn_unit_iid) annotation to the AtomArray or AtomArrayStack. Optimized to avoid expensive subarray operations by using vectorized operations and boolean masks. For symmetric assemblies with many identical chains, this provides significant speedup. + + Args: + atom_array: The AtomArray or AtomArrayStack to annotate. + overwrite: If ``True``, recompute and replace annotation if it exists. + If ``False``, skip if annotation already exists. Defaults to ``True``. + + Returns: + The AtomArray or AtomArrayStack with pn_unit_iid annotation added. """ + # Check if annotation exists and skip if not overwriting + if not overwrite and "pn_unit_iid" in atom_array.get_annotation_categories(): + return atom_array + # ...create an array that concatenates the pn_unit_id and transformation_id _temp_pn_unit_iid = sum_string_arrays(atom_array.pn_unit_id, "_", atom_array.transformation_id) _final_pn_unit_iid = np.full(atom_array.array_length(), fill_value="", dtype=object) @@ -612,13 +505,34 @@ def add_pn_unit_iid_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomAr return atom_array -def add_molecule_id_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: - """Adds the molecule ID (molecule_id) annotation to the AtomArray.""" - # ...initialize the pn_unit_id to chain_id (we will later update for multi-chain non-polymer PN units) - atom_array.add_annotation("molecule_id", dtype=np.int16) +def add_molecule_id_annotation( + atom_array: AtomArray | AtomArrayStack, + overwrite: bool = True, + exclude_bond_types: set[struc.BondType] | None = None, +) -> AtomArray | AtomArrayStack: + """Adds the molecule ID (molecule_id) annotation to the AtomArray. + + Args: + atom_array: The AtomArray to process. + overwrite: If ``True``, recompute and replace annotation if it exists. + If ``False``, skip if annotation already exists. Defaults to ``True``. + exclude_bond_types: Bond types to exclude when determining connectivity. + + Returns: + The AtomArray including the ``molecule_id`` annotation. + """ + # Check if annotation exists and skip if not overwriting + if not overwrite and "molecule_id" in atom_array.get_annotation_categories(): + return atom_array + + # Initialize annotation (will overwrite if exists) + if "molecule_id" not in atom_array.get_annotation_categories(): + atom_array.add_annotation("molecule_id", dtype=np.int16) # ...make the NetworkX graph for all pn_units - connected_pn_units = get_connected_nodes(*get_coarse_graph_as_nodes_and_edges(atom_array, "pn_unit_id")) + connected_pn_units = get_connected_nodes( + *get_coarse_graph_as_nodes_and_edges(atom_array, "pn_unit_id", exclude_bond_types=exclude_bond_types) + ) # ...iterate through connected pn_units for idx, connected_pn_unit in enumerate(connected_pn_units): @@ -630,8 +544,21 @@ def add_molecule_id_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomAr return atom_array -def add_molecule_iid_annotation(atom_array_stack: AtomArrayStack) -> AtomArrayStack: - """Adds the molecule instance ID (molecule_iid) annotation to the AtomArrayStack""" +def add_molecule_iid_annotation(atom_array_stack: AtomArrayStack, overwrite: bool = True) -> AtomArrayStack: + """Adds the molecule instance ID (molecule_iid) annotation to the AtomArrayStack. + + Args: + atom_array_stack: The AtomArrayStack to annotate. + overwrite: If ``True``, recompute and replace annotation if it exists. + If ``False``, skip if annotation already exists. Defaults to ``True``. + + Returns: + The AtomArrayStack with molecule_iid annotation added. + """ + # Check if annotation exists and skip if not overwriting + if not overwrite and "molecule_iid" in atom_array_stack.get_annotation_categories(): + return atom_array_stack + # ...concatenate molecule_id and transformation_id to create a unique molecule instance ID molecule_iids_str = np.char.add( atom_array_stack.molecule_id.astype(str), atom_array_stack.transformation_id.astype(str) @@ -652,9 +579,10 @@ def annotate_entities( lower_level_id: str | list[str], lower_level_entity: str, add_inter_level_bond_hash: bool = True, -) -> tuple[AtomArray, dict]: - """ - Annotates entities in an AtomArray at a given `id` level, based on the connectivity and annotations at the lower level. + overwrite: bool = True, + exclude_bond_types: set[struc.BondType] | None = None, +) -> tuple[AtomArray, dict[int, list[str]]]: + """Annotates entities in an AtomArray at a given `id` level, based on the connectivity and annotations at the lower level. The intended use is, for example: - For the `molecule` level, `molecule_entities` are generated for each `molecule_id` based on the connectivty @@ -665,20 +593,20 @@ def annotate_entities( level. Args: - - atom_array (AtomArray): The AtomArray to process. - - level (str): The level at which to annotate entities (e.g., "chain", "pn_unit", "entity") - - lower_level_id (str | list[str]): A list of annotations to consider for determining segment boundaries at a lower level. + atom_array: The AtomArray to process. + level: The level at which to annotate entities (e.g., "chain", "pn_unit", "entity"). + lower_level_id: A list of annotations to consider for determining segment boundaries at a lower level. E.g. "pn_unit_id", "chain_id" or "res_id". - - lower_level_entity (str): The annotation to use for identifying entities at the lower level. + lower_level_entity: The annotation to use for identifying entities at the lower level. E.g. "pn_unit_entity", "chain_entity" or "res_name". - - add_inter_level_bond_hash (bool): Whether to add a hash of the inter-level bonds to the entity hash. + add_inter_level_bond_hash: Whether to add a hash of the inter-level bonds to the entity hash. For some cases, this may be necessary to distinguish entities (e.g., when determining molecule-level entities). In others (e.g., for polymers), this may be overkill. + overwrite: If ``True``, recompute and replace annotation if it exists. + If ``False``, skip if annotation already exists. Defaults to ``True``. Returns: - - Tuple[AtomArray, dict]: A tuple containing: - - atom_array (AtomArray): The updated AtomArray with the entity annotation. - - entities_info (dict): A dictionary mapping entity IDs to lists of instance IDs. + Tuple of (annotated atom_array, entity_dict). Example: >>> atom_array = AtomArray(...) @@ -690,13 +618,20 @@ def annotate_entities( >>> print(entities_info) {0: [0, 1], 1: [2, 3], 2: [4, 5]} """ + entity_annotation_name = f"{level}_entity" + + # Check if annotation exists and skip if not overwriting + if not overwrite and entity_annotation_name in atom_array.get_annotation_categories(): + # Return empty dict since we didn't compute anything + return atom_array, {} + _next_available_entity_id = 0 _hash_to_entity_id = {} ids_at_level = np.unique(atom_array.get_annotation(level + "_id")) # ... initialize annotations to fill - entities_annotation = np.zeros(len(atom_array), dtype=int) + entities_annotation = np.zeros(len(atom_array), dtype=np.int16) entities_info = defaultdict(list) for instance_id in np.unique(ids_at_level): @@ -704,18 +639,15 @@ def annotate_entities( instance = atom_array[is_instance] # ... get connectivity and node annotations for the coarse graph at the lower level - _, edges = get_coarse_graph_as_nodes_and_edges(instance, lower_level_id) + _, edges = get_coarse_graph_as_nodes_and_edges(instance, lower_level_id, exclude_bond_types=exclude_bond_types) instance_graph = nx.Graph() instance_graph.add_edges_from(edges) - # ... set node attributes to lower level entities - lower_level_iter = struc.segments.segment_iter( - instance, annot_start_stop_idxs(instance, lower_level_id, add_exclusive_stop=True) - ) - node_attrs = { - idx: lower_level_instance.get_annotation(lower_level_entity)[0] - for idx, lower_level_instance in enumerate(lower_level_iter) - } + # ... set node attributes to lower level entities (vectorized) + # Direct annotation indexing instead of creating AtomArray objects via segment_iter() + segment_boundaries = annot_start_stop_idxs(instance, lower_level_id, add_exclusive_stop=True) + entity_annotation = instance.get_annotation(lower_level_entity) + node_attrs = {idx: entity_annotation[start] for idx, start in enumerate(segment_boundaries[:-1])} nx.set_node_attributes(instance_graph, node_attrs, "node") # ... create the graph hash @@ -727,6 +659,7 @@ def annotate_entities( atom_array=instance, lower_level_id=lower_level_id[0] if isinstance(lower_level_id, list) else lower_level_id, lower_level_entity=lower_level_entity, + exclude_bond_types=exclude_bond_types, ) # ... check if the graph has been seen before @@ -746,8 +679,21 @@ def annotate_entities( return atom_array, dict(entities_info) -def add_chain_iid_annotation(atom_array_stack: AtomArrayStack) -> AtomArrayStack: - """Adds the chain instance ID (chain_iid) annotation to the AtomArrayStack""" +def add_chain_iid_annotation(atom_array_stack: AtomArrayStack, overwrite: bool = True) -> AtomArrayStack: + """Adds the chain instance ID (chain_iid) annotation to the AtomArrayStack. + + Args: + atom_array_stack: The AtomArrayStack to annotate. + overwrite: If ``True``, recompute and replace annotation if it exists. + If ``False``, skip if annotation already exists. Defaults to ``True``. + + Returns: + The AtomArrayStack with chain_iid annotation added. + """ + # Check if annotation exists and skip if not overwriting + if not overwrite and "chain_iid" in atom_array_stack.get_annotation_categories(): + return atom_array_stack + # ...concatenate chain_id and transformation_id to create a unique chain instance ID chain_iid = sum_string_arrays( atom_array_stack.chain_id, @@ -761,49 +707,125 @@ def add_chain_iid_annotation(atom_array_stack: AtomArrayStack) -> AtomArrayStack def add_iid_annotations_to_assemblies( assemblies_dict: dict[str | int, AtomArray | AtomArrayStack], ) -> dict[str | int, AtomArray | AtomArrayStack]: - """Adds chain, PN unit, and molecule IIDs to assembly AtomArrayStacks.""" + """Adds chain, PN unit, and molecule IIDs to assembly AtomArrayStacks. + + This is a convenience wrapper around :py:func:`add_iid_annotations` that operates on + a dictionary of assemblies. + """ for assembly_id, assembly in assemblies_dict.items(): - if "transformation_id" not in assembly.get_annotation_categories(): - raise ValueError( - f"Assembly '{assembly_id}' missing transformation_id annotation (required for instance IDs)" - ) + assemblies_dict[assembly_id] = add_iid_annotations(assembly, overwrite=True) + return assemblies_dict + - # Add instance ID annotations - assembly = add_chain_iid_annotation(assembly) +def add_iid_annotations( + atom_array_or_stack: AtomArray | AtomArrayStack, + overwrite: bool = True, +) -> AtomArray | AtomArrayStack: + """Add instance ID annotations (chain_iid, pn_unit_iid, molecule_iid). - if "pn_unit_id" in assembly.get_annotation_categories(): - assembly = add_pn_unit_iid_annotation(assembly) + Requires transformation_id annotation to exist in the atom array/stack. + Analogous to :py:func:`add_id_and_entity_annotations` but for instance IDs. - if "molecule_id" in assembly.get_annotation_categories(): - assembly = add_molecule_iid_annotation(assembly) + Instance IDs are created by concatenating base IDs with transformation_id: - assemblies_dict[assembly_id] = assembly + - chain_iid = chain_id + "_" + transformation_id + - pn_unit_iid = pn_unit_id + "_" + transformation_id + - molecule_iid = molecule_id + "_" + transformation_id - return assemblies_dict + Args: + atom_array_or_stack: AtomArray or AtomArrayStack with transformation_id annotation. + overwrite: If ``True``, recompute and replace annotations if they exist. + If ``False``, skip individual annotations that already exist. Defaults to ``True``. + + Returns: + Same type as input with iid annotations added. + + Raises: + ValueError: If transformation_id annotation is not present. + """ + # Check for transformation_id + if "transformation_id" not in atom_array_or_stack.get_annotation_categories(): + raise ValueError( + "transformation_id annotation required for instance IDs. " + "Instance IDs are created by concatenating base IDs with transformation_id." + ) + + # Pass overwrite to each helper function (matches add_id_and_entity_annotations pattern) + atom_array_or_stack = add_chain_iid_annotation(atom_array_or_stack, overwrite=overwrite) + + if "pn_unit_id" in atom_array_or_stack.get_annotation_categories(): + atom_array_or_stack = add_pn_unit_iid_annotation(atom_array_or_stack, overwrite=overwrite) + if "molecule_id" in atom_array_or_stack.get_annotation_categories(): + atom_array_or_stack = add_molecule_iid_annotation(atom_array_or_stack, overwrite=overwrite) -def add_id_and_entity_annotations(atom_array: AtomArray) -> AtomArray: - """Adds all 6 ('chain', 'pn_unit', 'molecule') x ('id', 'entity') annotations to the AtomArray.""" - # ...annotate PN units (requires bonds) - atom_array = add_pn_unit_id_annotation(atom_array) + return atom_array_or_stack - # ...annotate molecules (requires bonds) - atom_array = add_molecule_id_annotation(atom_array) + +def add_id_and_entity_annotations( + atom_array: AtomArray | AtomArrayStack, + overwrite: bool = True, + exclude_bond_types: set[struc.BondType] | None = frozenset({struc.BondType.COORDINATION}), +) -> AtomArray | AtomArrayStack: + """Adds all 6 ('chain', 'pn_unit', 'molecule') x ('id', 'entity') annotations to the AtomArray. + + Args: + atom_array: The AtomArray or AtomArrayStack to annotate. + overwrite: If ``True``, recompute and replace annotations if they exist. + If ``False``, skip individual annotations that already exist. Defaults to ``True``. + exclude_bond_types: Bond types to exclude when determining pn_unit / molecule + connectivity and entity hashing. Defaults to ``{COORDINATION}`` so that + coordination (dative) bonds do not merge metals into their ligand's + molecule/entity — a metal coordinated to a ligand is its own molecule. + Pass an empty set to count all bonds. + + Returns: + The AtomArray or AtomArrayStack with added annotations. + """ + # For stacks, compute on model[0] and copy annotations back + is_stack = isinstance(atom_array, AtomArrayStack) + if is_stack: + model = atom_array[0] + else: + model = atom_array + + # Pass overwrite to each sub-function + model = add_pn_unit_id_annotation(model, overwrite=overwrite, exclude_bond_types=exclude_bond_types) + model = add_molecule_id_annotation(model, overwrite=overwrite, exclude_bond_types=exclude_bond_types) levels = ["chain", "pn_unit", "molecule"] lower_level_ids = ["res_id", "chain_id", "pn_unit_id"] lower_level_entities = ["res_name", "chain_entity", "pn_unit_entity"] + inter_level_bond_hashes = [False, True, True] - for level, lower_level_id, lower_level_entity in zip(levels, lower_level_ids, lower_level_entities, strict=False): - # ...annotate entities at appropriate level - atom_array, _ = annotate_entities( - atom_array=atom_array, + for level, lower_level_id, lower_level_entity, inter_level_bond_hash in zip( + levels, lower_level_ids, lower_level_entities, inter_level_bond_hashes, strict=False + ): + model, _ = annotate_entities( + atom_array=model, level=level, lower_level_id=lower_level_id, lower_level_entity=lower_level_entity, + add_inter_level_bond_hash=inter_level_bond_hash, + overwrite=overwrite, + exclude_bond_types=exclude_bond_types, ) - return atom_array + # Copy computed annotations back to the stack + if is_stack: + _ID_ENTITY_ANNOTATIONS = [ # noqa: N806 + "pn_unit_id", + "molecule_id", + "chain_entity", + "pn_unit_entity", + "molecule_entity", + ] + for annot_name in _ID_ENTITY_ANNOTATIONS: + if annot_name in model.get_annotation_categories(): + atom_array.set_annotation(annot_name, model.get_annotation(annot_name)) + return atom_array + + return model def add_chain_type_annotation( @@ -828,12 +850,3 @@ def add_chain_type_annotation( # Return the modified atom array return atom_array - - -def add_atomic_number_annotation(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: - """Adds the atomic number (atomic_number) annotation to the AtomArray.""" - atom_array.set_annotation( - "atomic_number", - np.array(listmap(ELEMENT_NAME_TO_ATOMIC_NUMBER.get, np.char.upper(atom_array.element)), dtype=np.int8), - ) - return atom_array diff --git a/src/atomworks/io/transforms/atomize.py b/src/atomworks/io/transforms/atomize.py new file mode 100644 index 00000000..a8662430 --- /dev/null +++ b/src/atomworks/io/transforms/atomize.py @@ -0,0 +1,230 @@ +"""Utilities to identify and annotate non-token (e.g., small molecules, non-canonical amino acids) atoms within an AtomArray for downstream use.""" + +from __future__ import annotations + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray + +from atomworks.constants import STANDARD_AA, STANDARD_DNA, STANDARD_RNA +from atomworks.io.transforms.atom_array import add_pn_unit_iid_annotation +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.bonds import get_inter_pn_unit_bond_mask +from atomworks.io.utils.ccd import get_polymerization_atoms + + +def compute_standard_atomize_mask(atom_array: AtomArray) -> np.ndarray: + """Return a boolean mask indicating which atoms should be atomized. + + Replicates the io-level equivalent of the ML pipeline sequence: + ``FlagNonPolymersForAtomization`` → ``AnnotateCovalentModifications`` + → ``AtomizeByCCDName``. + + Works on a copy of *atom_array* so the input is never mutated. + + Args: + atom_array: Input structure. Must have bonds assigned. ``is_polymer`` + is inferred via the annotator if absent. ``pn_unit_iid`` is used + to detect covalently-modified polymer residues when present. + + Returns: + Boolean array of length ``atom_array.array_length()``. ``True`` for atoms that + should be treated atomistically (non-polymers and polymer residues + covalently bound to a non-polymer unit). + """ + arr = atom_array.copy() + ensure_annotations(arr, "is_polymer") + + atomize = ~arr.is_polymer + + if "atomize" not in arr.get_annotation_categories(): + arr.set_annotation("atomize", atomize) + else: + arr.atomize |= atomize + + if "pn_unit_iid" in arr.get_annotation_categories(): + arr = flag_and_reassign_covalent_modifications(arr) + elif "pn_unit_id" in arr.get_annotation_categories(): + if "transformation_id" not in arr.get_annotation_categories(): + arr.set_annotation("transformation_id", np.full(arr.array_length(), "1")) + arr = add_pn_unit_iid_annotation(arr) + arr = flag_and_reassign_covalent_modifications(arr) + else: + raise ValueError( + "To compute the standard atomize mask, the input AtomArray must have either `pn_unit_iid` or `pn_unit_id` annotation. " + "Please run `parse` or `prepare_atom_array` with `add_id_and_entity_annotations=True`." + ) + + standard_res_names = STANDARD_AA + STANDARD_RNA + STANDARD_DNA + arr = atomize_by_ccd_name(arr, atomize_by_default=True, res_names_to_ignore=standard_res_names) + return arr.atomize + + +def _validate_atomize(atom_array: AtomArray, atomize: np.ndarray) -> None: + _all = struc.apply_residue_wise(atom_array, atomize, np.all) + _any = struc.apply_residue_wise(atom_array, atomize, np.any) + if np.any(_all != _any): + raise ValueError("For each residue, all atoms must be atomized or none must be atomized.") + + +def atomize_by_ccd_name( + atom_array: AtomArray, + atomize_by_default: bool = True, + res_names_to_atomize: list[str] = [], + res_names_to_ignore: list[str] = [], + move_atomized_part_to_end: bool = False, + validate_atomize: bool = False, +) -> AtomArray: + """ + Atomize residues by breaking down the res_name field into the actual element names. + + Args: + atom_array (AtomArray): The atom array to atomize. + atomize_by_default (bool): Whether to atomize residues by default. + res_names_to_atomize (list[str]): List of residue names to atomize. Defaults to []. + res_names_to_ignore (list[str]): List of residue names to ignore. These residues + will only be atomized, if their `atomize` flag is already explicitly set to `True`, e.g. from a + previous transform to sample random residues for atomization for data augmentation. Defaults to []. + move_atomized_part_to_end (bool, optional): Whether to move atomized parts to the end of the array. Defaults to False. + This is relevant for RF2AA, which follows the convention that atomized parts are grouped together at the end of the + input. + validate_atomize (bool, optional): Whether to validate that a residue is either atomized or not. Defaults to False. + + Returns: + AtomArray: The atomized atom array. The `atomize` flag is set for each atom in the array. + NOTE: The returned array may be reordered if `move_atomized_part_to_end` is True. + """ + atomize = np.full(atom_array.array_length(), atomize_by_default, dtype=bool) + + # Exclude residues to ignore + if len(res_names_to_ignore) > 0: + atomize[np.isin(atom_array.res_name, res_names_to_ignore)] = False + + # Include residues to atomize + if len(res_names_to_atomize) > 0: + atomize[np.isin(atom_array.res_name, res_names_to_atomize)] = True + + # Include everything with the `atomize` flag from possible previous transforms + # this is used to manually define residues to atomize, e.g. as a data augmentation + if "atomize" in atom_array.get_annotation_categories(): + atomize |= atom_array.atomize + + if validate_atomize: + # ... validate that a residue is either atomized or not + _validate_atomize(atom_array, atomize) + + # Perform atomization + atom_array.set_annotation("atomize", atomize) + + if move_atomized_part_to_end: + # as per RF2AA convention, the atomized parts are grouped together at the end + # of the input. This flag enables that. + # NOTE: This needs to be done via `reshuffling` in order to preserve the correct + # bonding information. + _idxs_pre_shuffling = np.arange(atom_array.array_length()) + reordered_idxs = np.concatenate( + [_idxs_pre_shuffling[~atom_array.atomize], _idxs_pre_shuffling[atom_array.atomize]] + ) + atom_array = atom_array[reordered_idxs] + + return atom_array + + +def flag_and_reassign_covalent_modifications( + atom_array: AtomArray, + p_reassign_modified_residue: float = 1.0, + atomize_backbone_modifications: bool = True, +) -> AtomArray: + """ + Mark covalent modifications for atomization and reassign the corresponding + PN unit annotations. + + Args: + atom_array (AtomArray): Current `AtomArray` within the Transform pipeline + p_reassign_modified_residue: Probability of atomizing each side-chain-modified + residue, sampled independently per residue and reused across its bonds. + Must be between 0 and 1 inclusive. Defaults to `1.0`. + atomize_backbone_modifications (bool, optional): Atomize the residue when the modifier + attaches at its polymerization atom (a cyclization, crosslink, or terminal cap). + Defaults to `True`; set `False` to treat such bonds as ordinary polymer linkages. + + Returns: + AtomArray: The modified `AtomArray` with updated annotations for covalent + modifications. The `pn_unit_id` and `pn_unit_iid` of polymer atoms are + reassigned to those of the non-polymer unit they are bound to, and the + `atomize` annotation is set to `True` for these atoms. Additionally, the + entire pn_unit is marked with `is_covalent_modification = True`. + + NOTE: If `atomize` annotation is not present in the `AtomArray`, it will be added. + NOTE: If `is_covalent_modification` annotation is not present in the `AtomArray`, it will be added. + NOTE: We do not modify the `is_polymer` annotation, which will still refer to the protein chain + for the atomized polymer atoms. + """ + if not 0.0 <= p_reassign_modified_residue <= 1.0: + raise ValueError("p_reassign_modified_residue must be between 0 and 1 inclusive.") + + # Get all inter-PN unit bonds in the entry (i.e. between a polymer and a non-polymer PN unit) + inter_pn_unit_bond_mask = get_inter_pn_unit_bond_mask(atom_array) + bonds_to_check = atom_array.bonds.as_array()[inter_pn_unit_bond_mask] + + # Filter out bonds that are not between a polymer and a non-polymer PN unit + bonds_to_check = bonds_to_check[ + # One atom is a polymer, the other is not => must be polymer/non-polymer bond + atom_array.is_polymer[bonds_to_check[:, 0]] != atom_array.is_polymer[bonds_to_check[:, 1]] + ] + + # Add the atomize annotation to the AtomArray, if not already present + if "atomize" not in atom_array.get_annotation_categories(): + atom_array.set_annotation("atomize", np.array([False] * atom_array.array_length())) + + # Add the is_covalent_modification annotation to the AtomArray, if not already present + if "is_covalent_modification" not in atom_array.get_annotation_categories(): + atom_array.set_annotation("is_covalent_modification", np.array([False] * atom_array.array_length())) + + # Capture residue identities before PN unit annotations are reassigned. + residue_starts = struc.get_residue_starts(atom_array) + reassign_by_residue: dict[int, bool] = {} + + # Loop through inter-molecular bonds + # NOTE: There aren't likely to be many inter-molecular bonds in the entry, so vectorization is not necessary and would be less readable + for bond in bonds_to_check: + # Get the atoms involved in the inter-molecular bonds + atom_a = atom_array[bond[0]] + atom_b = atom_array[bond[1]] + + # Note which atom is in the polymer and which is in the non-polymer + polymer_atom, non_polymer_atom = (atom_a, atom_b) if atom_a.is_polymer else (atom_b, atom_a) + + # Backbone modifications use their own flag; side-chain decisions are shared across + # all modifier bonds to the same original residue. + is_backbone_modification = polymer_atom.atom_name in get_polymerization_atoms(polymer_atom.res_name) + if is_backbone_modification: + reassign = atomize_backbone_modifications + else: + polymer_atom_idx = bond[0] if atom_a.is_polymer else bond[1] + residue_idx = int(np.searchsorted(residue_starts, polymer_atom_idx, side="right") - 1) + if residue_idx not in reassign_by_residue: + p = p_reassign_modified_residue + reassign_by_residue[residue_idx] = p == 1.0 or (p > 0.0 and np.random.random() < p) + reassign = reassign_by_residue[residue_idx] + if reassign: + # Create a mask of the atoms in the residue that is covalently bound to the non-polymer PN unit + # We can uniquely identify a residue by its res_id, pn_unit_iid, and chain_id (or chain_iid, either works) + polymer_residue_mask = ( + (atom_array.res_id == polymer_atom.res_id) + & (atom_array.chain_id == polymer_atom.chain_id) + & (atom_array.pn_unit_iid == polymer_atom.pn_unit_iid) + ) + + # For all atoms in the target polymer residue, set the pn_unit_iid and the pn_unit_id to that of the non-polymer PN unit + num_residues = np.sum(polymer_residue_mask) + atom_array.pn_unit_id[polymer_residue_mask] = np.array([non_polymer_atom.pn_unit_id] * num_residues) + atom_array.pn_unit_iid[polymer_residue_mask] = np.array([non_polymer_atom.pn_unit_iid] * num_residues) + + # Mark the non-polymer residue for atomization (now includes all atoms in the bonded polymer residue) + atom_array.atomize[(atom_array.pn_unit_iid == non_polymer_atom.pn_unit_iid)] = True + + # Mark the entire pn_unit as a covalent modification + atom_array.is_covalent_modification[(atom_array.pn_unit_iid == non_polymer_atom.pn_unit_iid)] = True + + return atom_array diff --git a/src/atomworks/io/transforms/categories.py b/src/atomworks/io/transforms/categories.py index eabea63a..f4a35f52 100644 --- a/src/atomworks/io/transforms/categories.py +++ b/src/atomworks/io/transforms/categories.py @@ -4,136 +4,84 @@ """ import logging -import os import re from contextlib import suppress from datetime import datetime -import biotite.structure as struc import numpy as np import pandas as pd import toolz -from biotite.structure import AtomArray -from biotite.structure.io.pdbx import CIFBlock +from biotite.structure.io.pdbx import BinaryCIFBlock, BinaryCIFCategory, CIFBlock, CIFCategory from atomworks.common import exists -from atomworks.constants import CCD_MIRROR_PATH -from atomworks.enums import ChainType -from atomworks.io.utils.selection import get_residue_starts -from atomworks.io.utils.sequence import get_1_from_3_letter_code logger = logging.getLogger("atomworks.io") -def category_to_df(cif_block: CIFBlock, category: str) -> pd.DataFrame | None: - """Convert a CIF block to a pandas DataFrame. +def category_to_df( + cif_block_or_category: CIFBlock | BinaryCIFBlock | CIFCategory | BinaryCIFCategory, category: str | None = None +) -> pd.DataFrame | None: + """Convert CIF component to pandas DataFrame. - Args: - cif_block: The CIF block to convert. - category: The category name to extract. - - Returns: - DataFrame containing the category data, or None if category doesn't exist. - """ - return pd.DataFrame(category_to_dict(cif_block, category)) if category in cif_block else None - - -def category_to_dict(cif_block: CIFBlock, category: str) -> dict[str, np.ndarray]: - """Convert a CIF block to a dictionary. + Accepts either ``(CIFBlock, category_name)`` or ``(CIFCategory)`` directly. + Supports both text CIF and binary CIF (BinaryCIF) formats. Args: - cif_block: The CIF block to convert. - category: The category name to extract. + cif_block_or_category: :py:class:`~biotite.structure.io.pdbx.CIFBlock`, + :py:class:`~biotite.structure.io.pdbx.BinaryCIFBlock`, or + :py:class:`~biotite.structure.io.pdbx.CIFCategory` | :py:class:`~biotite.structure.io.pdbx.BinaryCIFCategory` + category: Category name when passing CIFBlock/BinaryCIFBlock, omit when passing CIFCategory Returns: - Dictionary containing the category data as numpy arrays. + DataFrame containing the category data, or None if category doesn't exist (CIFBlock mode only) """ - if exists(cif_block.get(category)): - return toolz.valmap(lambda x: x.as_array(), dict(cif_block[category])) + # Check for both CIFBlock and BinaryCIFBlock + if isinstance(cif_block_or_category, CIFBlock | BinaryCIFBlock): + # Extract category from CIFBlock or BinaryCIFBlock + cif_block = cif_block_or_category + if category in cif_block: + return pd.DataFrame(category_to_dict(cif_block, category)) + return None else: - return {} - + # Convert CIFCategory directly + category_obj = cif_block_or_category + return pd.DataFrame(category_to_dict(category_obj)) -def initialize_chain_info_from_category(cif_block: CIFBlock, atom_array: AtomArray) -> dict: - """Extracts chain entity-level information from the CIF block. - Requires the categories 'entity' and 'entity_poly' to be present in the CIF block. +def category_to_dict( + cif_block_or_category: CIFBlock | BinaryCIFBlock | CIFCategory | BinaryCIFCategory, category: str | None = None +) -> dict[str, np.ndarray]: + """Convert CIF component to dict mapping column names to numpy arrays. - In particular, this function adds the following information to the chain_info_dict: - - The RCSB entity ID for each chain (e.g., 1, 2, 3, etc.) - - The chain type as an IntEnum (e.g., polypeptide(L), non-polymer, etc.) - - The unprocessed one-letter entity canonical and non-canonical sequences. - - A boolean flag indicating whether the chain is a polymer. - - The EC numbers for the chain. - - Note that three-letter sequence information is added to the chain_info_dict in a later step. + Accepts either ``(CIFBlock, category_name)`` or ``(CIFCategory)`` directly. + Supports both text CIF and binary CIF (BinaryCIF) formats. Args: - cif_block (CIFBlock): Parsed CIF block. - atom_array (AtomArray): Atom array containing the chain information. + cif_block_or_category: :py:class:`~biotite.structure.io.pdbx.CIFBlock`, + :py:class:`~biotite.structure.io.pdbx.BinaryCIFBlock`, or + :py:class:`~biotite.structure.io.pdbx.CIFCategory` | :py:class:`~biotite.structure.io.pdbx.BinaryCIFCategory` + category: Category name when passing CIFBlock/BinaryCIFBlock, omit when passing CIFCategory Returns: - dict: Dictionary containing the sequence details of each chain. + Dict mapping column names to numpy arrays """ - assert "entity" in cif_block, "entity category not found in CIF block." - assert "entity_poly" in cif_block, "entity_poly category not found in CIF block." - - # ... initialize - chain_info_dict = {} - - # Step 1: Build a mapping of chain id to entity id from the `atom_site` - chain_ids = atom_array.get_annotation("chain_id") - rcsb_entities = atom_array.get_annotation("label_entity_id").astype(str) - unique_chain_entity_map = dict(zip(chain_ids, rcsb_entities, strict=True)) - - # Step 2: Load additional chain information - rcsb_entity_df = category_to_df(cif_block, "entity") - rcsb_entity_df["id"] = rcsb_entity_df["id"].astype(str) - rcsb_entity_df.rename(columns={"type": "entity_type", "pdbx_ec": "ec_numbers"}, inplace=True) - rcsb_entity_dict = rcsb_entity_df.set_index("id").to_dict(orient="index") - - # From `entity_poly` - polymer_df = category_to_df(cif_block, "entity_poly") - - required_columns = ["entity_id", "type", "pdbx_strand_id"] - optional_columns = ["pdbx_seq_one_letter_code", "pdbx_seq_one_letter_code_can"] - polymer_df = polymer_df[required_columns + [col for col in optional_columns if col in polymer_df.columns]] - - # Rename columns if they exist - rename_map = { - "type": "polymer_type", - "pdbx_seq_one_letter_code": "non_canonical_sequence", - "pdbx_seq_one_letter_code_can": "canonical_sequence", - } - polymer_df.rename(columns=rename_map, inplace=True) - - polymer_df["entity_id"] = polymer_df["entity_id"].astype(str) - polymer_dict = polymer_df.set_index("entity_id").to_dict(orient="index") - - # Step 3: Merge additional information into the dictionary - for chain_id, rscb_entity in unique_chain_entity_map.items(): - chain_info = rcsb_entity_dict.get(rscb_entity, {}) - polymer_info = polymer_dict.get(rscb_entity, {}) - if chain_info.get("ec_numbers", "?") != "?": - ec_numbers = [ec.strip() for ec in chain_info.get("ec_numbers", "").split(",")] + # Check for both CIFBlock and BinaryCIFBlock (they don't share a common base class) + if isinstance(cif_block_or_category, CIFBlock | BinaryCIFBlock): + # Extract category from CIFBlock or BinaryCIFBlock + cif_block = cif_block_or_category + if exists(cif_block.get(category)): + return toolz.valmap(lambda x: x.as_array(), dict(cif_block[category])) else: - ec_numbers = [] - - # First check if the chain is a polymer; if so, use the polymer type (which is more specific). Otherwise, use the entity type - chain_type = ChainType.as_enum(polymer_info.get("polymer_type", chain_info.get("entity_type", "non-polymer"))) + return {} + else: + # Convert CIFCategory directly + category_obj = cif_block_or_category + return {key: value.as_array() for key, value in category_obj.items()} - chain_info_dict[chain_id] = { - "rcsb_entity": rscb_entity, - "chain_type": chain_type, - "unprocessed_entity_canonical_sequence": polymer_info.get("canonical_sequence", "").replace("\n", ""), - "unprocessed_entity_non_canonical_sequence": polymer_info.get("non_canonical_sequence", "").replace( - "\n", "" - ), - "is_polymer": chain_type.is_polymer(), - "ec_numbers": ec_numbers, - } - return chain_info_dict +# Entity ID Concepts: +# - label_entity (GIVEN): From data source, stored in chain_info["label_entity"] (CIF files only) +# - chain_entity (DERIVED): Computed via graph hashing, stored in atom_array annotations def get_metadata_from_category(cif_block: CIFBlock, fallback_id: str | None = None) -> dict: @@ -147,6 +95,7 @@ def get_metadata_from_category(cif_block: CIFBlock, fallback_id: str | None = No - Deposition date (initial) - Release date (smallest revision date) - Resolution (e.g., 5.0, 3.0, etc.) + - Chem comp type (elements of atomworks.constants.CHEM_COMP_TYPES) For custom CIF files (e.g., distillation), this function extracts: - Extra metadata (all other categories) @@ -216,104 +165,6 @@ def get_metadata_from_category(cif_block: CIFBlock, fallback_id: str | None = No return metadata -def load_monomer_sequence_information_from_category( - cif_block: CIFBlock, chain_info_dict: dict, atom_array: AtomArray, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH -) -> dict: - """Load monomer sequence information into a chain_info_dict - - Uses: - (a) The CIFCategory 'entity_poly_seq' as the sequence ground-truth for polymers. - (b) The AtomArray as the ground-truth for non-polymers. - - We must rely on the CIFCategory 'entity_poly_seq' for polymers, as the AtomArray may not contain the full sequence information (e.g., unresolved residues) - For non-polymers, there's no standard equivalent to 'entity_poly_seq', so we must use the AtomArray to get the sequence information. - - When loading both polymer and non-polymer sequences, we also filter out unknown or otherwise ignored residues. - - Args: - cif_block (CIFBlock): The CIF block containing the monomer sequence information. - chain_info_dict (dict): The dictionary where the monomer sequence information will be stored. - atom_array (AtomArray): The atom array used to get the sequence for non-polymers. - - Returns: - The updated chain_info_dict with monomer sequence information. Adds the following keys: - - 'res_name': The CCD residue names for each chain. - - 'res_id': The residue IDs for each chain (does not perform re-indexing) - - 'processed_entity_non_canonical_sequence': The processed non-canonical sequence for each chain. - - 'processed_entity_canonical_sequence': The processed canonical sequence for each chain. - - 'has_sequence_heterogeneity': A boolean flag indicating whether the chain has - """ - # Assert that entity_poly_seq category is present - assert "entity_poly_seq" in cif_block, "entity_poly_seq category not found in CIF block." - - # Handle polymers by using `entity_poly_seq` - polymer_seq_df = category_to_df(cif_block, "entity_poly_seq") - polymer_seq_df = polymer_seq_df.loc[:, ["entity_id", "num", "mon_id"]].rename( - columns={"entity_id": "rcsb_entity", "num": "res_id", "mon_id": "res_name"} - ) - - # Keep only the last occurrence of each residue - duplicates = polymer_seq_df.duplicated(subset=["rcsb_entity", "res_id"], keep="last") - entities_with_sequence_heterogeneity = polymer_seq_df[duplicates]["rcsb_entity"].unique() - if duplicates.any(): - logger.info("Sequence heterogeneity detected, keeping only the last occurrence of each residue.") - polymer_seq_df = polymer_seq_df[~duplicates] - - # Map rcsb_entity to lists of residue names and residue IDs - polymer_seq_df["rcsb_entity"] = polymer_seq_df["rcsb_entity"].astype(int) - polymer_entity_id_to_res_names_and_ids = { - rcsb_entity: {"res_name": group["res_name"].tolist(), "res_id": group["res_id"].tolist()} - for rcsb_entity, group in polymer_seq_df.groupby("rcsb_entity") - } - - # Build up the chain_info_dict with the sequence information - res_starts = get_residue_starts(atom_array) - # ... get the unique chain IDs by order of first appearance in the AtomArray - chain_ids = dict.fromkeys(struc.get_chains(atom_array)) - for chain_id in chain_ids: - rcsb_entity = int(chain_info_dict[chain_id]["rcsb_entity"]) - - if rcsb_entity in polymer_entity_id_to_res_names_and_ids: - # For polymers, we use the stored entity residue list - residue_names = polymer_entity_id_to_res_names_and_ids[rcsb_entity]["res_name"] - chain_type = chain_info_dict[chain_id]["chain_type"] - if residue_names: - chain_info_dict[chain_id]["res_name"] = residue_names - chain_info_dict[chain_id]["res_id"] = polymer_entity_id_to_res_names_and_ids[rcsb_entity]["res_id"] - - # Create the processed single-letter sequence representations - processed_entity_non_canonical_sequence = [ - get_1_from_3_letter_code(ccd_code, chain_type, use_closest_canonical=False) - for ccd_code in residue_names - ] - processed_entity_canonical_sequence = [ - get_1_from_3_letter_code(ccd_code, chain_type, use_closest_canonical=True) - for ccd_code in residue_names - ] - chain_info_dict[chain_id]["processed_entity_non_canonical_sequence"] = "".join( - processed_entity_non_canonical_sequence - ) - chain_info_dict[chain_id]["processed_entity_canonical_sequence"] = "".join( - processed_entity_canonical_sequence - ) - else: - # For non-polymers, we must re-compute every time, since entities are not guaranteed to have the same monomer sequence (e.g., for H2O chains) - chain_res_starts = res_starts[atom_array.chain_id[res_starts] == chain_id] - chain_info_dict[chain_id]["res_name"] = list(atom_array.res_name[chain_res_starts]) - chain_info_dict[chain_id]["res_id"] = list(atom_array.res_id[chain_res_starts]) - - chain_info_dict[chain_id]["has_sequence_heterogeneity"] = ( - str(rcsb_entity) in entities_with_sequence_heterogeneity - ) - - # Remove entries from chain_info_dict that have no residues - chain_info_dict = { - chain_id: chain_info for chain_id, chain_info in chain_info_dict.items() if "res_name" in chain_info - } - - return chain_info_dict - - def get_ligand_of_interest_info(cif_block: CIFBlock) -> dict: """Extract ligand of interest information from a CIF block. diff --git a/src/atomworks/io/utils/altloc.py b/src/atomworks/io/utils/altloc.py new file mode 100644 index 00000000..2a01c886 --- /dev/null +++ b/src/atomworks/io/utils/altloc.py @@ -0,0 +1,436 @@ +"""Clash-aware alternate conformer selection that respects multiple-occupancy correlations across chains and residues.""" + +__all__ = ["has_multiple_altlocs_per_residue", "select_altlocs_clash_aware"] + +import logging +import random +from collections import defaultdict +from typing import Any, Literal + +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack +from scipy.spatial import cKDTree + +from atomworks.constants import ALTLOC_DEFAULT_IDS +from atomworks.io.utils.ccd import ( + get_parent_comp_id, + get_parent_template_atom_names, + get_polymerization_atoms, + is_parent_strict_atom_subset, +) +from atomworks.io.utils.selection import get_annotation + +logger = logging.getLogger(__name__) + +AtomKey = tuple[str, str, str] # (chain_id, res_id, atom_name) for struct_conn lookup +ResidueKey = tuple[str, int] # (chain_id, res_id) + + +def has_multiple_altlocs_per_residue(atom_array: AtomArray | AtomArrayStack) -> bool: + """Check if any residue has more than one altloc letter.""" + if "altloc_id" not in atom_array.get_annotation_categories(): + return False + + alt_id = get_annotation(atom_array, "altloc_id") + non_default = ~np.isin(alt_id, ALTLOC_DEFAULT_IDS) + if not np.any(non_default): + return False + + nd_idx = np.where(non_default)[0] + chains = atom_array.chain_id[nd_idx] + res_ids = atom_array.res_id[nd_idx] + alts = alt_id[nd_idx] + + has_trans = "transformation_id" in atom_array.get_annotation_categories() + if has_trans: + trans = atom_array.transformation_id[nd_idx] + residue_keys = set(zip(chains, res_ids, trans, strict=True)) + residue_alt_keys = set(zip(chains, res_ids, trans, alts, strict=True)) + else: + residue_keys = set(zip(chains, res_ids, strict=True)) + residue_alt_keys = set(zip(chains, res_ids, alts, strict=True)) + + return len(residue_alt_keys) > len(residue_keys) + + +def select_altlocs_clash_aware( + atom_array: AtomArray | AtomArrayStack, + clash_distance: float = 1.5, + proximity_distance: float = 5.0, + seed: int | None = None, + unresolved_clash_policy: Literal["raise", "warn"] = "raise", + cif_block: Any = None, +) -> AtomArray | AtomArrayStack: + """Select one altloc per residue avoiding steric clashes, and return the subsetted structure. + + Considers: + - **Intra-chain clashes**: avoids selecting altloc combinations that overlap spatially (``1CBN``). + - **Cross-chain correlation**: proximal residues sharing altloc letters get the same letter (``6LZB``, ``6QHP``). + - **Bonded atom exclusion**: struct_conn and polymer bonds are not counted as clashes (``1XVK``, ``1RCQ``). + - **Parent fallback**: omitted modified residues fall back to their CCD parent (``6QHP``: ASB -> ASP). + + Args: + atom_array: Structure with all altlocs loaded. If an ``AtomArrayStack``, + coordinates from the first model are used. + clash_distance: Distance threshold (Angstroms) below which two atoms from + different altloc groups are considered clashing. Defaults to ``1.5``. + proximity_distance: Distance threshold (Angstroms) for detecting cross-chain + correlated altlocs. Defaults to ``5.0``. + seed: Random seed for deterministic selection. ``None`` for non-deterministic. + unresolved_clash_policy: What to do when no fully clash-free assignment exists. + ``"raise"`` raises a ``ValueError``, ``"warn"`` logs a warning and + returns the best-effort assignment. Defaults to ``"raise"``. + cif_block: Optional CIF block for parsing ``struct_conn`` bonds to exclude + from clash detection. ``None`` (default) when no CIF is available. + """ + arr = atom_array[0] if isinstance(atom_array, AtomArrayStack) else atom_array + + altloc_ids = get_annotation(atom_array, "altloc_id") + if altloc_ids is None: + return atom_array + + defaults = np.isin(altloc_ids, ALTLOC_DEFAULT_IDS) + altloc_groups = _identify_altloc_groups(arr, altloc_ids, defaults) + + if not altloc_groups: + return atom_array + + struct_conn_bonds = _parse_struct_conn_bonds(cif_block) + clashes, preferences = _build_constraints(arr, altloc_groups, clash_distance, proximity_distance, struct_conn_bonds) + + # Remove clashes between preference-linked residues (not real clashes) + if preferences: + pref_pairs = set(preferences) | {(r2, r1) for r1, r2 in preferences} + clashes = [(ri, li, rj, lj) for ri, li, rj, lj in clashes if (ri, rj) not in pref_pairs] + + rng = random.Random(seed) + assignment, omitted = _solve_greedy(altloc_groups, clashes, preferences, rng, unresolved_clash_policy) + + mask = defaults.copy() + for res_key, letter in assignment.items(): + mask[altloc_groups[res_key][letter]] = True + + parent_fallbacks = _apply_parent_fallbacks(arr, mask, altloc_groups, omitted, clash_distance, struct_conn_bonds) + + result = atom_array[..., mask] + for (chain_id, res_id), parent_code in parent_fallbacks.items(): + fb_mask = (result.chain_id == chain_id) & (result.res_id == res_id) + result.res_name[fb_mask] = parent_code + + return result + + +def _apply_parent_fallbacks( + arr: AtomArray, + mask: np.ndarray, + altloc_groups: dict[ResidueKey, dict[str, np.ndarray]], + omitted: set[ResidueKey], + clash_distance: float, + struct_conn_bonds: set[tuple[AtomKey, AtomKey]], +) -> dict[ResidueKey, str]: + """Fall back omitted modified residues to their canonical CCD parent. + + Only applies when the rest of the chain is present (single-residue omission, + not whole-chain altlocs). Mutates ``mask`` in place. + + Always raises on clash regardless of ``unresolved_clash_policy`` — the fallback + is deterministic, so a clash indicates a data issue rather than bad luck. + """ + if not omitted: + return {} + + # Build KD-tree of selected atoms once for all fallback checks + selected_indices = np.where(mask)[0] + selected_coords = arr.coord[selected_indices] + valid_sel = ~np.any(np.isnan(selected_coords), axis=1) + sel_tree = cKDTree(selected_coords[valid_sel]) if np.any(valid_sel) else None + sel_valid_indices = selected_indices[valid_sel] + + parent_fallbacks: dict[ResidueKey, str] = {} + for res_key in omitted: + chain_id = res_key[0] + if not np.any(mask & (arr.chain_id == chain_id)): + continue + + any_indices = next(iter(altloc_groups[res_key].values())) + res_name = arr.res_name[any_indices[0]] + + parent = get_parent_comp_id(res_name) + if parent is None or not is_parent_strict_atom_subset(res_name, parent): + continue + + parent_atoms = get_parent_template_atom_names(parent) + first_letter = next(iter(altloc_groups[res_key])) + indices = altloc_groups[res_key][first_letter] + keep = np.array([arr.atom_name[i] in parent_atoms for i in indices]) + fallback_indices = indices[keep] + + # Check fallback atoms don't clash with selected atoms + clashing_idx = None + if sel_tree is not None and len(fallback_indices) > 0: + fb_coords = arr.coord[fallback_indices] + valid_fb = ~np.any(np.isnan(fb_coords), axis=1) + for ci, coord in zip(fallback_indices[valid_fb], fb_coords[valid_fb], strict=False): + for nb in sel_tree.query_ball_point(coord, r=clash_distance): + si = sel_valid_indices[nb] + if not _is_bonded_pair(arr, ci, si, struct_conn_bonds): + clashing_idx = si + break + if clashing_idx is not None: + break + + if clashing_idx is not None: + raise ValueError( + f"Parent fallback for chain {chain_id} res {res_key[1]} " + f"({res_name} -> {parent}) clashes with " + f"chain {arr.chain_id[clashing_idx]} res {arr.res_id[clashing_idx]} " + f"atom {arr.atom_name[clashing_idx]}." + ) + + mask[fallback_indices] = True + parent_fallbacks[res_key] = parent + logger.info( + "Altloc fallback: %s res %d (%s) -> parent %s (%d/%d atoms kept)", + res_key[0], + res_key[1], + res_name, + parent, + int(keep.sum()), + len(indices), + ) + + return parent_fallbacks + + +def _is_bonded_pair( + arr: AtomArray, + idx_a: int, + idx_b: int, + struct_conn_bonds: set[tuple[AtomKey, AtomKey]], +) -> bool: + """Check if two atoms are bonded via polymer bond or struct_conn.""" + name_a, name_b = arr.atom_name[idx_a], arr.atom_name[idx_b] + + out_a, in_a = get_polymerization_atoms(arr.res_name[idx_a]) + out_b, in_b = get_polymerization_atoms(arr.res_name[idx_b]) + + if (name_a == out_a and name_b == in_b) or (name_b == out_b and name_a == in_a): + return True + + key_a = (arr.chain_id[idx_a], str(arr.res_id[idx_a]), name_a) + key_b = (arr.chain_id[idx_b], str(arr.res_id[idx_b]), name_b) + return (key_a, key_b) in struct_conn_bonds + + +def _identify_altloc_groups( + atom_array: AtomArray, + altloc_ids: np.ndarray, + defaults: np.ndarray, +) -> dict[ResidueKey, dict[str, np.ndarray]]: + """Map each residue with altlocs to ``{letter: atom_indices}``.""" + non_default = ~defaults + if not np.any(non_default): + return {} + + groups: dict[ResidueKey, dict[str, list]] = {} + for idx in np.where(non_default)[0]: + key = (atom_array.chain_id[idx], int(atom_array.res_id[idx])) + if key not in groups: + groups[key] = defaultdict(list) + groups[key][altloc_ids[idx]].append(idx) + + return { + key: {letter: np.array(indices, dtype=int) for letter, indices in letters.items()} + for key, letters in groups.items() + } + + +def _parse_struct_conn_bonds(cif_block: Any) -> set[tuple[AtomKey, AtomKey]]: + """Extract bonded atom pairs from the ``struct_conn`` CIF category.""" + bonds: set[tuple[AtomKey, AtomKey]] = set() + if cif_block is None or "struct_conn" not in cif_block: + return bonds + + sc = cif_block["struct_conn"] + chains_1 = sc["ptnr1_label_asym_id"].as_array() + seq_ids_1 = sc["ptnr1_label_seq_id"].as_array() + atoms_1 = sc["ptnr1_label_atom_id"].as_array() + chains_2 = sc["ptnr2_label_asym_id"].as_array() + seq_ids_2 = sc["ptnr2_label_seq_id"].as_array() + atoms_2 = sc["ptnr2_label_atom_id"].as_array() + + # Non-polymer residues have label_seq_id="."; fall back to auth_seq_id + # so keys match arr.res_id (which uses auth_seq_id for non-polymers). + for partner, seq_ids in [("1", seq_ids_1), ("2", seq_ids_2)]: + try: + auth = sc[f"ptnr{partner}_auth_seq_id"].as_array() + seq_ids[:] = np.where(seq_ids == ".", auth, seq_ids) + except KeyError: + pass + + for c1, s1, a1, c2, s2, a2 in zip(chains_1, seq_ids_1, atoms_1, chains_2, seq_ids_2, atoms_2, strict=False): + key1 = (str(c1), str(s1), str(a1)) + key2 = (str(c2), str(s2), str(a2)) + bonds.add((key1, key2)) + bonds.add((key2, key1)) + + return bonds + + +def _build_constraints( + atom_array: AtomArray, + altloc_groups: dict[ResidueKey, dict[str, np.ndarray]], + clash_distance: float, + proximity_distance: float, + struct_conn_bonds: set[tuple[AtomKey, AtomKey]] | None = None, +) -> tuple[list[tuple[ResidueKey, str, ResidueKey, str]], list[tuple[ResidueKey, ResidueKey]]]: + """Build hard clash constraints and soft proximity preferences in one pass. + + Bonded atom pairs (polymer bonds, struct_conn) are excluded from clash detection. + Altloc residues within ``proximity_distance`` are preference-linked. + """ + if struct_conn_bonds is None: + struct_conn_bonds = set() + + # Build parallel arrays for each altloc atom + all_indices: list[int] = [] + all_res_keys: list[ResidueKey] = [] + all_letters: list[str] = [] + for res_key, letters in altloc_groups.items(): + for letter, indices in letters.items(): + all_indices.extend(indices) + all_res_keys.extend([res_key] * len(indices)) + all_letters.extend([letter] * len(indices)) + + all_indices_arr = np.array(all_indices, dtype=int) + if len(all_indices_arr) < 2: + return [], [] + + coords = atom_array.coord[all_indices_arr] + valid = ~np.any(np.isnan(coords), axis=1) + valid_mask_idx = np.where(valid)[0] + valid_indices = all_indices_arr[valid_mask_idx] + valid_coords = coords[valid_mask_idx] + if len(valid_coords) < 2: + return [], [] + + valid_res_keys = [all_res_keys[i] for i in valid_mask_idx] + valid_letters_arr = [all_letters[i] for i in valid_mask_idx] + + tree = cKDTree(valid_coords) + pairs = tree.query_pairs(r=max(clash_distance, proximity_distance), output_type="ndarray") + if len(pairs) == 0: + return [], [] + + dists = np.linalg.norm(valid_coords[pairs[:, 0]] - valid_coords[pairs[:, 1]], axis=1) + + clash_set: set[tuple[ResidueKey, str, ResidueKey, str]] = set() + pref_set: set[tuple[ResidueKey, ResidueKey]] = set() + + for k in range(len(pairs)): + pi, pj = pairs[k, 0], pairs[k, 1] + rk_i, rk_j = valid_res_keys[pi], valid_res_keys[pj] + if rk_i == rk_j: + continue + + dist = dists[k] + + if dist <= clash_distance: + gi, gj = valid_indices[pi], valid_indices[pj] + if not _is_bonded_pair(atom_array, gi, gj, struct_conn_bonds): + lt_i, lt_j = valid_letters_arr[pi], valid_letters_arr[pj] + edge = (rk_i, lt_i, rk_j, lt_j) + if (rk_j, lt_j, rk_i, lt_i) not in clash_set: + clash_set.add(edge) + + # Soft preference: any different residues within proximity_distance get linked + if dist <= proximity_distance: + pair = (rk_i, rk_j) if rk_i < rk_j else (rk_j, rk_i) + pref_set.add(pair) + + return list(clash_set), list(pref_set) + + +def _solve_greedy( + altloc_groups: dict[ResidueKey, dict[str, np.ndarray]], + clashes: list[tuple[ResidueKey, str, ResidueKey, str]], + preferences: list[tuple[ResidueKey, ResidueKey]], + rng: random.Random, + unresolved_clash_policy: Literal["raise", "warn"] = "raise", +) -> tuple[dict[ResidueKey, str], set[ResidueKey]]: + """Greedy altloc assignment minimizing clashes and respecting preference links. + + Preference-linked residues are grouped via union-find and assigned the same letter. + """ + residues = list(altloc_groups.keys()) + options = {res: list(altloc_groups[res].keys()) for res in residues} + for letters in options.values(): + rng.shuffle(letters) + + hard: dict[tuple[ResidueKey, str], set[tuple[ResidueKey, str]]] = defaultdict(set) + for res_i, let_i, res_j, let_j in clashes: + hard[(res_i, let_i)].add((res_j, let_j)) + hard[(res_j, let_j)].add((res_i, let_i)) + + # Union-find for preference-linked residues + uf_parent: dict[ResidueKey, ResidueKey] = {r: r for r in residues} + + def find(x: ResidueKey) -> ResidueKey: + while uf_parent[x] != x: + uf_parent[x] = uf_parent[uf_parent[x]] + x = uf_parent[x] + return x + + for r_i, r_j in preferences: + if r_i in uf_parent and r_j in uf_parent: + uf_parent[find(r_i)] = find(r_j) + + group_map: dict[ResidueKey, list[ResidueKey]] = defaultdict(list) + for r in residues: + group_map[find(r)].append(r) + groups = list(group_map.values()) + rng.shuffle(groups) + + assignment: dict[ResidueKey, str] = {} + omitted: set[ResidueKey] = set() + + def count_clashes(res: ResidueKey, letter: str) -> int: + return sum(1 for o_r, o_l in hard.get((res, letter), set()) if assignment.get(o_r) == o_l) + + for group in groups: + rng.shuffle(group) + if len(group) == 1: + res = group[0] + assignment[res] = min(options[res], key=lambda lt: count_clashes(res, lt)) + else: + all_letters: set[str] = set() + for res in group: + all_letters.update(options[res]) + candidates = sorted(all_letters) + rng.shuffle(candidates) + + def group_score(letter: str, grp: list[ResidueKey] = group) -> tuple[int, int]: + supporters = sum(1 for r in grp if letter in options[r]) + clash_total = sum(count_clashes(r, letter) for r in grp if letter in options[r]) + return (-supporters, clash_total) + + best = min(candidates, key=group_score) + for res in group: + if best in options[res]: + assignment[res] = best + else: + omitted.add(res) + + total = sum( + 1 for r, lt in assignment.items() for o_r, o_l in hard.get((r, lt), set()) if assignment.get(o_r) == o_l + ) + if total > 0: + msg = ( + f"No fully clash-free altloc assignment found for {len(residues)} residues. " + f"{total // 2} unresolved clashes remain." + ) + if unresolved_clash_policy == "raise": + raise ValueError(msg) + logger.warning("%s Using best-effort assignment.", msg) + + return assignment, omitted diff --git a/src/atomworks/ml/conditions/annotator.py b/src/atomworks/io/utils/annotator.py similarity index 65% rename from src/atomworks/ml/conditions/annotator.py rename to src/atomworks/io/utils/annotator.py index a32f47b4..09ecfdca 100644 --- a/src/atomworks/ml/conditions/annotator.py +++ b/src/atomworks/io/utils/annotator.py @@ -36,8 +36,10 @@ [ True True True ... False False False] """ +import contextlib import functools from collections.abc import Callable +from itertools import pairwise from typing import Any import biotite.structure as struc @@ -45,10 +47,11 @@ from biotite.structure import AtomArray from jaxtyping import Bool, Float, Int -from atomworks.common import listmap from atomworks.constants import ( DNA_BACKBONE_ATOM_NAMES, ELEMENT_NAME_TO_ATOMIC_NUMBER, + MASKED, + MAX_CHEM_TYPE_LENGTH, METAL_ELEMENTS, NUCLEIC_ACID_BACKBONE_ATOM_NAMES, PROTEIN_BACKBONE_ATOM_NAMES, @@ -60,13 +63,18 @@ STANDARD_PYRIMIDINE_RESIDUES, STANDARD_RNA, UNKNOWN_AA, + UNKNOWN_ATOMIC_NUMBER, UNKNOWN_DNA, UNKNOWN_RNA, ) from atomworks.enums import ChainType -from atomworks.io.utils.atom_array import apply_and_spread -from atomworks.ml.transforms.atom_array import get_within_group_res_idx -from atomworks.ml.utils.token import get_token_starts +from atomworks.io.transforms.atom_array import add_chain_type_annotation +from atomworks.io.utils.atom_array_plus import AnnotationList2D +from atomworks.io.utils.ccd import get_chem_comp_type +from atomworks.io.utils.chain_info import build_chain_info +from atomworks.io.utils.scatter import apply_and_spread_segment_wise +from atomworks.io.utils.selection import get_annotation_categories +from atomworks.io.utils.standard_annotations.base import STANDARD_ANNOTATIONS Array = np.ndarray """Alias for numpy.ndarray""" @@ -97,9 +105,14 @@ def _register_lazy_annotator(annot_name: str) -> Callable: def decorator(fn: Callable[[AtomArray], Array]) -> Callable[[AtomArray], None]: @functools.wraps(fn) def wrapper(atom_array: AtomArray) -> None: - if annot_name not in atom_array.get_annotation_categories(): + if annot_name not in get_annotation_categories(atom_array, n_body="all"): values = fn(atom_array) - atom_array.set_annotation(annot_name, values) + if isinstance(values, AnnotationList2D): + atom_array.set_annotation(annot_name, values, n_body=2) + elif np.ndim(values) == 0: + atom_array.set_annotation(annot_name, values, n_body=0) + else: + atom_array.set_annotation(annot_name, values) # Register the annotation in the ANNOTATOR_REGISTRY ANNOTATOR_REGISTRY[annot_name] = wrapper @@ -136,10 +149,7 @@ def decorator(fn: Callable) -> Callable: def wrapper(atom_array: AtomArray, *args, **kwargs) -> Any: # Generate missing annotations for annot_name in annot_names: - if annot_name not in atom_array.get_annotation_categories(): - if annot_name not in ANNOTATOR_REGISTRY: - raise ValueError(f"No generator found for annotation: {annot_name}") - ANNOTATOR_REGISTRY[annot_name](atom_array) + _ensure_single_annotation(atom_array, annot_name) # Call the original function return fn(atom_array, *args, **kwargs) @@ -149,27 +159,56 @@ def wrapper(atom_array: AtomArray, *args, **kwargs) -> Any: return decorator +def _resolve_standard_annotation(name: str) -> type | None: + """Return the StandardAnnotation matching a registry name, canonical name, or alias.""" + for annotation_cls in STANDARD_ANNOTATIONS: + if name in (annotation_cls.name, annotation_cls.full_name, *annotation_cls.aliases): + return annotation_cls + return None + + +def _ensure_single_annotation(atom_array: AtomArray, name: str) -> None: + """Ensure a single annotation exists on ``atom_array``, generating it if absent. + + Checks ``ANNOTATOR_REGISTRY`` first, then falls back to + :py:class:`~atomworks.io.utils.standard_annotations.base.StandardAnnotationBase` subclasses + registered in :py:attr:`~atomworks.io.utils.standard_annotations.base.StandardAnnotationMeta._registry`. + + Raises: + ValueError: If no generator is found for ``name``. + """ + if name not in get_annotation_categories(atom_array, n_body="all"): + if name in ANNOTATOR_REGISTRY: + ANNOTATOR_REGISTRY[name](atom_array) + else: + result = _resolve_standard_annotation(name) + if result is not None: + result.set_annotation(atom_array, result.annotation(atom_array, default="generate")) + else: + raise ValueError(f"No generator found for annotation: {name}") + + def ensure_annotations(atom_array: AtomArray, *annotation_names: str) -> None: """ Ensure that specified annotations exist on the AtomArray. If an annotation does not exist, it will be generated according to the - generator function registered in the `ANNOTATOR_REGISTRY` and added to - the `AtomArray` in-place. + generator function registered in the `ANNOTATOR_REGISTRY`, or by the + default generation method of a registered :py:class:`StandardAnnotationBase` + subclass, and added to the `AtomArray` in-place. + + StandardAnnotation names resolve to canonical storage fields, preserving existing alias values. Args: atom_array: The AtomArray to annotate *annotation_names: Names of annotations to ensure. Must be - registered in the `ANNOTATOR_REGISTRY`. To register an annotation, - decorate a function with `_register_lazy_annotator("annot_name")`. + registered in ``ANNOTATOR_REGISTRY`` or correspond to a + registered StandardAnnotation's registry name, ``full_name``, or accepted alias. Raises: ValueError: If a requested annotation has no generator """ for name in annotation_names: - if name not in atom_array.get_annotation_categories(): - if name not in ANNOTATOR_REGISTRY: - raise ValueError(f"No generator found for annotation: {name}") - ANNOTATOR_REGISTRY[name](atom_array) + _ensure_single_annotation(atom_array, name) def remove_annotations(atom_array: AtomArray, *annotation_names: str) -> None: @@ -183,10 +222,15 @@ def remove_annotations(atom_array: AtomArray, *annotation_names: str) -> None: Note: Silently skips annotations that don't exist. """ - existing_annotations = atom_array.get_annotation_categories() + existing = {n_body: get_annotation_categories(atom_array, n_body=n_body) for n_body in (1, 2, 0)} for name in annotation_names: - if name in existing_annotations: - atom_array.del_annotation(name) + for n_body, categories in existing.items(): + if name in categories: + if n_body == 1: + atom_array.del_annotation(name) + else: + atom_array.del_annotation(name, n_body=n_body) + break def clear_generated_annotations(atom_array: AtomArray) -> None: @@ -196,32 +240,41 @@ def clear_generated_annotations(atom_array: AtomArray) -> None: Args: atom_array: The AtomArray to modify """ - existing_annotations = atom_array.get_annotation_categories() - for name in ANNOTATOR_REGISTRY: - if name in existing_annotations: - atom_array.del_annotation(name) + remove_annotations(atom_array, *ANNOTATOR_REGISTRY) # Custom annotation generators +@_register_lazy_annotator("chain_type") +def chain_type(atom_array: AtomArray) -> Array: + """Annotate chain type using the ChainType enum.""" + chain_info = build_chain_info(atom_array) + modified_atom_array = add_chain_type_annotation(atom_array.copy(), chain_info) # avoid inplace operations + return modified_atom_array.chain_type + + @_register_lazy_annotator("is_protein") +@_requires_annotations("chain_type") def is_protein(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to protein chains.""" return np.isin(atom_array.chain_type, ChainType.get_proteins()) @_register_lazy_annotator("is_nucleic_acid") +@_requires_annotations("chain_type") def is_nucleic_acid(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to nucleic acid chains.""" return np.isin(atom_array.chain_type, ChainType.get_nucleic_acids()) @_register_lazy_annotator("is_dna") +@_requires_annotations("chain_type") def is_dna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to DNA chains.""" return np.isin(atom_array.chain_type, ChainType.DNA) @_register_lazy_annotator("is_rna") +@_requires_annotations("chain_type") def is_rna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to RNA chains.""" return np.isin(atom_array.chain_type, ChainType.RNA) @@ -242,6 +295,18 @@ def is_standard_or_unknown_aa(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: return np.isin(atom_array.res_name, [*STANDARD_AA, UNKNOWN_AA]) +@_register_lazy_annotator("is_standard_unknown_or_masked_aa") +@_requires_annotations("chain_type", "is_standard_or_unknown_aa") +def is_standard_unknown_or_masked_aa(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 + """Check if atoms belong to standard, unknown, or masked amino acid residue. + + NOTE: May be different than the chain-level "is_protein" in the case of mixed-type chains (e.g., a protein with a non-canonical amino acid). + """ + standard_and_unknown = atom_array.get_annotation("is_standard_or_unknown_aa") + masked = (atom_array.res_name == MASKED) & (atom_array.chain_type == ChainType.POLYPEPTIDE_L) + return standard_and_unknown | masked + + @_register_lazy_annotator("is_protein_backbone") @_requires_annotations("is_protein") def is_protein_backbone(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 @@ -309,6 +374,18 @@ def is_standard_or_unknown_dna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: return np.isin(atom_array.res_name, [*STANDARD_DNA, UNKNOWN_DNA]) +@_register_lazy_annotator("is_standard_unknown_or_masked_dna") +@_requires_annotations("chain_type", "is_standard_or_unknown_dna") +def is_standard_unknown_or_masked_dna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 + """Check if atoms belong to standard, unknown, or masked DNA residue. + + NOTE: May be different than the chain-level "is_dna" in the case of mixed-type chains (e.g., DNA/RNA hybrids). + """ + standard_and_unknown = atom_array.get_annotation("is_standard_or_unknown_dna") + masked = (atom_array.res_name == MASKED) & (atom_array.chain_type == ChainType.DNA) + return standard_and_unknown | masked + + @_register_lazy_annotator("is_standard_or_unknown_rna") def is_standard_or_unknown_rna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to standard or unknown RNA residue. @@ -318,6 +395,18 @@ def is_standard_or_unknown_rna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: return np.isin(atom_array.res_name, [*STANDARD_RNA, UNKNOWN_RNA]) +@_register_lazy_annotator("is_standard_unknown_or_masked_rna") +@_requires_annotations("chain_type", "is_standard_or_unknown_rna") +def is_standard_unknown_or_masked_rna(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 + """Check if atoms belong to standard, unknown, or masked RNA residue. + + NOTE: May be different than the chain-level "is_rna" in the case of mixed-type chains (e.g., DNA/RNA hybrids). + """ + standard_and_unknown = atom_array.get_annotation("is_standard_or_unknown_rna") + masked = (atom_array.res_name == MASKED) & (atom_array.chain_type == ChainType.RNA) + return standard_and_unknown | masked + + @_register_lazy_annotator("is_pyrimidine") def is_pyrimidine(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to pyrimidine residues.""" @@ -351,15 +440,6 @@ def is_res_start(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 return is_res_start -@_register_lazy_annotator("is_token_start") -def is_token_start(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 - """Mark the first atom of each token.""" - token_starts = get_token_starts(atom_array, add_exclusive_stop=False) - is_token_start = np.zeros(atom_array.array_length(), dtype=bool) - is_token_start[token_starts] = True - return is_token_start - - @_register_lazy_annotator("is_chain_start") def is_chain_start(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to chain starts.""" @@ -370,15 +450,17 @@ def is_chain_start(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F8 @_register_lazy_annotator("is_polymer") +@_requires_annotations("chain_type") def is_polymer(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to polymers.""" return np.isin(atom_array.chain_type, ChainType.get_polymers()) @_register_lazy_annotator("is_ligand") +@_requires_annotations("chain_type") def is_ligand(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 """Check if atoms belong to ligands.""" - return np.isin(atom_array.chain_type, ChainType.NON_POLYMER) + return np.isin(atom_array.chain_type, ChainType.get_non_polymers()) @_register_lazy_annotator("is_metal") @@ -393,14 +475,6 @@ def is_carbohydrate(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F return struc.filter_carbohydrates(atom_array) -@_register_lazy_annotator("within_chain_res_idx") -def within_chain_res_idx(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F821 - """Get the within-chain residue index for the atom array.""" - annotations = atom_array.get_annotation_categories() - chain_group = "chain_iid" if "chain_iid" in annotations else "chain_id" - return get_within_group_res_idx(atom_array, group_by=chain_group) - - @_register_lazy_annotator("res_min_occupancy") @_requires_annotations("is_res_start") def res_min_occupancy(atom_array: AtomArray) -> Float[Array, "n_atoms"]: # noqa: F821 @@ -408,28 +482,7 @@ def res_min_occupancy(atom_array: AtomArray) -> Float[Array, "n_atoms"]: # noqa is_res_start = atom_array.get_annotation("is_res_start") res_start_idxs = np.where(is_res_start)[0] res_segments = np.concatenate([res_start_idxs, [atom_array.array_length()]]) - return apply_and_spread(res_segments, atom_array.occupancy, np.min) - - -@_register_lazy_annotator("token_min_occupancy") -@_requires_annotations("is_token_start") -def token_min_occupancy(atom_array: AtomArray) -> Float[Array, "n_atoms"]: # noqa: F821 - """Calculate minimum occupancy for each token.""" - is_token_start = atom_array.get_annotation("is_token_start") - token_start_idxs = np.where(is_token_start)[0] - token_segments = np.concatenate([token_start_idxs, [atom_array.array_length()]]) - return apply_and_spread(token_segments, atom_array.occupancy, np.min) - - -@_register_lazy_annotator("token_id") -@_requires_annotations("is_token_start") -def token_id(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F821 - """Assign a unique ID to each token.""" - is_token_start = atom_array.get_annotation("is_token_start") - token_start_idxs = np.where(is_token_start)[0] - token_id = np.arange(sum(is_token_start)) - token_segments = np.concatenate([token_start_idxs, [atom_array.array_length()]]) - return struc.segments.spread_segment_wise(token_segments, token_id) + return apply_and_spread_segment_wise(res_segments, atom_array.occupancy, np.min) @_register_lazy_annotator("res_has_tip_atom") @@ -440,10 +493,45 @@ def res_has_tip_atom(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: is_res_start = atom_array.get_annotation("is_res_start") res_start_idxs = np.where(is_res_start)[0] res_segments = np.concatenate([res_start_idxs, [atom_array.array_length()]]) - return apply_and_spread(res_segments, is_tip_atom, np.any) + return apply_and_spread_segment_wise(res_segments, is_tip_atom, np.any) @_register_lazy_annotator("atomic_number") def atomic_number(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F821 - """Get atomic numbers for each atom.""" - return np.array(listmap(ELEMENT_NAME_TO_ATOMIC_NUMBER.get, np.char.upper(atom_array.element)), dtype=np.int8) + """Get atomic numbers for each atom. Unknown elements map to ``UNKNOWN_ATOMIC_NUMBER``.""" + atomic_numbers = [ + ELEMENT_NAME_TO_ATOMIC_NUMBER.get(elem, UNKNOWN_ATOMIC_NUMBER) for elem in np.char.upper(atom_array.element) + ] + return np.array(atomic_numbers, dtype=np.int8) + + +@_register_lazy_annotator("chem_comp_type") +def chem_comp_type(atom_array: AtomArray) -> np.ndarray: + """Get chemical component types for each atom (`` BondList | None: + """The stored ``atom_id`` bond table re-indexed to the atoms' positions; without ``atom_ids`` the + table may describe an earlier array and is ignored.""" + try: + bonds = np.asarray(bonds, dtype=np.int64) + except (TypeError, ValueError): + bonds = None + if bonds is None or bonds.ndim != 2 or bonds.shape[1] not in (2, 3) or atom_ids is None: + logger.warning("Ignoring info[%r]: it does not describe these %d atoms.", _BONDS, n_atoms) + return None + + # An id seen twice (ASE fills an appended atom's with 0, repeats it on concatenation) names no atom + ids, counts = np.unique(atom_ids, return_counts=True) + repeated = set(ids[counts > 1].tolist()) + position_of = {int(atom_id): index for index, atom_id in enumerate(atom_ids) if atom_id not in repeated} + kept = [] + for row in bonds: + left, right = position_of.get(int(row[0])), position_of.get(int(row[1])) + if left is not None and right is not None: + kept.append((left, right, int(row[2]) if len(row) > 2 else 1)) + if len(kept) < len(bonds): + logger.warning("Dropped %d bonds whose atom_ids are duplicated or no longer present.", len(bonds) - len(kept)) + return BondList(n_atoms, np.array(kept, dtype=np.uint32).reshape(-1, 3)) + + +def ase_to_atom_array(atoms: Atoms, *, formal_charges: bool = False) -> AtomArray: + """Convert ASE Atoms object to AtomArray. + + Args: + atoms: The ASE atoms. + formal_charges: Read whole-number ``initial_charges`` as the formal ``charge``, as + :py:func:`atom_array_to_ase` writes it. ASE does not say which kind they are, so this is opt-in. + + See Also: + :py:func:`atom_array_to_ase`: Convert Biotite AtomArray back to ASE format + """ + if not isinstance(atoms, Atoms): + raise TypeError(f"Expected ASE Atoms, got {type(atoms).__name__}") + + # Extract required data + symbols = atoms.get_chemical_symbols() # Returns list of str + positions = atoms.get_positions() + box = atoms.cell if atoms.cell is not None and np.linalg.norm(atoms.cell) > 0 else None + + # ... initialize AtomArray + array = AtomArray(len(symbols)) + array.element = np.array(symbols) # Biotite expects array-like + array.coord = positions.astype(np.float32) + array.atomic_number = atoms.get_atomic_numbers().tolist() + + # Copy box and PBC information, if available + if box is not None: + array.box = np.array(box) + # Store PBC for round-trip conversion using private attribute + if hasattr(atoms, "pbc"): + array._pbc = tuple(atoms.pbc) + + # Transfer any additional arrays (annotations) from ASE to Biotite + for key, value in atoms.arrays.items(): + if key in ("numbers", "positions"): # Skip default ASE arrays + continue + name = key + if formal_charges and key == "initial_charges" and np.all(value == np.round(value)): + name, value = "charge", value.astype(np.int8) + try: + array.set_annotation(name, value) + except ValueError as e: + logger.debug(f"Could not add annotation '{name}': {e}") + + # ... transfer metadata from atoms.info to array._info + + # NOTE: Biotite's AtomArray doesn't provide a public API for arbitrary metadata. + # Using _info (private attribute) is necessary for complete data transfer in + # round-trip conversions. + if hasattr(atoms, "info") and atoms.info: + info = dict(atoms.info) + bonds = info.pop(_BONDS, None) + if bonds is not None: + array.bonds = _bonds_for_these_atoms(bonds, atoms.arrays.get("atom_id"), len(array)) + array._info = info + + return array + + +def atom_array_to_ase(array: AtomArray) -> Atoms: + """Convert AtomArray to ASE Atoms object. + + The formal ``charge`` goes to ``initial_charges``, and the bonds to ``info["atomworks_bonds"]`` as + ``(atom_id, atom_id, BondType)`` rows, preserving Biotite's bond-type codes, including aromatic + and coordination types. IDs start at 0 if the array has no ``atom_id``. Bonds survive in memory + only: ASE trajectory and database files drop the per-atom IDs needed to restore them. + + See Also: + :py:func:`ase_to_atom_array`: Convert ASE Atoms to Biotite format + """ + if not isinstance(array, AtomArray): + raise TypeError(f"Expected Biotite AtomArray, got {type(array).__name__}") + + # Extract required attributes + symbols = np.array([elem.capitalize() for elem in array.element]) + positions = array.coord + box = getattr(array, "box", None) + + # Determine PBC from stored value (or infer from box) + if hasattr(array, "_pbc"): + pbc = array._pbc + else: + # If no PBC stored, set True for all dimensions if box exists + pbc = [box is not None] * 3 + + # Create ASE Atoms object + atoms = Atoms( + symbols=symbols, + positions=positions, + cell=box, + pbc=pbc, + ) + + # ... transfer any additional arrays (annotations) from AtomArray to ASE Atoms + + # NOTE: Biotite stores annotations in _annot dict. While private, this is + # the standard pattern for bulk annotation access (similar to how atoms.arrays works in ASE). + annotations = dict(array._annot) + charges = annotations.pop("charge", None) + atoms.arrays.update(annotations) + if charges is not None: + atoms.set_initial_charges(np.asarray(charges, dtype=float)) + + # Transfer metadata, if available + if hasattr(array, "_info"): + atoms.info.update(array._info) + else: + logger.debug("No _info attribute found in the AtomArray.") + + # After ``_info``, so the array's current bonds replace any carried table + if array.bonds is not None: + if "atom_id" not in atoms.arrays: + atoms.arrays["atom_id"] = np.arange(array.array_length()) + bonds = array.bonds.as_array().astype(np.int64) + # Remap only endpoints; the third column retains the Biotite bond type. + bonds[:, :2] = atoms.arrays["atom_id"][bonds[:, :2]] + atoms.info[_BONDS] = bonds + + return atoms diff --git a/src/atomworks/io/utils/assembly.py b/src/atomworks/io/utils/assembly.py index b6e7ccb7..81955321 100644 --- a/src/atomworks/io/utils/assembly.py +++ b/src/atomworks/io/utils/assembly.py @@ -6,16 +6,17 @@ from typing import Literal +import biotite.structure as struc import biotite.structure.io.pdbx as pdbx import numpy as np from biotite.structure import AtomArrayStack -from biotite.structure.atoms import repeat from biotite.structure.io.pdbx import CIFCategory from atomworks.io.transforms.atom_array import ( add_iid_annotations_to_assemblies, maybe_fix_non_polymer_at_symmetry_center, ) +from atomworks.io.utils.atom_array_plus import AtomArrayPlusStack, as_atom_array_plus_stack, repeat_atom_array_plus def _matrix_rotate(v: np.ndarray, matrix: np.ndarray) -> np.ndarray: @@ -60,8 +61,8 @@ def _parse_transformations(struct_oper: CIFCategory) -> dict[str, tuple[np.ndarr def _apply_assembly_transformation( - structure: AtomArrayStack, transformation_dict: dict, operation: tuple[str] -) -> AtomArrayStack: + structure: AtomArrayStack | AtomArrayPlusStack, transformation_dict: dict, operation: tuple[str] +) -> AtomArrayStack | AtomArrayPlusStack: """ Get subassembly by applying the given operation to the input structure containing affected asym IDs. @@ -81,7 +82,12 @@ def _apply_assembly_transformation( # Add a dimension to coord to match expected shape or `repeat` (first dimension is # repeats) coord = coord[np.newaxis, ...] - return repeat(structure, coord) + out_stack = repeat_atom_array_plus(structure, coord) + + if isinstance(structure, AtomArrayPlusStack): + out_stack = as_atom_array_plus_stack(out_stack) + + return out_stack def _build_bioassembly_from_asym_unit( @@ -132,7 +138,7 @@ def _build_bioassembly_from_asym_unit( # get the transformations and apply to affected asym IDs transformations = _parse_transformations(struct_oper_category) # {id: rotation, translation} - assemblies = {} + assembly_parts: dict[str, list] = {} for _id, op_expr, asym_id_expr in zip( assembly_gen_category["assembly_id"].as_array(str), assembly_gen_category["oper_expression"].as_array(str), @@ -154,8 +160,12 @@ def _build_bioassembly_from_asym_unit( # for referencing the operation later on operation = "".join(operation) sub_assembly.set_annotation("transformation_id", np.full(sub_assembly.array_length(), operation)) - # Merge the chains with asym IDs for this operation with chains from other operations - assemblies[_id] = assemblies[_id] + sub_assembly if _id in assemblies else sub_assembly + assembly_parts.setdefault(_id, []).append(sub_assembly) + + # Concatenate all parts per assembly in one operation + assemblies = {} + for _id, parts in assembly_parts.items(): + assemblies[_id] = parts[0] if len(parts) == 1 else struc.concatenate(parts) return assemblies @@ -197,3 +207,33 @@ def build_assemblies_from_asym_unit( assemblies[idx] = maybe_fix_non_polymer_at_symmetry_center(assembly) return assemblies + + +def get_identity_op_expr_category() -> CIFCategory: + return CIFCategory.deserialize( + """_pdbx_struct_oper_list.id 1 + _pdbx_struct_oper_list.type 'identity operation' + _pdbx_struct_oper_list.name 1_555 + _pdbx_struct_oper_list.symmetry_operation x,y,z + _pdbx_struct_oper_list.matrix[1][1] 1.0000000000 + _pdbx_struct_oper_list.matrix[1][2] 0.0000000000 + _pdbx_struct_oper_list.matrix[1][3] 0.0000000000 + _pdbx_struct_oper_list.vector[1] 0.0000000000 + _pdbx_struct_oper_list.matrix[2][1] 0.0000000000 + _pdbx_struct_oper_list.matrix[2][2] 1.0000000000 + _pdbx_struct_oper_list.matrix[2][3] 0.0000000000 + _pdbx_struct_oper_list.vector[2] 0.0000000000 + _pdbx_struct_oper_list.matrix[3][1] 0.0000000000 + _pdbx_struct_oper_list.matrix[3][2] 0.0000000000 + _pdbx_struct_oper_list.matrix[3][3] 1.0000000000 + _pdbx_struct_oper_list.vector[3] 0.0000000000 """ # noqa: W291 + ) + + +def get_identity_assembly_gen_category(chain_ids: list[str]) -> CIFCategory: + return CIFCategory.deserialize( + f"""_pdbx_struct_assembly_gen.assembly_id 1 + _pdbx_struct_assembly_gen.oper_expression 1 + _pdbx_struct_assembly_gen.asym_id_list {",".join(chain_ids)} + """ + ) diff --git a/src/atomworks/io/utils/atom_array.py b/src/atomworks/io/utils/atom_array.py index 29215283..dcc1ba08 100644 --- a/src/atomworks/io/utils/atom_array.py +++ b/src/atomworks/io/utils/atom_array.py @@ -1,57 +1,242 @@ -from collections.abc import Callable +import logging +from collections.abc import Iterable import biotite.structure as struc import numpy as np +from biotite.structure import AtomArray, AtomArrayStack -NUMPY_REDUCE_FUNCS = { - np.any: np.logical_or.reduceat, - np.all: np.logical_and.reduceat, - np.maximum: np.maximum.reduceat, - np.minimum: np.minimum.reduceat, - np.max: np.maximum.reduceat, - np.min: np.minimum.reduceat, - np.add: np.add.reduceat, - np.sum: np.add.reduceat, -} - - -def apply_and_spread( - segment_start_stop_idxs: np.ndarray, data: np.ndarray, function: Callable, axis: int | None = None -) -> np.ndarray: +from atomworks.constants import BIOTITE_BOND_TYPE_TO_BOND_ORDER, HYDROGEN_LIKE_SYMBOLS, WATER_LIKE_CCDS + +# Backwards compatability: imported here to avoid breaking changes, but should be imported from atomworks.io.utils.scatter in the future. +from atomworks.io.utils.scatter import apply_and_spread_segment_wise as apply_and_spread # noqa: F401 + +logger = logging.getLogger(__name__) + +# Preserve the numeric fallback for bond types without a defined order (plain aromatic). +_BOND_ORDERS = np.array([BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(bt, int(bt)) for bt in struc.BondType]) + + +def _count_explicit_h_neighbors(atom_array: AtomArray | AtomArrayStack) -> np.ndarray: + """Per-atom count of explicit hydrogen atoms directly bonded to each atom.""" + counts = np.zeros(atom_array.array_length(), dtype=np.int8) + if atom_array.bonds is None: + return counts + bond_arr = atom_array.bonds.as_array() + if len(bond_arr) == 0: + return counts + is_h = np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS) + i_atoms, j_atoms = bond_arr[:, 0], bond_arr[:, 1] + # Bonds from H (i) to non-H (j): increment on j; and the reverse orientation. + np.add.at(counts, j_atoms[is_h[i_atoms] & ~is_h[j_atoms]], 1) + np.add.at(counts, i_atoms[is_h[j_atoms] & ~is_h[i_atoms]], 1) + return counts + + +def _assert_no_nhyd_with_explicit_h(atom_array: AtomArray | AtomArrayStack) -> None: + """Assert the ``nhyd`` invariant: no atom carries both ``nhyd > 0`` and an explicit bonded H.""" + if "nhyd" not in atom_array.get_annotation_categories(): + return + has_nhyd = atom_array.nhyd > 0 + if not has_nhyd.any(): + return + has_explicit_h = _count_explicit_h_neighbors(atom_array) > 0 + assert not (has_nhyd & has_explicit_h).any(), ( + "Atom(s) carry both `nhyd > 0` and an explicit bonded hydrogen; hydrogens would be " + "double-counted. Call `remove_hydrogens` after `annotate_hydrogens` " + "(or use `annotate_and_remove_hydrogens`)." + ) + + +def _bonds_to_dict(atom_array: struc.AtomArray) -> dict[tuple[str, str], int] | None: + """Convert AtomArray bonds to Biotite-comptabile dictionary format.""" + if atom_array.bonds is None or len(atom_array.bonds.as_array()) == 0: + return None + + bond_dict = {} + for bond in atom_array.bonds.as_array(): + # Convert numpy strings to Python strings (biotite requirement) + atom1 = str(atom_array.atom_name[bond[0]]) + atom2 = str(atom_array.atom_name[bond[1]]) + bond_type = int(bond[2]) + # Ensure consistent ordering (biotite accepts both, but we normalize) + key = tuple(sorted([atom1, atom2])) + bond_dict[key] = bond_type + + return bond_dict + + +def remove_components( + atoms: AtomArray | AtomArrayStack, + *, + remove_waters: bool = True, + remove_ccds: Iterable[str] = (), +) -> AtomArray | AtomArrayStack: + """Remove excluded components unless covalently connected to a non-excluded residue. + + Args: + atoms: The atom array or stack to filter. + remove_waters: Remove water-like components regardless of connectivity. + remove_ccds: Removal candidates. Supplied bonds preserve whole connected residues, + excluding coordination bonds; molecules made entirely of candidates are removed. """ - Apply a function segment-wise and then spread the result to the original data size. + if not remove_ccds and not remove_waters: + return atoms - This function applies a given function to segments of the input data and then - spreads the result back to the original data size, effectively assigning the - segment-wise result to all elements within each segment. + remove_set = set(map(str.upper, remove_ccds)) + if remove_waters: + remove_set.update(WATER_LIKE_CCDS) + + keep = ~np.isin(atoms.res_name, list(remove_set)) + if remove_ccds and keep.any() and not keep.all() and atoms.bonds is not None: + residue_index = struc.get_all_residue_positions(atoms) + keep_residue = keep[struc.get_residue_starts(atoms)] + + # Build covalent connectivity between whole residues. + bonds = atoms.bonds.as_array() + pairs = bonds[bonds[:, 2] != struc.BondType.COORDINATION, :2] + graph = struc.BondList(len(keep_residue), residue_index[pairs]) + + # Keep connected groups containing any non-excluded residue. + for component in struc.get_molecule_indices(graph): + keep_residue[component] = keep_residue[component].any() + keep = keep_residue[residue_index] + + if remove_waters: + keep &= ~np.isin(atoms.res_name, WATER_LIKE_CCDS) + + if isinstance(atoms, AtomArrayStack): + return atoms[:, keep] + + return atoms[keep] + + +def subset_to_first_transformation(atom_array: AtomArray | AtomArrayStack) -> AtomArray: + """Subset atom array to first transformation_id if present. Args: - segment_start_stop_idxs: A 1D array indicating the start and stop indices - of each segment. This is expected to be in the format returned by - `biotite.structure.segments.get_segment_starts`. - data: The input data array. - function: The function to apply to each segment. This function should - take a segment of the data array as input and return a single value - or an array of reduced values. - axis: The axis along which to apply the function. If `None`, the function - is applied to the entire segment. + atom_array: Atom array or stack to subset. Returns: - A new array with the same shape as `data`, where the result of the - function applied to each segment has been spread across the elements - of that segment. - - Example: - >>> import numpy as np - >>> segment_start_stop_idxs = np.array([0, 3, 6]) - >>> data = np.array([1, 2, 3, 4, 5, 6]) - >>> result = apply_and_spread(segment_start_stop_idxs, data, np.sum) - >>> print(result) - [ 6 6 6 15 15 15] + Atom array subsetted to first transformation_id, or original if no transformation_id annotation. + """ + if "transformation_id" in atom_array.get_annotation_categories(): + first_transform = atom_array.transformation_id[0] + return atom_array[atom_array.transformation_id == first_transform] + + return atom_array + + +def annotate_hydrogens( + template: AtomArray | AtomArrayStack, + *, + increment: bool = False, +) -> AtomArray | AtomArrayStack: + """Set the ``nhyd`` annotation: count of H atoms directly bonded to each heavy atom. + + Args: + template: The atom array to annotate. + increment: If ``True``, add the newly counted H atoms to any existing ``nhyd`` + values rather than overwriting them. Useful when some Hs are already counted + implicitly and additional explicit Hs remain. Defaults to ``False`` (overwrite). + + .. note:: + To maintain the invariant that ``nhyd`` is present only when no explicit H atoms exist, + this function should always be followed by :py:func:`remove_hydrogens`. """ - if function in NUMPY_REDUCE_FUNCS and axis is None: - data_after_apply = NUMPY_REDUCE_FUNCS[function](data, segment_start_stop_idxs[:-1]) + nhyd_delta = _count_explicit_h_neighbors(template) + + if increment and "nhyd" in template.get_annotation_categories(): + nhyd_delta += template.nhyd + + template.set_annotation("nhyd", nhyd_delta) + + return template + + +def remove_hydrogens(atom_array: AtomArray | AtomArrayStack) -> AtomArray | AtomArrayStack: + """Remove hydrogen atoms from an AtomArray or AtomArrayStack.""" + keep = ~np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS) + if isinstance(atom_array, AtomArrayStack): + return atom_array[:, keep] + return atom_array[keep] + + +def annotate_and_remove_hydrogens( + atom_array: AtomArray | AtomArrayStack, + *, + increment: bool = False, +) -> AtomArray | AtomArrayStack: + """Convenience wrapper around :py:func:`annotate_hydrogens` followed by :py:func:`remove_hydrogens`. + + Args: + atom_array: The atom array to annotate and remove hydrogens from. + increment: Passed through to :py:func:`annotate_hydrogens`. Defaults to ``False``. + """ + return remove_hydrogens(annotate_hydrogens(atom_array, increment=increment)) + + +def _get_bond_neighbors(bonds_arr: np.ndarray, atom_idx: int) -> np.ndarray: + """Return indices of all atoms bonded to ``atom_idx``.""" + mask0 = bonds_arr[:, 0] == atom_idx + mask1 = bonds_arr[:, 1] == atom_idx + return np.concatenate([bonds_arr[mask0, 1], bonds_arr[mask1, 0]]) + + +def _find_bonded_hydrogens(atom_array: AtomArray, atom_idx: int) -> np.ndarray: + """Return indices of hydrogen atoms directly bonded to ``atom_idx``.""" + bonds_array = atom_array.bonds.as_array() + mask_col0 = bonds_array[:, 0] == atom_idx + mask_col1 = bonds_array[:, 1] == atom_idx + neighbors = np.concatenate([bonds_array[mask_col0, 1], bonds_array[mask_col1, 0]]) + is_h = np.isin(atom_array.element[neighbors], HYDROGEN_LIKE_SYMBOLS) + return neighbors[is_h] + + +def count_bonded_hydrogens(atom_array: AtomArray, atom_idx: int, include_implicit: bool = False) -> int: + """Return the count of hydrogen atoms bonded to ``atom_idx``. + + Args: + include_implicit: If ``True`` and an ``nhyd`` annotation is present, + return the implicit hydrogen count instead of searching explicit bonds. + """ + if include_implicit and "nhyd" in atom_array.get_annotation_categories(): + _assert_no_nhyd_with_explicit_h(atom_array) + return int(atom_array.nhyd[atom_idx]) + return len(_find_bonded_hydrogens(atom_array, atom_idx)) + + +def has_annotation(arr: AtomArray, annotation: str | list[str]) -> bool: + """Check if an AtomArray has an annotation. + + Args: + arr: AtomArray to check. + annotation: Annotation(s) to check for. + """ + existing_annotations = frozenset(["coord", *arr.get_annotation_categories()]) + if isinstance(annotation, str): + return annotation in existing_annotations else: - data_after_apply = struc.segments.apply_segment_wise(segment_start_stop_idxs, data, function, axis) - data_after_spread = struc.segments.spread_segment_wise(segment_start_stop_idxs, data_after_apply) - return data_after_spread + return set(annotation).issubset(existing_annotations) + + +def chain_identifier(atom_array: AtomArray) -> np.ndarray: + """Return ``chain_iid`` if present, else ``chain_id``.""" + if "chain_iid" in atom_array.get_annotation_categories(): + return atom_array.chain_iid + return atom_array.chain_id + + +def get_bond_degree_per_atom(atom_array: AtomArray) -> np.ndarray: + """Returns the total degree (= sum of bond orders) for each atom.""" + # Count both ends of each edge + edge_list = atom_array.bonds._bonds[:, :2] + bond_types = atom_array.bonds._bonds[:, -1] + weights = _BOND_ORDERS[bond_types] + degree = np.bincount(edge_list.ravel(), weights=np.repeat(weights, 2), minlength=atom_array.array_length()) + + # ... add implicit hydrogens if nhyd annotation is present + if "nhyd" in atom_array.get_annotation_categories(): + _assert_no_nhyd_with_explicit_h(atom_array) + degree += atom_array.nhyd + + return degree diff --git a/src/atomworks/io/utils/atom_array_plus.py b/src/atomworks/io/utils/atom_array_plus.py index ec5dbc70..53ed79ec 100644 --- a/src/atomworks/io/utils/atom_array_plus.py +++ b/src/atomworks/io/utils/atom_array_plus.py @@ -1,17 +1,21 @@ import copy import logging import numbers +import warnings from collections import defaultdict from collections.abc import Sequence -from typing import Any, Generic, TypeVar, Union +from typing import Any, Generic, Literal, TypeVar, Union import biotite.structure as struc import numpy as np -from biotite.structure import AtomArray, AtomArrayStack +from biotite.structure import AtomArray, AtomArrayStack, repeat + +from atomworks.io.utils.selection import get_annotation_categories logger = logging.getLogger("atomworks.io") T = TypeVar("T") +AnnotationMismatchPolicy = Literal["raise", "warn", "drop"] def _get_sensible_default(dtype: Any) -> Any: @@ -404,54 +408,124 @@ def __len__(self) -> int: class _AtomArrayPlusBase: - """ - Mixin for AtomArrayPlus and AtomArrayPlusStack to support 2D (pairwise) annotations. - - Provides methods for setting, getting, copying, and comparing 2D annotations. - """ + """Shared system-wide and pairwise annotation support for AtomArrayPlus and AtomArrayPlusStack.""" + _annot_0d: dict[str, np.ndarray] _annot_2d: dict[str, "AnnotationList2D"] def __init__(self, *args, **kwargs) -> None: + self._annot_0d: dict[str, np.ndarray] = {} self._annot_2d: dict[str, AnnotationList2D] = {} + self._custom_ccd_registry: dict[str, Any] = {} - def set_annotation_2d(self, name: str, pairs: Sequence[Sequence[int]], values: Sequence[Any]) -> None: - """ - Set (create or replace) a 2D annotation. + def _get_annot_0d(self) -> dict[str, np.ndarray]: + """Return system-annotation storage, initializing it if absent.""" + return self.__dict__.setdefault("_annot_0d", {}) - Args: - name (str): Name of the annotation. - pairs (Sequence[Sequence[int]]): List of (i, j) pairs. - values (Sequence[Any]): List of values for each pair. + def _n_body_annotations(self, n_body: int) -> dict: + if n_body == 0: + return self._get_annot_0d() + if n_body == 2: + return self._annot_2d + raise ValueError(f"n_body must be 0, 1 or 2, got {n_body!r}") + + def set_annotation(self, category: str, array: Any, *, n_body: int = 1) -> None: + """Set a system scalar (0), per-atom array (1), or sparse AnnotationList2D (2). + + The default delegates to Biotite unchanged. Scalars are copied; pairwise annotations + are revalidated in a new container retaining their arrays. System values survive atom slicing. """ - if not isinstance(pairs, np.ndarray) or not np.issubdtype(pairs.dtype, np.integer): - pairs = np.array(pairs, dtype=np.int32) - if not isinstance(values, np.ndarray): - values = np.array(values) - self._annot_2d[name] = AnnotationList2D(self.array_length(), pairs, values) + if n_body == 1: + return super().set_annotation(category, array) + annotations = self._n_body_annotations(n_body) + if n_body == 0: + array = np.asarray(array) + if array.ndim != 0: + raise ValueError(f"System annotation '{category}' must be a scalar, got shape {array.shape}.") + array = array.copy() + elif not isinstance(array, AnnotationList2D) or array.n_atoms != self.array_length(): + raise ValueError( + f"Pairwise annotation '{category}' must be an AnnotationList2D for {self.array_length()} atoms." + ) + else: + array = AnnotationList2D(self.array_length(), array.pairs, array.values) + annotations[category] = array + + def get_annotation(self, category: str, *, n_body: int = 1) -> np.ndarray | AnnotationList2D: + """Get an annotation; defaults to Biotite's per-atom annotations.""" + if n_body == 1: + return super().get_annotation(category) + try: + return self._n_body_annotations(n_body)[category] + except KeyError: + raise ValueError(f"{n_body}-body annotation category '{category}' does not exist") from None + + def get_annotation_categories(self, *, n_body: int = 1) -> list[str]: + """List annotation names at the requested body order (per-atom by default).""" + if n_body == 1: + return super().get_annotation_categories() + return list(self._n_body_annotations(n_body)) + + def del_annotation(self, category: str, *, n_body: int = 1) -> None: + """Delete an annotation at the requested body order.""" + if n_body == 1: + return super().del_annotation(category) + self._n_body_annotations(n_body).pop(category, None) + + def _copy_annotations(self, clone: AtomArray | AtomArrayStack, *, n_body: int = 1) -> None: + """Copy annotations of one body order; delegate per-atom copying to Biotite.""" + if n_body == 1: + return super()._copy_annotations(clone) + annotations = self._n_body_annotations(n_body) + copied = ( + {name: np.copy(value) for name, value in annotations.items()} if n_body == 0 else copy.deepcopy(annotations) + ) + setattr(clone, f"_annot_{n_body}d", copied) + + def __eq_0d_annotations__(self, other: Any, equal_nan: bool = True) -> bool: + """Whether this structure's 0D annotations match another's.""" + self_0d = self._get_annot_0d() + other_0d = other._get_annot_0d() if isinstance(other, _AtomArrayPlusBase) else {} + if set(self_0d) != set(other_0d): + return False + return all( + np.array_equal( + value, + other_0d[name], + equal_nan=equal_nan and value.dtype.kind in "fc" and other_0d[name].dtype.kind in "fc", + ) + for name, value in self_0d.items() + ) + + def set_annotation_2d(self, name: str, pairs: Sequence[Sequence[int]], values: Sequence[Any]) -> None: + """Deprecated: use set_annotation(name, AnnotationList2D(...), n_body=2).""" + warnings.warn( + "Use set_annotation(..., n_body=2) instead of set_annotation_2d().", DeprecationWarning, stacklevel=2 + ) + self.set_annotation(name, AnnotationList2D(self.array_length(), pairs, values), n_body=2) def get_annotation_2d(self, name: str) -> "AnnotationList2D": - """Return a 2D annotation (AnnotationList2D).""" - if name not in self._annot_2d: - raise ValueError(f"2D annotation category '{name}' does not exist") - return self._annot_2d[name] + """Deprecated: use get_annotation(name, n_body=2).""" + warnings.warn( + "Use get_annotation(..., n_body=2) instead of get_annotation_2d().", DeprecationWarning, stacklevel=2 + ) + return self.get_annotation(name, n_body=2) def get_annotation_2d_categories(self) -> list[str]: - """Return a list of all 2D annotation names (categories).""" - return list(self._annot_2d.keys()) + """Deprecated: use get_annotation_categories(n_body=2).""" + warnings.warn( + "Use get_annotation_categories(n_body=2) instead of get_annotation_2d_categories().", + DeprecationWarning, + stacklevel=2, + ) + return self.get_annotation_categories(n_body=2) def del_annotation_2d(self, name: str) -> None: - """Remove a 2D annotation category. - - Args: - name: The 2D annotation category to remove. - """ - if name in self._annot_2d: - del self._annot_2d[name] - - def _copy_2d_annotations(self, clone: Any) -> None: - """Deep copy 2D annotations to the clone.""" - clone._annot_2d = copy.deepcopy(self._annot_2d) + """Deprecated: use del_annotation(name, n_body=2).""" + warnings.warn( + "Use del_annotation(..., n_body=2) instead of del_annotation_2d().", DeprecationWarning, stacklevel=2 + ) + self.del_annotation(name, n_body=2) def __eq_2d_annotations__(self, other: Any, equal_nan: bool = True) -> bool: """ @@ -478,12 +552,37 @@ def __getitem_2d_annotations__(self, item: Any) -> dict[str, "AnnotationList2D"] """Subset all 2D annotations using the given item/index.""" return {k: v[item] for k, v in self._annot_2d.items()} + def _warn_on_conversion_loss(self, src_cls: str, dst_cls: str) -> None: + """Log warnings for 0D/2D annotations and custom CCD entries discarded on downcast.""" + if self._get_annot_0d(): + logger.warning( + "Converting %s to %s will discard 0D annotations: %s", + src_cls, + dst_cls, + list(self._get_annot_0d()), + ) + if self._annot_2d: + logger.warning( + "Converting %s to %s will discard 2D annotations: %s", + src_cls, + dst_cls, + list(self._annot_2d.keys()), + ) + custom_ccd_registry = getattr(self, "_custom_ccd_registry", None) + if custom_ccd_registry: + logger.warning( + "Converting %s to %s will discard custom CCD registry entries: %s", + src_cls, + dst_cls, + list(custom_ccd_registry.keys()), + ) + class AtomArrayPlus(_AtomArrayPlusBase, AtomArray): """ - Extension of AtomArray supporting arbitrary 2D (pairwise) annotations. + Extension of AtomArray supporting system-wide and sparse pairwise annotations. - 2D annotations are stored as sparse lists of (i, j, value), accessible via set/get_annotation_2d methods. + Use set/get_annotation with n_body=0 for system scalars or n_body=2 for AnnotationList2D values. Automatically filters 2D annotations on slicing, following BondList semantics. """ @@ -504,16 +603,25 @@ def from_atom_array(cls, atom_array: AtomArray) -> "AtomArrayPlus": """ obj = cls(len(atom_array)) vars(obj).update(vars(atom_array)) + obj._annot_0d = {} obj._annot_2d = {} + obj._custom_ccd_registry = dict(getattr(atom_array, "_custom_ccd_registry", {})) return obj - def as_atom_array(self) -> AtomArray: + def as_atom_array(self, suppress_loss_warnings: bool = False) -> AtomArray: """ Convert the AtomArrayPlus object back to an AtomArray object (removes 2D annotations). + Args: + suppress_loss_warnings: If ``True``, do not warn when 2D annotations or custom + CCD registry entries are discarded. Used by internal callers that reconstruct + that state on the result. + Returns: AtomArray: A copy of this object as a plain AtomArray. """ + if not suppress_loss_warnings: + self._warn_on_conversion_loss("AtomArrayPlus", "AtomArray") atom_array = AtomArray.__copy_create__(self) AtomArray.__copy_fill__(self, atom_array) return atom_array @@ -539,10 +647,13 @@ def __getitem__(self, item: Any) -> Union["AtomArrayPlus", Any]: new_obj = self.__class__.__new__(self.__class__) # Copy AtomArray internals + _METADATA_ATTRS = {"_annot_0d", "_annot_2d", "_custom_ccd_registry"} # noqa: N806 for attr in self.__dict__: - if attr != "_annot_2d": + if attr not in _METADATA_ATTRS: setattr(new_obj, attr, getattr(result, attr)) + self._copy_annotations(new_obj, n_body=0) new_obj._annot_2d = self.__getitem_2d_annotations__(item) + new_obj._custom_ccd_registry = dict(getattr(self, "_custom_ccd_registry", {})) return new_obj def __copy_create__(self) -> "AtomArrayPlus": @@ -562,7 +673,9 @@ def __copy_fill__(self, clone: "AtomArrayPlus") -> None: clone (AtomArrayPlus): The freshly instantiated copy to fill. """ super().__copy_fill__(clone) - self._copy_2d_annotations(clone) + self._copy_annotations(clone, n_body=0) + self._copy_annotations(clone, n_body=2) + clone._custom_ccd_registry = dict(getattr(self, "_custom_ccd_registry", {})) def equal_annotations(self, item: Any, equal_nan: bool = True) -> bool: """ @@ -575,8 +688,10 @@ def equal_annotations(self, item: Any, equal_nan: bool = True) -> bool: Returns: bool: True if the annotations are equal, False otherwise. """ - return super().equal_annotations(item, equal_nan=equal_nan) and self.__eq_2d_annotations__( - item, equal_nan=equal_nan + return ( + super().equal_annotations(item, equal_nan=equal_nan) + and self.__eq_0d_annotations__(item, equal_nan=equal_nan) + and self.__eq_2d_annotations__(item, equal_nan=equal_nan) ) @@ -611,15 +726,26 @@ def from_atom_array_stack(cls, atom_array_stack: AtomArrayStack) -> "AtomArrayPl new_stack = cls(atom_array_stack.stack_depth(), atom_array_stack.array_length()) # ... copy all attributes from the original atom array stack vars(new_stack).update(vars(atom_array_stack)) + new_stack._annot_0d = {} # ... initialize empty 2D annotation dictionary new_stack._annot_2d = {} # ... initialize empty per-stack annotation dictionary new_stack._annot_per_stack = {} + # ... preserve custom CCD registry + new_stack._custom_ccd_registry = dict(getattr(atom_array_stack, "_custom_ccd_registry", {})) return new_stack - def as_atom_array_stack(self) -> AtomArrayStack: - """Convert the AtomArrayPlusStack back to an AtomArrayStack object (removes 2D annotations and per-stack annotations).""" + def as_atom_array_stack(self, suppress_loss_warnings: bool = False) -> AtomArrayStack: + """Convert the AtomArrayPlusStack back to an AtomArrayStack object (removes 2D annotations and per-stack annotations). + + Args: + suppress_loss_warnings: If ``True``, do not warn when 2D annotations or custom + CCD registry entries are discarded. Used by internal callers that reconstruct + that state on the result. + """ + if not suppress_loss_warnings: + self._warn_on_conversion_loss("AtomArrayPlusStack", "AtomArrayStack") atom_array_stack = AtomArrayStack.__copy_create__(self) AtomArrayStack.__copy_fill__(self, atom_array_stack) return atom_array_stack @@ -627,9 +753,12 @@ def as_atom_array_stack(self) -> AtomArrayStack: def get_array(self, index: int) -> AtomArrayPlus: """Obtain the AtomArrayPlus instance of the stack at the specified index.""" array = AtomArrayPlus.from_atom_array(super().get_array(index)) + self._copy_annotations(array, n_body=0) # Copy 2D annotations for name, ann in self._annot_2d.items(): array._annot_2d[name] = ann + # Copy custom CCD registry + array._custom_ccd_registry = dict(getattr(self, "_custom_ccd_registry", {})) # Apply per-stack annotations for this index for name, annot in self._annot_per_stack.items(): @@ -646,19 +775,19 @@ def get_per_stack_annotation(self, name: str) -> np.ndarray: def set_per_stack_annotation(self, name: str, array: np.ndarray) -> None: """ - Set a per-stack 1D annotation with shape (stack_depth, array_length). + Set a per-stack 1D annotation with shape (stack_depth, array_length, ...). Args: name: The name of the annotation. - array: Array with shape (stack_depth, array_length). + array: Array with shape (stack_depth, array_length, ...). Raises: ValueError: If the array shape is incorrect. """ array = np.asarray(array) - if array.shape != (self.stack_depth(), self.array_length()): + if array.shape[:2] != (self.stack_depth(), self.array_length()): raise ValueError( - f"Expected array shape ({self.stack_depth()}, {self.array_length()}), but got {array.shape}" + f"Expected array shape ({self.stack_depth()}, {self.array_length()},...), but got {array.shape}" ) self._annot_per_stack[name] = array @@ -728,6 +857,8 @@ def __getitem__(self, index: Any) -> Union["AtomArrayPlusStack", "AtomArrayPlus" # The parent method returns an AtomArrayStack, but we need to convert it to AtomArrayPlusStack # and copy over our special annotations new_stack = AtomArrayPlusStack.from_atom_array_stack(result) + new_stack._custom_ccd_registry = dict(getattr(self, "_custom_ccd_registry", {})) + self._copy_annotations(new_stack, n_body=0) # Case 3a: Two-dimensional indexing (stack[:, 0:10]) - slice both stack and atom dimensions if isinstance(index, tuple) and len(index) == 2: @@ -743,7 +874,7 @@ def __getitem__(self, index: Any) -> Union["AtomArrayPlusStack", "AtomArrayPlus" # Case 3b: One-dimensional indexing (stack[1:3], stack[mask]) - only stack dimension else: # Keep all 2D annotations since they apply to all atoms - new_stack._annot_2d = copy.deepcopy(self._annot_2d) + self._copy_annotations(new_stack, n_body=2) # Filter per-stack annotations using only the stack index for name, annot in self._annot_per_stack.items(): @@ -758,14 +889,19 @@ def __copy_create__(self) -> "AtomArrayPlusStack": def __copy_fill__(self, clone: "AtomArrayPlusStack") -> None: """Fill the clone with all data from this instance, including a deep copy of 2D annotations and per-stack annotations.""" super().__copy_fill__(clone) - self._copy_2d_annotations(clone) + self._copy_annotations(clone, n_body=0) + self._copy_annotations(clone, n_body=2) clone._annot_per_stack = {name: np.copy(annot) for name, annot in self._annot_per_stack.items()} + clone._custom_ccd_registry = dict(getattr(self, "_custom_ccd_registry", {})) def equal_annotations(self, item: Any, equal_nan: bool = True) -> bool: """Check if the annotations of this AtomArrayPlusStack are equal to another AtomArrayPlusStack.""" if not super().equal_annotations(item, equal_nan=equal_nan): return False + if not self.__eq_0d_annotations__(item, equal_nan=equal_nan): + return False + if not self.__eq_2d_annotations__(item, equal_nan=equal_nan): return False @@ -808,7 +944,10 @@ def add_per_stack_annotation(self, name: str, values: np.ndarray) -> None: self.set_per_stack_annotation(name, values) -def as_atom_array(array: AtomArrayPlus | AtomArray | struc.Atom | list[struc.Atom]) -> AtomArray: +def as_atom_array( + array: AtomArrayPlus | AtomArray | struc.Atom | list[struc.Atom], + suppress_loss_warnings: bool = False, +) -> AtomArray: """ Ensures that an AtomArrayPlus, AtomArray, list of Atoms, or Atom object is converted to an AtomArray. @@ -817,13 +956,16 @@ def as_atom_array(array: AtomArrayPlus | AtomArray | struc.Atom | list[struc.Ato Args: array: The input to convert, which can be an AtomArrayPlus, AtomArray, a single Atom, or a list of Atoms. + suppress_loss_warnings: If ``True``, do not warn when 2D annotations or + custom CCD registry entries are discarded during an AtomArrayPlus + conversion. Returns: AtomArray: The converted atom array. """ if isinstance(array, AtomArrayPlus): # ... AtomArrayPlus (must check before AtomArray, as AtomArray is a subclass of AtomArrayPlus) - return array.as_atom_array() + return array.as_atom_array(suppress_loss_warnings=suppress_loss_warnings) if isinstance(array, AtomArray): # ... unchanged return array @@ -853,48 +995,220 @@ def as_atom_array_plus(array: AtomArrayPlus | AtomArray | struc.Atom | list[stru return AtomArrayPlus.from_atom_array(as_atom_array(array)) -def concatenate_atom_array_plus(arrays: list[AtomArrayPlus | AtomArray | struc.Atom]) -> AtomArrayPlus: - """ - Concatenate multiple AtomArrayPlus objects, including their 2D annotations. - - Only annotations present in all arrays are concatenated. +def _merge_custom_ccd_registries(arrays: Sequence[Any]) -> dict[str, Any]: + """Merge ``_custom_ccd_registry`` dicts from ``arrays``, rejecting conflicting entries.""" + merged: dict[str, Any] = {} + for arr in arrays: + registry = getattr(arr, "_custom_ccd_registry", None) + if not registry: + continue + for res_name, entry in registry.items(): + existing = merged.get(res_name) + if existing is not None and existing is not entry: + existing_bonds = existing.bonds.as_array() if existing.bonds is not None else None + entry_bonds = entry.bonds.as_array() if entry.bonds is not None else None + same = ( + existing.array_length() == entry.array_length() + and np.array_equal(existing.atom_name, entry.atom_name) + and np.array_equal(existing.element, entry.element) + and np.array_equal(existing_bonds, entry_bonds) + ) + if not same: + raise ValueError( + f"Conflicting custom CCD registry entries for residue {res_name!r}: " + "the same code is registered with different AtomArrays across inputs." + ) + merged[res_name] = entry + return merged + + +def _fill_missing_standard_annotations_for_concatenation( + arrays: list[AtomArrayPlus], +) -> list[AtomArrayPlus]: + """Copy inputs and fill the union of their registered StandardAnnotations.""" + # StandardAnnotationBase imports AnnotationList2D from this module. + from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS + + present = { + n_body: set().union(*(get_annotation_categories(array, n_body=n_body) for array in arrays)) for n_body in (1, 2) + } + filled_arrays = [array.copy() for array in arrays] + for annotation_cls in STANDARD_ANNOTATIONS: + if annotation_cls.n_body == 0: + continue + names = present[annotation_cls.n_body].intersection((annotation_cls.full_name, *annotation_cls.aliases)) + if not names: + continue + for array in filled_arrays: + value = annotation_cls.annotation(array, default="generate") + for alias in names - {annotation_cls.full_name}: + if annotation_cls.n_body == 1: + array.set_annotation(alias, value) + else: + array.set_annotation_2d(alias, pairs=value.pairs, values=value.values) + annotation_cls.set_annotation(array, value) + + return filled_arrays + + +def _merge_0d_annotations(arrays: Sequence[Any]) -> dict[str, np.ndarray]: + """Copy agreed system values; missing values do not veto, conflicts raise.""" + from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS + + carriers = [annot for arr in arrays if (annot := arr._get_annot_0d())] + if not carriers: + return {} + + fields_by_name = { + field: (annotation_cls.full_name, *annotation_cls.aliases) + for annotation_cls in STANDARD_ANNOTATIONS + if annotation_cls.n_body == 0 + for field in (annotation_cls.full_name, *annotation_cls.aliases) + } + merged: dict[str, np.ndarray] = {} + for name in {name for annot in carriers for name in annot}: + values = [annot[field] for annot in carriers for field in fields_by_name.get(name, (name,)) if field in annot] + equal_nan = all(value.dtype.kind in "fc" for value in values) + if not all(np.array_equal(values[0], value, equal_nan=equal_nan) for value in values[1:]): + raise ValueError(f"Cannot concatenate conflicting system annotation '{name}'.") + merged[name] = np.copy(values[0]) + return merged + + +def concatenate_atom_array_plus( + arrays: list[AtomArrayPlus | AtomArray | struc.Atom], + *, + on_annotation_mismatch_policy: AnnotationMismatchPolicy = "raise", + fill_missing_standard_annotations: bool = False, +) -> AtomArrayPlus: + """Concatenate structures while preserving bonds, registries, and annotations. + + Missing registered StandardAnnotations can optionally be filled before + remaining annotation mismatches are handled by ``on_annotation_mismatch_policy``. + System values present in any input are copied; conflicting system values always raise. Args: - arrays (list[AtomArrayPlus]): List of AtomArrayPlus objects to concatenate. + arrays: Structures to concatenate. + on_annotation_mismatch_policy: How to handle annotation categories that + are absent from at least one input. ``"raise"`` rejects the mismatch, + ``"warn"`` warns before dropping mismatched categories, ``"drop"`` + drops them silently. + fill_missing_standard_annotations: Fill registered StandardAnnotations + that are missing from some inputs on copies before handling any + remaining mismatches. Defaults to ``False``. Returns: - AtomArrayPlus: Concatenated AtomArrayPlus object. + Concatenated structure. + + Raises: + ValueError: If the policy is invalid or is ``"raise"`` and annotations + differ between inputs, or if system values conflict regardless of policy. """ - # Use standard AtomArray concatenation for base AtomArrays - # (Biotite's concatenate is optimized for lists of AtomArrays) - base_arrays = [as_atom_array(arr) for arr in arrays] - arr_cat = struc.concatenate(base_arrays) + arrays_plus = [as_atom_array_plus(array) for array in arrays] + + valid_policies = {"raise", "warn", "drop"} + if on_annotation_mismatch_policy not in valid_policies: + raise ValueError( + f"Invalid on_annotation_mismatch_policy {on_annotation_mismatch_policy!r}; " + f"expected one of {sorted(valid_policies)}." + ) + + if fill_missing_standard_annotations: + arrays_plus = _fill_missing_standard_annotations_for_concatenation(arrays_plus) + + categories_1d = [set(array.get_annotation_categories()) for array in arrays_plus] + categories_2d = [set(array.get_annotation_categories(n_body=2)) for array in arrays_plus] + common_1d = set.intersection(*categories_1d) if categories_1d else set() + common_2d = set.intersection(*categories_2d) if categories_2d else set() + mismatched_1d = (set.union(*categories_1d) if categories_1d else set()) - common_1d + mismatched_2d = (set.union(*categories_2d) if categories_2d else set()) - common_2d + if mismatched_1d or mismatched_2d: + mismatch_parts = [] + if mismatched_1d: + mismatch_parts.append(f"1D annotations {sorted(mismatched_1d)}") + if mismatched_2d: + mismatch_parts.append(f"2D annotations {sorted(mismatched_2d)}") + mismatch_message = "Annotation mismatch across inputs: " + "; ".join(mismatch_parts) + if on_annotation_mismatch_policy == "raise": + raise ValueError(mismatch_message) + if on_annotation_mismatch_policy == "warn": + warnings.warn(f"{mismatch_message}; dropping them.", UserWarning, stacklevel=2) + + arr_cat = struc.concatenate(arrays_plus) + if arr_cat.box is not None: + arr_cat.box = arr_cat.box.copy() # Find common annotation names for 2D annotations - all_names = [set(arr._annot_2d.keys()) for arr in arrays if hasattr(arr, "_annot_2d")] + all_names = [set(arr._annot_2d.keys()) for arr in arrays_plus] common_names = set.intersection(*all_names) if all_names else set() - n_atoms_list = [len(arr) for arr in base_arrays] + n_atoms_list = [len(arr) for arr in arrays_plus] annotations_2d = {} for name in common_names: - empty_annotation = lambda array: AnnotationList2D(array.array_length(), [], []) # noqa: E731 - lists: list[AnnotationList2D] = [ - arr._annot_2d[name] if hasattr(arr, "_annot_2d") else empty_annotation(as_atom_array(arr)) for arr in arrays - ] + lists: list[AnnotationList2D] = [arr._annot_2d[name] for arr in arrays_plus] annotations_2d[name] = AnnotationList2D.concatenate(lists, n_atoms_list) # Create new AtomArrayPlus and assign annotations result = AtomArrayPlus.from_atom_array(arr_cat) - for attr in arr_cat.__dict__: - setattr(result, attr, getattr(arr_cat, attr)) + result._annot_0d = _merge_0d_annotations(arrays_plus) result._annot_2d = annotations_2d + result._custom_ccd_registry = _merge_custom_ccd_registries(arrays_plus) return result +def repeat_atom_array_plus( + atoms: AtomArray | AtomArrayPlus | AtomArrayStack | AtomArrayPlusStack, coord: np.ndarray +) -> AtomArrayPlus: + """Add support for AtomArrayPlus and AtomArrayPlusStack in biotite's `repeat` function. + + This function should always be used within ``atomworks`` instead of biotite's native `repeat`. + This function depends on imports from `atomworks.io`, so it is excluded from the biotite monkey-patch. + + atoms (AtomArray | AtomArrayPlus | AtomArrayStack | AtomArrayPlusStack): The atom array or stack to repeat. + coord (np.ndarray): The coordinates for the repeated array. + + For full documentation of the base function, see https://www.biotite-python.org/latest/apidoc/biotite.structure.repeat.html + """ + + repetitions = len(coord) + repeated_array = repeat(atoms, coord) + + if isinstance(atoms, AtomArrayPlus): + repeated_array = as_atom_array_plus(repeated_array) + elif isinstance(atoms, AtomArrayPlusStack): + repeated_array = as_atom_array_plus_stack(repeated_array) + for per_stack_annot_name in atoms.get_per_stack_annotation_categories(): + original_annot = atoms.get_per_stack_annotation(per_stack_annot_name) + repeated_array.set_per_stack_annotation(per_stack_annot_name, np.tile(original_annot, (1, repetitions))) + + if isinstance(atoms, _AtomArrayPlusBase): + atoms._copy_annotations(repeated_array, n_body=0) + + # Copy over 2D annotations, if any + for category in get_annotation_categories(atoms, n_body=2): + original_annot = atoms.get_annotation(category, n_body=2) + annot_values = np.tile(original_annot.values, repetitions) + + # Update the indices of the 2-body pairs within the repeat regions + num_pairs = original_annot.pairs.shape[0] + annot_pairs = np.tile(original_annot.pairs, (repetitions, 1)) + offsets = np.repeat(np.arange(repetitions) * atoms.array_length(), num_pairs) + annot_pairs += offsets[:, None] + + repeated_array.set_annotation( + category, AnnotationList2D(repeated_array.array_length(), annot_pairs, annot_values), n_body=2 + ) + + return repeated_array + + def concatenate_any( arrays: list[AtomArrayPlus | AtomArray | struc.Atom | list[struc.Atom]], + *, + on_annotation_mismatch_policy: AnnotationMismatchPolicy = "raise", + fill_missing_standard_annotations: bool = False, ) -> AtomArrayPlus | AtomArray: """ Concatenate a list of AtomArrayPlus or AtomArray objects. @@ -903,6 +1217,12 @@ def concatenate_any( Args: arrays (list[Any]): List of arrays to concatenate. + on_annotation_mismatch_policy: Forwarded to + :func:`concatenate_atom_array_plus` when an input is an + :class:`AtomArrayPlus`. + fill_missing_standard_annotations: Forwarded to + :func:`concatenate_atom_array_plus` when an input is an + :class:`AtomArrayPlus`. Returns: Any: Concatenated array of the appropriate type. @@ -911,7 +1231,11 @@ def concatenate_any( raise ValueError("Input list is empty.") if any(isinstance(arr, AtomArrayPlus) for arr in arrays): - return concatenate_atom_array_plus(arrays) + return concatenate_atom_array_plus( + arrays, + on_annotation_mismatch_policy=on_annotation_mismatch_policy, + fill_missing_standard_annotations=fill_missing_standard_annotations, + ) return struc.concatenate(arrays) @@ -920,19 +1244,33 @@ def insert_atoms( arr: AtomArray | AtomArrayPlus, new_atoms: list[AtomArray | AtomArrayPlus | struc.Atom], insert_positions: list[int], + *, + on_annotation_mismatch_policy: AnnotationMismatchPolicy = "raise", + fill_missing_standard_annotations: bool = False, ) -> AtomArray | AtomArrayPlus: - """ - Insert atoms into an AtomArray or AtomArrayPlus BEFORE the specified positions. + """Insert atoms into an AtomArray or AtomArrayPlus before specified positions. Atoms are first concatenated to the end, then the array is sorted so that the new atoms appear at the specified positions. The function is robust to both AtomArray and AtomArrayPlus, and preserves 2D annotations and bonds. + + Args: + arr: Array into which atoms are inserted. + new_atoms: Atoms or arrays to insert. + insert_positions: Position before which each corresponding entry in + ``new_atoms`` is inserted. + on_annotation_mismatch_policy: Forwarded to :func:`concatenate_any`. + fill_missing_standard_annotations: Forwarded to :func:`concatenate_any`. """ n_atoms_orig = arr.array_length() assert isinstance(new_atoms, list) and isinstance( new_atoms[0], AtomArray | AtomArrayPlus | struc.Atom ), "new_atoms must be a list of AtomArray, AtomArrayPlus, or Atom objects." assert len(new_atoms) == len(insert_positions), "Each new atom must have a corresponding insert position." - arr_all = concatenate_any([arr, *new_atoms]) + arr_all = concatenate_any( + [arr, *new_atoms], + on_annotation_mismatch_policy=on_annotation_mismatch_policy, + fill_missing_standard_annotations=fill_missing_standard_annotations, + ) # Build a mapping from position to list of new atom indices to insert there insert_map = defaultdict(list) @@ -965,8 +1303,7 @@ def stack_atom_array_plus(arrays: list[AtomArrayPlus]) -> AtomArrayPlusStack: """ Create an AtomArrayPlusStack from a list of AtomArrayPlus. - All atom arrays must have an equal number of atoms and equal annotation arrays (including 2D annotations). - Arrays must have identical 1D annotations. + All atom arrays must have an equal number of atoms and equal annotations at every body order. TODO: Optionally, allow differing annotations (which will be converted to per-stack annotations) @@ -993,16 +1330,16 @@ def stack_atom_array_plus(arrays: list[AtomArrayPlus]) -> AtomArrayPlusStack: ) # Create a stack of AtomArrays... - atom_arrays = [array.as_atom_array() for array in arrays] + atom_arrays = [array.as_atom_array(suppress_loss_warnings=True) for array in arrays] atom_array_stack = struc.stack(atom_arrays) # ... and convert to AtomArrayPlusStack array_stack = AtomArrayPlusStack.from_atom_array_stack(atom_array_stack) - # Add 2D annotations from the first array to the stack (only for those 2D annotations that are present in all arrays) - for name in ref_array.get_annotation_2d_categories(): - if all(name in array.get_annotation_2d_categories() for array in arrays): - array_stack._annot_2d[name] = ref_array.get_annotation_2d(name) + ref_array._copy_annotations(array_stack, n_body=0) + ref_array._copy_annotations(array_stack, n_body=2) + + array_stack._custom_ccd_registry = _merge_custom_ccd_registries(arrays) return array_stack diff --git a/src/atomworks/io/utils/bonds.py b/src/atomworks/io/utils/bonds.py index 00fbe1a9..4438cdc0 100644 --- a/src/atomworks/io/utils/bonds.py +++ b/src/atomworks/io/utils/bonds.py @@ -2,569 +2,385 @@ Utility functions for the detection, and creation, of bonds in a structure. """ -__all__ = [ - "generate_inter_level_bond_hash", - "get_coarse_graph_as_nodes_and_edges", - "get_connected_nodes", - "get_inferred_polymer_bonds", - "get_struct_conn_bonds", - "hash_atom_array", - "hash_graph", -] - import hashlib import logging -from typing import Any +from typing import Any, Literal import biotite.structure as struc import networkx as nx import numpy as np -import pandas as pd -from biotite.structure import AtomArray -from biotite.structure.io.pdbx.convert import ( - PDBX_BOND_TYPE_TO_TYPE_ID, - _filter_bonds, - _filter_canonical_links, - _get_struct_conn_col_name, -) +from biotite.structure import AtomArray, AtomArrayStack, BondType +from biotite.structure.io import pdbx -from atomworks.common import sum_string_arrays, to_hashable +from atomworks.common import sum_string_arrays from atomworks.constants import ( - AA_LIKE_CHEM_TYPES, - CHEM_TYPE_POLYMERIZATION_ATOMS, - DEFAULT_VALENCE, - HYDROGEN_LIKE_SYMBOLS, - NA_LIKE_CHEM_TYPES, + BOND_DISTANCE_THRESHOLD_CHNO, + BOND_DISTANCE_THRESHOLD_CHNOPS, + BOND_DISTANCE_THRESHOLD_OTHER, + CCD_MIRROR_PATH, + STANDARD_AND_UNKNOWN_POLYMER_RESIDUES, STRUCT_CONN_BOND_ORDER_TO_INT, STRUCT_CONN_BOND_TYPES, ) -from atomworks.enums import ChainType, ChainTypeInfo -from atomworks.io.utils.ccd import get_chem_comp_leaving_atom_names, get_chem_comp_type +from atomworks.io.transforms.categories import category_to_dict +from atomworks.io.utils.atom_array import ( + _bonds_to_dict, +) +from atomworks.io.utils.ccd import ( + atom_array_from_ccd_code, +) +from atomworks.io.utils.link_chemistry import ( + _element_distance_thresholds, + filter_link_distances, + infer_link_order, +) +from atomworks.io.utils.link_chemistry import ( + correct_charged_amide_nitrogens as correct_charged_amide_nitrogens, +) +from atomworks.io.utils.link_chemistry import ( + correct_formal_charges_for_specified_atoms as correct_formal_charges_for_specified_atoms, +) from atomworks.io.utils.selection import get_annotation, get_residue_starts -from atomworks.io.utils.testing import has_ambiguous_annotation_set logger = logging.getLogger("atomworks.io") +LongBondPolicyType = Literal[ + "keep", + "filter", + "warn", + "raise", + "filter_nonstandard_only", + "warn_nonstandard_only", + "raise_nonstandard_only", +] +"""Type alias for long bond filtering policy options.""" -def _get_leaving_atom_idxs_for(atom_name: str, res_name: str, atom_names: np.ndarray, offset: int = 0) -> np.ndarray: - """ - Get the indices of the leaving atoms for a given residue and atom. - """ - leaving_atoms = get_chem_comp_leaving_atom_names(res_name).get(atom_name, ()) - return offset + np.where(np.isin(atom_names, leaving_atoms))[0] +def remap_intra_residue_coordination_bonds(structure: AtomArray) -> AtomArray: + """Remap intra-residue COORDINATION bonds to SINGLE. -def get_inferred_polymer_bonds(atom_array: AtomArray) -> tuple[list[tuple[int, int, struc.BondType]], np.ndarray]: + Biotite's CIF writer routes intra-residue bonds to ``chem_comp_bond``, which + cannot represent COORDINATION; inter-residue bonds go to ``struct_conn`` (which can). """ - Infers and returns polymer bonds between consecutive residues in an atom array based on chemical component types - and chain types. + if structure.bonds is None: + return structure - The function identifies bonds by looking at consecutive residues within the same chain and determining the - appropriate bonding atoms based on either the chain type (as a fallback) or more detailed chemical component - types. It also tracks leaving atoms that are displaced during bond formation. Leaving groups are inferred from - the CCD entries for the chemical components. If a CCD code is missing from your local CCD mirror, - leaving groups will not be inferred. + bond_array = structure.bonds.as_array() + is_coord = bond_array[:, 2] == BondType.COORDINATION + if not np.any(is_coord): + return structure - Args: - - atom_array (AtomArray): The atom array containing the structure information. Must include annotations for - chain_id, res_id, res_name, and atom_name. Optionally includes chain_type annotation. + res_starts = get_residue_starts(structure) + atom1_res = np.searchsorted(res_starts, bond_array[:, 0], side="right") - 1 + atom2_res = np.searchsorted(res_starts, bond_array[:, 1], side="right") - 1 + remap_mask = is_coord & (atom1_res == atom2_res) + if not np.any(remap_mask): + return structure - Returns: - - polymer_bonds (np.array[[int, int, struc.BondType]]): List of tuples containing (atom1_idx, atom2_idx, - bond_type) for each inferred polymer bond. - - leaving_atom_idxs (np.ndarray): Array of atom indices that represent leaving groups displaced during bond - formation. + structure = structure.copy() + new_bonds = structure.bonds.as_array() + new_bonds[remap_mask, 2] = BondType.SINGLE + structure.bonds = struc.BondList(structure.array_length(), new_bonds) - Example: - >>> # Create an atom array with two consecutive peptide residues - >>> atom_array = AtomArray(length=10) - >>> atom_array.chain_id = ["A"] * 10 - >>> atom_array.res_id = [1] * 5 + [2] * 5 - >>> atom_array.res_name = ["ALA"] * 5 + ["GLY"] * 5 - >>> atom_array.atom_name = ["N", "CA", "C", "OXT", "CB"] + ["N", "CA", "C", "O", "H2"] - >>> # Get the polymer bonds - >>> bonds, leaving = get_inferred_polymer_bonds(atom_array) - >>> print(bonds) # Shows C-N peptide bond between residues - [(2, 5, )] # C of ALA to N of GLY - >>> print(leaving) # Shows leaving OXT from C and H2 from N (other hydrogen atom names not shown for simplicity) - [array([3]), array([9])] - """ - # ... initialize return values - bonds: list[tuple[int, int, struc.BondType]] = [] - leaving: list[np.ndarray] = [] - - # ... get annotations we need to work with - chain_ids = atom_array.chain_id - res_ids = atom_array.res_id - res_names = atom_array.res_name - atom_names = atom_array.atom_name - chain_types = get_annotation(atom_array, "chain_type", default=None) - is_polymer = get_annotation(atom_array, "is_polymer", default=np.zeros(atom_array.array_length(), dtype=bool)) - - # ... get iterators over the residues - residue_starts = get_residue_starts(atom_array, add_exclusive_stop=True) - this_res_starts = residue_starts[:-2] - next_res_starts = residue_starts[1:-1] - next_res_stops = residue_starts[2:] - - # ... loop over the residues and add the bonds - for this_res_start, next_res_start, next_res_stop in zip( - this_res_starts, next_res_starts, next_res_stops, strict=False - ): - # ... skip if residues are not on the same chain - if chain_ids[this_res_start] != chain_ids[next_res_start]: - continue + return structure - # ... and skip if residues don't have consecutive res_id's - # (NOTE: same res_id is allowed, if ins_code is different) - if res_ids[next_res_start] - res_ids[this_res_start] > 1: - continue - # ... get fallback default bonding atoms based on chain type - bonding_atoms = None - if chain_types is not None: - chain_type = ChainType.as_enum(chain_types[this_res_start]) - bonding_atoms = ChainTypeInfo.ATOMS_AT_POLYMER_BOND.get(chain_type, None) - - # ... get (more detailed) bonding atoms based on chem-comp types - this_link = get_chem_comp_type(res_names[this_res_start], mode="warn") - next_link = get_chem_comp_type(res_names[next_res_start], mode="warn") - - # ... decide which bonds to form: - both_aa = (this_link in AA_LIKE_CHEM_TYPES) and (next_link in AA_LIKE_CHEM_TYPES) - both_na = (this_link in NA_LIKE_CHEM_TYPES) and (next_link in NA_LIKE_CHEM_TYPES) - if (this_link in CHEM_TYPE_POLYMERIZATION_ATOMS) and (both_aa or both_na): - bonding_atoms = CHEM_TYPE_POLYMERIZATION_ATOMS[this_link] - - # ... add the bonds if we have bonding atoms - if bonding_atoms is not None: - # bonding_atoms: tuple[str, str] = (atom1_name, atom2_name) - atom1_name, atom2_name = bonding_atoms - - # ... get the atoms names within the current residues - this_res_atom_names = atom_names[this_res_start:next_res_start] - next_res_atom_names = atom_names[next_res_start:next_res_stop] - - # ... find the indices of the bonding atoms based on the atoms names - atom1_idx = np.where(this_res_atom_names == atom1_name)[0] - atom2_idx = np.where(next_res_atom_names == atom2_name)[0] - - if len(atom1_idx) == 0 or len(atom2_idx) == 0: - # ... bonding atoms are not found in the adjacent residues - # ... -> skip this bond - logger.info( - f"Bonding atoms {atom1_name} and {atom2_name} not found " - f"in the adjacent residues {this_res_start} and {next_res_start}!" - ) - continue - - # ... add the bond - bonds.append( - ( - this_res_start + atom1_idx[0], # ... add global atom idx offset - next_res_start + atom2_idx[0], # ... add global atom idx offset - struc.BondType.SINGLE, - ) - ) +def build_bond_dict_for_atom_array( + atom_array: AtomArray, + custom_bond_dict: dict[str, dict[tuple[str, str], int]] | None = None, + ccd_mirror_path: str | None = CCD_MIRROR_PATH, +) -> dict[str, dict[tuple[str, str], int]] | None: + """Build a complete bond dictionary for an AtomArray by merging CCD bonds with custom overrides. - # ... compute the leaving atoms - leaving_this_res = _get_leaving_atom_idxs_for( - atom_name=atom1_name, - res_name=res_names[this_res_start], - atom_names=this_res_atom_names, - offset=this_res_start, - ) - leaving_next_res = _get_leaving_atom_idxs_for( - atom_name=atom2_name, - res_name=res_names[next_res_start], - atom_names=next_res_atom_names, - offset=next_res_start, - ) - leaving.append(leaving_this_res) if len(leaving_this_res) > 0 else None - leaving.append(leaving_next_res) if len(leaving_next_res) > 0 else None + Fetches CCD bonds for **all** residues as the baseline, then overlays ``custom_bond_dict`` + on top. This ensures that atoms added later (e.g., H atoms via ``add_missing_atoms``) always + have bonds available from CCD, while custom connectivity still takes precedence for the pairs + it explicitly defines. - # ... optionally add `is_polymer` annotation to the atom array - is_polymer[this_res_start:next_res_stop] = True + Args: + atom_array: Structure containing residues to get bonds for. + custom_bond_dict: Optional custom bonds (e.g., from CIF ``chem_comp_bond``). + Maps residue names to ``{(atom1_name, atom2_name): bond_type_int}``. + These override the CCD entries for matching atom pairs. + ccd_mirror_path: Path to local CCD mirror. Defaults to ``CCD_MIRROR_PATH``. - if "is_polymer" not in atom_array.get_annotation_categories(): - # ... if polymer annotation was not present before, we set it here based on the inferred bonds - atom_array.set_annotation("is_polymer", is_polymer) + Returns: + Complete bond dictionary with CCD bonds as baseline and custom bonds as overrides, + or ``None`` if no bonds could be found for any residue. - return np.array(bonds).reshape(-1, 3), np.concatenate(leaving) if len(leaving) > 0 else np.array([], dtype=int) + Example: + >>> # Custom bonds for VER override CCD heavy-atom pairs; CCD still provides H bonds + >>> custom_bonds = {"VER": {("C1", "C2"): 1, ("C2", "O1"): 2}} + >>> bond_dict = build_bond_dict_for_atom_array(atom_array, custom_bond_dict=custom_bonds, ccd_mirror_path="/path/to/ccd") + >>> # bond_dict contains VER bonds (CCD H-bonds + custom heavy-atom overrides) + ALA, GLY, etc. (CCD) + """ + # Get unique residue names from atom_array + unique_res_names = np.unique(atom_array.res_name) + # Fetch CCD bonds for ALL residues... + result_dict: dict[str, dict[tuple[str, str], int]] = {} + for res_name in unique_res_names: + try: + template = atom_array_from_ccd_code(res_name, ccd_mirror_path, coords=None) + bond_dict = _bonds_to_dict(template) + if bond_dict is not None: + result_dict[res_name] = bond_dict + except (ValueError, KeyError, AttributeError): + # Residue not found in CCD or malformed CCD entry (e.g., ions without atom data), skip + continue -def get_struct_conn_dict_from_atom_array( - atom_array: AtomArray, -) -> dict[str, np.ndarray]: - """Returns a struct_conn dictionary corresponding to a given AtomArray. + # ... and override with custom bonds where provided + if custom_bond_dict is not None: + for res_name, bonds in custom_bond_dict.items(): + # Normalize custom bond keys to sorted tuples to match _bonds_to_dict's convention. + normalized = {tuple(sorted(k)): v for k, v in bonds.items()} + if res_name in result_dict: + result_dict[res_name].update(normalized) + else: + result_dict[res_name] = normalized - This contains the keys used in `get_struct_conn_bonds`. - NOTE: These AtomArray-derived struct_conn_dicts will never contain disulfide or hydrogen bonds, - as Biotite does not distinguish these in the BondList. Possible types are "covale" and "metalc". + return result_dict if result_dict else None - Args: - atom_array (AtomArray): The atom array to get the struct_conn dictionary from. - Returns: - dict[str, np.ndarray]: The struct_conn dictionary. - """ +# +---- Adapted from biotite.structure.io.pdbx.convert ----+ +_FIND_MATCHES_SWITCH_THRESHOLD = 4_000_000 - struct_conn_dict = {} +# atom_site field names used to match struct_conn bond partners against atoms +_STRUCT_CONN_MATCH_FIELDS = ( + "label_asym_id", + "label_comp_id", + "label_seq_id", # special: "." → auth_seq_id (non-polymer residues) + "label_atom_id", # atom name + "pdbx_label_alt_id", # special: "."/"?" → "" (optional, skipped if not on atom array) +) - for res_array in struc.residue_iter(atom_array): - if len(np.unique(res_array.atom_name)) != len(res_array.atom_name): - raise ValueError( - "Duplicate atom names detected in the same residue -- cannot infer struct_conn. " - "This may happen when a non-polymer is loaded from a CIF file without using `atomworks.io.parser.parse`. " - ) - # Keep only inter-residue bonds - bond_array = _filter_bonds(atom_array, "inter") - if len(bond_array) == 0: - return struct_conn_dict +def _get_struct_conn_col_name(col_name: str, partner: int) -> str: + """Translate an atom_site column name to the struct_conn partner column name.""" + if col_name.startswith("pdbx_"): + return f"pdbx_ptnr{partner}_{col_name[5:]}" + return f"ptnr{partner}_{col_name}" - # Filter out 'standard' links, i.e. backbone bonds between adjacent canonical - # nucleotide/amino acid residues - bond_array = bond_array[~_filter_canonical_links(atom_array, bond_array)] - if len(bond_array) == 0: - return struct_conn_dict - use_iids = False # By default, we use chain_ids to determine bonds - has_chain_iids = "chain_iid" in atom_array.get_annotation_categories() +def _find_matches(query_arrays: list[np.ndarray], reference_arrays: list[np.ndarray]) -> np.ndarray: + """Return the reference index matching each query row, or -1 if not found.""" + if query_arrays[0].shape[0] * reference_arrays[0].shape[0] <= _FIND_MATCHES_SWITCH_THRESHOLD: + return _find_matches_by_dense_array(query_arrays, reference_arrays) + return _find_matches_by_dict(query_arrays, reference_arrays) - # Determine whether we need to fall back to using chain_iids - if has_ambiguous_annotation_set(atom_array): - if not has_chain_iids: - raise ValueError( - "Ambiguous bond annotations detected. This happens when there are atoms that " - "have the same `(chain_id, res_id, res_name, atom_id, ins_code)` identifier. " - "This happens for example when you have a bio-assembly with multiple copies " - "of a chain that only differ by `transformation_id`.\n" - "You can fix this for example by re-naming the chains to be named uniquely. " - "For the purposes of this function, you can also add a unambiguous chain_iid annotation instead. " - ) - elif has_ambiguous_annotation_set( - atom_array, annotation_set=["chain_iid", "res_id", "res_name", "atom_name", "ins_code"] - ): - raise ValueError( - "Ambiguous bond annotations detected. This happens when there are atoms that " - "have the same `(chain_id, res_id, res_name, atom_id, ins_code)` identifier. " - "This happens for example when you have a bio-assembly with multiple copies " - "of a chain that only differ by `transformation_id`.\n" - "In this case, falling back to the `chain_iid` annotation was insufficient to resolve the ambiguity." - "You can fix this for example by re-naming the chains to be named uniquely. " - "For the purposes of this function, you can also add a unambiguous chain_iid annotation instead. " - ) - else: - use_iids = True - - # Add the bond type information - struct_conn_dict["conn_type_id"] = np.array([PDBX_BOND_TYPE_TO_TYPE_ID[btype] for btype in bond_array[:, 2]]) - - label_asym_id_field = "chain_iid" if use_iids else "chain_id" - cif_field_to_annot = { - "label_asym_id": label_asym_id_field, - "label_comp_id": "res_name", - "label_seq_id": "res_id", - "label_atom_id": "atom_name", - "pdbx_PDB_ins_code": "ins_code", - } - for col_name, annot_name in cif_field_to_annot.items(): - annot = atom_array.get_annotation(annot_name) - # ...for each bond partner - for i in range(2): - atom_indices = bond_array[:, i] - struct_conn_dict[_get_struct_conn_col_name(col_name, i + 1)] = annot[atom_indices].astype(str) +def _find_matches_by_dense_array(query_arrays: list[np.ndarray], reference_arrays: list[np.ndarray]) -> np.ndarray: + """Pure-numpy matching via broadcasting; last match wins (handles alt-locs).""" + masks = np.stack( + [q[:, np.newaxis] == r[np.newaxis, :] for q, r in zip(query_arrays, reference_arrays, strict=False)], + axis=-1, + ) + result = np.full(len(query_arrays[0]), -1, dtype=int) + query_matches, ref_matches = np.where(np.all(masks, axis=-1)) + result[query_matches] = ref_matches # last match wins for duplicates (alt-locs) + return result + - return struct_conn_dict +def _find_matches_by_dict(query_arrays: list[np.ndarray], reference_arrays: list[np.ndarray]) -> np.ndarray: + """Dict-based matching for large structures; last match wins (handles alt-locs).""" + ref_dict = {row: idx for idx, row in enumerate(zip(*reference_arrays, strict=False))} + return np.array([ref_dict.get(tuple(q), -1) for q in zip(*query_arrays, strict=False)]) def get_struct_conn_bonds( atom_array: AtomArray, struct_conn_dict: dict[str, np.ndarray], - add_bond_types: list[str] = ["covale"], + add_bond_types: tuple[str, ...] = ("covale",), raise_on_failure: bool = False, -) -> tuple[np.ndarray, np.ndarray]: - """ - Adds bonds from the 'struct_conn' category of a CIF block to an atom array. Only covalent bonds are considered. + distance_policy: LongBondPolicyType = "filter", + *, + allow_missing_templates: bool = False, +) -> struc.BondList: + """Find inter-residue bonds from the CIF ``struct_conn`` category. + + Modified from biotite's internal ``_get_struct_conn_bonds``. Args: - atom_array (AtomArray): The atom array used to get atom indices. - struct_conn_dict (dict[str, np.ndarray]): The struct_conn category of a CIF block as a dictionary. - E.g. (Only mandatory fields, as defined by the RCSB, are shown) - ``` - { - 'conn_type_id': array(['disulf', ...]), - 'ptnr1_label_asym_id': array(['A', ...]), - 'ptnr1_label_comp_id': array(['CYS', ...]), - 'ptnr1_label_seq_id': array(['6', ...]), - 'ptnr1_label_atom_id': array(['SG', ...]), - 'ptnr1_symmetry': array(['1_555', ...]), - 'ptnr2_label_asym_id': array(['A', ...]), - 'ptnr2_label_comp_id': array(['CYS', ...]), - 'ptnr2_label_seq_id': array(['127', ...]), - 'ptnr2_label_atom_id': array(['SG', ...]), - 'ptnr2_symmetry': array(['1_555', ...]), - } - ``` - However, in this function, we only require the following fields: - - conn_type_id (e.g., "covale") - - ptnr1_label_asym_id (chain_id or chain_iid, e.g., "A" or "A_1") - - ptnr1_label_comp_id (residue name in the CCD, e.g., "CYS") - - ptnr1_label_seq_id (residue ID, e.g., "6") - - ptnr1_label_atom_id (atom name, e.g., "SG") - - ptnr2_label_asym_id - - ptnr2_label_comp_id - - ptnr2_label_seq_id - - ptnr2_label_atom_id - - add_bond_types (list[str]): A list of bond types that should be added. Valid bond types - are: ["covale", "disulf", "metalc", "hydrog"]. Defaults to ["covale"], which is - the use-case in structure-prediction, where we would a-priori know covalent bonds - (except for disulfides). - raise_on_failure(bool): If True, raise an error if specified bonds cannot be made (e.g., - if the atoms are missing). Defaults to False. - - NOTE: While chain_iid annotations are allowed, a given bond is expected to contain only one annotation type, - i.e. both chain_id or both chain_iid + atom_array: The atom array used to look up atom indices. + struct_conn_dict: The ``struct_conn`` category of a CIF block as a dict of numpy arrays. + Required keys: ``conn_type_id``, ``ptnr{1,2}_label_asym_id``, + ``ptnr{1,2}_label_comp_id``, ``ptnr{1,2}_label_seq_id``, + ``ptnr{1,2}_label_atom_id``. + add_bond_types: Bond type IDs to include. Valid values are ``"covale"``, ``"disulf"``, + and ``"metalc"``. Defaults to ``["covale"]``. + raise_on_failure: If ``True``, raise on missing atoms or residues. Defaults to ``False``. + distance_policy: How to handle implausibly long bonds. Explicit glycosylation links and + covalent links between CCD-recognized carbohydrates are warned above 1.7 A but kept through 2.4 A. + allow_missing_templates: Preserve an unknown bond order when a CCD template is unavailable. Returns: - bonds (np.array[[int, int, struc.BondType]]): A List of bonds to be added to the atom array. - leaving (np.ndarray): An array of indices of atoms that are leaving groups for bookkeeping. + A :py:class:`biotite.structure.BondList` ready to merge into the atom array's bond list. Reference: `struct_conn.conn_type_id `_ """ - - def match_or_wildcard(array: np.ndarray, value: str) -> np.ndarray: - if value == "*": - return np.ones_like(array, dtype=bool) - return array == value - - # ... validate input invalid_bond_types = set(add_bond_types) - STRUCT_CONN_BOND_TYPES - if len(invalid_bond_types) > 0: + if invalid_bond_types: raise ValueError( f"Invalid bond type(s) provided: {invalid_bond_types}! Valid bond types are: {STRUCT_CONN_BOND_TYPES}" ) - if len(struct_conn_dict) == 0: - return np.empty((0, 3), dtype=int), np.empty((0,), dtype=int) - - # ... convert struct_conn_dict to a DataFrame - struct_conn_df = pd.DataFrame(struct_conn_dict) - struct_conn_df = struct_conn_df[struct_conn_df["conn_type_id"].isin(add_bond_types)] - if struct_conn_df.empty: - # ... skip if no bonds to add - return np.empty((0, 3), dtype=int), np.empty((0,), dtype=int) - logger.debug(f"Attempting to add {len(struct_conn_df)} bonds from `struct_conn`") - - # ... extract relevant annotations - chain_ids = atom_array.chain_id - res_names = atom_array.res_name - res_ids = atom_array.res_id - ins_codes = atom_array.ins_code - atom_names = atom_array.atom_name - is_polymer = atom_array.is_polymer - global_atom_idx = np.arange(atom_array.array_length()) - alt_atom_ids = get_annotation(atom_array, "alt_atom_id", default=atom_names) - uses_alt_atom_id = get_annotation(atom_array, "uses_alt_atom_id", default=np.zeros(len(atom_array), dtype=bool)) - all_res_names = np.append(np.unique(res_names), "*") - all_chain_ids = np.unique(chain_ids) - polymer_chain_ids = np.unique(chain_ids[is_polymer]) + n_atoms = atom_array.array_length() - # Get iid-level annotations if present - if "chain_iid" in atom_array.get_annotation_categories(): - chain_iids = atom_array.chain_iid - all_chain_iids = np.unique(chain_iids) - polymer_chain_iids = np.unique(chain_iids[is_polymer]) - else: - chain_iids = None - all_chain_iids = None - polymer_chain_iids = None - - # ... initialize return values - bonds: list[tuple[int, int, struc.BondType]] = [] - leaving: list[np.ndarray] = [] - - for _, row in struct_conn_df.iterrows(): - res_name1 = str(row["ptnr1_label_comp_id"]) - res_name2 = str(row["ptnr2_label_comp_id"]) - if (res_name1 not in all_res_names) or (res_name2 not in all_res_names): - # ... skip if the residues were removed from the structure - if raise_on_failure: - raise ValueError(f"Residue {res_name1} or {res_name2} not found in the atom array!") - continue + if not struct_conn_dict: + return struc.BondList(n_atoms) - chain_id1 = row["ptnr1_label_asym_id"] - chain_id2 = row["ptnr2_label_asym_id"] + # Filter rows by requested bond types + conn_types = struct_conn_dict["conn_type_id"] + row_mask = np.isin(conn_types, add_bond_types) - # Default to using id-level identifiers - relevant_chain_identifiers = chain_ids - relevant_polymer_chain_identifiers = polymer_chain_ids + # Filter out bonds involving crystal-symmetry mates (identity symmetry = "1_555") + _IDENTITY = "1_555" # noqa: N806 + if "ptnr1_symmetry" in struct_conn_dict: + row_mask &= struct_conn_dict["ptnr1_symmetry"].astype(str) == _IDENTITY + if "ptnr2_symmetry" in struct_conn_dict: + row_mask &= struct_conn_dict["ptnr2_symmetry"].astype(str) == _IDENTITY - # If iid-level identifiers are present, use these as a fallback - if (chain_id1 not in all_chain_ids) or (chain_id2 not in all_chain_ids): - if (chain_iids is not None) and (chain_id1 in all_chain_iids) and (chain_id2 in all_chain_iids): - relevant_chain_identifiers = chain_iids - relevant_polymer_chain_identifiers = polymer_chain_iids - else: - # ... skip, but warn if the chains are not present in the structure - logger.info( - f"Found covalent bond involving chains {chain_id1} and {chain_id2}, but at least one " - "chain was removed during cleaning. This is likely because the chain is made up of a " - "residue that is not in the local CCD. This should automatically be resolved once you " - "update your CCD, unless you are working with an outdated structure file." - ) - if raise_on_failure: - raise ValueError(f"Chain {chain_id1} or {chain_id2} not found in the atom array!") - continue - - # For non-polymers, we use the auth_seq_id if available and valid (i.e., not "." or "?"); otherwise we use the label_seq_id - # (Required to avoid ambiguity, since if using `label` only we may have multiple residue within a - # chain with the same label_seq_id and the same res_name; see: 6MUB) - - res_id1 = int( - row["ptnr1_label_seq_id"] - if ((chain_id1 in relevant_polymer_chain_identifiers) or ("ptnr1_auth_seq_id" not in row)) - and row["ptnr1_label_seq_id"] != "." - else row["ptnr1_auth_seq_id"] - ) - res_id2 = int( - row["ptnr2_label_seq_id"] - if ((chain_id2 in relevant_polymer_chain_identifiers) or ("ptnr2_auth_seq_id" not in row)) - and row["ptnr2_label_seq_id"] != "." - else row["ptnr2_auth_seq_id"] - ) + if not row_mask.any(): + # No matches + return struc.BondList(n_atoms) - ins_code1 = row.get("pdbx_ptnr1_PDB_ins_code", "") - ins_code2 = row.get("pdbx_ptnr2_PDB_ins_code", "") - ins_code1 = "" if ins_code1 in (".", "?") else ins_code1 - ins_code2 = "" if ins_code2 in (".", "?") else ins_code2 - - # ... get masks for the residues to which atoms 1 & 2 belong - in_res1 = ( - (relevant_chain_identifiers == chain_id1) - & (res_ids == res_id1) - & match_or_wildcard(res_names, res_name1) - & (ins_codes == ins_code1) - ) - in_res2 = ( - (relevant_chain_identifiers == chain_id2) - & (res_ids == res_id2) - & match_or_wildcard(res_names, res_name2) - & (ins_codes == ins_code2) - ) + filtered = {k: v[row_mask] for k, v in struct_conn_dict.items()} + n_rows = int(row_mask.sum()) + logger.debug(f"Attempting to add {n_rows} bonds from `struct_conn`") - if (not in_res1.any()) or (not in_res2.any()): - logger.info( - f"Residue {chain_id1}/{res_id1}/{res_name1} or {chain_id2}/{res_id2}/{res_name2} " - "not found in the atom array!" - ) - if raise_on_failure: - raise ValueError( - f"Residue {chain_id1}/{res_id1}/{res_name1} or {chain_id2}/{res_id2}/{res_name2} " - "not found in the atom array!" - ) - continue + # --- Extract per-row struct_conn fields --- + def _get_field(name: str, default: str = "") -> np.ndarray: + return filtered[name] if name in filtered else np.full(n_rows, default) - in_res1_start = global_atom_idx[in_res1][0] - in_res2_start = global_atom_idx[in_res2][0] - - # Ensure that the we picked the correct residue (to handle sequence heterogeneity; see PDB ID `3nez` for an example) - # (short circuit eval to avoid indexing errors in cases where we don't have one of the residues due to seq. heterogeneity - # - e.g. 3nez) - if ( - (in_res1.sum() == 0) - or (in_res2.sum() == 0) - or (res_name1 != res_names[in_res1_start] if res_name1 != "*" else False) - or (res_name2 != res_names[in_res2_start] if res_name2 != "*" else False) - ): - logger.info( - f"Covalent bond involving residues {chain_id1}/{res_id1}/{res_name1} and " - f"{chain_id2}/{res_id2}/{res_name2} was found in `struct_conn`, but the " - f"residues are not present in the atom array. This is likely due to " - f"resolved sequence heterogeneity which removed one of the residues." - ) - if raise_on_failure: - raise ValueError( - f"Residue {chain_id1}/{res_id1}/{res_name1} or {chain_id2}/{res_id2}/{res_name2} " - "not found in the atom array!" - ) - continue + # --- Build reference arrays (one entry per atom) --- + atom_names = atom_array.atom_name + alt_atom_ids = get_annotation(atom_array, "alt_atom_id", default=atom_names) + uses_alt_atom_id = get_annotation(atom_array, "uses_alt_atom_id", default=np.zeros(n_atoms, dtype=bool)) + eff_atom_names = np.where(uses_alt_atom_id, alt_atom_ids, atom_names) + + p1_transformation_id = filtered.get("ptnr1_transformation_id") + p2_transformation_id = filtered.get("ptnr2_transformation_id") + has_transformation = "transformation_id" in atom_array.get_annotation_categories() + use_transformation_in_key = ( + has_transformation and p1_transformation_id is not None and p2_transformation_id is not None + ) - # If all residues are present, we can proceed with identifying the global indices of the - # atoms in the bond and add the bond - # ... get the indices of the atoms and append to the list - atom_name1 = row["ptnr1_label_atom_id"] - atom_name2 = row["ptnr2_label_atom_id"] + # --- Build reference values keyed by field name --- + # (built once, reused for both partners) + ref_vals: dict[str, np.ndarray] = { + "label_asym_id": atom_array.chain_id, + "label_comp_id": atom_array.res_name, + "label_seq_id": atom_array.res_id.astype(str), + "label_atom_id": eff_atom_names, + } - # ... skip, but warn if the atoms (either the standard are not present in the atom array - all_names = np.concatenate((atom_names, alt_atom_ids)) - if (atom_name1 not in all_names) or (atom_name2 not in all_names): + # Optional: alt-conf matching (only if label_alt_id is on the atom array AND + # struct_conn actually contains the pdbx_ptnr{N}_label_alt_id columns + if "label_alt_id" in atom_array.get_annotation_categories() and ( + "pdbx_ptnr1_label_alt_id" in filtered or "pdbx_ptnr2_label_alt_id" in filtered + ): + alt_ids_norm = atom_array.label_alt_id.copy() + alt_ids_norm[np.isin(alt_ids_norm, (".", "?", " "))] = "" + ref_vals["pdbx_label_alt_id"] = alt_ids_norm + + # --- Build per-partner query arrays and match --- + atom_indices: list[np.ndarray] = [] + for partner in (1, 2): + q_arrays: list[np.ndarray] = [] + r_arrays: list[np.ndarray] = [] + + for field in _STRUCT_CONN_MATCH_FIELDS: + if field not in ref_vals: + continue # optional field (e.g., pdbx_label_alt_id) not on atom array + sc_col = _get_struct_conn_col_name(field, partner) + q = _get_field(sc_col) + if field == "label_seq_id": + auth_col = _get_struct_conn_col_name("auth_seq_id", partner) + q = np.where(q == ".", _get_field(auth_col), q) + elif field == "pdbx_label_alt_id": + q = np.where(np.isin(q, (".", "?", " ")), "", q) + q_arrays.append(q) + r_arrays.append(ref_vals[field]) + + # NOTE: We differ from Biotite by adding custom handling of pre-built transformations + if use_transformation_in_key: + tfm_col = f"ptnr{partner}_transformation_id" + q_arrays.append(filtered[tfm_col].astype(str)) + r_arrays.append(atom_array.transformation_id.astype(str)) + + atom_indices.append(_find_matches(q_arrays, r_arrays)) + idx1, idx2 = atom_indices + + # --- Filter missing bonds, log, and optionally raise --- + # kept only for error logging + chains = [filtered["ptnr1_label_asym_id"], filtered["ptnr2_label_asym_id"]] + label_seqs = [filtered["ptnr1_label_seq_id"], filtered["ptnr2_label_seq_id"]] + res_names_q = [filtered["ptnr1_label_comp_id"], filtered["ptnr2_label_comp_id"]] + atom_ids = [filtered["ptnr1_label_atom_id"], filtered["ptnr2_label_atom_id"]] + + valid = (idx1 != -1) & (idx2 != -1) + if not valid.all(): + for i in np.where(~valid)[0]: logger.info( - f"Covalent bond involving atoms {atom_name1} and {atom_name2} was found in `struct_conn`, but the " - "atoms are not present in the residue's AtomArray!" + f"Covalent bond involving atoms {chains[0][i]}/{label_seqs[0][i]}/{res_names_q[0][i]}/{atom_ids[0][i]}" + f" or {chains[1][i]}/{label_seqs[1][i]}/{res_names_q[1][i]}/{atom_ids[1][i]}" + " not found in the atom array!" + ) + if raise_on_failure: + i = int(np.where(~valid)[0][0]) + raise ValueError( + f"Atom {atom_ids[0][i]} in residue {chains[0][i]}/{label_seqs[0][i]}/{res_names_q[0][i]}" + f" or atom {atom_ids[1][i]} in residue {chains[1][i]}/{label_seqs[1][i]}/{res_names_q[1][i]}" + " not found in the atom array!" ) - continue - - if uses_alt_atom_id[in_res1_start]: - atom1_local_idx = np.where(alt_atom_ids[in_res1] == atom_name1)[0][0] - else: - atom1_local_idx = np.where(atom_names[in_res1] == atom_name1)[0][0] - - if uses_alt_atom_id[in_res2_start]: - atom2_local_idx = np.where(alt_atom_ids[in_res2] == atom_name2)[0][0] - else: - atom2_local_idx = np.where(atom_names[in_res2] == atom_name2)[0][0] - # ... convert local atom indices to global indices - atom1_global_idx = in_res1_start + atom1_local_idx - atom2_global_idx = in_res2_start + atom2_local_idx + valid = filter_link_distances(atom_array, filtered, idx1, idx2, valid, distance_policy) - # ... add the bond - # Metal coordination bonds don't have a `pdbx_value_order`, so these are handled separately - if row["conn_type_id"] == "metalc": - bonds.append([atom1_global_idx, atom2_global_idx, struc.BondType.COORDINATION]) + # Assign bond types after atom matching and distance filtering; metal coordination has no covalent order. + # Preserve recognized explicit orders, infer missing/unknown orders from CCD chemistry, and reject other values. + pdbx_value_order = filtered.get("pdbx_value_order") + bond_types = np.empty(n_rows, dtype=int) + for i in np.flatnonzero(valid): + if filtered["conn_type_id"][i] == "metalc": + bond_types[i] = int(struc.BondType.COORDINATION) + continue + order = str(pdbx_value_order[i]) if pdbx_value_order is not None else "?" + if order in STRUCT_CONN_BOND_ORDER_TO_INT: + bond_types[i] = STRUCT_CONN_BOND_ORDER_TO_INT[order] + elif order in ("?", ".", ""): + bond_types[i] = infer_link_order( + atom_array, int(idx1[i]), int(idx2[i]), allow_missing_templates=allow_missing_templates + ) else: - bond_order = STRUCT_CONN_BOND_ORDER_TO_INT.get(row.get("pdbx_value_order"), 1) - bonds.append([atom1_global_idx, atom2_global_idx, struc.BondType(bond_order)]) - - # ... and identify the leaving atoms - leaving_res1 = _get_leaving_atom_idxs_for( - atom_name=atom_names[atom1_global_idx], - res_name=res_name1, - atom_names=atom_names[in_res1], - offset=in_res1_start, - ) - leaving_res2 = _get_leaving_atom_idxs_for( - atom_name=atom_names[atom2_global_idx], - res_name=res_name2, - atom_names=atom_names[in_res2], - offset=in_res2_start, - ) - leaving.append(leaving_res1) if len(leaving_res1) > 0 else None - leaving.append(leaving_res2) if len(leaving_res2) > 0 else None + raise ValueError(f"Unsupported struct_conn bond order: {order!r}") - return np.array(bonds).reshape(-1, 3), np.concatenate(leaving) if len(leaving) > 0 else np.array([], dtype=int) + bonds_array = ( + np.stack([idx1[valid], idx2[valid], bond_types[valid]], axis=1) if valid.any() else np.empty((0, 3), dtype=int) + ) + return struc.BondList(n_atoms, bonds_array) def get_coarse_graph_as_nodes_and_edges( - atom_array: AtomArray, annotations: str | tuple[str] + atom_array: AtomArray, + annotations: str | tuple[str], + exclude_bond_types: set[BondType] | None = None, ) -> tuple[np.ndarray, np.ndarray]: - """ - Returns the coarse-grained nodes and edges at the given annotation level based on the atom array's bond connectivity. + """Returns the coarse-grained nodes and edges at the given annotation level based on the atom array's bond connectivity. Args: - - atom_array (AtomArray): The atom array containing atomic information and bonds. - - annotations (str | tuple[str]): A single annotation or a tuple of annotations to be used for node + atom_array (AtomArray): The atom array containing atomic information and bonds. + annotations (str | tuple[str]): A single annotation or a tuple of annotations to be used for node identification. + exclude_bond_types (set | None): Bond types to exclude from connectivity. For example, + pass ``{BondType.COORDINATION}`` to prevent metal-ligand bonds from merging components. Returns: - - nodes (np.ndarray): An array of unique nodes, each represented by a combination of annotations. - - edges (np.ndarray): An array of edges, where each edge is a tuple of node indices representing a bond - between two nodes. + tuple[np.ndarray, np.ndarray]: Nodes and edges: + + - ``nodes``: An array of unique nodes, each represented by a combination of annotations. + - ``edges``: An array of edges, where each edge is a tuple of node indices representing a bond + between two nodes. Example: >>> atom_array = cached_parse("5ocm")["atom_array"] @@ -583,7 +399,11 @@ def get_coarse_graph_as_nodes_and_edges( """ annotations = [annotations] if isinstance(annotations, str) else annotations - atom1, atom2, _ = atom_array.bonds.as_array().T + bond_array = atom_array.bonds.as_array() + if exclude_bond_types: + mask = ~np.isin(bond_array[:, 2], [int(bt) for bt in exclude_bond_types]) + bond_array = bond_array[mask] + atom1, atom2 = bond_array[:, 0], bond_array[:, 1] if len(annotations) > 1: _annots = np.zeros( @@ -594,19 +414,20 @@ def get_coarse_graph_as_nodes_and_edges( else: _annots = atom_array.get_annotation(annotations[0]) # [n_atoms] - annot1 = _annots[atom1] # [n_bonds, n_annotations] - annot2 = _annots[atom2] # [n_bonds, n_annotations] + # Group atoms by annotation and assign sorted node indices. + nodes, atom_to_node = np.unique(_annots, axis=0, return_inverse=True) + if len(nodes) == 0: + return nodes, np.empty((0, 2), dtype=nodes.dtype) - nodes = np.unique(_annots, axis=0) # [n_nodes, n_annotations] - self_edges = np.vstack([nodes, nodes]).T # [n_nodes, 2] - edges = np.unique(np.vstack([self_edges, np.vstack([annot1, annot2]).T]), axis=0) # [n_edges, 2] + # Include a self-edge for every node, including isolated nodes. + node_indices = np.arange(len(nodes)) + self_edges = np.column_stack((node_indices, node_indices)) - # Map nodes to integers - node_to_idx = {to_hashable(node): i for i, node in enumerate(nodes)} - if len(edges) > 0: - edges = np.apply_along_axis( - lambda x: (node_to_idx[to_hashable(x[0])], node_to_idx[to_hashable(x[1])]), 1, edges - ) + # Map bonds between distinct nodes, then sort and deduplicate edges. + node1, node2 = atom_to_node[atom1], atom_to_node[atom2] + between_nodes = node1 != node2 + bond_edges = np.column_stack((node1[between_nodes], node2[between_nodes])) + edges = np.unique(np.concatenate((self_edges, bond_edges)), axis=0) return nodes, edges @@ -706,6 +527,7 @@ def _atom_array_to_networkx_graph( bond_type[bond_type == struc.BondType.AROMATIC_SINGLE] = 0 bond_type[bond_type == struc.BondType.AROMATIC_DOUBLE] = 0 bond_type[bond_type == struc.BondType.AROMATIC_TRIPLE] = 0 + nx.set_edge_attributes( bond_graph, {tuple(bond): type for bond, type in zip(bond_list, bond_type, strict=False)}, "bond_type" ) @@ -762,46 +584,67 @@ def hash_atom_array( def generate_inter_level_bond_hash( - atom_array: AtomArray, lower_level_id: str, lower_level_entity: str | None = None + atom_array: AtomArray, + lower_level_id: str, + lower_level_entity: str | None = None, + exclude_bond_types: set[BondType] | None = None, ) -> str: - """ - Generates a hash string representing the inter-level bonds within an AtomArray. + """Generates a hash string representing the inter-level bonds within an AtomArray. + When computing entities IDs, we must consider inter-level bonds at the atom- and residue-level to avoid ambiguity. Args: atom_array (AtomArray): The array of atoms containing bond and annotation information. lower_level_id (str): The level which to find, and hash, the inter-level bonds. For example, when computing molecule entities, we'd consider the inter-PN Unit bonds. - lower_level_entity (str } None): An additional entity annotation to use when computing the hash. Optional; if None, then only residue ID, residue name, and atom name are used. + lower_level_entity (str | None): An additional entity annotation to use when computing the hash. Optional; if None, then only residue ID, residue name, and atom name are used. Returns: str: A hash string representing the inter-level bonds. """ - # ...find the inter-level bonds + # ... find the inter-level bonds bond_a = atom_array.get_annotation(lower_level_id)[atom_array.bonds.as_array()[:, 0]] bond_b = atom_array.get_annotation(lower_level_id)[atom_array.bonds.as_array()[:, 1]] inter_level_bonds = atom_array.bonds.as_array()[bond_a != bond_b] + # Filter excluded bond types + if exclude_bond_types and inter_level_bonds.size: + mask = ~np.isin(inter_level_bonds[:, 2], [int(bt) for bt in exclude_bond_types]) + inter_level_bonds = inter_level_bonds[mask] + if inter_level_bonds.size: - # ...loop over the bonds and create a (sorted) list of tuples with the relevant information + # Extract annotations once (outside loop) for efficient indexing + res_ids = atom_array.res_id + res_names = atom_array.res_name + atom_names = atom_array.atom_name + + # Handle optional lower_level_entity annotation + if lower_level_entity is not None: + lower_level_entity_values = atom_array.get_annotation(lower_level_entity) + else: + lower_level_entity_values = None + + # ... loop over the bonds and create a (sorted) list of tuples with the relevant information bond_tuples = [] for atom_idx in range(inter_level_bonds.shape[0]): - atom_a = atom_array[inter_level_bonds[atom_idx, 0]] - atom_b = atom_array[inter_level_bonds[atom_idx, 1]] + # Use direct indexing instead of AtomArray slicing + idx_a = inter_level_bonds[atom_idx, 0] + idx_b = inter_level_bonds[atom_idx, 1] + bond_tuples.append( tuple( sorted( [ ( - getattr(atom_a, lower_level_entity) if lower_level_entity else None, - atom_a.res_id, - atom_a.res_name, - atom_a.atom_name, + lower_level_entity_values[idx_a] if lower_level_entity_values is not None else None, + res_ids[idx_a], + res_names[idx_a], + atom_names[idx_a], ), ( - getattr(atom_b, lower_level_entity) if lower_level_entity else None, - atom_b.res_id, - atom_b.res_name, - atom_b.atom_name, + lower_level_entity_values[idx_b] if lower_level_entity_values is not None else None, + res_ids[idx_b], + res_names[idx_b], + atom_names[idx_b], ), ] ) @@ -814,200 +657,157 @@ def generate_inter_level_bond_hash( return "" -def spoof_struct_conn_dict_from_string(bonds: list[tuple[str, str]]) -> dict[str, list[str]]: - """Spoof a struct_conn_dict from a list of bond strings. - - NOTE: For SMILES, atoms are named with their element type and the order that they - appear in the SMILES string. For example, the first carbon atom in a SMILES string - would be named "C1", the second "C2", and so on. - - NOTE: We only support covalent bonds. - - TODO: Use AtomSelection to parse the bond strings +def filter_bonds_by_distance( + atom_array: AtomArray, + policy: LongBondPolicyType = "filter", + chno_threshold: float = BOND_DISTANCE_THRESHOLD_CHNO, + chnops_threshold: float = BOND_DISTANCE_THRESHOLD_CHNOPS, + other_threshold: float = BOND_DISTANCE_THRESHOLD_OTHER, +) -> AtomArray: + """Handle unphysical bonds based on element-dependent distance thresholds. + + The thresholds are: + 1. Bonds involving ONLY C, H, N, O: flagged if distance > chno_threshold + 2. Bonds involving ONLY C, H, N, O, P, S: flagged if distance > chnops_threshold + 3. Any other bonds (metals, etc.): flagged if distance > other_threshold Args: - bonds (list[tuple[str, str]]): A list of bond strings. - Each bond string should be in the format: - "CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME, CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME" - - In PyMol, you can hover over an atom to display the relevant information. - For example, clicking a CA atom in PyMol prints in the console: - ``` - >>> You clicked /6wtf/A/A/ALA`294/CA - ``` - We could then copy this information to specify an atom, "A/ALA/294/CA" + atom_array: The AtomArray containing atoms and bonds to check. + policy: How to handle long bonds. Options: + + - ``"keep"``: No checking, return atom_array unchanged. + - ``"filter"``: Remove all bonds exceeding thresholds. + - ``"warn"``: Log warning about all long bonds but keep them. + - ``"raise"``: Raise ``ValueError`` if any long bonds detected. + - ``"filter_nonstandard_only"``: Remove long bonds only in non-standard residues. + Bonds where both atoms are in standard AA (20 canonical + UNK), RNA (A, C, G, U + N), + or DNA (DA, DC, DG, DT + DN) are preserved. + - ``"warn_nonstandard_only"``: Warn about long bonds in non-standard residues only. + - ``"raise_nonstandard_only"``: Raise error if long bonds in non-standard residues. + + Defaults to ``"filter"``. + chno_threshold: Maximum distance for bonds between only C, H, N, O atoms. + Defaults to 1.8 Angstroms. + chnops_threshold: Maximum distance for bonds between only C, H, N, O, P, S atoms. + Defaults to 2.4 Angstroms. + other_threshold: Maximum distance for bonds involving any other elements. + Defaults to 3.6 Angstroms. Returns: - dict[str, list[str]]: A dictionary in struct_conn format. + The input AtomArray, potentially with long bonds removed (if ``policy="filter"``). - Example: - ``` - >>> bonds = [ - ... ("A/THR/4/CG", "D/UNL/1/C5"), - ... ("A/CYS/5/SG", "A/CYS/137/SG") - ... ] - >>> struct_conn_dict = spoof_struct_conn_dict_from_string(bonds) - >>> print(struct_conn_dict) - { - 'conn_type_id': ['covale', 'covale'], - 'ptnr1_label_asym_id': ['A', 'A'], - 'ptnr1_label_comp_id': ['THR', 'CYS'], - 'ptnr1_label_seq_id': ['4', '5'], - 'ptnr1_label_atom_id': ['CG', 'SG'], - 'ptnr2_label_asym_id': ['D', 'A'], - 'ptnr2_label_comp_id': ['UNL', 'CYS'], - 'ptnr2_label_seq_id': ['1', '137'], - 'ptnr2_label_atom_id': ['C5', 'SG'], - } - ``` + Raises: + ValueError: If ``policy="raise"`` and long bonds are detected. + Note: + Bonds involving atoms with NaN coordinates are preserved (not flagged). """ - struct_conn_dict = { - "conn_type_id": [], - "ptnr1_label_asym_id": [], - "ptnr1_label_comp_id": [], - "ptnr1_label_seq_id": [], - "ptnr1_label_atom_id": [], - "ptnr2_label_asym_id": [], - "ptnr2_label_comp_id": [], - "ptnr2_label_seq_id": [], - "ptnr2_label_atom_id": [], - } - - for bond in bonds: - try: - # Split the bond string into two parts - ptnr1, ptnr2 = bond - - # Parse the first partner - ptnr1_chain_id, ptnr1_res_name, ptnr1_res_id, ptnr1_atom_name = ptnr1.split("/") - struct_conn_dict["ptnr1_label_asym_id"].append(ptnr1_chain_id) - struct_conn_dict["ptnr1_label_comp_id"].append(ptnr1_res_name) - struct_conn_dict["ptnr1_label_seq_id"].append(ptnr1_res_id) - struct_conn_dict["ptnr1_label_atom_id"].append(ptnr1_atom_name) - - # Parse the second partner - ptnr2_chain_id, ptnr2_res_name, ptnr2_res_id, ptnr2_atom_name = ptnr2.split("/") - struct_conn_dict["ptnr2_label_asym_id"].append(ptnr2_chain_id) - struct_conn_dict["ptnr2_label_comp_id"].append(ptnr2_res_name) - struct_conn_dict["ptnr2_label_seq_id"].append(ptnr2_res_id) - struct_conn_dict["ptnr2_label_atom_id"].append(ptnr2_atom_name) - - # Assuming all bonds are covalent for simplicity; adjust as needed - struct_conn_dict["conn_type_id"].append("covale") - - except ValueError as e: - raise ValueError(f"Error parsing bond string '{bond}': {e}") from None + if policy == "keep": + return atom_array - return struct_conn_dict + if atom_array.bonds is None or len(atom_array.bonds.as_array()) == 0: + return atom_array + bonds_arr = atom_array.bonds.as_array() + atom1_idxs = bonds_arr[:, 0] + atom2_idxs = bonds_arr[:, 1] -def _get_bond_degree_per_atom(atom_array: struc.AtomArray) -> np.ndarray: - """ - Returns the total degree (= sum of bond orders) for each atom. - """ - # Count both ends of each edge - edge_list = atom_array.bonds._bonds[:, :2] - weights = atom_array.bonds._bonds[:, -1].copy() + # Get coordinates and elements + coords = atom_array.coord + elements = atom_array.element - # ... remove aromaticity from the weights: - weights[weights == struc.BondType.AROMATIC_SINGLE] = 1 - weights[weights == struc.BondType.AROMATIC_DOUBLE] = 2 - weights[weights == struc.BondType.AROMATIC_TRIPLE] = 3 + # Compute distances + distances = np.linalg.norm(coords[atom1_idxs] - coords[atom2_idxs], axis=1) - degree = np.bincount(edge_list.ravel(), weights=np.repeat(weights, 2)) + # Check for NaN coordinates (unresolved atoms) - preserve these bonds + has_nan_coords = np.any(np.isnan(coords[atom1_idxs]), axis=1) | np.any(np.isnan(coords[atom2_idxs]), axis=1) - # ... pad in case of unbonded atoms - if len(degree) <= atom_array.array_length(): - degree = np.pad(degree, (0, atom_array.array_length() - len(degree))) + # Determine threshold for each bond based on elements + thresholds = _element_distance_thresholds( + elements[atom1_idxs], elements[atom2_idxs], chno_threshold, chnops_threshold, other_threshold + ) - return degree + # Identify bonds exceeding thresholds (excluding NaN coords) + exceeds_threshold = (distances > thresholds) & ~has_nan_coords + # Apply standard residue exemption for *_nonstandard_only policies + if policy.endswith("_nonstandard_only"): + # Check if both atoms in bond are in standard/unknown residues + res_names = atom_array.res_name + is_standard_1 = np.isin(res_names[atom1_idxs], STANDARD_AND_UNKNOWN_POLYMER_RESIDUES) + is_standard_2 = np.isin(res_names[atom2_idxs], STANDARD_AND_UNKNOWN_POLYMER_RESIDUES) + both_standard = is_standard_1 & is_standard_2 -def correct_formal_charges_for_specified_atoms(atom_array: struc.AtomArray, to_update: np.ndarray) -> struc.AtomArray: - """ - Fix formal charges for atoms in an AtomArray after forming bonds between CCD components. + # Exclude standard residue bonds from being flagged + exceeds_threshold = exceeds_threshold & ~both_standard - Args: - atom_array (AtomArray): The AtomArray to fix. - to_update (np.ndarray): A boolean mask of atoms whose formal charges should be fixed. - These are normally the atoms for which bonds were modified. + n_long_bonds = np.sum(exceeds_threshold) - Returns: - AtomArray: The AtomArray with fixed formal charges. - """ - # ... check that the AtomArray has hydrogens - if not np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS).any(): - logger.warning("Hydrogens not given. Cannot fix formal charges.") + if n_long_bonds == 0: return atom_array - # ... get valences (masked for elements with no default valence) - _invalid = -10 - default_valence = np.array([DEFAULT_VALENCE.get(elt, _invalid) for elt in atom_array.element[to_update]]) - - # ... compute total number of bonds per atom - degree = _get_bond_degree_per_atom(atom_array)[to_update] - - # ... compute formal charge - formal_charge = degree - default_valence - - # ... update the relevant entries - valid = default_valence != _invalid - - # ... convert local indices to global indices - global_idxs = np.arange(atom_array.array_length())[to_update] - atom_array.charge[global_idxs[valid]] = formal_charge[valid] - return atom_array + msg = ( + f"Found {n_long_bonds} bonds exceeding distance thresholds " + f"(CHNO: {chno_threshold}A, CHNOPS: {chnops_threshold}A, other: {other_threshold}A)" + ) + # Extract base policy action (filter, warn, or raise) + base_policy = policy.split("_")[0] if "_" in policy else policy -def correct_bond_types_for_nucleophilic_additions( - atom_array: struc.AtomArray, to_update: np.ndarray -) -> struc.AtomArray: - """ - Account for nucleophilic additions that result in carbons that violate the octet rule. + if base_policy == "raise": + raise ValueError(msg) + elif base_policy == "warn": + logger.warning(msg) + return atom_array + elif base_policy == "filter": + logger.warning(f"Filtering {n_long_bonds} long bonds. {msg}") + # Create new BondList with only valid bonds + valid_bonds = bonds_arr[~exceeds_threshold] + atom_array.bonds = struc.BondList(atom_array.array_length(), valid_bonds) + return atom_array + else: + raise ValueError( + f"Invalid policy: {policy}. Must be 'keep', 'filter', 'warn', 'raise', " + f"or one of their *_nonstandard_only variants." + ) - In some cases (see: 1TQH), there is no leaving group specified, since the bond is formed by a nucleophilic addition to a carbonyl carbon. - In this case, we should convert the C=O double bond to a C-O single bond. - Args: - atom_array (AtomArray): The AtomArray to fix. - to_update (np.ndarray): A boolean mask of atoms that are candidates for correction. +def add_bonds_from_struct_conn( + atom_array: AtomArray | AtomArrayStack, + cif_block: pdbx.CIFBlock, + add_bond_types_from_struct_conn: tuple[str, ...] = ("covale",), + struct_conn_distance_policy: LongBondPolicyType = "filter", + *, + allow_missing_templates: bool = False, +) -> AtomArray | AtomArrayStack: + """Convenience wrapper to add bonds to an AtomArray from a struct_conn CIF category. - Returns: - AtomArray: The AtomArray with fixed bond types. + See Also: + :py:func:`~atomworks.io.utils.bonds.get_struct_conn_bonds` - + For arguments and return types. """ - updated_carbon_mask = (atom_array.element == "C") & to_update - - if not updated_carbon_mask.any(): - # (Early exit) - return atom_array - - invalid_carbon_mask = (_get_bond_degree_per_atom(atom_array) > 4) & updated_carbon_mask + struct_conn_dict = category_to_dict(cif_block, "struct_conn") + + # For AtomArrayStack, bonds are shared across all frames, so we only need to + # extract bonds once using the first frame's annotations + reference_array = atom_array[0] if isinstance(atom_array, AtomArrayStack) else atom_array + + # Get bonds from struct_conn and merge with existing bonds + new_bonds = get_struct_conn_bonds( + reference_array, + struct_conn_dict=struct_conn_dict, + add_bond_types=add_bond_types_from_struct_conn, + distance_policy=struct_conn_distance_policy, + allow_missing_templates=allow_missing_templates, + ) + atom_array.bonds = atom_array.bonds.merge(new_bonds) - bonds_arr = atom_array.bonds.as_array() - for c_idx in np.where(invalid_carbon_mask)[0]: - mask = (bonds_arr[:, 0] == c_idx) | (bonds_arr[:, 1] == c_idx) - - # If any of the bonds are to a hyrogen, we skip - # (Handling hydrogens requires inferring leaving atoms, which is out-of-scope for this function) - if np.any(np.isin(atom_array.element[bonds_arr[mask, 0]], HYDROGEN_LIKE_SYMBOLS)) or np.any( - np.isin(atom_array.element[bonds_arr[mask, 1]], HYDROGEN_LIKE_SYMBOLS) - ): - continue + return atom_array - # Check if any of the bonds are double bonds to an oxygen - for bond_idx in np.where(mask)[0]: - atom1, atom2, bond_type = bonds_arr[bond_idx] - other_idx = atom2 if atom1 == c_idx else atom1 - - if atom_array.element[other_idx] == "O" and bond_type == struc.BondType.DOUBLE: - # Set the bond order to single and log a warning - atom_array.bonds.remove_bond(atom1, atom2) - atom_array.bonds.add_bond(atom1, atom2, struc.BondType.SINGLE) - logger.warning( - f"Corrected C=O double bond to single bond between atom {c_idx} (C) and {other_idx} (O) due to nucleophilic addition (degree > 4). " - f"chain_id: {atom_array.chain_id[c_idx]}, res_name: {atom_array.res_name[c_idx]}, res_id: {atom_array.res_id[c_idx]}, atom_name of invalid carbon: {atom_array.atom_name[c_idx]}, atom_name of oxygen: {atom_array.atom_name[other_idx]}" - ) - break - return atom_array +def get_inter_pn_unit_bond_mask(atom_array: AtomArray) -> np.ndarray: + """Return a mask indicating which bonds are between two distinct PN units.""" + bond_arr = atom_array.bonds.as_array() + bond_pn_unit_a = atom_array.pn_unit_iid[bond_arr[:, 0]] + bond_pn_unit_b = atom_array.pn_unit_iid[bond_arr[:, 1]] + return bond_pn_unit_a != bond_pn_unit_b diff --git a/src/atomworks/io/utils/catcif.py b/src/atomworks/io/utils/catcif.py new file mode 100644 index 00000000..a03c4c2f --- /dev/null +++ b/src/atomworks/io/utils/catcif.py @@ -0,0 +1,85 @@ +"""Utilities for reading and writing catcif archives.""" + +import io +import os +from pathlib import Path +from typing import Any + +from biotite.structure import AtomArray + + +def _resolve_catcif_path(path: os.PathLike) -> str: + """Obtain the CIF string pointed to by a catcif path.""" + import catcif_tools + + return catcif_tools.get_structure(str(path)) + + +def resolve_catcif_source( + source: os.PathLike | io.StringIO | io.BytesIO, +) -> tuple[io.StringIO | os.PathLike | io.BytesIO, str | None]: + """Resolve a catcif path to a StringIO and return the inferred file type. + + If ``source`` is a catcif path (contains ``.catcif:``), reads the embedded + CIF string and returns an ``(io.StringIO, 'cif')`` pair. Otherwise returns + ``(source, None)`` unchanged. + """ + if isinstance(source, str | Path) and ".catcif:" in str(source): + return io.StringIO(_resolve_catcif_path(source)), "cif" + return source, None + + +def to_catcif_file( + structure: AtomArray | list[AtomArray], + path: os.PathLike, + id: str, + *, + scores: dict[str, Any] | None = None, + compress: bool = False, + **kwargs, +) -> str: + """Write a structure into a catcif archive and return its catcif path. + + Args: + structure: The atomic structure (or list of structures) to write. + path: Path to the ``.catcif`` archive file. Must end with ``.catcif``. + id: Tag identifying the entry inside the archive. Must be non-empty. + scores: Optional score dictionary to embed alongside the structure. + compress: Whether to write the entry in compressed form. + **kwargs: Additional keyword arguments forwarded to + :py:class:`~atomworks.io.utils.io_utils.CIFWriteConfig` (e.g. ``save_standard_annotations``). + + Returns: + The ``path:id`` catcif path of the written entry. + """ + # as an optional dependency, catcif_tools is imported as needed + import catcif_tools + + # avoid circular import + from atomworks.io.utils.io_utils import CIFWriteConfig, _to_cif_or_bcif + + if not id: + raise ValueError("Structures in catcif files must have ids") + + # Turn any relative path into an absolute path + path = str(os.path.abspath(path)) + if not path.endswith(".catcif"): + raise ValueError(f"path passed to to_catcif_file() must end with .catcif: {path}") + + # catcif always stores CIF; reject any incompatible file_type + file_type = kwargs.pop("file_type", None) + if file_type is not None and file_type not in ("cif", "catcif"): + raise ValueError(f"to_catcif_file() only supports file_type 'cif' or 'catcif', got: {file_type!r}") + model_ids = kwargs.pop("model_ids", None) + + # Build config from parameters + config = CIFWriteConfig(id=id, **kwargs) + + # Generate the cif text + file_obj = _to_cif_or_bcif(structure, config, model_ids=model_ids) + buffer = io.StringIO() + file_obj.write(buffer) + + catcif_tools.append_to_catcif_file(path, buffer.getvalue(), id, scores=scores, compress=compress) + + return f"{path}:{id}" diff --git a/src/atomworks/io/utils/ccd.py b/src/atomworks/io/utils/ccd.py index a08de410..f849e04d 100644 --- a/src/atomworks/io/utils/ccd.py +++ b/src/atomworks/io/utils/ccd.py @@ -1,23 +1,28 @@ import functools import logging import os -from collections import defaultdict -from collections.abc import Iterable +import threading +from collections.abc import Callable, Generator, Iterable +from contextlib import contextmanager from pathlib import Path -from typing import Literal +from typing import Any, Literal import biotite.structure as struc import biotite.structure.io.pdbx as pdbx -import networkx as nx import numpy as np import toolz from atomworks.common import exists, immutable_lru_cache from atomworks.constants import ( AA_LIKE_CHEM_TYPES, + ALLOW_BIOTITE_CCD, CCD_MIRROR_PATH, + CCD_POLYMERIZATION_ATOM_OVERRIDES, + CHEM_TYPE_POLYMERIZATION_ATOMS, + DEFAULT_CCD_ANNOTATIONS, DNA_LIKE_CHEM_TYPES, - DO_NOT_MATCH_CCD, + HYDROGEN_LIKE_SYMBOLS, + MAX_CHEM_TYPE_LENGTH, NA_LIKE_CHEM_TYPES, RNA_LIKE_CHEM_TYPES, UNKNOWN_AA, @@ -26,10 +31,17 @@ UNKNOWN_RNA, ) from atomworks.enums import ChainType, ChainTypeInfo +from atomworks.io.utils.atom_array import _bonds_to_dict, annotate_hydrogens, remove_hydrogens logger = logging.getLogger(__name__) +# ===== CUSTOM CCD REGISTRY ===== +# Global thread-safe registry for custom CCD entries +_ccd_registry_lock = threading.RLock() +_ccd_registry: dict[str, struc.AtomArray] = {} + + @functools.cache def aa_chem_comps() -> frozenset[str]: """Set of amino acid chemical components. @@ -101,22 +113,9 @@ def chem_comp_to_one_letter() -> dict[str, str]: @functools.cache -def get_available_ccd_codes_in_mirror(ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> frozenset[str]: - """Set of all CCD codes available in the local mirror. - - Only counts codes when they adhere to the CCD mirror layout (e.g. .../H/HEM/HEM.cif) - - Args: - ccd_mirror_path: Path to the CCD mirror directory. - - Returns: - Set of all available CCD codes in the mirror. - - References: - `RCSB Chemical Component Dictionary `_ - `CCD Mirror Layout `_ - """ - root = os.fspath(ccd_mirror_path) +def _get_available_ccd_codes_in_mirror_cached(ccd_mirror_path_str: str) -> frozenset[str]: + """Internal cached implementation. Do not call directly.""" + root = ccd_mirror_path_str # Already normalized # Check if we have a pre-computed cache file cache_file = os.path.join(root, ".ccd_codes_cache") @@ -128,7 +127,7 @@ def get_available_ccd_codes_in_mirror(ccd_mirror_path: os.PathLike = CCD_MIRROR_ if cache_mtime > dir_mtime: with open(cache_file) as f: codes = {line.strip() for line in f if line.strip()} - return frozenset(codes) + return frozenset(codes) - {UNKNOWN_LIGAND} except OSError: # If cache is corrupted, fall back to scanning pass @@ -165,30 +164,100 @@ def get_available_ccd_codes_in_mirror(ccd_mirror_path: os.PathLike = CCD_MIRROR_ # If we can't write cache, that's okay pass - return frozenset(codes) + # Exclude UNL — it's a generic placeholder with no reliable CCD definition. + # Unknown polymer residues (UNK, N, DN) are kept: they have stable CCD templates. + return frozenset(codes) - {UNKNOWN_LIGAND} + + +def get_available_ccd_codes_in_mirror(ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> frozenset[str]: + """Set of all CCD codes available in the local mirror. + + Only counts codes when they adhere to the CCD mirror layout (e.g. .../H/HEM/HEM.cif) + + Args: + ccd_mirror_path: Path to the CCD mirror directory. + + Returns: + Set of all available CCD codes in the mirror. + + References: + `RCSB Chemical Component Dictionary `_ + `CCD Mirror Layout `_ + """ + # Normalize path and delegate to cached implementation + return _get_available_ccd_codes_in_mirror_cached(os.fspath(ccd_mirror_path)) @functools.cache def get_available_ccd_codes_in_biotite() -> frozenset[str]: """Set of all CCD codes available in Biotite's built-in Chemical Component Dictionary.""" - return frozenset(struc.info.ccd.get_ccd()["chem_comp"]["id"].as_array()) + + # Exclude UNL — it's a generic placeholder with no reliable CCD definition. + # Unknown polymer residues (UNK, N, DN) are kept: they have stable CCD templates. + return frozenset(struc.info.ccd.get_ccd()["chem_comp"]["id"].as_array()) - {UNKNOWN_LIGAND} @functools.cache +def _get_standard_ccd_codes_cached(ccd_mirror_path_str: str) -> frozenset[str]: + """Internal cached implementation. Do not call directly.""" + if ccd_mirror_path_str: + # Local mirror configured — use ONLY mirror codes + codes = get_available_ccd_codes_in_mirror(ccd_mirror_path_str) + elif ALLOW_BIOTITE_CCD: + # No local mirror — fall back to biotite if allowed + codes = get_available_ccd_codes_in_biotite() + else: + # No mirror, biotite not allowed + return frozenset() + + return codes + + +def get_standard_ccd_codes(ccd_mirror_path: os.PathLike | None = CCD_MIRROR_PATH) -> frozenset[str]: + """Returns standard CCD codes (from mirror + Biotite). Cached.""" + # Normalize path (use empty string for None) + cache_key = os.fspath(ccd_mirror_path) if ccd_mirror_path else "" + return _get_standard_ccd_codes_cached(cache_key) + + def get_available_ccd_codes(ccd_mirror_path: os.PathLike | None = CCD_MIRROR_PATH) -> frozenset[str]: - """Returns a frozenset of all CCD codes available. + """Returns all available CCD codes including registry entries.""" + # Get (cached) standard codes from mirror (+ Biotite if allowed) + standard_codes = get_standard_ccd_codes(ccd_mirror_path) + + # ... add custom registry codes + with _ccd_registry_lock: + registry_codes = frozenset(_ccd_registry.keys()) + + return standard_codes if registry_codes.issubset(standard_codes) else standard_codes | registry_codes + + +def _standard_ccd_only_cache(cache_decorator: Callable) -> Callable: + """Wrap a cache decorator to skip caching for non-standard or registry-overridden codes. - If a mirror path is provided, it will be used to check the local mirror first. - Otherwise, Biotite's built-in CCD will be used. + Registry codes are checked first and never cached, so an override of a real code is + never shadowed by a bundled template that was resolved (and cached) earlier. """ - mirror_codes = get_available_ccd_codes_in_mirror(ccd_mirror_path) if ccd_mirror_path else frozenset() - biotite_codes = get_available_ccd_codes_in_biotite() - return mirror_codes | biotite_codes + + def decorator(func: Callable) -> Callable: + cached = cache_decorator(func) + + @functools.wraps(func) + def wrapper(ccd_code: str, *args: Any, **kwargs: Any) -> Any: + code_upper = ccd_code.upper() + if code_upper in _ccd_registry or code_upper not in get_standard_ccd_codes(): + return func(ccd_code, *args, **kwargs) + return cached(ccd_code, *args, **kwargs) + + wrapper.cache_clear = getattr(cached, "cache_clear", None) + wrapper.cache_info = getattr(cached, "cache_info", None) + return wrapper + + return decorator def get_ccd_component_from_biotite(ccd_code: str, **parse_ccd_cif_kwargs) -> struc.AtomArray: - """ - Retrieves a component from the Chemical Component Dictionary using Biotite's built-in functionality. + """Retrieve an isolated copy of a cached component from Biotite's built-in CCD. Args: - ccd_code (str): The three-letter code of the chemical component to retrieve. @@ -196,6 +265,14 @@ def get_ccd_component_from_biotite(ccd_code: str, **parse_ccd_cif_kwargs) -> str Returns: - AtomArray: The atomic structure of the requested component. """ + if isinstance(parse_ccd_cif_kwargs.get("coords"), list): + parse_ccd_cif_kwargs["coords"] = tuple(parse_ccd_cif_kwargs["coords"]) + return _get_ccd_component_from_biotite_cached(ccd_code, **parse_ccd_cif_kwargs) + + +@immutable_lru_cache(maxsize=1000) +def _get_ccd_component_from_biotite_cached(ccd_code: str, **parse_ccd_cif_kwargs) -> struc.AtomArray: + """Parse bundled components once per option set; return a deep copy per lookup.""" try: block = _filter_biotite_ccd_for_ccd_code(ccd_code) atom_array = parse_ccd_cif(block, **parse_ccd_cif_kwargs) @@ -207,15 +284,14 @@ def get_ccd_component_from_biotite(ccd_code: str, **parse_ccd_cif_kwargs) -> str def check_ccd_codes_are_available( ccd_codes: Iterable[str], ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, mode: Literal["warn", "raise"] = "warn" ) -> bool: - """Checks if the provided CCD codes are available in the local mirror.""" + """Checks if the provided CCD codes are available in the current environment""" available_ccds = get_available_ccd_codes(ccd_mirror_path) invalid_ccds = set(ccd_codes) - available_ccds if invalid_ccds: - which_mirror = "Biotite's built-in CCD" if ccd_mirror_path is None else f"the local mirror at {ccd_mirror_path}" if mode == "warn": - logger.warning(f"The following CCD codes were not found in {which_mirror}: {invalid_ccds}") + logger.warning(f"The following CCD codes were not found in the current environment: {invalid_ccds}") elif mode == "raise": - raise ValueError(f"The following CCD codes were not found in {which_mirror}: {invalid_ccds}") + raise ValueError(f"The following CCD codes were not found in the current environment: {invalid_ccds}") return not bool(invalid_ccds) @@ -233,6 +309,7 @@ def _get_ccd_path(ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) return os.path.join(ccd_mirror_path, ccd_code[0], ccd_code, ccd_code + ".cif") +@functools.cache def _filter_biotite_ccd_for_ccd_code(ccd_code: str) -> pdbx.CIFBlock: """Filter the Biotite CCD for a given CCD code.""" if ccd_code not in get_available_ccd_codes_in_biotite(): @@ -261,6 +338,42 @@ def _filter_biotite_ccd_for_ccd_code(ccd_code: str) -> pdbx.CIFBlock: ) +def _build_ccd_template_atom_array( + comp_id: str, + chem_comp_type: str, + atom_names: np.ndarray, + elements: np.ndarray, + *, + charges: np.ndarray | None = None, + is_aromatic: np.ndarray | None = None, + is_leaving_atom: np.ndarray | None = None, + coords: np.ndarray | None = None, +) -> struc.AtomArray: + """Build an AtomArray with the core annotations shared across CCD template construction sites. + + Sets ``res_name``, ``atom_name``, ``element``, ``charge``, ``res_id``, + ``hetero``, and ``chem_comp_type``. Callers are responsible for setting + ``bonds`` and any additional CCD-specific annotations (e.g. ``stereo``). + """ + n = len(atom_names) + atoms = struc.AtomArray(n) + atoms.set_annotation("res_name", np.full(n, comp_id)) + atoms.set_annotation("atom_name", atom_names) + atoms.set_annotation("element", elements) + atoms.set_annotation("charge", charges if charges is not None else np.zeros(n, dtype=np.int8)) + atoms.set_annotation("res_id", np.full(n, 1)) + atoms.set_annotation("hetero", np.full(n, comp_id not in struc.info.atoms.NON_HETERO_RESIDUES)) + atoms.set_annotation("chem_comp_type", np.full(n, chem_comp_type, dtype=f" np.ndarray: """Get string field or return default.""" field = atom_data.get(field_name) - return field.as_array(str) if field is not None else np.full(n_atoms, default, dtype=str) + return field.as_array(str).copy() if field is not None else np.full(n_atoms, default, dtype=str) def _get_bool(field_name: str) -> np.ndarray: """Get boolean field (Y/N) or return False.""" field = atom_data.get(field_name) return np.where(field.as_array(str) == "Y", True, False) if field is not None else np.full(n_atoms, False) - # Required annotations (no defaults) - atoms.set_annotation("res_name", atom_data.get("comp_id").as_array(str)) - atoms.set_annotation("atom_name", atom_data.get("atom_id").as_array(str)) - atoms.set_annotation("element", atom_data.get("type_symbol").as_array(str)) - atoms.set_annotation("charge", atom_data.get("charge").as_array(np.int8)) - atoms.set_annotation("res_id", np.full(n_atoms, 1)) # We 1-index residue IDs to be consistent with RCSB + # NOTE: We must ``.copy()`` the arrays to ensure they are mutable and not views into the cached CIFBlock data + atoms = _build_ccd_template_atom_array( + comp_id=ccd_code, + chem_comp_type=chem_comp_type, + atom_names=atom_data.get("atom_id").as_array(str).copy(), + elements=atom_data.get("type_symbol").as_array(str).copy(), + charges=atom_data.get("charge").as_array(np.int8).copy(), + is_aromatic=_get_bool("pdbx_aromatic_flag"), + is_leaving_atom=_get_bool("pdbx_leaving_atom_flag"), + ) - # Optional annotations (with defaults) + # Misc. additional annotations that are not essential but can be convenient atoms.set_annotation("alt_atom_id", _get_str("alt_atom_id")) atoms.set_annotation("stereo", _get_str("pdbx_stereo_config")) - atoms.set_annotation("is_aromatic", _get_bool("pdbx_aromatic_flag")) - atoms.set_annotation("is_leaving_atom", _get_bool("pdbx_leaving_atom_flag")) atoms.set_annotation("is_backbone_atom", _get_bool("pdbx_backbone_atom_flag")) atoms.set_annotation("is_n_terminal_atom", _get_bool("pdbx_n_terminal_atom_flag")) atoms.set_annotation("is_c_terminal_atom", _get_bool("pdbx_c_terminal_atom_flag")) - # Try setting hetero flag - hetero = ccd_code not in struc.info.atoms.NON_HETERO_RESIDUES - atoms.set_annotation("hetero", [hetero] * len(atoms)) - # Define coordinate columns for each type coordinate_columns = { "model": ["model_Cartn_x", "model_Cartn_y", "model_Cartn_z"], @@ -406,9 +529,10 @@ def _get_bool(field_name: str) -> np.ndarray: # Log warning if no coordinates were set if not coords_set and coord_types and coord_types[0] is not None: - logger.warning( - f"No suitable coordinates found for '{ccd_code}' among preferences {coord_types}. Coordinates will be 'nan'." - ) + if len(atoms) != 1: + logger.warning( + f"No suitable coordinates found for '{ccd_code}' among preferences {coord_types}. Coordinates will be 'nan'." + ) atoms.coord = np.full((len(atoms), 3), np.nan) # Extract bond data @@ -453,11 +577,12 @@ def get_ccd_component_from_mirror( Args: ccd_code: The three-letter code of the chemical component. ccd_mirror_path: Path to the root of the CCD mirror directory. - **parse_ccd_cif_kwargs: Additional keyword arguments passed to parse_ccd_cif(): - coords: Type of coordinates to use ("model", "ideal_pdbx", "ideal_rdkit", or None). + **parse_ccd_cif_kwargs: Additional keyword arguments passed to ``parse_ccd_cif``. + + * ``coords``: Type of coordinates to use ("model", "ideal_pdbx", "ideal_rdkit", or None). Defaults to "ideal_pdbx". - add_properties: Whether to include RDKit-computed properties. Defaults to True. - add_mapping: Whether to include external resource mappings, such as e.g. the ChEMBL ID. + * ``add_properties``: Whether to include RDKit-computed properties. Defaults to True. + * ``add_mapping``: Whether to include external resource mappings, such as e.g. the ChEMBL ID. Defaults to False. Returns: @@ -471,195 +596,220 @@ def get_ccd_component_from_mirror( return atom_array -@immutable_lru_cache(maxsize=200, copy_func=lambda x: x.copy()) def atom_array_from_ccd_code( ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, **parse_ccd_cif_kwargs ) -> struc.AtomArray: - """Retrieves and parses a component from the Chemical Component Dictionary. + """Retrieves a component from the Chemical Component Dictionary. - First attempts to retrieve the component from a local mirror if provided and the code exists there. - Falls back to Biotite's built-in CCD if the code is not found in the local mirror or no mirror path is provided. + Checks custom registry first (overriding standard CCD), then uses the local mirror + if configured (never falling back to biotite), or biotite if no mirror and allowed. Args: - ccd_code: The three-letter code of the chemical component. - ccd_mirror_path: Path to the root of the CCD mirror directory. - **parse_ccd_cif_kwargs: Additional keyword arguments passed to parse_ccd_cif(): - coords: Type of coordinates to use ("model", "ideal_pdbx", "ideal_rdkit", or None). - Defaults to "ideal_pdbx". - add_properties: Whether to include RDKit-computed properties. Defaults to True. - add_mapping: Whether to include external resource mappings. Defaults to False. + ccd_code: The chemical component code + ccd_mirror_path: Path to local CCD mirror + **parse_ccd_cif_kwargs: Additional args passed to parse_ccd_cif() Returns: - struc.AtomArray: The parsed atomic structure of the requested component. + AtomArray of the requested component - Raises: - ValueError: If the CCD code is not found in the local mirror or Biotite's built-in CCD. - - Example: - >>> atom_array = atom_array_from_ccd_code("ALA") + Note: + Registry entries override standard CCD definitions. Use :py:func:`register_custom_ccd_entry` + to provide custom templates that will be used instead of standard CCD data. """ - if ccd_mirror_path and ccd_code in get_available_ccd_codes_in_mirror(ccd_mirror_path): - return get_ccd_component_from_mirror(ccd_code, ccd_mirror_path, **parse_ccd_cif_kwargs) - else: - return get_ccd_component_from_biotite(ccd_code, **parse_ccd_cif_kwargs) + # Check registry first + code_upper = ccd_code.upper() + if code_upper in _ccd_registry: + entry = _ccd_registry[code_upper].copy() + # Ensure we honor an explicit ``coords=None`` + if "coords" in parse_ccd_cif_kwargs and parse_ccd_cif_kwargs["coords"] is None: + entry.coord[:] = np.nan + return entry + return atom_array_from_bundled_ccd_code(ccd_code, ccd_mirror_path, **parse_ccd_cif_kwargs) -def _find_connected_components_after_removal(graph: nx.Graph, node_to_remove: int) -> list[list[int]]: - """ - Identifies connected components that would form after removing a node from a graph. - Args: - - graph (nx.Graph): The input graph. - - node_to_remove (int): The node to hypothetically remove. +def atom_array_from_bundled_ccd_code( + ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, **parse_ccd_cif_kwargs +) -> struc.AtomArray: + """Retrieve a component from the bundled CCD, bypassing the custom registry. - Returns: - - list[list[int]]: List of lists containing node indices in each new component. + Args: + ccd_code: The chemical component code. + ccd_mirror_path: Path to local CCD mirror. + **parse_ccd_cif_kwargs: Additional args passed to :py:func:`parse_ccd_cif`. """ - # Get the neighbors before removal - neighbors = set(graph.neighbors(node_to_remove)) - if not neighbors: - return [] - - # Create subgraph without the node - subgraph = graph.subgraph(set(graph.nodes) - {node_to_remove}) + # Normalize path: None → "" for consistent caching downstream + ccd_mirror_path = str(ccd_mirror_path or "") + + if ccd_mirror_path: + # Local mirror configured — use ONLY mirror, no biotite fallback + if ccd_code in get_available_ccd_codes_in_mirror(ccd_mirror_path): + return get_ccd_component_from_mirror(ccd_code, ccd_mirror_path, **parse_ccd_cif_kwargs) + else: + raise ValueError( + f"Cannot load CCD component '{ccd_code}': not found in local mirror at {ccd_mirror_path!r}. " + f"Component may not exist in CCD, or mirror may be outdated." + ) + elif ALLOW_BIOTITE_CCD: + # No mirror — use biotite + return get_ccd_component_from_biotite(ccd_code, **parse_ccd_cif_kwargs) + else: + raise ValueError( + f"Cannot load CCD component '{ccd_code}': no local CCD mirror configured. " + f"Set CCD_MIRROR_PATH environment variable or set ALLOW_BIOTITE_CCD=1." + ) - # Use BFS from any neighbor to find components - components = [] - unvisited = neighbors.copy() - while unvisited: - start = unvisited.pop() - if start not in subgraph: - continue +def _get_ccd_block(ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> pdbx.CIFBlock | None: + """Resolve a CCD code to a CIFBlock, trying mirror first then biotite. - # Find all nodes reachable from this neighbor - component = list(nx.bfs_tree(subgraph, start)) - components.append(component) - unvisited -= set(component) + Args: + ccd_code: The CCD code. + ccd_mirror_path: Path to mirror, or empty string for biotite fallback. + """ + if ccd_mirror_path: + try: + cif = pdbx.CIFFile.read(_get_ccd_path(ccd_code, ccd_mirror_path)) + return pdbx.convert._get_block(cif, None) + except FileNotFoundError: + return None + if ALLOW_BIOTITE_CCD: + try: + return _filter_biotite_ccd_for_ccd_code(ccd_code) + except KeyError: + return None + return None - return components +@_standard_ccd_only_cache(functools.lru_cache(maxsize=20_000)) +def _read_chem_comp_field(ccd_code: str, field: str, ccd_mirror_path: str) -> str | None: + """Read a single field from the ``chem_comp`` category of a CCD entry.""" + block = _get_ccd_block(ccd_code, ccd_mirror_path) + if block is None: + return None + try: + value = block["chem_comp"][field].as_item() + return value.upper() if value not in ("?", ".", "") else None + except KeyError: + return None -@functools.cache -def get_chem_comp_leaving_atom_names( - ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, mode: Literal["warn", "raise"] = "warn" -) -> dict[str, tuple[str, ...]]: - """ - Computes the canonical leaving groups for a given CCD entry based on the PDBs annotation - of leaving atoms. - The returned dictionary maps the name of the atom to the names of the atoms that would - become disconnected if the atom were removed. +def _infer_chem_comp_type_from_atom_names(atom_names: set[str], *, warn: bool = False) -> str: + """Infer chem_comp_type from atom names. - Example: - >>> get_chem_comp_leaving_atom_names("ALA") - {'N': ('H2',), 'C': ('OXT', 'HXT'), 'OXT': ('HXT',)} + Args: + atom_names: Atom names to use for inference. + warn: If ``True``, emit a warning when a polymer type is inferred. """ - # Skip CCD lookup for codes that shouldn't be matched against CCD (e.g., UNL, water-like codes) - if ccd_code in DO_NOT_MATCH_CCD: - if mode == "warn": - logger.debug(f"Skipping CCD lookup for `{ccd_code}` as it's in DO_NOT_MATCH_CCD") - return {} - - try: - chem_comp = atom_array_from_ccd_code(ccd_code, ccd_mirror_path) - except (ValueError, AttributeError) as e: - if mode == "warn": - logger.warning(f"Failed to compute leaving groups for `{ccd_code}`: {e}") - elif mode == "raise": - raise ValueError(f"Failed to compute leaving groups for `{ccd_code}`: {e}") from e - return {} + # Default is non-polymer + chem_comp_type = "NON-POLYMER" + + # Peptide backbone: N, CA, C + if {"N", "CA", "C"}.issubset(atom_names): + chem_comp_type = "L-PEPTIDE LINKING" + + # Nucleotide backbone: O3', P + elif {"O3'", "P"}.issubset(atom_names): + if {"O2'"}.issubset(atom_names): + chem_comp_type = "RNA LINKING" + else: + chem_comp_type = "DNA LINKING" + + if warn and chem_comp_type != "NON-POLYMER": + preview = sorted(str(a) for a in atom_names) + shown = preview[:8] + [f"…(+{len(preview) - 8} more)"] if len(preview) > 8 else preview + logger.warning( + f"Inferred polymer type {chem_comp_type} from atom names {shown}. " + "This is a fallible process! To avoid this warning, set the " + "`chem_comp_type` annotation explicitly on your custom CCD entry." + ) - if "is_leaving_atom" not in chem_comp.get_annotation_categories(): - if mode == "warn": - logger.warning( - f"No 'is_leaving_atom' annotation found for `{ccd_code}`. " - "Cannot compute leaving groups, returning empty dictionary. " - "Check if your CCD mirror is up to date." - ) - elif mode == "raise": - raise ValueError( - f"No 'is_leaving_atom' annotation found for `{ccd_code}`. " - "Cannot compute leaving groups. Check if your CCD mirror is up to date." - ) - return {} + return chem_comp_type - # ... initialize output - leaving_atom_names = defaultdict(list) - # ... get relevant annotations - is_leaving_atom = chem_comp.get_annotation("is_leaving_atom") - atom_name = chem_comp.get_annotation("atom_name") - element = chem_comp.get_annotation("element") +def get_chem_comp_type( + ccd_code: str, + atom_names: set[str] | None = None, + mode: Literal["warn", "raise"] = "warn", + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, +) -> str: + """Get the chemical component type for a CCD code. - # ... skip if no atoms are annotated as leaving atoms (majority of CCD entries) - if not any(is_leaving_atom): - return {} + Routes through :func:`atom_array_from_ccd_code` so the custom CCD registry + is the single interception point. Falls back to inference from ``atom_names`` + when the code is unknown. - # ... compute the leaving groups based on the bond graph and annotation - bond_graph = chem_comp.bonds.as_graph() - for atom_idx in range(chem_comp.array_length()): - # ... find the connected groups of atoms if the current atom were removed - connected_groups = _find_connected_components_after_removal(bond_graph, atom_idx) - - # ... check if all atoms in the connected group are flagged as leaving atoms - # by the CCD entry - for connected_group in connected_groups: - heavy_atoms: list[int] = list(filter(lambda x: element[x] != "H", connected_group)) - is_leaving_group = ( - all(is_leaving_atom[heavy_atoms]) if len(heavy_atoms) > 0 else all(is_leaving_atom[connected_group]) - ) + Args: + ccd_code: The CCD code for the component. E.g. ``"ALA"`` for alanine. + atom_names: Atom names used to infer the type when the code is not in the CCD. + mode: How to handle unknown chemical component types. + ccd_mirror_path: Path to the local CCD mirror directory. - if is_leaving_group: - leaving_atom_names[atom_name[atom_idx]] += [atom_name[idx] for idx in connected_group] + Example: + >>> get_chem_comp_type("ALA") + 'L-PEPTIDE LINKING' + """ + try: + arr = atom_array_from_ccd_code(ccd_code, ccd_mirror_path, coords=None) + if len(arr) > 0 and "chem_comp_type" in arr.get_annotation_categories(): + return arr.chem_comp_type[0] + except (ValueError, KeyError): + pass - # ... turn leaving_atom_names into a dictionary of tuples - leaving_atom_names = {k: tuple(v) for k, v in leaving_atom_names.items()} + # Fallback: read just the type field without loading the full atom array + chem_comp_type = _read_chem_comp_field(ccd_code, "type", str(ccd_mirror_path or "")) + if exists(chem_comp_type): + return chem_comp_type - return leaving_atom_names + if exists(atom_names): + return _infer_chem_comp_type_from_atom_names(atom_names, warn=True) + if mode == "raise": + raise ValueError(f"Chemical component type for `{ccd_code=}` not found in CCD or custom CCD.") + logger.info(f"Chemical component type for `{ccd_code=}` not found in CCD or custom CCD. Using 'OTHER'.") + return "OTHER" -@functools.cache -def _chem_comp_type_dict() -> dict[str, str]: - """Get a dictionary of all residue names and their corresponding chemical component types. +def get_parent_comp_id(ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> str | None: + """Return the canonical parent residue for a modified CCD code, or ``None``. - Example: - >>> _chem_comp_type_dict()["ALA"] - 'L-PEPTIDE LINKING' + Uses the local CCD mirror when configured, falling back to biotite's built-in CCD. """ - ccd = struc.info.ccd.get_ccd() # NOTE: biotite caches this internally - chem_comp_ids = np.char.upper(ccd["chem_comp"]["id"].as_array()) - chem_comp_types = np.char.upper(ccd["chem_comp"]["type"].as_array()) - return dict(zip(chem_comp_ids, chem_comp_types, strict=True)) + return _read_chem_comp_field(ccd_code.upper(), "mon_nstd_parent_comp_id", str(ccd_mirror_path or "")) -def get_chem_comp_type(ccd_code: str, mode: Literal["warn", "raise"] = "warn") -> str: - """Get the chemical component type for a CCD code from the Chemical Component Dictionary (CCD). - - Can be combined with CHEM_TYPES from `atomworks.io_biotite.constants` to determine if a component is a - protein, nucleic acid, or carbohydrate. +def is_parent_strict_atom_subset( + child_code: str, + parent_code: str, + ccd_mirror_path: str = "", + hydrogen_policy: Literal["keep", "remove"] = "remove", +) -> bool: + """Check if the parent template's heavy-atom names are a strict subset of the child's. Args: - ccd_code (str): The CCD code for the component. E.g. `ALA` for alanine, `NAP` for N-acetyl-D-glucosamine. - mode (Literal["warn", "raise"]): How to handle unknown chemical component types. - - Example: - >>> get_chem_comp_type("ALA") - 'L-PEPTIDE LINKING' + child_code: Modified residue CCD code (e.g. ``"ASB"``). + parent_code: Parent residue CCD code (e.g. ``"ASP"``). + ccd_mirror_path: Path to CCD mirror, or empty string for Biotite's built-in. + hydrogen_policy: Hydrogen handling for templates. """ - chem_comp_type = _chem_comp_type_dict().get(ccd_code, None) - - # ... handle unknown chemical component types - if not exists(chem_comp_type): - if mode == "raise": - # ... raise an error if we want to fail loudly - raise ValueError(f"Chemical component type for `{ccd_code=}` not found in CCD.") - elif mode == "warn": - # ... otherwise set chemical component type to "other" - the equivalent of unknown. - logger.info(f"Chemical component type for `{ccd_code=}` not found in CCD. Using 'other'.") - chem_comp_type = "OTHER" + try: + child_template = _get_base_ccd_template(child_code, ccd_mirror_path, hydrogen_policy) + parent_template = _get_base_ccd_template(parent_code, ccd_mirror_path, hydrogen_policy) + except Exception: + return False - return chem_comp_type + child_atoms = set(child_template.atom_name) + parent_atoms = set(parent_template.atom_name) + return parent_atoms < child_atoms # strict subset + + +def get_parent_template_atom_names( + parent_code: str, + ccd_mirror_path: str = "", + hydrogen_policy: Literal["keep", "remove"] = "remove", +) -> set[str]: + """Return the set of atom names from a parent CCD template.""" + template = _get_base_ccd_template(parent_code, ccd_mirror_path, hydrogen_policy) + return set(template.atom_name) def get_chain_type_from_chem_comp_type(chem_comp_type: str) -> ChainType: @@ -667,9 +817,88 @@ def get_chain_type_from_chem_comp_type(chem_comp_type: str) -> ChainType: return ChainTypeInfo.CHEM_COMP_TYPE_TO_ENUM.get(chem_comp_type, ChainType.OTHER_POLYMER) -def get_chain_type_from_ccd_code(ccd_code: str) -> ChainType: +def get_chain_type_from_ccd_code(ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> ChainType: """Get the ChainType enum corresponding to a CCD code.""" - return get_chain_type_from_chem_comp_type(get_chem_comp_type(ccd_code)) + return get_chain_type_from_chem_comp_type(get_chem_comp_type(ccd_code, ccd_mirror_path=ccd_mirror_path)) + + +def _derive_polymerization_atoms_from_template( + template: struc.AtomArray, expected_elements: tuple[str, str] +) -> tuple[str | None, str | None]: + """Recover a component's polymerization atoms as those bonded to the leaving atoms.""" + if template.bonds is None or "is_leaving_atom" not in template.get_annotation_categories(): + return None, None + is_leaving = template.is_leaving_atom + if not is_leaving.any(): + return None, None + + is_heavy = ~np.isin(template.element, list(HYDROGEN_LIKE_SYMBOLS)) + + i, j = template.bonds.as_array()[:, 0], template.bonds.as_array()[:, 1] + neighbours = np.concatenate([j[is_leaving[i] & ~is_leaving[j]], i[is_leaving[j] & ~is_leaving[i]]]) + candidates = np.unique(neighbours[is_heavy[neighbours]]) + + sides = (candidates[template.element[candidates] == element] for element in expected_elements) + exiting, entering = (str(template.atom_name[s[0]]) if len(s) == 1 else None for s in sides) + return exiting, entering + + +@functools.lru_cache(maxsize=1024) +def _get_polymerization_atoms_cached(res_name: str, chain_type: ChainType | None) -> tuple[str | None, str | None]: + """Cached lookup for canonical (non-registry) residues only.""" + cct = get_chem_comp_type(res_name) + canonical = CHEM_TYPE_POLYMERIZATION_ATOMS.get(cct) + if canonical is None: + canonical = ChainTypeInfo.ATOMS_AT_POLYMER_BOND.get(get_chain_type_from_chem_comp_type(cct)) + if canonical is None: + # Use the chain's convention for non-polymer caps, e.g. the terminal NH2 in PDB 3N95. + canonical = ChainTypeInfo.ATOMS_AT_POLYMER_BOND.get(chain_type) + if canonical is None: + return None, None + + try: + template = atom_array_from_ccd_code(res_name, coords=None) + except (ValueError, KeyError): + # Type came from a component we cannot load -- nothing to cross-check against. + return canonical + + present = set(template.atom_name) + if set(canonical).issubset(present): + return canonical + + # ~15% of polymeric CCD entries do not carry the canonical atom names for their type: the GFP + # chromophore ``CRO`` is ``L-PEPTIDE LINKING`` but calls its backbone ``N1``/``C3``. Only such + # components consult the overrides, so a custom component registered under an overridden code + # still resolves by its own atom names. + override = CCD_POLYMERIZATION_ATOM_OVERRIDES.get(res_name.upper()) + if override is not None: + return override + + derived = _derive_polymerization_atoms_from_template(template, struc.infer_elements(canonical)) + return tuple( + found if found is not None else (canon if canon in present else None) + for canon, found in zip(canonical, derived, strict=True) + ) + + +def get_polymerization_atoms(res_name: str, chain_type: ChainType | None = None) -> tuple[str | None, str | None]: + """Return candidate ``(leaving_atom, entering_atom)`` sites for a polymer bond. + + Looks up the canonical atoms for the chemical component type via :py:func:`get_chem_comp_type` + against ``CHEM_TYPE_POLYMERIZATION_ATOMS`` (falling back to + ``ChainTypeInfo.ATOMS_AT_POLYMER_BOND``), and returns them when the component actually carries + those atom names. A component that does not carry them falls back to + ``CCD_POLYMERIZATION_ATOM_OVERRIDES``, and then to + :py:func:`_derive_polymerization_atoms_from_template`. Each side is resolved independently and + may be ``None``. For caps, ``chain_type`` supplies candidate sites (``NH2`` gives ``(None, "N")``), + but does not establish that a bond exists. + """ + # Skip cache for custom registry entries (mutable state) + if res_name.upper() in _ccd_registry: + return _get_polymerization_atoms_cached.__wrapped__(res_name, chain_type) + + # Use cached lookup for canonical CCD entries (immutable state) + return _get_polymerization_atoms_cached(res_name, chain_type) def get_unknown_ccd_code_for_chem_comp_type(chem_comp_type: str) -> str: @@ -686,5 +915,649 @@ def get_unknown_ccd_code_for_chem_comp_type(chem_comp_type: str) -> str: def get_std_to_alt_atom_name_map(ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> dict[str, str]: """Get a map from standard atom names to alternative atom names.""" - chem_comp = atom_array_from_ccd_code(ccd_code, ccd_mirror_path) + chem_comp = atom_array_from_ccd_code(ccd_code, ccd_mirror_path, coords=None) return dict(zip(chem_comp.atom_name, chem_comp.alt_atom_id, strict=True)) + + +def get_ccd_descriptors(ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH) -> dict[str, str]: + """Extract SMILES and InChI descriptors from a CCD entry. + + Parses the ``_pdbx_chem_comp_descriptor`` category to retrieve molecular + descriptors. Available descriptor types depend on the CCD entry. + + Args: + ccd_code: The CCD code for the component. + ccd_mirror_path: Path to the local CCD mirror. + + Returns: + Dictionary with descriptor types as keys and descriptor values as values. + Common keys include: "SMILES", "SMILES_CANONICAL", "InChI", "InChIKey". + Returns empty dict if the CCD code is not found or has no descriptors. + + Example: + >>> get_ccd_descriptors("ATP") + {'SMILES_CANONICAL': 'Nc1ncnc2c1ncn2...', 'InChI': 'InChI=1S/...', ...} + """ + block = _get_ccd_block(ccd_code, str(ccd_mirror_path or "")) + if block is None: + return {} + + descriptor_data = block.get("pdbx_chem_comp_descriptor") + if descriptor_data is None: + return {} + + # Extract type and descriptor columns + try: + desc_types = descriptor_data["type"].as_array(str) + descriptors = descriptor_data["descriptor"].as_array(str) + except (KeyError, AttributeError): + return {} + + # Build the result dictionary + result = {} + for desc_type, descriptor in zip(desc_types, descriptors, strict=True): + # Clean up the descriptor type to create a simple key + desc_type_upper = desc_type.upper() + if "SMILES" in desc_type_upper: + if "CANONICAL" in desc_type_upper: + result["SMILES_CANONICAL"] = descriptor + else: + result["SMILES"] = descriptor + elif "INCHI" in desc_type_upper: + if "KEY" in desc_type_upper: + result["InChIKey"] = descriptor + else: + result["InChI"] = descriptor + + return result + + +# ===== CCD ANNOTATION TRANSFER ===== + + +def add_annotations_from_ccd( + atom_array: struc.AtomArray, + annotations: list[str] | None = None, + overwrite: dict[str, bool] | bool = False, + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, + hydrogen_policy: Literal["keep", "remove"] = "keep", + on_mismatch: Literal["ignore", "error_heavy", "error"] = "ignore", + match_alt_atom_ids: bool = False, +) -> struc.AtomArray: + """Add CCD structural annotations to atoms in an existing AtomArray. + + Matches atoms to CCD templates by residue and copies specified annotations. + + Args: + atom_array: Structure to annotate. + annotations: List of annotation names to add. If ``None``, adds all core + structural annotations: ``["charge", "stereo", "is_aromatic", + "is_leaving_atom", "is_backbone_atom", "is_n_terminal_atom", + "is_c_terminal_atom", "nhyd"]``. + overwrite: Control overwriting of existing annotations. If bool, applies + to all. If dict, maps annotation name to overwrite policy. + Example: ``{"charge": False, "stereo": True}`` keeps existing charges + but overwrites stereochemistry. Defaults to ``False``. + ccd_mirror_path: Path to local CCD mirror. + hydrogen_policy: Whether to use the ``"keep"`` or ``"remove"`` CCD template. + When ``"remove"``, the template has ``nhyd`` set on heavy atoms so that + implicit H counts are copied to ``atom_array`` before bond inference. + on_mismatch: How to handle residue atoms whose names are not in the CCD + template. One of: + + - ``"ignore"`` (default): silently skip unmatched atoms (debug log only). + - ``"error_heavy"``: raise ``ValueError`` if any unmatched atom is a heavy atom + - ``"error"``: raise ``ValueError`` on any unmatched atom. + match_alt_atom_ids: If True, use the template's ``alt_atom_id`` column for + the entire residue when it matches more heavy atoms than ``atom_name``. + Ties use standard names. Defaults to ``False``. + + Returns: + AtomArray with CCD annotations added (modified in-place). + + Raises: + ValueError: If ``on_mismatch`` is ``"error"`` or ``"error_heavy"`` and + unmatched atoms are found. + """ + # Default annotations + if annotations is None: + annotations = list(DEFAULT_CCD_ANNOTATIONS) + + # Normalize overwrite to dict + if isinstance(overwrite, bool): + overwrite_dict = {annot: overwrite for annot in annotations} + else: + overwrite_dict = overwrite + + # Get available CCD codes for proactive checking + available_ccds = get_available_ccd_codes(str(ccd_mirror_path or "")) + + # Use start/stop indices instead of boolean masks for O(1) slicing + _res_start_stops = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + _res_starts, _res_stops = _res_start_stops[:-1], _res_start_stops[1:] + + # Missing value checkers for each annotation + def _make_checker(annot: str) -> Callable: + """Create a missing value checker function for the given annotation.""" + if annot == "charge": + return lambda v: v == 0 + elif annot in ["stereo", "alt_atom_id"]: + return lambda v: (v == "") | (v == ".") | (v == "?") + elif annot.startswith("is_"): + return lambda v: v == False # noqa: E712 + elif annot == "nhyd": + return lambda v: v == 0 + else: + # Handle floats with NaN + return lambda v: (np.isnan(v) if np.issubdtype(v.dtype, np.floating) else np.zeros(len(v), dtype=bool)) + + missing_value_checkers = {annot: _make_checker(annot) for annot in annotations} + present_annotations = atom_array.get_annotation_categories() + annotation_arrays = { + annot: atom_array.get_annotation(annot) for annot in annotations if annot in present_annotations + } + # Registry overrides remain reusable within this call without surviving registry changes. + get_template = functools.cache(_get_base_ccd_template) + matching_templates = {} + + # Process each unique residue + for res_start, res_stop in zip(_res_starts, _res_stops, strict=False): + # Get residue metadata + chain_id = atom_array.chain_id[res_start] + res_name = atom_array.res_name[res_start] + res_id = atom_array.res_id[res_start] + + # Check CCD availability + if res_name not in available_ccds: + logger.debug(f"CCD {res_name} not available, skipping annotation for chain {chain_id}, res_id {res_id}") + continue + + # CCD is available - try to get template + # (Some CCD files may be malformed, so catch parsing errors) + try: + template = get_template(res_name, str(ccd_mirror_path or ""), hydrogen_policy=hydrogen_policy) + except (AttributeError, ValueError, KeyError) as e: + logger.debug(f"CCD parsing failed for {res_name} (chain {chain_id}, res_id {res_id}): {e}") + continue + + # Get atom names for this residue (direct slicing instead of boolean mask) + residue_atom_names = atom_array.atom_name[res_start:res_stop] + + # Repeated residues share a name layout even when partial or reordered. + heavy_mask = ( + ~np.isin(atom_array.element[res_start:res_stop], HYDROGEN_LIKE_SYMBOLS) if match_alt_atom_ids else None + ) + layout = res_name, tuple(residue_atom_names), tuple(heavy_mask) if heavy_mask is not None else () + if layout not in matching_templates: + # Select one naming convention for the entire residue using heavy atoms. + template_names = template.atom_name + if match_alt_atom_ids and "alt_atom_id" in template.get_annotation_categories(): + heavy_names = residue_atom_names[heavy_mask] + n_std = np.isin(heavy_names, template.atom_name).sum() + n_alt = np.isin(heavy_names, template.alt_atom_id).sum() + if n_alt > n_std: + template_names = template.alt_atom_id + + if len(template) == len(residue_atom_names) and np.array_equal(template_names, residue_atom_names): + template_idxs = residue_idxs = np.arange(len(template), dtype=np.intp) + else: + _, template_idxs, residue_idxs = np.intersect1d( + template_names, residue_atom_names, assume_unique=True, return_indices=True + ) + template_annotations = template.get_annotation_categories() + matching_templates[layout] = ( + residue_idxs, + [ + (annot, template.get_annotation(annot)[template_idxs]) + for annot in annotations + if annot in template_annotations + ], + ) + residue_idxs, matched_annotations = matching_templates[layout] + + # Handle atoms that don't match the template according to ``on_mismatch``. + if len(residue_idxs) != len(residue_atom_names): + unmatched_local_mask = np.ones(len(residue_atom_names), dtype=bool) + unmatched_local_mask[residue_idxs] = False + unmatched_names = set(residue_atom_names[unmatched_local_mask]) + + if on_mismatch == "error": + raise ValueError( + f"Residue {res_name} (chain {chain_id}, res_id {res_id}) has atoms " + f"not in CCD template: {unmatched_names}." + ) + if on_mismatch == "error_heavy": + residue_elements = atom_array.element[res_start:res_stop] + heavy_unmatched_mask = unmatched_local_mask & ~np.isin(residue_elements, HYDROGEN_LIKE_SYMBOLS) + if heavy_unmatched_mask.any(): + heavy_unmatched = set(residue_atom_names[heavy_unmatched_mask]) + raise ValueError( + f"Residue {res_name} (chain {chain_id}, res_id {res_id}) has " + f"heavy atoms not in CCD template: {heavy_unmatched}." + ) + + logger.debug( + f"Residue {res_name} (chain {chain_id}, res_id {res_id}) has atoms not in CCD template: {unmatched_names}. " + f"These atoms will not receive CCD annotations. This may indicate non-standard atom naming." + ) + + # Get global indices of matched atoms (direct offset instead of mask indexing) + residue_global_idxs = res_start + residue_idxs + + # Copy annotations for matched atoms only + for annot, template_values in matched_annotations: + # Ensure annotation exists in atom_array + if annot not in annotation_arrays: + # Create annotation with default values + if annot in ["stereo", "alt_atom_id"]: + default = np.full(len(atom_array), "", dtype=" bool: + """Return True if ``a`` and ``b`` have the same atom names and bonds.""" + if sorted(a.atom_name.tolist()) != sorted(b.atom_name.tolist()): + return False + return _bonds_to_dict(a) == _bonds_to_dict(b) + + +def _fill_ideal_coords_from_bundled_ccd(code: str, entry: struc.AtomArray) -> None: + """Fill NaN coordinates on a registry ``entry`` in place from the bundled CCD, matched by atom name. + + Genuine custom ligands (no bundled definition, or atoms not a subset) are left untouched. + """ + missing = np.isnan(entry.coord).any(axis=-1) + if not missing.any(): + return + try: + bundled = atom_array_from_bundled_ccd_code(code) + except (ValueError, KeyError): + return + name_to_coord = dict(zip(bundled.atom_name, bundled.coord, strict=False)) + if not set(map(str, entry.atom_name)).issubset(name_to_coord): + return + for i in np.where(missing)[0]: + entry.coord[i] = name_to_coord[entry.atom_name[i]] + + +def register_custom_ccd_entry(code: str, atom_array: struc.AtomArray) -> None: + """Register a custom CCD entry to override/supplement standard CCD lookups.""" + code = code.upper() + + # Copy first so any annotation we add below never leaks back to the caller's array. + entry = atom_array.copy() + + # Backfill missing ideal coords from the CCD so we ensure all entries can reference geometry + _fill_ideal_coords_from_bundled_ccd(code, entry) + + # Ensure that the AtomArray contains the chem_comp_type annotation + if "chem_comp_type" not in entry.get_annotation_categories(): + logger.warning( + f"Custom CCD entry '{code}' is missing 'chem_comp_type' annotation. " + "Inferring from atom names, but this may be inaccurate. " + "To avoid this warning, set the 'chem_comp_type' annotation explicitly on your custom CCD entry." + ) + inferred_type = _infer_chem_comp_type_from_atom_names(set(entry.atom_name)) + entry.set_annotation( + "chem_comp_type", np.full(entry.array_length(), inferred_type, dtype=f" None: + """Register multiple custom CCD entries at once.""" + for code, atom_array in entries.items(): + register_custom_ccd_entry(code, atom_array) + + +def unregister_custom_ccd_entry(code: str) -> bool: + """Unregister a custom CCD entry. Returns True if entry existed.""" + code = code.upper() + + with _ccd_registry_lock: + was_present = code in _ccd_registry + _ccd_registry.pop(code, None) + + if was_present: + logger.info(f"Unregistered custom CCD entry: {code}") + + return was_present + + +def get_custom_ccd_entries() -> frozenset[str]: + """Get set of currently registered custom CCD codes.""" + with _ccd_registry_lock: + return frozenset(_ccd_registry.keys()) + + +def snapshot_custom_ccd_registry() -> dict[str, struc.AtomArray]: + """Return a deep copy of the current custom CCD registry.""" + with _ccd_registry_lock: + return {code: arr.copy() for code, arr in _ccd_registry.items()} + + +def clear_custom_ccd_registry() -> int: + """Clear all custom CCD entries. Returns number of entries cleared.""" + with _ccd_registry_lock: + count = len(_ccd_registry) + _ccd_registry.clear() + + if count > 0: + logger.info(f"Cleared {count} custom CCD entries") + + return count + + +@contextmanager +def custom_ccd_residues(entries: dict[str, struc.AtomArray]) -> Generator[None, None, None]: + """Temporarily register custom CCD entries. Restores registry state on exit. + + Args: + entries: Dict mapping CCD codes to AtomArrays. + + Example: + >>> with custom_ccd_residues({"ALA": custom_ala, "GLY": custom_gly}): + ... ala = atom_array_from_ccd_code("ALA") + """ + # Save current registry state + with _ccd_registry_lock: + saved_registry = {code: arr.copy() for code, arr in _ccd_registry.items()} + + try: + register_custom_ccd_entries(entries) + yield + finally: + # Restore original registry state + with _ccd_registry_lock: + _ccd_registry.clear() + _ccd_registry.update(saved_registry) + + +def register_custom_residues_from_atom_array( + atom_array: struc.AtomArray, + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, +) -> list[str]: + """Extract and register non-standard residues from AtomArray. + + Compares residue names against standard CCD codes only (not registry). + This allows re-registration/overwriting of custom residues. + + Args: + atom_array: Structure containing residues to extract. + ccd_mirror_path: Path to CCD mirror for standard codes check. + + Returns: + List of CCD codes that were successfully registered. + """ + # Get unique residue names + unique_res_names = set(np.unique(atom_array.res_name)) + + # Compare against STANDARD codes only (excludes registry) + standard_ccd_codes = get_standard_ccd_codes(ccd_mirror_path) + non_standard_residues = unique_res_names - standard_ccd_codes + + if not non_standard_residues: + return [] + + # Extract only non-standard residues + non_standard_mask = np.isin(atom_array.res_name, list(non_standard_residues)) + non_standard_atoms = atom_array[non_standard_mask] + + # Track what we've registered to avoid duplicates in single call + registered_codes = [] + seen = set() + + for residue in struc.residue_iter(non_standard_atoms): + res_name = residue.res_name[0] + + # Skip if already processed in this call + if res_name in seen: + continue + seen.add(res_name) + + # Skip if contains NaN coordinates + if np.isnan(residue.coord).any(): + logger.info(f"Skipping {res_name}: contains NaN coordinates") + continue + + try: + register_custom_ccd_entry(res_name, residue) + registered_codes.append(res_name) + except Exception as e: + logger.warning(f"Failed to register {res_name}: {e}") + + if registered_codes: + logger.info(f"Registered {len(registered_codes)} custom CCD entries: {registered_codes}") + + return registered_codes + + +def _extract_chem_comp_type_from_cif_block(cif_block: pdbx.CIFBlock) -> dict[str, str] | None: + """Extract chem comp type information from the CIF block, if it exists. + + Structure CIFs use ``chem_comp.type``; CCD CIFs use ``chem_comp.pdbx_type``. + + Returns: + Mapping from ``chem_comp.id`` to the uppercase chem_comp type, or + ``None`` if the relevant category/fields are not found. + """ + if "chem_comp" not in cif_block or "id" not in cif_block["chem_comp"]: + return None + + chem_comp = cif_block["chem_comp"] + type_field = next((c for c in ("type", "pdbx_type") if c in chem_comp), None) + if type_field is None: + return None + + ids = chem_comp["id"].as_array() + types = chem_comp[type_field].as_array() + return {cid: ctype.upper() for cid, ctype in zip(ids, types, strict=False)} + + +def build_ccd_entries_from_cif_block( + cif_block: pdbx.CIFBlock, + on_mismatch: Literal["ignore", "error_heavy", "error"] = "error_heavy", +) -> dict[str, struc.AtomArray]: + """Build CIF component templates, supplementing only name-compatible CCD entries. + + Authored fields win. CCD fallback requires heavy-atom names from one CCD naming convention; + otherwise missing types are inferred from names. Multi-atom entries require bonds. + ``on_mismatch`` rejects unmatched heavy atoms by default (``"error_heavy"``), or any + unmatched atom with ``"error"``. ``"ignore"`` keeps incompatible components without supplementation. + """ + if "chem_comp_atom" not in cif_block: + return {} + + atom_cat = cif_block["chem_comp_atom"] + all_comp_ids = atom_cat["comp_id"].as_array(str) + all_atom_ids = atom_cat["atom_id"].as_array(str) + + def _col(name: str, dtype: type = str) -> np.ndarray | None: + return atom_cat[name].as_array(dtype) if name in atom_cat else None + + def _bool_col(name: str) -> np.ndarray | None: + return np.where(atom_cat[name].as_array(str) == "Y", True, False) if name in atom_cat else None + + all_elements = _col("type_symbol", str) + all_charges = _col("charge", np.int8) + all_aromatic = _bool_col("pdbx_aromatic_flag") + all_leaving_atom = _bool_col("pdbx_leaving_atom_flag") + all_x_coords = _col("pdbx_model_Cartn_x_ideal", np.float64) + all_y_coords = _col("pdbx_model_Cartn_y_ideal", np.float64) + all_z_coords = _col("pdbx_model_Cartn_z_ideal", np.float64) + all_coords = ( + np.stack([all_x_coords, all_y_coords, all_z_coords], axis=-1) + if all_x_coords is not None and all_y_coords is not None and all_z_coords is not None + else None + ) + + bond_dict = ( + pdbx.convert._parse_intra_residue_bonds(cif_block["chem_comp_bond"]) if "chem_comp_bond" in cif_block else {} + ) + + id_to_type = _extract_chem_comp_type_from_cif_block(cif_block) or {} + + entries: dict[str, struc.AtomArray] = {} + for comp_id_np in np.unique(all_comp_ids): + comp_id = str(comp_id_np) + mask = all_comp_ids == comp_id_np + + atom_names = all_atom_ids[mask].copy() + if len(atom_names) > 1 and comp_id not in bond_dict: + # No way to know intra-residue connectivity without authored bonds; + # skip registration and let downstream lookups fall through to the + # bundled CCD. + logger.warning( + f"Skipping custom CCD registration for '{comp_id}': " + f"{len(atom_names)} atoms in 'chem_comp_atom' but no " + f"'chem_comp_bond' rows. Falling through to bundled CCD." + ) + continue + + elements = all_elements[mask].copy() if all_elements is not None else np.full(len(atom_names), "", dtype=str) + ccd_atom_names, ccd_alt_names, _ = get_atom_names_for_residue(comp_id, str(CCD_MIRROR_PATH or "")) + heavy_atom_names = atom_names[~np.isin(elements, HYDROGEN_LIKE_SYMBOLS)] + matches_ccd = set(heavy_atom_names).issubset(ccd_atom_names) or set(heavy_atom_names).issubset(ccd_alt_names) + chem_comp_type = id_to_type.get(comp_id) or ( + get_chem_comp_type(comp_id) + if matches_ccd + else _infer_chem_comp_type_from_atom_names(set(atom_names), warn=True) + ) + + atoms = _build_ccd_template_atom_array( + comp_id=comp_id, + chem_comp_type=chem_comp_type, + atom_names=atom_names, + elements=elements, + charges=all_charges[mask] if all_charges is not None else None, + is_aromatic=all_aromatic[mask] if all_aromatic is not None else None, + is_leaving_atom=all_leaving_atom[mask] if all_leaving_atom is not None else None, + coords=all_coords[mask] if all_coords is not None else None, + ) + atoms.bonds = ( + struc.connect_via_residue_names(atoms, custom_bond_dict={comp_id: bond_dict[comp_id]}) + if comp_id in bond_dict + else struc.BondList(len(atom_names)) + ) + # Compatibility is component-wide: never supplement just the overlapping atoms. + missing_annotations = [ + name + for name in DEFAULT_CCD_ANNOTATIONS + if name not in atoms.get_annotation_categories() or (name == "charge" and all_charges is None) + ] + if matches_ccd or on_mismatch != "ignore": + atoms = add_annotations_from_ccd( + atoms, + annotations=missing_annotations if matches_ccd else [], + on_mismatch=on_mismatch, + match_alt_atom_ids=True, + ) + entries[comp_id] = atoms + + return entries + + +@_standard_ccd_only_cache(functools.cache) +def _get_base_ccd_template( + ccd_code: str, + ccd_mirror_path: str, # Must be hashable for lru_cache + hydrogen_policy: Literal["keep", "remove"], +) -> struc.AtomArray: + """Get base CCD template (cached implementation). + + This is cached to avoid redundant disk I/O for the same CCD codes. + Returns a template that will be copied and annotated by get_empty_ccd_template(). + + Args: + ccd_code: CCD code to fetch + ccd_mirror_path: Path to CCD mirror (as string), or empty string to use Biotite's built-in CCD + hydrogen_policy: Whether to keep or remove hydrogens from template. + + Returns: + CCD template AtomArray + """ + template = atom_array_from_ccd_code(ccd_code, ccd_mirror_path, coords=None) + + if hydrogen_policy == "remove": + template = annotate_hydrogens(template) + template = remove_hydrogens(template) + + return template + + +@_standard_ccd_only_cache(functools.cache) +def get_atom_names_for_residue( + res_name: str, + ccd_mirror_path: str, +) -> tuple[frozenset[str], frozenset[str], dict[str, str]]: + """Get atom name sets and alt→standard mapping from a CCD template. + + Args: + res_name: Residue name (3-letter code). + ccd_mirror_path: Path to CCD mirror (as string), or empty string to use Biotite's built-in CCD. + + Returns: + Tuple of (std_names_set, alt_names_set, alt_to_std_mapping). + Returns (frozenset(), frozenset(), {}) if template unavailable. + """ + try: + template = _get_base_ccd_template(res_name, ccd_mirror_path, hydrogen_policy="keep") + std_names_set = frozenset(template.atom_name) + + if "alt_atom_id" in template.get_annotation_categories(): + alt_names_set = frozenset(template.alt_atom_id) + alt_to_std = { + alt: std + for std, alt in zip(template.atom_name, template.alt_atom_id, strict=False) + if alt and alt != std + } + return std_names_set, alt_names_set, alt_to_std + + return std_names_set, frozenset(), {} + + except (AttributeError, ValueError, KeyError): + return frozenset(), frozenset(), {} diff --git a/src/atomworks/io/utils/chain.py b/src/atomworks/io/utils/chain.py index afc1a6b4..1bd72a54 100644 --- a/src/atomworks/io/utils/chain.py +++ b/src/atomworks/io/utils/chain.py @@ -7,7 +7,7 @@ def create_chain_id_generator(unavailable_chain_ids: Sequence[str] = []) -> Iter """ Generate the next available chain ID that is not in the unavailable_chain_ids list. The chain IDs are generated in lexicographical order, - i.e. A, B, C, ..., Z, AA, AB, ..., ZZ, AAA, etc. + i.e. A, B, C, ..., Z, AA, AB, ..., ZZ, AAA, etc. The first available chain ID will be returned, i.e. gaps in the unavailable_chain_ids list will be filled. diff --git a/src/atomworks/io/utils/chain_info.py b/src/atomworks/io/utils/chain_info.py new file mode 100644 index 00000000..637011b1 --- /dev/null +++ b/src/atomworks/io/utils/chain_info.py @@ -0,0 +1,292 @@ +"""Helpers to extract chain-level information from AtomArray and enrich with CIF data.""" + +__all__ = ["build_chain_info"] + +import logging + +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack + +from atomworks.common import exists +from atomworks.enums import ChainType +from atomworks.io.utils.atom_array import subset_to_first_transformation +from atomworks.io.utils.selection import get_residue_starts +from atomworks.io.utils.sequence import convert_to_one_letter_sequences, infer_chain_type_from_three_letter + +logger = logging.getLogger("atomworks.io") + + +def update_sequences_from_res_names(chain_data: dict) -> None: + """Update one-letter sequences in chain_data based on res_name list. + + Modifies ``chain_data`` in-place to add/update: + - ``processed_entity_non_canonical_sequence`` + - ``processed_entity_canonical_sequence`` + + For polymer chains, sequences are computed from 3-letter ``res_name`` list. + For non-polymer chains, sequences are 'X' repeated for each residue. + + Args: + chain_data: Chain info dict with ``res_name``, ``chain_type``, and ``is_polymer`` fields. + """ + chain_type = chain_data["chain_type"] + res_names = chain_data["res_name"] + + if chain_type.is_polymer(): + non_canon, canon = convert_to_one_letter_sequences(res_names, chain_type) + chain_data["processed_entity_non_canonical_sequence"] = non_canon + chain_data["processed_entity_canonical_sequence"] = canon + else: + # Non-polymers get one 'X' per residue as sequence representation + chain_data["processed_entity_non_canonical_sequence"] = "X" * len(res_names) + chain_data["processed_entity_canonical_sequence"] = "X" * len(res_names) + + +def build_chain_info( + atom_array: AtomArray | AtomArrayStack, + *, + entity: dict[str, np.ndarray] | None = None, + entity_poly: dict[str, np.ndarray] | None = None, + entity_poly_seq: dict[str, np.ndarray] | None = None, +) -> dict[str, dict]: + """Build chain information dictionary from available inputs. + + Flow: + (1) Extract baseline chain info from atom_array (inferring chain_type if not annotated) + (2) If available, enrich with entity/entity_poly metadata (e.g., overwriting chain_type, adding label_entity, EC numbers, unprocessed sequences) + (3) If available, enrich polymer sequences with poly_seq (e.g., adding unresolved N-terminal residues, detecting sequence heterogeneity) + + Args: + atom_array: Atom array containing chain information. + entity: Entity metadata from :py:func:`~atomworks.io.transforms.categories.category_to_dict`. + entity_poly: Polymer entity metadata from :py:func:`~atomworks.io.transforms.categories.category_to_dict`. + entity_poly_seq: Polymer sequence data from :py:func:`~atomworks.io.transforms.categories.category_to_dict`. + + Returns: + Dict keyed by chain ID with fields: ``res_name``, ``res_id``, ``chain_type``, + ``is_polymer``, ``processed_entity_{canonical,non_canonical}_sequence``, + optionally ``label_entity``, ``ec_numbers``, ``unprocessed_entity_*``, ``has_sequence_heterogeneity``. + """ + # --- Setup --- + # Handle stack vs single model + is_stack = isinstance(atom_array, AtomArrayStack) + atom_array = atom_array[0] if is_stack else atom_array + + # Subset to first transformation, if applicable + atom_array = subset_to_first_transformation(atom_array) + + # --- (1) Baseline from AtomArray --- + chain_info = _extract_baseline_from_atom_array(atom_array) + + # --- (2) Enrich with Entity Info --- + if exists(entity) or exists(entity_poly): + chain_info = _enrich_with_entity_info(chain_info, entity, entity_poly) + + # --- (3) Enrich with Poly Seq --- + if entity_poly_seq is not None: + chain_info = _enrich_with_poly_seq(chain_info, entity_poly_seq) + + # --- (4) Compute sequences from final res_name lists --- + for info in chain_info.values(): + update_sequences_from_res_names(info) + + # --- Cleanup --- + current_chains = set(np.unique(atom_array.chain_id)) + chain_info = {k: v for k, v in chain_info.items() if k in current_chains and "res_name" in v} + + return chain_info + + +def _extract_baseline_from_atom_array(atom_array: AtomArray) -> dict[str, dict]: + """Extract baseline chain info from atom_array.""" + assert "chain_id" in atom_array.get_annotation_categories(), "chain_id annotation not found in atom array" + + # Initialize chain_info dict + chain_info = {} + + # Get residue-level annotations by subsetting to residue starts + _res_starts = get_residue_starts(atom_array) + chain_identifiers = atom_array.chain_id[_res_starts] + res_ids = atom_array.res_id[_res_starts] + res_names = atom_array.res_name[_res_starts] + hetero = atom_array.hetero[_res_starts] + + # Get unique chain identifiers (in order of appearance) + unique_chain_identifiers, indices = np.unique(chain_identifiers, return_index=True) + unique_chain_identifiers = unique_chain_identifiers[np.argsort(indices)] + + # ... build dictionary entries for each chain + for chain_identifier in unique_chain_identifiers: + is_in_chain = chain_identifiers == chain_identifier + seq = res_names[is_in_chain] + chain_mask_atom_level = atom_array.chain_id == chain_identifier + + # --- Determine chain_type --- + if "chain_type" not in atom_array.get_annotation_categories(): + # INFER from sequence + if np.all(hetero[is_in_chain]): + # EDGE CASE: If all atoms are "HETATM", override to non-polymer + chain_type = ChainType.NON_POLYMER + else: + chain_type = infer_chain_type_from_three_letter(seq) + else: + # USE existing annotation from first atom in chain (should be consistent across chain) + chain_type = atom_array.chain_type[chain_mask_atom_level][0] + chain_type = ChainType.as_enum(chain_type) + + # --- Initialize chain info entry --- + chain_info[chain_identifier] = { + "chain_type": chain_type, + "is_polymer": chain_type.is_polymer(), + "res_id": res_ids[is_in_chain].tolist(), + "res_name": res_names[is_in_chain].tolist(), + } + + # Add label_entity if annotation exists + if "label_entity_id" in atom_array.get_annotation_categories(): + entity_id = str(atom_array.label_entity_id[chain_mask_atom_level][0]) + chain_info[chain_identifier]["label_entity"] = entity_id + + return chain_info + + +def _enrich_with_entity_info( + chain_info: dict[str, dict], + entity: dict[str, dict] | None, + entity_poly: dict[str, dict] | None, +) -> dict[str, dict]: + """Enrich chain info with entity/entity_poly metadata.""" + # Sanity check: if entity data provided, we need label_entity keys in chain_info to know which chains to enrich + has_entity_data = exists(entity) or exists(entity_poly) + any_chain_has_label = any("label_entity" in info for info in chain_info.values()) + + if has_entity_data and not any_chain_has_label: + raise ValueError( + "Entity data provided (entity/entity_poly) but no chains have label_entity field. " + "The atom_array must have label_entity_id annotation to use entity enrichment." + ) + + if not any_chain_has_label: + # No entity data available, skip enrichment + return chain_info + + # Build entity lookup dict: {entity_id: {key: value}} + entity_lookup = {} + if entity and "id" in entity: + for i, entity_id in enumerate(entity["id"]): + entity_lookup[str(entity_id)] = {key: str(val[i]) for key, val in entity.items()} + + # Build entity_poly lookup dict: {entity_id: {key: value}} + poly_lookup = {} + if entity_poly and "entity_id" in entity_poly: + for i, entity_id in enumerate(entity_poly["entity_id"]): + poly_lookup[str(entity_id)] = {key: str(val[i]) for key, val in entity_poly.items()} + + # Enrich each chain + for chain_id in chain_info: + entity_id = chain_info[chain_id].get("label_entity") + if not entity_id: + continue + + # Get entity and entity_poly data from lookup dicts + entity_data = entity_lookup.get(entity_id, {}) + poly_data = poly_lookup.get(entity_id, {}) + + # Add EC numbers (if applicable) + ec_raw = entity_data.get("pdbx_ec", "?") + if ec_raw and ec_raw != "?": + chain_info[chain_id]["ec_numbers"] = [ec.strip() for ec in ec_raw.split(",")] + else: + chain_info[chain_id]["ec_numbers"] = [] + + # Add unprocessed sequences from entity_poly + if "pdbx_seq_one_letter_code_can" in poly_data: + chain_info[chain_id]["unprocessed_entity_canonical_sequence"] = poly_data[ + "pdbx_seq_one_letter_code_can" + ].replace("\n", "") + + if "pdbx_seq_one_letter_code" in poly_data: + chain_info[chain_id]["unprocessed_entity_non_canonical_sequence"] = poly_data[ + "pdbx_seq_one_letter_code" + ].replace("\n", "") + + # Override chain_type and is_polymer with more specific information from the `_entity` or `_entity_poly` categories, if available + if "type" in poly_data: + # First preference: use chain type from entity_poly if available + poly_chain_type = ChainType.as_enum(poly_data["type"]) + chain_info[chain_id]["chain_type"] = poly_chain_type + chain_info[chain_id]["is_polymer"] = poly_chain_type.is_polymer() + elif "type" in entity_data: + # Fallback: use type from entity category if polymer_type not available for this entity + entity_type = entity_data["type"].strip().lower() + if entity_type == "polymer": + # _entity.type states membership, not the polymer chemistry. + # Re-infer the chemistry from the observed residue names. + inferred = chain_info[chain_id]["chain_type"] + if not inferred.is_polymer(): + inferred = infer_chain_type_from_three_letter(chain_info[chain_id]["res_name"]) + entity_chain_type = inferred if inferred.is_polymer() else ChainType.OTHER_POLYMER + else: + entity_chain_type = ChainType.as_enum(entity_type) + chain_info[chain_id]["chain_type"] = entity_chain_type + chain_info[chain_id]["is_polymer"] = entity_chain_type.is_polymer() + + return chain_info + + +def _enrich_with_poly_seq( + chain_info: dict[str, dict], + entity_poly_seq: dict[str, np.ndarray] | None, +) -> dict[str, dict]: + """Enrich chain info with poly_seq, merging observed + missing residues.""" + for chain_id in chain_info: + entity_id = chain_info[chain_id].get("label_entity") + if not entity_id: + continue + + # Check if this entity has poly_seq data + entity_mask = entity_poly_seq["entity_id"].astype(str) == str(entity_id) + if not np.any(entity_mask): + continue + + # Detect sequence heterogeneity + has_heterogeneity = False + if "hetero" in entity_poly_seq: + has_heterogeneity = bool(np.any(entity_poly_seq["hetero"][entity_mask].astype(str) == "y")) + chain_info[chain_id]["has_sequence_heterogeneity"] = has_heterogeneity + + # If entity has poly_seq data, MERGE sequences (polymers) + # ... sequence from entity_poly_seq (what's in the CIF file, including missing residues) + poly_seq_res_ids = entity_poly_seq["num"][entity_mask].astype(int) + poly_seq_res_names = entity_poly_seq["mon_id"][entity_mask] + + # ... existing sequence (from what's observed in the structure; e.g., after alt_loc resolution) + existing_res_ids = np.array(chain_info[chain_id]["res_id"]).astype(int) + existing_res_names = np.array(chain_info[chain_id]["res_name"]) + + # Subset the sequence from entity_poly_seq to only residues that are NOT existing + missing_mask = ~np.isin(poly_seq_res_ids, existing_res_ids) + missing_res_ids = poly_seq_res_ids[missing_mask] + missing_res_names = poly_seq_res_names[missing_mask] + + if missing_res_ids.size > 0: + # Ensure no duplicates in missing residues + assert len(missing_res_ids) == len(set(missing_res_ids)), ( + f"Chain {chain_id}: Duplicate res_ids found in missing residues from entity_poly_seq. " + f"Missing res_ids: {missing_res_ids}" + ) + + # Concatenate existing and missing residues (e.g., add unresolved N-terminal residues) + combined_res_ids = np.concatenate([existing_res_ids, missing_res_ids]) + combined_res_names = np.concatenate([existing_res_names, missing_res_names]) + + # Sort by res_id to maintain proper sequence order + sort_indices = np.argsort(combined_res_ids) + sorted_res_ids = combined_res_ids[sort_indices] + sorted_res_names = combined_res_names[sort_indices] + + # Update chain_info with merged sequences + chain_info[chain_id]["res_id"] = sorted_res_ids.tolist() + chain_info[chain_id]["res_name"] = sorted_res_names.tolist() + + return chain_info diff --git a/src/atomworks/io/utils/compression.py b/src/atomworks/io/utils/compression.py index 546e7ee2..c8222f2e 100644 --- a/src/atomworks/io/utils/compression.py +++ b/src/atomworks/io/utils/compression.py @@ -1,177 +1,182 @@ """Compression utilities for file operations.""" -__all__ = ["compress_file", "is_compressed_file", "maybe_decompress_file", "transfer_with_compression"] - +__all__ = [ + "compress_file", + "is_compressed_file", + "maybe_decompress_file", + "open_compressed", + "transfer_with_compression", +] + +import errno import gzip +import io +import os import shutil +import tempfile from os import PathLike from pathlib import Path +from typing import BinaryIO, Literal, TextIO import zstandard as zstd +Compression = Literal["gzip", "zstd"] | None -def is_compressed_file(path: PathLike | str) -> bool: - """Check if a file is compressed based on its extension.""" - path_str = str(path) - return path_str.endswith(".gz") or path_str.endswith(".gzip") or path_str.endswith(".zst") +def _compression_format(path: PathLike | str) -> Compression: + suffix = Path(path).suffix.lower() + if suffix in (".gz", ".gzip"): + return "gzip" + return "zstd" if suffix == ".zst" else None -def compress_file( - input_file: PathLike | str, output_file: PathLike | str | None = None, remove_original: bool = False -) -> Path: - """Compress a file using gzip or zstd. - Args: - input_file: Path to the input file to compress. - output_file: Path to the output compressed file. If None, uses input_file path + ".gz". - remove_original: Whether to remove the original file after compression. +def is_compressed_file(path: PathLike | str) -> bool: + """Check if a file is compressed based on its extension.""" + return _compression_format(path) is not None - Returns: - Path to the compressed output file. - Raises: - OSError: If files cannot be read/written. - FileNotFoundError: If input file doesn't exist. +def _open_stream( + path: PathLike | str, mode: str, compression: Compression, encoding: str | None = None +) -> BinaryIO | TextIO: + kwargs = {"encoding": encoding} if "t" in mode else {} + if compression == "gzip": + return gzip.open(path, mode, **kwargs) + if compression == "zstd": + if "r" in mode: + reader = io.BufferedReader(zstd.open(path, "rb")) + return io.TextIOWrapper(reader, encoding=encoding) if "t" in mode else reader + writer = io.BufferedWriter(zstd.open(path, "wb", cctx=zstd.ZstdCompressor(level=3))) + return io.TextIOWrapper(writer, encoding=encoding) if "t" in mode else writer + return open(path, mode, **kwargs) - Examples: - >>> # Compress a file, keeping the original - >>> compress_file("data.txt") - PosixPath('data.txt.gz') - >>> # Compress with custom output path, removing original - >>> compress_file("data.txt", "compressed/data.txt.gz", remove_original=True) - PosixPath('compressed/data.txt.gz') +def open_compressed(path: PathLike | str, mode: str = "rt", *, encoding: str | None = None) -> BinaryIO | TextIO: + """Open a file, selecting compression by suffix. - >>> # Compress with zstd - >>> compress_file("data.txt", "compressed/data.txt.zst") - PosixPath('compressed/data.txt.zst') - """ - input_path = Path(input_file) + Args: + path: File path. ``.gz``/``.gzip`` use gzip; ``.zst`` uses Zstandard. + mode: ``rt``, ``rb``, ``wt``, or ``wb``. Bare ``r``/``w`` mean text mode. + encoding: Text encoding; defaults to the platform encoding. + Returns: + A stream that the caller must close, typically using a ``with`` block. + Zstandard writes use level 3; gzip writes use its default level 9. + Zstandard reads support concatenated frames and frames without a content size. + """ + if mode in ("r", "w"): + mode += "t" + if mode not in ("rt", "rb", "wt", "wb"): + raise ValueError(f"Unsupported mode {mode!r}; use rt, rb, wt, or wb") + if "b" in mode and encoding is not None: + raise ValueError("encoding is only supported in text mode") + return _open_stream(path, mode, _compression_format(path), encoding) + + +def _copy_zstd(source: BinaryIO, destination: BinaryIO) -> None: + """Decompress concatenated frames, requiring a complete final frame.""" + decoder = zstd.ZstdDecompressor().decompressobj() + while chunk := source.read(zstd.DECOMPRESSION_RECOMMENDED_INPUT_SIZE): + while chunk: + if decoder.eof: + decoder = zstd.ZstdDecompressor().decompressobj() + destination.write(decoder.decompress(chunk)) + chunk = decoder.unused_data + if not decoder.eof: + raise EOFError("Zstandard file ended before the end of a complete frame") + + +def _transfer( + input_path: Path, + output_path: Path, + input_compression: Compression, + output_compression: Compression, + remove_original: bool, +) -> Path: + """Stream into a temporary file, replacing the destination only after success.""" if not input_path.exists(): raise FileNotFoundError(f"Input file not found: {input_path}") - - # Set default output path if not specified - if output_file is None: - output_path = input_path.with_suffix(f"{input_path.suffix}.gz") - else: - output_path = Path(output_file) - - # Ensure output directory exists + if input_path.resolve() == output_path.resolve() or (output_path.exists() and input_path.samefile(output_path)): + raise shutil.SameFileError(f"Input and output refer to the same file: {input_path}") output_path.parent.mkdir(parents=True, exist_ok=True) - - # Determine compression format based on output extension - if str(output_path).endswith(".zst"): - # Use zstd compression - cctx = zstd.ZstdCompressor() - with open(input_path, "rb") as f_in, open(output_path, "wb") as f_out: - cctx.copy_stream(f_in, f_out) - else: - # Use gzip compression (default) - with open(input_path, "rb") as f_in, gzip.open(output_path, "wb") as f_out: - shutil.copyfileobj(f_in, f_out) - - # Remove original file if requested + if input_compression == output_compression and remove_original: + try: + os.replace(input_path, output_path) + except OSError as error: + if error.errno != errno.EXDEV: + raise + else: + return output_path + with tempfile.NamedTemporaryFile(dir=output_path.parent, delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + if input_compression == output_compression: + shutil.copy2(input_path, temporary_path) + else: + with ( + _open_stream(input_path, "rb", None if input_compression == "zstd" else input_compression) as source, + _open_stream(temporary_path, "wb", output_compression) as destination, + ): + if input_compression == "zstd": + _copy_zstd(source, destination) + else: + shutil.copyfileobj(source, destination, length=1024 * 1024) + shutil.copymode(input_path, temporary_path) + os.replace(temporary_path, output_path) + finally: + temporary_path.unlink(missing_ok=True) if remove_original: input_path.unlink() - return output_path -def maybe_decompress_file( +def compress_file( input_file: PathLike | str, output_file: PathLike | str | None = None, remove_original: bool = False ) -> Path: - """Decompress a file only if it's compressed, otherwise return the original path. + """Compress raw file bytes, selecting compression by the output suffix. Args: - input_file: Path to the input file (may or may not be compressed). - output_file: Path to the output decompressed file (if None, auto-generates). - remove_original: Whether to remove the original file after decompression. + input_file: File to compress. Its contents are treated as uncompressed bytes. + output_file: Destination. Defaults to the input path with ``.gz`` appended. + remove_original: Remove the source only after a successful transfer. Returns: - Path to the uncompressed file (either the original or the decompressed one). - - Raises: - OSError: If files cannot be read/written. - FileNotFoundError: If input file doesn't exist. + The compressed file path. Input and output must be distinct files. """ input_path = Path(input_file) + output_path = Path(output_file) if output_file is not None else input_path.with_suffix(input_path.suffix + ".gz") + return _transfer(input_path, output_path, None, _compression_format(output_path) or "gzip", remove_original) - if not input_path.exists(): - raise FileNotFoundError(f"Input file not found: {input_path}") - # If not compressed, just return the input path - no operation needed - if not is_compressed_file(input_path): - return input_path - - # Handle decompression - if output_file is None: - # Handle .ext.gz -> .ext or .ext.zst -> .ext - if input_path.suffix in (".gz", ".zst"): - output_path = input_path.with_suffix("") - else: - # Handle .gzip - output_path = Path(str(input_path).replace(".gzip", "")) - else: - output_path = Path(output_file) - - # Ensure output directory exists - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Decompress the file based on format - if str(input_path).endswith(".zst"): - # Use zstd decompression - dctx = zstd.ZstdDecompressor() - with open(input_path, "rb") as f_in, open(output_path, "wb") as f_out: - dctx.copy_stream(f_in, f_out) - else: - # Use gzip decompression - with gzip.open(input_path, "rb") as f_in, open(output_path, "wb") as f_out: - shutil.copyfileobj(f_in, f_out) - - # Remove original if requested - if remove_original: - input_path.unlink() - - return output_path - - -def transfer_with_compression(input_file: PathLike | str, output_file: PathLike | str, move: bool = False) -> Path: - """Copy or move a file with automatic compression/decompression based on file extensions. +def maybe_decompress_file( + input_file: PathLike | str, output_file: PathLike | str | None = None, remove_original: bool = False +) -> Path: + """Decompress a file only if compressed, otherwise return its path unchanged. Args: - input_file: Path to the source file - output_file: Path to the destination file - move: If True, removes the input file after operation (default: False) + input_file: File whose suffix determines compression. + output_file: Destination. Defaults to removing the final compression suffix. + remove_original: Remove the source only after a successful transfer. Returns: - Path to the output file - - Raises: - FileNotFoundError: If input file doesn't exist - OSError: If files cannot be read/written + The uncompressed path. For compressed inputs, the destination must be distinct. """ input_path = Path(input_file) - output_path = Path(output_file) - if not input_path.exists(): raise FileNotFoundError(f"Input file not found: {input_path}") + compression = _compression_format(input_path) + if compression is None: + return input_path + output_path = Path(output_file) if output_file is not None else input_path.with_suffix("") + return _transfer(input_path, output_path, compression, None, remove_original) - # Ensure output directory exists - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Case 1: Input is compressed, output is not compressed -> decompress - if is_compressed_file(input_path) and not is_compressed_file(output_path): - return maybe_decompress_file(input_path, output_path, remove_original=move) - # Case 2: Input is not compressed, output is compressed -> compress - elif not is_compressed_file(input_path) and is_compressed_file(output_path): - return compress_file(input_path, output_path, remove_original=move) +def transfer_with_compression(input_file: PathLike | str, output_file: PathLike | str, move: bool = False) -> Path: + """Copy or move a file with automatic compression/decompression based on file extensions. - # Case 3: Both compressed or both uncompressed -> simple copy/move - else: - if move: - shutil.move(input_path, output_path) - else: - shutil.copy2(input_path, output_path) - return output_path + Equal codecs are copied byte-for-byte; different codecs are streamed through + decompression/compression. Zstandard writes use level 3. The source is removed + only after success when ``move=True``. Input and output must be distinct files. + Same-codec moves use a rename when source and destination share a filesystem. + """ + input_path, output_path = Path(input_file), Path(output_file) + return _transfer(input_path, output_path, _compression_format(input_path), _compression_format(output_path), move) diff --git a/src/atomworks/io/utils/extra_fields.py b/src/atomworks/io/utils/extra_fields.py new file mode 100644 index 00000000..2299e911 --- /dev/null +++ b/src/atomworks/io/utils/extra_fields.py @@ -0,0 +1,303 @@ +"""Utilities for extra field defaults and dtype inference.""" + +import logging +from typing import Any, Literal + +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack +from biotite.structure.io import pdbx + +from atomworks.common import as_list + +logger = logging.getLogger(__name__) + +# Translation map for builtin field names to CIF field names +_TRANSLATE_BUILTIN_FIELDS = { + "atom_id": "id", + "charge": "pdbx_formal_charge", + "b_factor": "B_iso_or_equiv", + "occupancy": "occupancy", +} + +ExtraFieldsType = list[str] | set[str] | frozenset[str] | dict[str, dict[str, Any] | None] | Literal["all"] | None + + +def infer_dtype_from_values(values: np.ndarray) -> tuple[np.dtype, np.ndarray]: + """Infer dtype from values and convert to numeric type if possible. + + Attempts conversion in priority order: int → float → string. + + Args: + values: Input array to infer dtype from. + + Returns: + Tuple of (inferred_dtype, converted_array). + """ + if np.issubdtype(values.dtype, np.number): + return values.dtype, values + for dtype in (np.int64, np.float64): + try: + return np.dtype(dtype), np.array(values, dtype=dtype) + except (ValueError, TypeError): + pass + if np.isin(values, ["True", "False"]).all(): + values = np.array([v == "True" for v in values], dtype=bool) + return values.dtype, values + return values.dtype, values + + +def normalize_extra_fields( + extra_fields: ExtraFieldsType, + require_defaults: bool = False, +) -> dict[str, dict[str, Any]]: + """Normalize extra_fields to dict of {name: {"default": ..., "dtype": ...}}. + + Args: + extra_fields: Iterable of field names (list, set, frozenset) or dict with specs. + require_defaults: If True, raise error for fields without defaults. + + Returns: + Dict mapping field name to spec dict with "default" and "dtype" keys. + """ + if extra_fields is None or extra_fields == "all": + return {} + + result: dict[str, dict[str, Any]] = {} + + if isinstance(extra_fields, dict): + for name, spec in extra_fields.items(): + if spec is None: + result[name] = {"default": None, "dtype": None} + else: + default = spec.get("default") + dtype = spec.get("dtype") + # Infer dtype from default if not provided + if dtype is None and default is not None: + dtype = np.array([default]).dtype + result[name] = {"default": default, "dtype": dtype} + else: + # Handle list-like types (list, set, frozenset, OmegaConf ListConfig) + # as_list handles these via duck typing + for name in as_list(extra_fields): + result[name] = {"default": None, "dtype": None} + + if require_defaults: + missing = [n for n, s in result.items() if s["default"] is None and s["dtype"] is None] + if missing: + logger.warning( + f"Skipping extra_fields without defaults (required when add_missing_atoms=True): {missing}. " + f"To load these fields, provide defaults: extra_fields={{'{missing[0]}': {{'default': 0}}}}" + ) + # Remove fields without defaults + result = {n: s for n, s in result.items() if n not in missing} + + return result + + +def merge_extra_fields( + required_fields: list[str], + extra_fields: ExtraFieldsType, +) -> ExtraFieldsType: + """Merge required fields into extra_fields. + + Args: + required_fields: Fields that must be included. + extra_fields: User-provided extra_fields (None, list, dict, or "all"). + + Returns: + Merged extra_fields maintaining input type. Returns list if extra_fields is None, + otherwise preserves the input type (list→list, dict→dict, "all"→"all"). + """ + if extra_fields is None: + return list(required_fields) + + if extra_fields == "all": + return "all" + + if isinstance(extra_fields, dict): + # Add required fields with None spec if not present + result = dict(extra_fields) + for field in required_fields: + if field not in result: + result[field] = None + return result + + # List-like types (list, set, frozenset) + field_list = as_list(extra_fields) + return list(set(required_fields) | set(field_list)) + + +def ensure_field_in_extra_fields( + field_name: str, + extra_fields: ExtraFieldsType, + spec: dict[str, Any] | None = None, +) -> ExtraFieldsType: + """Add field_name to extra_fields if not already present. + + Args: + field_name: Name of field to ensure is present. + extra_fields: Current extra_fields (None, list, dict, or "all"). + spec: Optional spec dict for the field (used when creating dict output from None). + + Returns: + Updated extra_fields maintaining input type. If extra_fields is None and spec is + provided, returns a dict; otherwise returns a list. For other types, preserves + the input type. + """ + if extra_fields == "all": + return "all" + + if extra_fields is None: + # Return dict if spec provided, else list + if spec is not None: + return {field_name: spec} + return [field_name] + + if isinstance(extra_fields, dict): + if field_name not in extra_fields: + return {**extra_fields, field_name: spec} + return extra_fields + + # List-like types + field_list = as_list(extra_fields) + if field_name not in field_list: + return [*field_list, field_name] + return list(field_list) + + +def get_default_array(spec: dict[str, Any], n_atoms: int) -> np.ndarray: + """Create array of default values from a field spec. + + If only dtype is provided, uses sensible defaults: nan for floats, -1 for ints. + + Args: + spec: Dict with ``"default"`` and ``"dtype"`` keys. + n_atoms: Length of the array to create. + + Returns: + Array filled with the default value. + """ + default = spec["default"] + dtype = spec["dtype"] + + if default is None and dtype is not None: + # Generate sensible default from dtype + if np.issubdtype(dtype, np.floating): + default = np.nan + elif np.issubdtype(dtype, np.integer): + default = -1 + elif np.issubdtype(dtype, np.bool_): + default = False + else: + default = "" + + if dtype is None and default is not None: + dtype = np.array([default]).dtype + + return np.full(n_atoms, default, dtype=dtype) + + +def transfer_annotation_if_exists( + source: AtomArray, + target: AtomArray, + annot: str, + source_idx: int, +) -> None: + """Transfer annotation value from source to target if it exists. + + Broadcasts source[source_idx] value to all atoms in target. + + Args: + source: AtomArray to copy from. + target: AtomArray to copy to (modified in-place). + annot: Annotation name. + source_idx: Index in source to take value from. + """ + if annot in source.get_annotation_categories(): + value = source.get_annotation(annot)[source_idx] + target.set_annotation(annot, np.full(len(target), value)) + + +def filter_extra_fields(extra_fields: list[str], atom_site: pdbx.CIFCategory) -> list[str]: + """Filter the extra fields to only include fields that are actually present in the file.""" + # Get all available fields + available_fields = set(atom_site.keys()) + + filtered = [ + field_name + for field_name in extra_fields + if _TRANSLATE_BUILTIN_FIELDS.get(field_name, field_name) in available_fields + ] + + # Log missing fields only if debug is enabled + if logger.isEnabledFor(logging.DEBUG): + missing = set(extra_fields) - set(filtered) + for field_name in missing: + logger.debug("Field %s not found in file, ignoring.", field_name) + + return filtered + + +def apply_extra_field_dtypes( + atom_array_stack: AtomArrayStack | AtomArray, + extra_field_specs: dict, + field_names: list[str] | None = None, +) -> AtomArrayStack | AtomArray: + """Apply dtype inference/conversion for extra field annotations. + + Args: + atom_array_stack: Structure containing the annotations to convert. + extra_field_specs: Dict mapping field names to spec dicts with dtype info. + field_names: Fields to process. Defaults to keys from ``extra_field_specs``. + + Returns: + The input structure with converted annotation dtypes. + """ + if field_names is None: + field_names = list(extra_field_specs.keys()) + + annotations = atom_array_stack.get_annotation_categories() + + for field_name in field_names: + if field_name not in annotations: + continue + + values = atom_array_stack.get_annotation(field_name) + spec = extra_field_specs.get(field_name) + dtype = spec.get("dtype") if spec is not None else None + + if dtype is not None: + try: + # Fill CIF unknown/inapplicable markers and empty strings with the declared default. + # Object dtype permits mixing string input with numeric defaults before conversion. + if spec.get("default") is not None: + values = np.where(np.isin(values, ["?", ".", ""]), spec["default"], values.astype(object)) + converted = np.array(values, dtype=dtype) + atom_array_stack.del_annotation(field_name) + atom_array_stack.set_annotation(field_name, converted) + except (ValueError, TypeError): + logger.warning(f"Could not convert field '{field_name}' to dtype {dtype}") + elif field_name in ( + "chain_entity", + "pn_unit_entity", + "molecule_entity", + "label_entity_id", + "chain_type", + "charge", + ): + # entity fields use int16 _except_ ... chain_type/charge use int8 + target_dtype = np.int8 if field_name in ("chain_type", "charge") else np.int16 + try: + # Delete annotation first to avoid dtype promotion + atom_array_stack.del_annotation(field_name) + atom_array_stack.set_annotation(field_name, np.array(values, dtype=target_dtype)) + except (ValueError, TypeError): + logger.warning(f"Could not convert {field_name} values to {target_dtype.__name__}") + else: + inferred_dtype, converted = infer_dtype_from_values(values) + if inferred_dtype != values.dtype: + # set_annotation would promote to a common dtype if an existing annotation is present + atom_array_stack.del_annotation(field_name) + atom_array_stack.set_annotation(field_name, converted) + + return atom_array_stack diff --git a/src/atomworks/io/utils/io_utils.py b/src/atomworks/io/utils/io_utils.py index dd230669..6556a162 100644 --- a/src/atomworks/io/utils/io_utils.py +++ b/src/atomworks/io/utils/io_utils.py @@ -1,6 +1,7 @@ """General utility functions for working with CIF files in Biotite.""" __all__ = [ + "CIFWriteConfig", "apply_sharding_pattern", "build_sharding_pattern", "get_structure", @@ -11,17 +12,21 @@ "to_cif_buffer", "to_cif_file", "to_cif_string", + "to_pdb_buffer", + "to_pdb_string", ] - -import gzip +import contextlib import io import json import logging import os +import random import re import warnings +from collections import defaultdict from collections.abc import Generator from contextlib import contextmanager +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Literal @@ -30,33 +35,55 @@ import biotite.structure.io.pdb as biotite_pdb import numpy as np from biotite.structure import AtomArray, AtomArrayStack -from biotite.structure.bonds import connect_via_residue_names from biotite.structure.io import mol, pdbx +from biotite.structure.io.pdbx import CIFColumn +from biotite.structure.io.pdbx.component import MaskValue +from biotite.structure.io.pdbx.convert import COMP_BOND_TYPE_TO_ORDER import atomworks.io.transforms.atom_array as ta # to avoid circular import from atomworks.common import exists -from atomworks.constants import ATOMIC_NUMBER_TO_ELEMENT, STANDARD_AA, STANDARD_DNA, STANDARD_RNA -from atomworks.enums import ChainType -from atomworks.io.template import add_inter_residue_bonds -from atomworks.io.transforms.categories import category_to_dict -from atomworks.io.utils.selection import get_annotation -from atomworks.io.utils.sequence import get_1_from_3_letter_code +from atomworks.constants import ( + ALTLOC_DEFAULT_IDS, + ATOMIC_NUMBER_TO_ELEMENT, + STANDARD_CIF_ANNOTATIONS, + STANDARD_POLYMER_RESIDUES, +) +from atomworks.io.utils.altloc import has_multiple_altlocs_per_residue, select_altlocs_clash_aware +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.atom_array_plus import AtomArrayPlus, AtomArrayPlusStack +from atomworks.io.utils.bonds import remap_intra_residue_coordination_bonds +from atomworks.io.utils.catcif import resolve_catcif_source +from atomworks.io.utils.ccd import atom_array_from_ccd_code, custom_ccd_residues +from atomworks.io.utils.chain_info import build_chain_info +from atomworks.io.utils.compression import is_compressed_file, open_compressed +from atomworks.io.utils.extra_fields import ( + ExtraFieldsType, + apply_extra_field_dtypes, + filter_extra_fields, + normalize_extra_fields, +) +from atomworks.io.utils.selection import get_annotation, get_residue_starts +from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS +from atomworks.io.utils.standard_annotations.serialization import ( + _LEGACY_MASK_FIELDS, + _canonicalize_standard_annotation_aliases, + _get_scalar_1body_sa_names, + _serialize_standard_annotation_categories, +) from atomworks.io.utils.testing import has_ambiguous_annotation_set logger = logging.getLogger("atomworks.io") + CIF_LIKE_EXTENSIONS = { - ".cif", - ".pdb", - ".bcif", - ".cif.gz", - ".pdb.gz", - ".bcif.gz", - ".json", - ".json.gz", - ".mmjson", - ".mmjson.gz", + base + compression for base in (".cif", ".pdb", ".bcif", ".mmjson") for compression in ("", ".gz", ".gzip", ".zst") } +CIFFileType = Literal["cif", "bcif", "cif.gz", "bcif.gz", "cif.gzip", "bcif.gzip", "cif.zst", "bcif.zst"] + +# Solution text for chain disambiguation methods +CHAIN_DISAMBIGUATION_SOLUTIONS = """To resolve, choose a disambiguation method: + (1) chain_disambiguation='transformation_id' (AtomWorks-only, preserves chain_id) + (2) chain_disambiguation='chain_iid' (universal compatibility, modifies chain_id)""" @contextmanager @@ -96,261 +123,333 @@ def _get_logged_in_user() -> str: return "unknown_user" +@dataclass +class CIFWriteConfig: + """Configuration for CIF/BCIF writing operations. + + Consolidates options used when writing structures to CIF/BCIF format. + + Attributes: + id: Entry ID for the data block. Defaults to "unknown_id". + author: Author name for metadata. Defaults to current user. + date: Date string (YYYY-MM-DD format). Auto-generated if None. + time: Time string (HH:MM:SS format). Auto-generated if None. + include_entity_categories: Write entity categories (_entity, _entity_poly, etc.). + Defaults to False for buffer/string operations, True for file operations. + include_chem_comp: Write _chem_comp CIF category. Defaults to True. + include_nan_coords: Include atoms with NaN coordinates. Defaults to True. + extra_fields: Additional AtomArray annotations to write. Use "all" to include all + non-standard annotations. Defaults to empty list. + extra_categories: Additional CIF categories as dict of {category_name: {column_name: value}}. + Defaults to None. + chain_disambiguation: Method for disambiguating chains in multi-transformation assemblies. + + - "transformation_id": Stores transformation_id in struct_conn symmetry fields. + Preserves original chain_id. Requires AtomWorks to read. + - "chain_iid": Writes chain_iid as chain_id to make chains unique. + Better compatibility with external software. + - None: No disambiguation. Raises error if chains are ambiguous. + + Defaults to None. + save_standard_annotations: Whether to serialize registered + :py:class:`~atomworks.io.utils.standard_annotations.base.StandardAnnotationBase` annotations + (conditions, ``atomize``, etc.) present on the structure into custom CIF + categories. Defaults to True. + ccd_entries: Optional dict mapping residue name to CCD template AtomArray. + When provided, these templates are used for ``chem_comp_atom`` and + ``chem_comp_bond`` emission. Falls back to the structure's + ``_custom_ccd_registry`` attribute (if available, only for AtomArrayPlus types), + then bundled CCD. + chem_comp_source: Source of ``chem_comp_atom`` / ``chem_comp_bond`` rows: + + - ``"array"`` — always the AtomArray itself (regardless of where it came from) + - ``"ccd"`` — always the CCD we have access to (AtomArray fallback if no template). + - ``"ccd_non_canonicals"`` — AtomArray for canonical residues, CCD for the rest. + + warn_on_ccd_without_registry: Warn on CCD write from a plain ``AtomArray`` + (no parse-time CCD snapshot). Set ``False`` to silence. Defaults to ``True``. + + Note: + ``atom_site`` always includes an ``atom_array_index`` column (0, 1, …, n-1). + + See Also: + :py:func:`to_cif_buffer` + :py:func:`to_cif_string` + :py:func:`to_cif_file` + """ + + id: str = "unknown_id" + author: str = field(default_factory=_get_logged_in_user) + date: str | None = None + time: str | None = None + include_entity_categories: bool = False + include_nan_coords: bool = True + include_chem_comp: bool = True + extra_fields: list[str] | Literal["all"] = field(default_factory=list) + extra_categories: dict[str, dict[str, float | int | str | list | np.ndarray]] | None = None + chain_disambiguation: Literal["transformation_id", "chain_iid"] | None = None + save_standard_annotations: bool = True + ccd_entries: dict[str, struc.AtomArray] | None = None + chem_comp_source: Literal["array", "ccd", "ccd_non_canonicals"] = "ccd" + warn_on_ccd_without_registry: bool = True + + def load_any( file_or_buffer: os.PathLike | io.StringIO | io.BytesIO, - file_type: Literal["cif", "mmcif", "pdbx", "pdb", "pdb1", "bcif", "mmjson"] | None = None, + file_type: Literal["cif", "mmcif", "pdbx", "pdb", "pdb1", "bcif", "catcif", "mmjson"] | None = None, *, - extra_fields: list[str] | Literal["all"] = [], + extra_fields: ExtraFieldsType = None, include_bonds: bool = True, model: int | None = None, - altloc: Literal["first", "occupancy", "all"] = "occupancy", -) -> AtomArrayStack | AtomArray: + altloc: Literal["first", "random_per_chain", "random_clash_aware"] | str = "first", + altloc_seed: int | None = None, + infer_dtypes: bool = True, +) -> AtomArrayStack | AtomArrayPlusStack | AtomArray | AtomArrayPlus: """Convenience function for loading a structure from a file or buffer. + NOTE: This function is deprecated! Please use :py:func:`atomworks.io.parse` + with `config="minimal"` + + By default, bonds are NOT loaded here - they are added later in + :py:func:`~atomworks.io.parser.parse_atom_array` using CCD templates for consistency. + Args: file_or_buffer: Path to the file or buffer to load the structure from. file_type: Type of the file to load. If None, it will be inferred. - extra_fields: List of extra fields to include as AtomArray annotations. - If "all", all fields in the 'atom_site' category of the file will be included. - include_bonds: Whether to include bonds in the structure. - model: The model number to use for loading the structure. If None, all models will be loaded. + extra_fields: Extra fields to include as AtomArray annotations. Can be: + + - List of field names: ``["annot1", "annot2"]`` + - Dict with specs: ``{"my_field": {"default": 0, "dtype": np.int32}}`` + - ``"all"``: Include all fields from the atom_site category + include_bonds: Whether to include bond information from biotite's parser. + Defaults to False. Bonds are typically added later in parse_atom_array() + using CCD templates for consistency. + model: The model number to use for loading the structure. If None, all models + will be loaded. altloc: The altloc ID to use for loading the structure. + altloc_seed: Seed for the random number generator when ``altloc="random_per_chain"`` + or ``altloc="random_clash_aware"``. + infer_dtypes: Whether to automatically infer dtypes for extra fields from their values. + For example, fields containing "1", "2", "3" will be converted to int. Returns: - The loaded structure with the specified fields and assumptions. + The loaded structure. References: `Biotite Structure I/O `_ `mmCIF Format Specification `_ """ + + warnings.warn( + "load_any() is deprecated and will be removed in a future release. " + "Please use atomworks.io.parse() with config='minimal' instead.", + DeprecationWarning, + stacklevel=2, + ) + file_obj = read_any(file_or_buffer, file_type=file_type) - return get_structure( + atom_array = get_structure( file_obj, extra_fields=extra_fields, include_bonds=include_bonds, model=model, altloc=altloc, + altloc_seed=altloc_seed, + infer_dtypes=infer_dtypes, ) + # Ensure chem_comp_type is always present + ensure_annotations(atom_array, "chem_comp_type") -def _add_bonds( - atom_array: AtomArray | AtomArrayStack, - cif_block: pdbx.CIFBlock, - add_bond_types_from_struct_conn: list[str] = ["covale"], - fix_bond_types: bool = True, -) -> AtomArray | AtomArrayStack: - """Add bonds to the AtomArray and filter by a given altloc strategy. - - Avoids the issue where spurious bonds are added due to uninformative label_seq_ids. - - Args: - atom_array: The AtomArray to add bonds to. Must contain `auth_seq_id` annotation. - cif_block: The CIFBlock containing the structure data. - add_bond_types_from_struct_conn: A list of bond types to add to the structure - from the `struct_conn` category. Defaults to `["covale"]`. This means that we will only - add covalent bonds to the structure (excluding metal coordination and disulfide bonds). - fix_bond_types: Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. + return atom_array - Returns: - AtomArray | AtomArrayStack: The AtomArray or AtomArrayStack with bonds and filtered by altloc. - """ - # If there are no uninformative res_ids, we can skip a few steps - contains_uninformative_res_ids = np.any(atom_array.res_id == -1) +def _validate_label_seq_ids(cif_block: pdbx.CIFBlock | pdbx.BinaryCIFBlock) -> None: + """Reject inconsistent CIF entity and polymer sequence identity.""" + atom_site = cif_block["atom_site"] + if not {"label_asym_id", "label_seq_id"}.issubset(atom_site): + return - if contains_uninformative_res_ids and not hasattr(atom_array, "auth_seq_id"): - raise ValueError( - "To ensure that bonds are added correctly when there are uninformative `label_seq_id` values present " - "(occurs for non-polymers), the `auth_seq_id` annotation must be given in the `AtomArray`." - "This error should not occur if the `AtomArray` was loaded with `atomworks.io`, but may " - "occur if biotite was used directly. Please re-load the structure from CIF using `atomworks.io`." - ) + asym_ids = atom_site["label_asym_id"].as_array(str) + seq_ids = atom_site["label_seq_id"].as_array(str) + missing = np.isin(seq_ids, (".", "?")) + entity_ids = atom_site["label_entity_id"].as_array(str) if "label_entity_id" in atom_site else None - # Compute intra-residue bonds as specified in the CIF, or fallback to CCD - custom_bond_dict = None - if "chem_comp_bond" in cif_block: - try: - custom_bond_dict = pdbx.convert._parse_intra_residue_bonds(cif_block["chem_comp_bond"]) - except KeyError: - warnings.warn( - "The 'chem_comp_bond' category has missing columns, " - "falling back to using Chemical Component Dictionary", - UserWarning, - stacklevel=2, + for asym_id in np.unique(asym_ids): + mask = asym_ids == asym_id + if missing[mask].any() and (~missing[mask]).any(): + raise ValueError( + f"CIF label_asym_id '{asym_id}' mixes defined and missing label_seq_id values. " + "Polymer members, including noncanonical monomers, require label_seq_id; standalone " + "non-polymers must use a separate label_asym_id." ) - custom_bond_dict = None - intra_residue_bonds: struc.bonds.BondList = connect_via_residue_names( - atom_array, custom_bond_dict=custom_bond_dict, inter_residue=False + if entity_ids is not None and len(np.unique(entity_ids[mask])) != 1: + raise ValueError(f"CIF label_asym_id '{asym_id}' maps to multiple label_entity_id values.") + + entity = cif_block.get("entity") + if entity_ids is None or entity is None or not {"id", "type"}.issubset(entity): + return + entity_types = dict(zip(entity["id"].as_array(str), entity["type"].as_array(str), strict=True)) + invalid_polymer = missing & np.array( + [entity_types.get(entity_id, "").strip().lower() == "polymer" for entity_id in entity_ids] ) - - if contains_uninformative_res_ids: - # Detect spurious inter-residue bonds - at this point all bonds should be intra-residue - bonds_array = intra_residue_bonds.as_array() - auth_seq_ids = atom_array.auth_seq_id.astype(int) - is_spurious = auth_seq_ids[bonds_array[:, 0]] != auth_seq_ids[bonds_array[:, 1]] - # Remove spurious inter-residue bonds - intra_residue_bonds._bonds = intra_residue_bonds._bonds[~is_spurious] - - # Temporarily replace uninformative label_seq_ids with auth_seq_ids - to_replace = atom_array.res_id == -1 - atom_array.set_annotation("_to_replace", to_replace) # To preserve info through leaving atom removal - atom_array.res_id[to_replace] = atom_array.auth_seq_id[to_replace] - - # Add intra-residue bonds - atom_array.bonds = intra_residue_bonds - - # Correctly add inter-residue bonds for each AtomArray - if isinstance(atom_array, AtomArrayStack): - processed_arrays = [] - for array in atom_array: - array = add_inter_residue_bonds( - array, - struct_conn_dict=category_to_dict(cif_block, "struct_conn"), - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - fix_formal_charges=False, # Only works if we have True (not inferred) hydrogens - fix_bond_types=fix_bond_types, - ) - processed_arrays.append(array) - atom_array = struc.stack(processed_arrays) - else: - atom_array = add_inter_residue_bonds( - atom_array, - struct_conn_dict=category_to_dict(cif_block, "struct_conn"), - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - fix_formal_charges=False, # Only works if we have True (not inferred) hydrogens - fix_bond_types=fix_bond_types, + if invalid_polymer.any(): + asym_id = asym_ids[np.flatnonzero(invalid_polymer)[0]] + raise ValueError( + f"CIF polymer label_asym_id '{asym_id}' has missing label_seq_id values. Every polymer member, " + "including noncanonical monomers, must have a sequence ID." ) - if contains_uninformative_res_ids: - # Revert back to the original label_seq_id - atom_array.res_id[atom_array._to_replace] = -1 - atom_array.del_annotation("_to_replace") - - return atom_array - def get_structure( file_obj: pdbx.CIFFile | biotite_pdb.PDBFile | pdbx.BinaryCIFFile | pdbx.CIFBlock, *, - extra_fields: list[str] | Literal["all"] = [], - include_bonds: bool = True, + extra_fields: ExtraFieldsType = None, + include_bonds: bool = False, model: int | None = None, - altloc: Literal["first", "occupancy", "all"] | str = "first", - add_bond_types_from_struct_conn: list[str] = ["covale"], - fix_bond_types: bool = True, + altloc: Literal["first", "random_per_chain", "random_clash_aware"] | str = "first", + altloc_seed: int | None = None, + infer_dtypes: bool = True, ) -> AtomArrayStack | AtomArray: - """ - Load example structure into Biotite's AtomArray or AtomArrayStack using the specified fields and assumptions. + """Load structure into Biotite's AtomArray or AtomArrayStack. + + By default, bonds are NOT loaded here - they are added later in + :py:func:`~atomworks.io.parser.parse_atom_array` using CCD templates for consistency. + + Note: + For CIF files, ``transformation_id`` is automatically included. This field is + required for assembly processing. Args: - - file_obj (pdbx.CIFFile | biotite_pdb.PDBFile | pdbx.BinaryCIFFile): The file object to load with Biotite. - - extra_fields (list | Literal["all"]): List of extra fields to include as AtomArray annotations. - If "all", all fields in the 'atom_site' category of the file will be included. - - include_bonds (bool): Whether to include bonds in the structure. These will not be affected by the issue - where spurious bonds are added due to uninformative label_seq_ids. - - model (int): The model number to use for loading the structure. - - altloc (Literal["first", "occupancy", "all"]): The altloc ID to use for loading the structure. - If a string is provided, it will be used as the altloc ID to filter the structure by and it is assumed - that that altloc ID is present in the file. If it is not present, an error will be raised. - - add_bond_types_from_struct_conn (list, optional): A list of bond types to add to the structure - from the `struct_conn` category. Defaults to `["covale"]`. This means that we will only - add covalent bonds to the structure (excluding metal coordination and disulfide bonds). - - fix_bond_types (bool, optional): Whether to correct for nucleophilic additions on atoms involved in inter-residue bonds. + file_obj: The file object to load with Biotite. + extra_fields: Extra fields to include as AtomArray annotations. Can be: + + - List of field names: ``["b_factor", "occupancy"]`` + - Dict with specs: ``{"my_field": {"default": 0, "dtype": np.int32}}`` + - ``"all"``: Include all fields from the atom_site category + include_bonds: Whether to include bond information. Passed to biotite's parser. + Defaults to False. + model: The model number to use for loading the structure. + altloc (Literal["first", "random_per_chain", "random_clash_aware"] | str): The altloc ID to use + for loading the structure. + ``"first"`` selects the alphabetically-first altloc letter per chain. + ``"random_per_chain"`` selects a random altloc independently for each chain. + ``"random_clash_aware"`` selects one altloc per residue, avoiding steric clashes between + nearby residues via constraint satisfaction. + A specific altloc ID (e.g., ``"A"``, ``"B"``) can also be provided. + If a specific altloc ID is not present in the file, an error will be raised. + altloc_seed: Seed for the random number generator when ``altloc="random_per_chain"`` or + ``altloc="random_clash_aware"``. + If ``None`` (default), selection is non-deterministic. Provide an integer + for reproducible results. + infer_dtypes: Whether to automatically infer dtypes for extra fields from their + values. Returns: - AtomArray | AtomArrayStack: The loaded structure with the specified fields and assumptions. + The loaded structure with bonds from the CIF file. Reference: `Biotite documentation `_ """ - tmp_altloc = altloc if altloc in {"first", "occupancy", "all"} else "all" - + # Normalize extra_fields and extract field names for biotite + extra_field_specs = normalize_extra_fields(extra_fields) if extra_fields not in (None, "all") else {} + extra_field_names = "all" if extra_fields == "all" else list(extra_field_specs.keys()) + + # For CIF files, auto-inject transformation_id into extra fields (which we need for assemblies) + if ( + isinstance(file_obj, pdbx.CIFFile | pdbx.BinaryCIFFile | pdbx.CIFBlock) + and extra_field_names != "all" + and "transformation_id" not in extra_field_names + ): + extra_field_names.append("transformation_id") + if "transformation_id" not in extra_field_specs: + extra_field_specs["transformation_id"] = {} + + cif_block = None match type(file_obj): case pdbx.CIFFile | pdbx.BinaryCIFFile | pdbx.CIFBlock: - # auth_seq_id must be included for the spurious bonds issue to be avoided - # This will be removed later if it wasn't requested - remove_auth_seq_id = False - if include_bonds and extra_fields != "all" and "auth_seq_id" not in extra_fields: - remove_auth_seq_id = True - extra_fields = ["auth_seq_id", *extra_fields] - # Filter extra annotations to fields that are actually present in the file if not isinstance(file_obj, pdbx.CIFBlock): cif_block = file_obj.block else: cif_block = file_obj - if extra_fields == "all": - extra_fields = list(cif_block["atom_site"].keys()) - extra_fields = _filter_extra_fields(extra_fields, cif_block["atom_site"]) - + _validate_label_seq_ids(cif_block) + atom_site = cif_block["atom_site"] + if extra_field_names == "all": + extra_field_names = list(atom_site.keys()) + extra_field_names = filter_extra_fields(extra_field_names, atom_site) + # Biotite requires label_alt_id for altloc="all", even without alternates. + load_altlocs = altloc if include_bonds else "all" + if "label_alt_id" not in atom_site: + load_altlocs = "first" atom_array_stack = pdbx.get_structure( file_obj, model=model, - extra_fields=extra_fields, + extra_fields=extra_field_names, use_author_fields=False, - altloc="first" if "occupancy" not in extra_fields else tmp_altloc, - include_bonds=False, + altloc=load_altlocs, + include_bonds=include_bonds, ) - # Add bonds and filter by altloc if requested, avoiding the spurious bonds issue - if include_bonds: - atom_array_stack = _add_bonds( - atom_array_stack, - cif_block, - add_bond_types_from_struct_conn=add_bond_types_from_struct_conn, - fix_bond_types=fix_bond_types, - ) - - # Remove the auth_seq_id annotation if it was not requested - if remove_auth_seq_id: - atom_array_stack.del_annotation("auth_seq_id") + # Normalize insertion codes to empty string for CIF files + # (ins_code is a legacy PDB artifact; CIF files typically don't use it meaningfully) + if "ins_code" in atom_array_stack.get_annotation_categories(): + atom_array_stack.ins_code[:] = "" case biotite_pdb.PDBFile: atom_array_stack = biotite_pdb.get_structure( file_obj, model=model, - extra_fields=extra_fields, - altloc=tmp_altloc, - include_bonds=include_bonds, # PDB files contain only auth_seq_ids so the biotite issue does not arise + extra_fields=extra_field_names if extra_field_names != "all" else [], + altloc="all" if not include_bonds else altloc, + include_bonds=include_bonds, ) case _: raise ValueError(f"Unsupported file type: {type(file_obj)}. Must be a CIFFile, BinaryCIFFile, or PDBFile.") - # Filter down to specified altloc if requested - if altloc != tmp_altloc: - altloc_ids = get_annotation(atom_array_stack, "altloc_id", default=[]) - existing_altloc_ids = np.unique(altloc_ids) - if altloc not in existing_altloc_ids: - raise ValueError( - f"Altloc ID '{altloc}' not found. " - f"Available altloc IDs: {existing_altloc_ids}. " - f"If you are using a PDB file, please ensure that the altloc ID is present in the file." - ) - to_keep = np.isin(altloc_ids, [altloc, ".", "?"]) # always keep default altloc IDs - atom_array_stack = atom_array_stack[..., to_keep] + # Apply altloc filtering (only when we loaded all altlocs; when include_bonds=True, Biotite already filtered altlocs at load time) + if not include_bonds: + if altloc == "random_clash_aware": + atom_array_stack = select_altlocs_clash_aware(atom_array_stack, seed=altloc_seed, cif_block=cif_block) + else: + altloc_ids = get_annotation(atom_array_stack, "altloc_id") + if altloc_ids is not None: + defaults = np.isin(altloc_ids, ALTLOC_DEFAULT_IDS) + + if altloc in ("first", "random_per_chain"): + # Keep one altloc per chain: first observed ("first") or random ("random_per_chain") + mask = defaults.copy() + chain_ids = get_annotation(atom_array_stack, "chain_id") + rng = random.Random(altloc_seed) if altloc == "random_per_chain" else None + for chain_id in np.unique(chain_ids): + chain_mask = chain_ids == chain_id + letters = [a for a in np.unique(altloc_ids[chain_mask]) if a.isalpha()] + if letters: + chosen = rng.choice(letters) if rng else letters[0] + mask |= chain_mask & (altloc_ids == chosen) + else: + # Keep global altloc + if altloc not in altloc_ids: + available = [a for a in np.unique(altloc_ids) if a.isalpha()] + raise ValueError(f"Altloc '{altloc}' not found. Available: {available}") + mask = defaults | (altloc_ids == altloc) + + atom_array_stack = atom_array_stack[..., mask] + + # Ensure charge is always present; some files (e.g. AF3 outputs) omit pdbx_formal_charge + if "charge" not in atom_array_stack.get_annotation_categories(): + atom_array_stack.set_annotation("charge", np.zeros(atom_array_stack.array_length(), dtype=np.int8)) + + # Apply dtype inference/conversion for extra fields + if infer_dtypes and extra_field_specs: + atom_array_stack = apply_extra_field_dtypes(atom_array_stack, extra_field_specs) + elif infer_dtypes and extra_field_names: + # Auto-infer dtypes even without explicit specs + atom_array_stack = apply_extra_field_dtypes(atom_array_stack, {}, extra_field_names) return atom_array_stack -def _infer_file_type_from_buffer(buffer: io.BytesIO | io.StringIO) -> str: - """Infer file type from buffer contents.""" - if isinstance(buffer, io.BytesIO): - return "bcif" - - # StringIO - peek at contents to determine format - buffer.seek(0) - first_char = buffer.read(1) - buffer.readline() # finish first line - second_line = buffer.readline() - buffer.seek(0) - - if first_char == "{": - return "mmjson" - if second_line.startswith("#"): - return "cif" - return "pdb" - - def infer_pdb_file_type( path_or_buffer: os.PathLike | io.StringIO | io.BytesIO, ) -> Literal["cif", "pdb", "bcif", "sdf", "mmjson"]: @@ -358,17 +457,25 @@ def infer_pdb_file_type( Infer the file type of a PDB file or buffer. """ # Convert string paths to Path objects - if isinstance(path_or_buffer, str): + if isinstance(path_or_buffer, str | os.PathLike): path_or_buffer = Path(path_or_buffer) # Determine file type and open context - if isinstance(path_or_buffer, io.StringIO | io.BytesIO): - return _infer_file_type_from_buffer(path_or_buffer) + if isinstance(path_or_buffer, io.BytesIO): + return "bcif" + elif isinstance(path_or_buffer, io.StringIO): + # ... if second line starts with '#', it is very likely a cif file + path_or_buffer.seek(0) + path_or_buffer.readline() # Skip the first line + second_line = path_or_buffer.readline().strip() + path_or_buffer.seek(0) + return "cif" if second_line.startswith("#") else "pdb" elif isinstance(path_or_buffer, Path): - if path_or_buffer.suffix in (".gz", ".gzip"): - inferred_file_type = Path(path_or_buffer.stem).suffix.lstrip(".") - else: - inferred_file_type = path_or_buffer.suffix.lstrip(".") + if is_compressed_file(path_or_buffer): + path_or_buffer = path_or_buffer.with_suffix("") + inferred_file_type = path_or_buffer.suffix.lstrip(".").lower() + else: + raise TypeError(f"Expected a path, StringIO, or BytesIO, got {type(path_or_buffer).__name__}") # Canonicalize the file type if inferred_file_type in ("cif", "mmcif", "pdbx"): @@ -379,32 +486,39 @@ def infer_pdb_file_type( return "bcif" elif inferred_file_type == "sdf": return "sdf" - elif inferred_file_type in ("json", "mmjson"): + elif inferred_file_type == "mmjson": return "mmjson" else: raise ValueError(f"Unsupported file type: {inferred_file_type}") -def _read_mmjson(file_obj: io.StringIO | io.BytesIO | io.TextIOWrapper) -> pdbx.CIFFile: - """Read an mmjson file into a CIFFile object.""" - data = json.load(file_obj) +def _read_mmjson(stream: io.TextIOBase | io.BytesIO) -> pdbx.CIFFile: + """Read mmJSON categories into Biotite's CIF representation. + + JSON nulls collapse CIF's unknown and inapplicable markers, so restore the + inapplicable marker for the two ``atom_site`` fields that require it. + """ + data = json.load(stream) cif_file = pdbx.CIFFile() for block_name, block_data in data.items(): cif_block = pdbx.CIFBlock() - for cat_name, cat_data in block_data.items(): - cif_category = pdbx.CIFCategory() - for col_name, col_data in cat_data.items(): - # Convert None to "?" and ensure all elements are strings - processed_data = [str(x) if x is not None else "?" for x in col_data] - cif_category[col_name] = pdbx.CIFColumn(processed_data) - cif_block[cat_name] = cif_category + for category_name, category_data in block_data.items(): + category = pdbx.CIFCategory() + for column_name, values in category_data.items(): + missing = ( + "." if category_name == "atom_site" and column_name in ("label_alt_id", "label_seq_id") else "?" + ) + category[column_name] = pdbx.CIFColumn( + [str(value) if value is not None else missing for value in values] + ) + cif_block[category_name] = category cif_file[block_name] = cif_block return cif_file def read_any( path_or_buffer: os.PathLike | io.StringIO | io.BytesIO, - file_type: Literal["cif", "pdb", "bcif", "sdf", "mmjson"] | None = None, + file_type: Literal["cif", "pdb", "bcif", "sdf", "catcif", "mmjson"] | None = None, ) -> pdbx.CIFFile | biotite_pdb.PDBFile | pdbx.BinaryCIFFile: """ Reads any of the allowed file types into the appropriate Biotite file object. @@ -412,7 +526,8 @@ def read_any( Args: path_or_buffer (PathLike | io.StringIO | io.BytesIO): The path to the file or a buffer to read from. If a buffer, it's highly recommended to specify the file_type. - file_type (Literal["cif", "pdb", "bcif", "mmjson"], optional): Type of the file. + Paths may be plain, gzip-compressed (``.gz``/``.gzip``), or Zstandard-compressed (``.zst``). + file_type: Type of the file, including ``"mmjson"``. If None, it will be inferred from the file extension. When using a buffer, the file type must be specified. Returns: @@ -421,131 +536,424 @@ def read_any( Raises: ValueError: If the file type is unsupported or cannot be determined. """ + # Resolve catcif paths before any file-type inference + resolved, catcif_file_type = resolve_catcif_source(path_or_buffer) + if catcif_file_type is not None: + if file_type not in (None, "catcif"): + raise ValueError(f"catcif path passed but file_type doesn't agree: {file_type}") + path_or_buffer, file_type = resolved, catcif_file_type + + if file_type == "catcif" and catcif_file_type is None: + raise ValueError("file_type='catcif' requires a path containing '.catcif:' (e.g. 'archive.catcif:entry_id')") + # Determine file type if file_type is None: file_type = infer_pdb_file_type(path_or_buffer) open_mode = "rb" if file_type == "bcif" else "rt" - # Convert string paths to Path objects and decompress if necessary - if isinstance(path_or_buffer, str | Path): - path_or_buffer = Path(path_or_buffer) - if path_or_buffer.suffix in (".gz", ".gzip"): - with gzip.open(path_or_buffer, open_mode) as f: - buffer_type = io.StringIO if open_mode == "rt" else io.BytesIO - path_or_buffer = buffer_type(f.read()) - - # Determine the appropriate file object based on file type + # Select the appropriate file reader. if file_type == "cif": - file_cls = pdbx.CIFFile + reader = pdbx.CIFFile.read elif file_type == "pdb": - file_cls = biotite_pdb.PDBFile + reader = biotite_pdb.PDBFile.read elif file_type == "bcif": - file_cls = pdbx.BinaryCIFFile + reader = pdbx.BinaryCIFFile.read elif file_type == "sdf": - file_cls = mol.SDFile + reader = mol.SDFile.read elif file_type == "mmjson": - # Special handling for mmjson - if isinstance(path_or_buffer, io.StringIO | io.BytesIO): - return _read_mmjson(path_or_buffer) - else: - with open(path_or_buffer) as f: - return _read_mmjson(f) + reader = _read_mmjson else: raise ValueError(f"Unsupported file type: {file_type}") # Load the file content - file_obj = file_cls.read(path_or_buffer) + if isinstance(path_or_buffer, str | os.PathLike): + with open_compressed(path_or_buffer, open_mode) as stream: + return reader(stream) + return reader(path_or_buffer) + + +def _rows_to_columns( + rows: list[dict], + *, + on_key_mismatch: Literal["raise", "intersection", "union"] = "raise", +) -> dict[str, list]: + """Convert list of row dicts to dict of column lists. + + Args: + rows: List of dictionaries, each representing a row. + on_key_mismatch: How to handle rows with differing key sets. + + * ``"raise"`` (default): require all rows to have identical keys; + raise :class:`ValueError` otherwise. + * ``"intersection"``: emit only columns present in every row; + column order follows ``rows[0]``. + * ``"union"``: emit the union of columns (first-seen order); + missing values are filled with ``"?"``. + + Returns: + Dictionary mapping column names to lists of values. + + Examples: + >>> rows = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + >>> _rows_to_columns(rows) + {'a': [1, 3], 'b': [2, 4]} + """ + if not rows: + return {} + + if on_key_mismatch == "raise": + first_keys = rows[0].keys() + for i, row in enumerate(rows): + if row.keys() != first_keys: + raise ValueError(f"Row {i} keys {set(row)} differ from row 0 keys {set(first_keys)}") + return {key: [row[key] for row in rows] for key in rows[0]} + + if on_key_mismatch == "intersection": + common = set(rows[0]).intersection(*(row.keys() for row in rows[1:])) + return {key: [row[key] for row in rows] for key in rows[0] if key in common} + + # union + all_keys = list(dict.fromkeys(key for row in rows for key in row)) + return {key: [row.get(key, "?") for row in rows] for key in all_keys} + + +def _template_to_chem_comp_atom_rows(comp_id: str, template: struc.AtomArray) -> list[dict]: + """Convert a CCD template AtomArray to chem_comp_atom rows.""" + annotations = template.get_annotation_categories() + has_charge = "charge" in annotations + has_aromatic = "is_aromatic" in annotations + has_leaving = "is_leaving_atom" in annotations + + rows = [] + for i in range(len(template)): + rows.append( + { + "comp_id": comp_id, + "atom_id": template.atom_name[i], + "type_symbol": template.element[i], + "charge": int(template.charge[i]) if has_charge else 0, + "pdbx_aromatic_flag": ("Y" if template.is_aromatic[i] else "N") if has_aromatic else "N", + "pdbx_leaving_atom_flag": ("Y" if template.is_leaving_atom[i] else "N") if has_leaving else "N", + "pdbx_model_Cartn_x_ideal": float(template.coord[i, 0]), + "pdbx_model_Cartn_y_ideal": float(template.coord[i, 1]), + "pdbx_model_Cartn_z_ideal": float(template.coord[i, 2]), + } + ) + + return rows + + +def _template_to_chem_comp_bond_rows(comp_id: str, template: struc.AtomArray) -> list[dict]: + """Convert a CCD template AtomArray's bonds to chem_comp_bond rows.""" + if template.bonds is None: + return [] + + rows = [] + for idx1, idx2, bond_type_int in template.bonds.as_array(): + bond_type = struc.BondType(bond_type_int) + order, aromatic = COMP_BOND_TYPE_TO_ORDER.get(bond_type, ("SING", "N")) + rows.append( + { + "comp_id": comp_id, + "atom_id_1": template.atom_name[idx1], + "atom_id_2": template.atom_name[idx2], + "value_order": order, + "pdbx_aromatic_flag": aromatic, + } + ) + + return rows - return file_obj +def _atom_array_chem_comp_bond_rows(atom_array: struc.AtomArray, res_names: set[str]) -> list[dict]: + """Emit chem_comp_bond rows for ``res_names`` from ``atom_array.bonds``. -def _build_entity_poly( + Delegates to biotite's :py:func:`_set_intra_residue_bonds` on the subset + of atoms in ``res_names``. + """ + if not res_names: + return [] + + subset = atom_array[np.isin(atom_array.res_name, np.asarray(sorted(res_names)))] + if subset.bonds is None or len(subset.bonds.as_array()) == 0: + return [] + + # _build_chem_comp_bond receives structures[0] (unpreprocessed), not s, so the upstream + # remap_intra_residue_coordination_bonds hasn't run on this input. Required because + # biotite's _set_intra_residue_bonds KeyErrors on COORDINATION bonds. + subset.bonds.convert_bond_type(struc.BondType.COORDINATION, struc.BondType.SINGLE) + + category = pdbx.convert._set_intra_residue_bonds(subset, pdbx.CIFCategory()) + if category is None: + return [] + + # Drop columns that are re-derived per call and/or are missing + cols = {k: category[k].as_array() for k in category if k not in ("pdbx_ordinal", "pdbx_stereo_config")} + n = len(next(iter(cols.values()))) if cols else 0 + + return [{k: v[i] for k, v in cols.items()} for i in range(n)] + + +def _atom_array_chem_comp_atom_rows(atom_array: struc.AtomArray, res_name: str) -> list[dict]: + """Emit chem_comp_atom rows for ``res_name`` from its first-occurrence residue slice.""" + res_starts = get_residue_starts(atom_array, add_exclusive_stop=True) + res_names_at_starts = atom_array.res_name[res_starts[:-1]] + first = np.where(res_names_at_starts == res_name)[0] + if len(first) == 0: + return [] + i = first[0] + sl = atom_array[res_starts[i] : res_starts[i + 1]] + + annotations = sl.get_annotation_categories() + has_charge = "charge" in annotations + has_aromatic = "is_aromatic" in annotations + has_leaving = "is_leaving_atom" in annotations + + rows = [] + for k in range(len(sl)): + rows.append( + { + "comp_id": res_name, + "atom_id": str(sl.atom_name[k]), + "type_symbol": str(sl.element[k]), + "charge": int(sl.charge[k]) if has_charge else 0, + "pdbx_aromatic_flag": ("Y" if sl.is_aromatic[k] else "N") if has_aromatic else "N", + "pdbx_leaving_atom_flag": ("Y" if sl.is_leaving_atom[k] else "N") if has_leaving else "N", + "pdbx_model_Cartn_x_ideal": float(sl.coord[k, 0]), + "pdbx_model_Cartn_y_ideal": float(sl.coord[k, 1]), + "pdbx_model_Cartn_z_ideal": float(sl.coord[k, 2]), + } + ) + return rows + + +def _build_chem_comp( atom_array: struc.AtomArray | struc.AtomArrayStack, ) -> dict[str, dict[str, float | int | str | list | np.ndarray]]: + """Build the chem_comp category for a CIF file. + + Args: + atom_array: AtomArray containing structure data with polymer chain information. """ - Build the entity_poly category for a CIF file from an AtomArray. + if isinstance(atom_array, struc.AtomArrayStack): + atom_array = atom_array[0] # Choose any model; annotations are shared across models. + + ensure_annotations(atom_array, "chem_comp_type") - This function processes polymer entities in the structure and generates their sequence information - in both canonical and non-canonical forms. + chem_comp_rows = [] + + res_starts_ends = get_residue_starts(atom_array, add_exclusive_stop=True) + res_names_at_starts = atom_array.res_name[res_starts_ends[:-1]] + for res_name in np.unique(atom_array.res_name): + first_occurrence = np.where(res_names_at_starts == res_name)[0][0] + + current_row = { + "id": res_name, + "name": res_name, + "type": atom_array.chem_comp_type[res_starts_ends[first_occurrence]], + "formula": "?", + "mon_nstd_parent_comp_id": "?", + "pdbx_synonyms": "?", + "formula_weight": "?", + } + + chem_comp_rows.append(current_row) + + return {"chem_comp": _rows_to_columns(chem_comp_rows)} + + +def _build_chem_comp_atom( + atom_array: struc.AtomArray | struc.AtomArrayStack, + *, + source: Literal["array", "ccd", "ccd_non_canonicals"] = "ccd", +) -> dict[str, dict[str, float | int | str | list | np.ndarray]]: + """Build the chem_comp_atom category for a CIF file. Args: - - atom_array: AtomArray containing the structure data with polymer chain information. + atom_array: Structure data. + source: Per-residue row source: - Returns: - A dictionary containing the entity_poly category with the following fields: - - entity_id: List of entity identifiers - - type: List of polymer types (polypeptide, polynucleotide, etc.) - - nstd_linkage: List indicating presence of non-standard linkages - - nstd_monomer: List indicating presence of non-standard monomers - - pdbx_seq_one_letter_code: List of sequences in one-letter code - - pdbx_seq_one_letter_code_can: List of canonical sequences - - pdbx_strand_id: List of chain identifiers - - pdbx_target_identifier: List of target identifiers + - ``"array"`` — always the AtomArray itself. + - ``"ccd"`` — always the CCD (AtomArray fallback if no template). + - ``"ccd_non_canonicals"`` — AtomArray for canonical residues, CCD for the rest. """ - _entity_poly_categories = ( - "entity_id", - "type", - "nstd_linkage", - "nstd_monomer", - "pdbx_seq_one_letter_code", - "pdbx_seq_one_letter_code_can", - "pdbx_strand_id", - "pdbx_target_identifier", - ) + if isinstance(atom_array, struc.AtomArrayStack): + # Use first model; annotations should be shared across models + atom_array = atom_array[0] + + canonicals = STANDARD_POLYMER_RESIDUES if source == "ccd_non_canonicals" else () + + chem_comp_atom_rows = [] + array_only_res_names: list[str] = [] + for res_name in np.unique(atom_array.res_name): + res_name_str = str(res_name) + use_ccd = source == "ccd" or (source == "ccd_non_canonicals" and res_name_str not in canonicals) + if not use_ccd: + array_only_res_names.append(res_name_str) + continue + try: + template = atom_array_from_ccd_code(res_name) + except (ValueError, KeyError): + # Fallback: synthesize rows from the AtomArray itself for residues without + # a CCD template (e.g. SMILES / SDF ligands with synthetic codes like "L:0"). + # Without this, downstream readers cannot register them as custom CCD + # entries on roundtrip. + array_only_res_names.append(res_name_str) + continue + chem_comp_atom_rows.extend(_template_to_chem_comp_atom_rows(res_name, template)) + + for res_name in array_only_res_names: + chem_comp_atom_rows.extend(_atom_array_chem_comp_atom_rows(atom_array, res_name)) + + return {"chem_comp_atom": _rows_to_columns(chem_comp_atom_rows)} + +def _build_chem_comp_bond( + atom_array: struc.AtomArray | struc.AtomArrayStack, + *, + source: Literal["array", "ccd", "ccd_non_canonicals"] = "ccd", +) -> dict[str, dict[str, float | int | str | list | np.ndarray]]: + """Build the chem_comp_bond category for a CIF file. + + Array-sourced residues (including SMILES/SDF ligands like ``L:0`` that have + no CCD template) have their bonds emitted from ``atom_array.bonds``, so + they aren't silently dropped when this category overwrites biotite's + auto-emitted one. + + Args: + atom_array: Structure data. + source: Per-residue row source. See :py:func:`_build_chem_comp_atom`. + """ + if isinstance(atom_array, struc.AtomArrayStack): + # Use first model to determine chem_comp_bond; annotations are shared across models. + atom_array = atom_array[0] + + canonicals = STANDARD_POLYMER_RESIDUES if source == "ccd_non_canonicals" else () + + chem_comp_bond_rows = [] + array_only_res_names: set[str] = set() + for res_name in np.unique(atom_array.res_name): + res_name_str = str(res_name) + use_ccd = source == "ccd" or (source == "ccd_non_canonicals" and res_name_str not in canonicals) + if not use_ccd: + array_only_res_names.add(res_name_str) + continue + try: + template = atom_array_from_ccd_code(res_name) + except (ValueError, KeyError): + array_only_res_names.add(res_name_str) + continue + chem_comp_bond_rows.extend(_template_to_chem_comp_bond_rows(res_name, template)) + + chem_comp_bond_rows.extend(_atom_array_chem_comp_bond_rows(atom_array, array_only_res_names)) + + if not chem_comp_bond_rows: + return {} + + return {"chem_comp_bond": _rows_to_columns(chem_comp_bond_rows)} + + +def _build_entity_categories( + atom_array: struc.AtomArray | struc.AtomArrayStack, +) -> dict[str, dict[str, float | int | str | list | np.ndarray]]: + """Build ``_entity``, ``_entity_poly``, and ``_entity_poly_seq`` categories for a CIF file. + + Iterates over unique entities once to produce all entity-related categories within standard CIF files. + Many CIF-reading programs (e.g., OpenStructure) expect these categories to be present for polymers. + + Non-polymer entities only get an ``_entity`` row; polymers get all three. + + Args: + atom_array: AtomArray containing structure data with polymer chain information. + + Returns: + Dict mapping category names to column dicts, suitable for + :pyfunc:`_write_categories_to_block`. + """ if isinstance(atom_array, struc.AtomArrayStack): atom_array = atom_array[0] # Choose any model - # ... get index of the first atom of each chain - chain_starts = struc.get_chain_starts(atom_array) + atom_array = _ensure_label_entity_id(atom_array) - # ... get chain ids, iids, entity ids, and chain types - chain_ids = atom_array.chain_id[chain_starts] - chain_iids = atom_array.chain_iid[chain_starts] - entity_ids = atom_array.chain_entity[chain_starts] - is_polymer = atom_array.is_polymer[chain_starts] - chain_types = atom_array.chain_type[chain_starts] + assert ( + "chain_id" in atom_array.get_annotation_categories() + ), "`chain_id` annotation is required to build entity categories." + chain_info = build_chain_info(atom_array) - if not np.any(is_polymer): - return {} + # Build entity→chains mapping + res_starts = get_residue_starts(atom_array) + chain_ids_at_res = atom_array.chain_id[res_starts] + entity_ids_at_res = atom_array.label_entity_id[res_starts] + + entity_to_chains = {} + for chain_id in chain_info: + # Get entity for this chain from atom_array annotation + entity_id = entity_ids_at_res[chain_ids_at_res == chain_id][0] + entity_to_chains.setdefault(entity_id, []).append(chain_id) - unique_polymer_entity_ids = np.unique(entity_ids[is_polymer]) - entity_poly = {cat: [] for cat in _entity_poly_categories} - for entity_id in unique_polymer_entity_ids: - # ... get all relevant chain ids - example_chain_ids = np.unique(chain_ids[entity_ids == entity_id]) - - # ... get chain type - chain_type = ChainType.as_enum(chain_types[entity_ids == entity_id][0]) - - # ... get sequence - example_chain_iid = chain_iids[entity_ids == entity_id][0] - res_starts = struc.get_residue_starts(atom_array[atom_array.chain_iid == example_chain_iid]) - seq = atom_array.res_name[res_starts] - wrap_every_n = lambda text, n: "\n".join(text[i : i + n] for i in range(0, len(text), n)) # noqa: E731 - processed_entity_non_canonical_sequence = "".join( - get_1_from_3_letter_code(ccd_code, chain_type, use_closest_canonical=False) for ccd_code in seq + # Helper for wrapping long sequences + wrap_every_n = lambda text, n: "\n".join(text[i : i + n] for i in range(0, len(text), n)) # noqa: E731 + + # Build categories as rows + entity_rows = [] + entity_poly_rows = [] + entity_poly_seq_rows = [] + + for entity_id in sorted(entity_to_chains.keys()): + chain_ids = entity_to_chains[entity_id] + representative_chain = chain_ids[0] + info = chain_info[representative_chain] + + # Add entity row (all entities) + entity_rows.append( + { + "id": str(entity_id), + "type": info["chain_type"].to_entity_type(), + "pdbx_description": ".", + } ) - processed_entity_non_canonical_sequence = wrap_every_n(processed_entity_non_canonical_sequence, 80) - processed_entity_canonical_sequence = "".join( - get_1_from_3_letter_code(ccd_code, chain_type, use_closest_canonical=True) for ccd_code in seq + + if not info["is_polymer"]: + continue # Skip polymer-specific categories + + # Check for non-standard monomers + has_non_standard = not np.all(np.isin(info["res_name"], STANDARD_POLYMER_RESIDUES)) + + # Add entity_poly row (polymers only) + entity_poly_rows.append( + { + "entity_id": str(entity_id), + "type": info["chain_type"].to_mmcif_string(), + "nstd_linkage": "no", + "nstd_monomer": "yes" if has_non_standard else "no", + "pdbx_seq_one_letter_code": wrap_every_n(info["processed_entity_non_canonical_sequence"], 80), + "pdbx_seq_one_letter_code_can": wrap_every_n(info["processed_entity_canonical_sequence"], 80), + "pdbx_strand_id": ",".join(str(cid) for cid in chain_ids), + "pdbx_target_identifier": "?", + } ) - processed_entity_canonical_sequence = wrap_every_n(processed_entity_canonical_sequence, 80) - # ... check for non-standard monomers - has_non_standard_monomer = ~np.all(np.isin(seq, STANDARD_AA + STANDARD_RNA + STANDARD_DNA)) + # Add entity_poly_seq rows (one per residue) + entity_poly_seq_rows.extend( + [ + { + "entity_id": str(entity_id), + "hetero": "n", + "mon_id": res_name, + "num": res_id, + } + for res_id, res_name in zip(info["res_id"], info["res_name"], strict=False) + ] + ) + + # Convert rows to columns (CIF format) + result = {"entity": _rows_to_columns(entity_rows)} + if entity_poly_rows: + result["entity_poly"] = _rows_to_columns(entity_poly_rows) + result["entity_poly_seq"] = _rows_to_columns(entity_poly_seq_rows) - # ... add to entity_poly - entity_poly["entity_id"].append(entity_id) - entity_poly["type"].append(chain_type.to_string().lower()) - entity_poly["nstd_linkage"].append("no") - entity_poly["nstd_monomer"].append("yes" if has_non_standard_monomer else "no") - entity_poly["pdbx_seq_one_letter_code"].append(processed_entity_non_canonical_sequence) - entity_poly["pdbx_seq_one_letter_code_can"].append(processed_entity_canonical_sequence) - entity_poly["pdbx_strand_id"].append(",".join(example_chain_ids)) - entity_poly["pdbx_target_identifier"].append("?") - return {"entity_poly": entity_poly} + return result def _write_categories_to_block( @@ -569,167 +977,395 @@ def _write_categories_to_block( def _cif_to_bcif(cif_file: pdbx.CIFFile | pdbx.BinaryCIFFile) -> pdbx.BinaryCIFFile: - """Convert a given CIF file to an optimized BCIF file.""" - from biotite.setup_ccd import _concatenate_blocks_into_category - + """Convert to BCIF while preserving column values, missing-value masks, and ordering.""" compressed_file = pdbx.BinaryCIFFile() for block_name, block in cif_file.items(): compressed_block = pdbx.BinaryCIFBlock() - for category_name in block: - _tmp_cif_file = pdbx.CIFFile() - _tmp_cif_file[block_name] = block - compressed_block[category_name] = pdbx.compress( - _concatenate_blocks_into_category(_tmp_cif_file, category_name) - ) + for category_name, category in block.items(): + compressed_category = pdbx.BinaryCIFCategory() + for column_name, column in category.items(): + data = pdbx.BinaryCIFData(column.data.array.copy()) + if np.issubdtype(data.array.dtype, np.str_) or np.issubdtype(data.array.dtype, np.integer): + data = pdbx.compress(data) + elif np.issubdtype(data.array.dtype, np.floating) and data.array.dtype.itemsize > 8: + raise ValueError(f"BinaryCIF cannot losslessly represent {data.array.dtype} in {column_name!r}") + mask = column.mask.array.copy() if column.mask is not None else None + compressed_category[column_name] = pdbx.BinaryCIFColumn(data, mask) + compressed_block[category_name] = compressed_category compressed_file[block_name] = compressed_block return compressed_file -def _to_cif_or_bcif( +def _disambiguate_via_transformation_id(extra_fields: list[str] | Literal["all"]) -> list[str] | Literal["all"]: + """Ensure transformation_id is in extra_fields for disambiguation.""" + if isinstance(extra_fields, list) and "transformation_id" not in extra_fields: + return [*list(extra_fields), "transformation_id"] + return extra_fields + + +def _disambiguate_via_chain_iid( structure: AtomArray, - *, - id: str = "unknown_id", - author: str = _get_logged_in_user(), - date: str | None = None, - time: str | None = None, - include_entity_poly: bool = False, - include_nan_coords: bool = True, - include_bonds: bool = True, - extra_fields: list[str] | Literal["all"] = [], - extra_categories: dict[str, dict[str, float | int | str | list | np.ndarray]] | None = None, - as_bcif: bool = False, - _allow_ambiguous_bond_annotations: bool = False, -) -> pdbx.CIFFile | pdbx.BinaryCIFFile: - structure = structure.copy() - cif_file = pdbx.CIFFile() +) -> AtomArray: + """Replace chain_id with chain_iid to make chains unique.""" + assert ( + "chain_iid" in structure.get_annotation_categories() + ), "chain_iid annotation must be present for chain_iid disambiguation." + structure.chain_id = structure.chain_iid + return structure - if not exists(date): - date = datetime.now().strftime("%Y-%m-%d") - if not exists(time): - time = datetime.now().strftime("%H:%M:%S") - if not _allow_ambiguous_bond_annotations and has_ambiguous_annotation_set(structure): - raise ValueError( - "Ambiguous bond annotations detected. This happens when there are atoms that " - "have the same `(chain_id, res_id, res_name, atom_id, ins_code)` identifier. " - "This happens for example when you have a bio-assembly with multiple copies " - "of a chain that only differ by `transformation_id`.\n" - "You can fix this for example by re-naming the chains to be named uniquely." - ) +def _preprocess_structure_for_cif( + structure: AtomArray, + config: CIFWriteConfig, +) -> tuple[AtomArray, list[str]]: + """Preprocess a single AtomArray for CIF writing. - # If elements are given as atomic numbers, convert them to (uppercase) element symbols + Returns: + Tuple of (preprocessed structure, resolved extra_fields list). + """ + structure = structure.copy() + structure.coord = np.round(structure.coord, 3) + + extra_fields = config.extra_fields + if has_ambiguous_annotation_set(structure): + if config.chain_disambiguation is None: + raise ValueError( + "Ambiguous chain annotations detected. Multiple atoms share the same " + "(chain_id, res_id, res_name, atom_name, ins_code) identifier.\n\n" + f"{CHAIN_DISAMBIGUATION_SOLUTIONS}" + ) + elif config.chain_disambiguation == "transformation_id": + # Mode 1: Keep original chain_id, store transformation_id in struct_conn symmetry fields + # (Only can be loaded with AtomWorks, not other software) + if "transformation_id" not in structure.get_annotation_categories(): + raise ValueError( + "chain_disambiguation='transformation_id' specified, but structure lacks " + "'transformation_id' annotation.\n\n" + f"{CHAIN_DISAMBIGUATION_SOLUTIONS}" + ) + logger.debug("Disambiguating chains via transformation_id; original chain_id will be preserved.") + extra_fields = _disambiguate_via_transformation_id(extra_fields) + + elif config.chain_disambiguation == "chain_iid": + # Mode 2: Replace chain_id with chain_iid to make chains unique + # (Can be loaded with any software, but loses original chain_id) + if "chain_iid" not in structure.get_annotation_categories(): + raise ValueError( + "chain_disambiguation='chain_iid' specified, but structure lacks " + "'chain_iid' annotation.\n\n" + f"{CHAIN_DISAMBIGUATION_SOLUTIONS}" + ) + logger.debug("Disambiguating chains via chain_iid; chain_id will be replaced with chain_iid.") + structure = _disambiguate_via_chain_iid(structure) + + # Standardization: If elements are given as atomic numbers, convert them to (uppercase) element symbols structure.element = np.vectorize(lambda x: ATOMIC_NUMBER_TO_ELEMENT.get(x, x))(structure.element) # If altloc information is present but no altloc id is given, set all to "." if "altloc_id" in structure.get_annotation_categories() and structure.altloc_id[0].strip() == "": structure.altloc_id = ["."] * structure.array_length() - block = pdbx.convert._get_or_create_block(cif_file, block_name=id) + canonical_names = set(STANDARD_ANNOTATIONS.get_field_names()) + accepted_names = set(STANDARD_ANNOTATIONS.get_field_names(include_aliases=True)) + legacy_names = (accepted_names - canonical_names) | set(_LEGACY_MASK_FIELDS.values()) + if extra_fields != "all": + unsupported_names = sorted(set(extra_fields) & legacy_names) + if unsupported_names: + raise ValueError( + f"Explicit StandardAnnotation aliases and legacy names in extra_fields are not supported: " + f"{unsupported_names}. " + 'Omit these fields and use save_standard_annotations=True, or use extra_fields="all"; ' + "StandardAnnotations are saved under their canonical names." + ) - # Build metadata - metadata = {"entry": {"id": id, "author": author, "date": date, "time": time}} - for flag, build_func in [ - (include_entity_poly, _build_entity_poly), - ]: - if flag: - try: - metadata.update(build_func(structure)) - except Exception as e: - logger.warning(f"Failed to build `{build_func.__name__}`: {e}") - # Write metadata to block - _write_categories_to_block(block, metadata) + if config.save_standard_annotations: + _canonicalize_standard_annotation_aliases(structure) + scalar_sa_names = _get_scalar_1body_sa_names(structure) + if extra_fields != "all" and scalar_sa_names: + existing = set(extra_fields) + extra_fields = list(extra_fields) + [f for f in scalar_sa_names if f not in existing] # Set the structure in the CIF file + # (Biotite writes the standard CIF categories by default) if extra_fields == "all": - _standard_cif_annotations = frozenset( - { - "chain_id", - "res_id", - "res_name", - "atom_name", - "atom_id", - "element", - "ins_code", - "hetero", - "altloc_id", - "charge", - "occupancy", - "b_factor", - } - ) - extra_fields = list(set(structure.get_annotation_categories()) - _standard_cif_annotations) + extra_fields = list(set(structure.get_annotation_categories()) - STANDARD_CIF_ANNOTATIONS) - if not include_nan_coords: + if config.save_standard_annotations: + extra_fields = [field for field in extra_fields if field not in legacy_names] + + if not config.include_nan_coords: structure = ta.remove_nan_coords(structure) - if include_bonds and structure.bonds is not None: - # TODO: Switch to using the `convert_bond_type` method once we upgrade to Biotite v1.4.0 - # structure.bonds.convert_bond_type(struc.bonds.BondType.COORDINATION, struc.bonds.BondType.SINGLE) - mask = structure.bonds._bonds[:, 2] == struc.bonds.BondType.COORDINATION - structure.bonds._bonds[mask, 2] = struc.bonds.BondType.SINGLE + structure = _ensure_label_entity_id(structure) + + structure = remap_intra_residue_coordination_bonds(structure) - pdbx.set_structure(cif_file, structure, data_block=id, include_bonds=include_bonds, extra_fields=extra_fields) + # Always include atom_array_index in atom_site so readers can correlate atom_site rows + # with idx0/idx1 values in other CIF categories (e.g. nonscalar StandardAnnotation blocks). + structure.set_annotation("atom_array_index", np.arange(structure.array_length())) + if "atom_array_index" not in extra_fields: + extra_fields = [*list(extra_fields), "atom_array_index"] - # Add extra categories if provided - extra_categories = extra_categories or {} + return structure, extra_fields + + +def _atom_site_from_array(structure: AtomArray, extra_fields: list[str]) -> pdbx.CIFCategory: + """Build an atom_site CIFCategory for one AtomArray via a temporary CIFFile.""" + tmp = pdbx.CIFFile() + pdbx.set_structure(tmp, structure, data_block="_tmp", extra_fields=extra_fields) + return tmp["_tmp"]["atom_site"] + + +def _concatenate_atom_sites(atom_sites: list[pdbx.CIFCategory]) -> pdbx.CIFCategory: + """Concatenate atom_site categories that may have different column sets. + + Missing columns are filled with zeros (numeric) or ``"?"`` (string) and + marked as ``MaskValue.MISSING``. + """ + if len(atom_sites) == 1: + return atom_sites[0] + + seen: set[str] = set() + all_keys: list[str] = [] + for site in atom_sites: + for k in site: + if k not in seen: + all_keys.append(k) + seen.add(k) + + lengths = [len(site[next(iter(site))].as_array()) for site in atom_sites] + + result = pdbx.CIFCategory() + for key in all_keys: + ref_col = next(site[key] for site in atom_sites if key in site) + ref_data = ref_col.as_array() + ref_dtype = ref_data.dtype + + data_parts: list[np.ndarray] = [] + mask_parts: list[np.ndarray] = [] + + for site, n in zip(atom_sites, lengths, strict=False): + if key in site: + col = site[key] + data_parts.append(col.as_array()) + if col.mask is not None: + mask_parts.append(col.mask._array) + else: + mask_parts.append(np.zeros(n, dtype=np.int8)) + else: + if ref_dtype.kind in ("U", "S", "O"): + data_parts.append(np.full(n, "?", dtype=ref_dtype)) + else: + data_parts.append(np.zeros(n, dtype=ref_dtype)) + mask_parts.append(np.full(n, MaskValue.MISSING, dtype=np.int8)) + + combined_mask = np.concatenate(mask_parts) + # Only attach a mask if any entry is non-PRESENT to keep output clean + if np.any(combined_mask != MaskValue.PRESENT): + result[key] = pdbx.CIFColumn(np.concatenate(data_parts), combined_mask) + else: + result[key] = pdbx.CIFColumn(np.concatenate(data_parts)) + + return result + + +def _ensure_label_entity_id(atom_array: AtomArray) -> AtomArray: + """Return a copy of ``atom_array`` with ``label_entity_id`` set (1-indexed). + + Ensures the entity table and ``label_entity_id`` on ``atom_site`` reference + matching IDs during CIF writing. ``chain_entity`` (0-indexed, from + :py:func:`annotate_entities`) is computed if missing but not modified. + Returns the input unchanged if ``label_entity_id`` is already set. + """ + if "label_entity_id" in atom_array.get_annotation_categories(): + return atom_array + annotated = atom_array.copy() + if "chain_entity" not in annotated.get_annotation_categories(): + annotated, _ = ta.annotate_entities( + atom_array=annotated, + level="chain", + lower_level_id="res_id", + lower_level_entity="res_name", + add_inter_level_bond_hash=False, + ) + annotated.set_annotation("label_entity_id", (annotated.chain_entity + 1).astype(np.int8)) + return annotated + + +def _to_cif_or_bcif( + structure: AtomArray | list[AtomArray], + config: CIFWriteConfig, + *, + model_ids: list[int] | None = None, + as_bcif: bool = False, +) -> pdbx.CIFFile | pdbx.BinaryCIFFile: + """Convert an AtomArray (or list thereof) to a CIF or BCIF file. + + When ``structure`` is a list, each element is written as a separate model. + Models may differ in atom count and annotation set; StandardAnnotation + categories are concatenated across models with a ``model_num`` column + that records the originating model ID. + Entity categories are only written for single-structure input. + + Args: + structure: Single structure or list of structures. + config: Configuration for the CIF write operation. + model_ids: Model IDs for list input. Defaults to ``[1, 2, …]``. + as_bcif: Return binary CIF format. Defaults to False. + """ + is_list = isinstance(structure, list) + structures: list[AtomArray] = structure if is_list else [structure] + + if not structures: + raise ValueError("structures list must not be empty") + if model_ids is None: + model_ids = list(range(1, len(structures) + 1)) + if len(model_ids) != len(structures): + raise ValueError(f"model_ids length {len(model_ids)} != structures length {len(structures)}") + + # Set label_entity_id once on the shared structure so the entity table and + # atom_site reference matching IDs. + structures = [_ensure_label_entity_id(s) for s in structures] + + date = config.date if exists(config.date) else datetime.now().strftime("%Y-%m-%d") + time_str = config.time if exists(config.time) else datetime.now().strftime("%H:%M:%S") + + cif_file = pdbx.CIFFile() + block = pdbx.convert._get_or_create_block(cif_file, block_name=config.id) + + # Extract CCD entries: prefer explicit config, then AtomArrayPlus attr, then empty + ccd_entries = config.ccd_entries or getattr(structures[0], "_custom_ccd_registry", None) or {} + + metadata: dict = {"entry": {"id": config.id, "author": config.author, "date": date, "time": time_str}} + if not is_list: + for flag, build_func in [ + (config.include_entity_categories, _build_entity_categories), + (config.include_chem_comp, _build_chem_comp), + ]: + if flag: + try: + metadata.update(build_func(structures[0])) + except Exception as e: + logger.warning(f"Failed to build `{build_func.__name__}`: {e}") + _write_categories_to_block(block, metadata) + + all_extra_categories: dict[str, dict] = {} + + if is_list: + all_atom_sites: list[pdbx.CIFCategory] = [] + # Accumulate SA data per category name across models. Each category + # gets a `model_num` column so the reader can filter rows by model, + # mirroring how atom_site uses pdbx_PDB_model_num. + per_sa_category: dict[str, list[dict]] = defaultdict(list) + for s, model_id in zip(structures, model_ids, strict=False): + s, extra_fields = _preprocess_structure_for_cif(s, config) + atom_site = _atom_site_from_array(s, extra_fields) + atom_site["pdbx_PDB_model_num"] = np.full(s.array_length(), model_id, dtype=np.int32) + all_atom_sites.append(atom_site) + if config.save_standard_annotations: + for key, val in _serialize_standard_annotation_categories(s).items(): + n_rows = len(next(iter(val.values()))) + val["model_num"] = np.full(n_rows, model_id, dtype=np.int32) + per_sa_category[key].append(val) + + # Concatenate each SA category across models into a single CIF category + for key, parts in per_sa_category.items(): + all_columns = sorted(set().union(*(p.keys() for p in parts))) + merged: dict[str, np.ndarray] = {} + for col in all_columns: + merged[col] = np.concatenate([p[col] for p in parts]) + all_extra_categories[key] = merged + + combined = _concatenate_atom_sites(all_atom_sites) + total_atoms = sum(len(site["group_PDB"].as_array()) for site in all_atom_sites) + combined["id"] = np.arange(1, total_atoms + 1, dtype=np.int32) + block["atom_site"] = combined + else: + s, extra_fields = _preprocess_structure_for_cif(structures[0], config) + pdbx.set_structure(cif_file, s, data_block=config.id, extra_fields=extra_fields) + + # Biotite hardcodes label_alt_id="." for all atoms — restore actual values, + # but only when multiple conformers genuinely coexist per residue. After altloc + # selection (one conformer per residue), keeping a non-"." label_alt_id would + # break downstream CIF consumers (e.g. PyMOL struct_conn atom matching) + if "label_alt_id" in s.get_annotation_categories(): + alt_id = s.label_alt_id + if has_multiple_altlocs_per_residue(s): + if isinstance(s, AtomArrayStack): + alt_id = np.tile(alt_id, len(s)) + block["atom_site"]["label_alt_id"] = CIFColumn(alt_id) + + if config.save_standard_annotations: + all_extra_categories.update(_serialize_standard_annotation_categories(s)) + + extra_categories = {**all_extra_categories, **(config.extra_categories or {})} if extra_categories: _write_categories_to_block(block, extra_categories) + # Emit chem_comp_atom + chem_comp_bond inside context manager + if not is_list and config.include_chem_comp: + source = config.chem_comp_source + uses_ccd = source in ("ccd", "ccd_non_canonicals") + if uses_ccd and config.warn_on_ccd_without_registry and not isinstance(structures[0], AtomArrayPlus): + logger.warning( + "Writing chem_comp with chem_comp_source=%r from a plain AtomArray. " + "A plain AtomArray has no preserved CCD context, so known component codes resolve to the bundled CCD. " + "If this structure came from a CIF, parse it with return_atom_array_plus=True to preserve its CCD " + "registry. If it was constructed or modified and a component slice is the complete intended CCD " + "definition, write it with ccd_entries={code: component_template}; run any earlier CCD-dependent " + "processing inside `with custom_ccd_residues(ccd_entries):`. " + "Silence this warning with CIFWriteConfig(warn_on_ccd_without_registry=False).", + source, + ) + ctx = custom_ccd_residues(ccd_entries) if uses_ccd and ccd_entries else contextlib.nullcontext() + with ctx: + post_categories: dict = {} + try: + post_categories.update(_build_chem_comp_atom(structures[0], source=source)) + except Exception as e: + logger.warning(f"Failed to build _build_chem_comp_atom: {e}") + try: + post_categories.update(_build_chem_comp_bond(structures[0], source=source)) + except Exception as e: + logger.warning(f"Failed to build _build_chem_comp_bond: {e}") + if post_categories: + _write_categories_to_block(block, post_categories) + if as_bcif: cif_file = _cif_to_bcif(cif_file) + return cif_file def to_cif_buffer( - structure: AtomArray, + structure: AtomArray | list[AtomArray], *, - id: str = "unknown_id", - author: str = _get_logged_in_user(), - date: str | None = None, - time: str | None = None, - include_entity_poly: bool = False, - include_nan_coords: bool = True, - include_bonds: bool = True, - extra_fields: list[str] | Literal["all"] = [], - extra_categories: dict[str, dict[str, float | int | str | list | np.ndarray]] | None = None, - _allow_ambiguous_bond_annotations: bool = False, + model_ids: list[int] | None = None, + config: CIFWriteConfig | None = None, as_bcif: bool = False, ) -> io.StringIO | io.BytesIO: - """Convert an AtomArray structure to a CIF formatted StringIO buffer. + """Convert an AtomArray (or list thereof) to a CIF formatted buffer. Args: - structure (AtomArray): The atomic structure to be converted. - id (str): The ID of the entry. This will be used as the data block name. - author (str): The author of the entry. - date (str): The date of the entry. - time (str): The time of the entry. - include_entity_poly (bool): Whether to write entity_poly category in the CIF file. - include_nan_coords (bool): Whether to write NaN coordinates in the CIF file. - include_bonds (bool): Whether to write bonds in the CIF file. - extra_fields (list[str] | Literal["all"]): Additional atom_array annotations to include in the CIF file. - extra_categories (dict[str, dict[str, float | int | str | list | np.ndarray]] | None, optional): - Additional CIF categories to include in data block. These must be a dict of form {category_name: {column_name: value}}. - Example: {"reflns": {"pdbx_reflns_number_d_mean": 1.0}, "my_metadata": {"hi": np.arange(10)}} - _allow_ambiguous_bond_annotations (bool, optional): Private argument, not meant for public use. - If True, allows ambiguous bond annotations. + structure: The atomic structure to be converted. Pass a list to write + each element as a separate model in a multi-model CIF. + model_ids: Model IDs for each element when ``structure`` is a list. + Defaults to ``[1, 2, …]``. + config: Write configuration. If None, uses default CIFWriteConfig(). + as_bcif: Return binary CIF format. Defaults to False. Returns: - StringIO | BytesIO: A buffer containing the CIF/BCIF formatted string/bytes representation of the structure. + StringIO or BytesIO buffer containing the CIF/BCIF formatted structure. + + See Also: + :py:class:`CIFWriteConfig` + :py:func:`to_cif_string` + :py:func:`to_cif_file` """ - file_obj = _to_cif_or_bcif( - structure, - id=id, - author=author, - date=date, - time=time, - include_entity_poly=include_entity_poly, - include_nan_coords=include_nan_coords, - include_bonds=include_bonds, - extra_fields=extra_fields, - extra_categories=extra_categories, - as_bcif=as_bcif, - _allow_ambiguous_bond_annotations=_allow_ambiguous_bond_annotations, - ) + if config is None: + config = CIFWriteConfig() + + file_obj = _to_cif_or_bcif(structure, config, model_ids=model_ids, as_bcif=as_bcif) buffer = io.BytesIO() if as_bcif else io.StringIO() file_obj.write(buffer) buffer.seek(0) @@ -737,59 +1373,44 @@ def to_cif_buffer( def to_cif_string( - structure: AtomArray, + structure: AtomArray | list[AtomArray], *, - id: str = "unknown_id", - author: str = _get_logged_in_user(), - date: str | None = None, - time: str | None = None, - include_entity_poly: bool = False, - include_nan_coords: bool = True, - include_bonds: bool = True, - extra_fields: list[str] | Literal["all"] = [], - extra_categories: dict[str, dict[str, float | int | str | list | np.ndarray]] | None = None, + model_ids: list[int] | None = None, + config: CIFWriteConfig | None = None, as_bcif: bool = False, - _allow_ambiguous_bond_annotations: bool = False, ) -> str | bytes: - """Convert an AtomArray structure to a CIF formatted string. + """Convert an AtomArray (or list thereof) to a CIF formatted string. Args: - structure (AtomArray): The atomic structure to be converted. - id (str): The ID of the entry. This will be used as the data block name. - author (str): The author of the entry. - date (str): The date of the entry. - time (str): The time of the entry. - include_entity_poly (bool): Whether to write entity_poly category in the CIF file. - include_nan_coords (bool): Whether to write NaN coordinates in the CIF file. - include_bonds (bool): Whether to write bonds in the CIF file. - extra_fields (list[str] | Literal["all"]): Additional atom_array annotations to include in the CIF file. - extra_categories (dict[str, dict[str, float | int | str | list | np.ndarray]] | None, optional): - Additional CIF categories to include in data block. These must be a dict of form {category_name: {column_name: value}}. - Example: {"reflns": {"pdbx_reflns_number_d_mean": 1.0}, "my_metadata": {"hi": np.arange(10)}} + structure: The atomic structure to be converted. Pass a list to write + each element as a separate model in a multi-model CIF. + model_ids: Model IDs for each element when ``structure`` is a list. + Defaults to ``[1, 2, …]``. + config: Write configuration. If None, uses default CIFWriteConfig(). + as_bcif: Return binary CIF format. Defaults to False. Returns: - str | bytes: The CIF/BCIF formatted string/bytes representation of the structure. + String or bytes containing the CIF/BCIF formatted structure. """ - return to_cif_buffer( - structure, - id=id, - author=author, - date=date, - time=time, - include_entity_poly=include_entity_poly, - include_nan_coords=include_nan_coords, - include_bonds=include_bonds, - extra_fields=extra_fields, - extra_categories=extra_categories, - _allow_ambiguous_bond_annotations=_allow_ambiguous_bond_annotations, - as_bcif=as_bcif, - ).getvalue() + return to_cif_buffer(structure, model_ids=model_ids, config=config, as_bcif=as_bcif).getvalue() + + +def _infer_file_type_from_path(path: str | os.PathLike) -> str: + """Infer CIF file type from path extension.""" + path = Path(path) + compression = path.suffix.lower() if is_compressed_file(path) else "" + structure_path = path.with_suffix("") if compression else path + if structure_path.suffix.lower() in (".cif", ".bcif"): + return structure_path.suffix.lower().lstrip(".") + compression + raise ValueError( + f"Could not infer file type from path: {path}. Use .cif or .bcif, optionally with .gz, .gzip, or .zst" + ) def _to_cif_file( file_obj: pdbx.CIFFile | pdbx.BinaryCIFFile, path: os.PathLike, - file_type: Literal["cif", "bcif", "cif.gz"] | None = None, + file_type: CIFFileType, ) -> str: # turn any relative path into an absolute path path = str(os.path.abspath(path)) @@ -797,108 +1418,138 @@ def _to_cif_file( # create the directory if it doesn't exist os.makedirs(os.path.dirname(path), exist_ok=True) - _file_type_map = { - # suffix: (open_func, open_mode, path_suffix) - "cif": (open, "wt", ".cif"), - "bcif": (open, "wb", ".bcif"), - "cif.gz": (gzip.open, "wt", ".cif.gz"), - "bcif.gz": (gzip.open, "wb", ".bcif.gz"), - # ... default if no suffix is provided - "": (gzip.open, "wt", ".cif.gz"), - } - if file_type is None: - file_name = os.path.basename(path) - file_type = file_name.split(".")[-1] if "." in file_name else "" - # ... with gz suffix by fetching the pre-gz suffix - file_type = file_name.split(".")[-2] + ".gz" if file_type == "gz" else file_type - - open_func, open_mode, path_suffix = _file_type_map[file_type] + path_suffix = "." + file_type + if _infer_file_type_from_path("structure" + path_suffix) != file_type: + raise ValueError(f"Unsupported CIF file type: {file_type}") + open_mode = "wb" if file_type.startswith("bcif") else "wt" # ... check that the file ends with the correct suffix, otherwise mutate the path to end with the correct suffix - if not path.endswith(path_suffix): + if not path.lower().endswith(path_suffix): path = path + path_suffix - # ... get the name minus the suffix - file_name = os.path.basename(path).replace(path_suffix, "") - with open_func(path, mode=open_mode) as f: + with open_compressed(path, mode=open_mode) as f: file_obj.write(f) return path def to_cif_file( - structure: AtomArray, + structure: AtomArray | list[AtomArray], path: os.PathLike, *, - file_type: Literal["cif", "bcif", "cif.gz"] | None = None, + model_ids: list[int] | None = None, + file_type: CIFFileType | None = None, id: str | None = None, author: str = _get_logged_in_user(), date: str | None = None, time: str | None = None, - include_entity_poly: bool = True, + include_entity_poly: bool | None = None, + include_entity_categories: bool = True, + include_chem_comp: bool = True, include_nan_coords: bool = True, - include_bonds: bool = True, extra_fields: list[str] | Literal["all"] = [], extra_categories: dict[str, dict[str, float | int | str | list | np.ndarray]] | None = None, - _allow_ambiguous_bond_annotations: bool = False, + chain_disambiguation: Literal["transformation_id", "chain_iid"] | None = None, + save_standard_annotations: bool = True, + include_bonds: bool | None = None, + ccd_entries: dict[str, struc.AtomArray] | None = None, ) -> os.PathLike: """Convert an AtomArray structure to a CIF/BCIF formatted file. Args: - structure (AtomArray): The atomic structure to be converted. - path (os.PathLike): The file path where the CIF formatted structure will be saved. - file_type (Literal["cif", "bcif", "cif.gz"] | None): The file type to save the structure as. - If None, the file type will be inferred from the path. - id (str | None): The ID of the entry. This will be used as the data block name. + structure: The atomic structure to be converted. + path: The file path where the CIF formatted structure will be saved. + file_type: The file type to save the structure as. + ``cif`` or ``bcif``, optionally suffixed with ``.gz``, ``.gzip``, or ``.zst``. + If None, inferred from the path. Zstandard uses level 3; gzip uses level 9. + id: The ID of the entry. This will be used as the data block name. If None, the data block name will be inferred from the path. - author (str): The author of the entry. - date (str): The date of the entry. - time (str): The time of the entry. - include_entity_poly (bool): Whether to write entity_poly category in the CIF file. - include_nan_coords (bool): Whether to write NaN coordinates in the CIF file. - include_bonds (bool): Whether to write bonds in the CIF file. - extra_fields (list[str] | Literal["all"]): Additional atom_array annotations to include in the CIF file. - extra_categories (dict[str, dict[str, float | int | str | list | np.ndarray]] | None, optional): - Additional CIF categories to include in data block. These must be a dict of form {category_name: {column_name: value}}. - Example: {"reflns": {"pdbx_reflns_number_d_mean": 1.0}, "my_metadata": {"hi": np.arange(10)}} + author: The author of the entry. Defaults to current user. + date: The date of the entry (YYYY-MM-DD). Auto-generated if None. + time: The time of the entry (HH:MM:SS). Auto-generated if None. + include_entity_poly: **DEPRECATED**. Use ``include_entity_categories`` instead. + include_entity_categories: Write entity categories (_entity, _entity_poly, etc.). + Defaults to True for file operations. + include_chem_comp: Write _chem_comp CIF category. Defaults to True. + include_nan_coords: Write atoms with NaN coordinates. Defaults to True. + extra_fields: Additional atom_array annotations to include in the CIF file. + Use "all" to include all non-standard annotations. + Explicit StandardAnnotation aliases and legacy names are unsupported. With + ``save_standard_annotations=True``, "all" silently filters them and saves canonical names. + extra_categories: Additional CIF categories to include in data block. + Must be dict of form {category_name: {column_name: value}}. + Example: {"reflns": {"d_mean": 1.0}, "my_metadata": {"hi": np.arange(10)}} + chain_disambiguation: Method for disambiguating chains in multi-transformation assemblies. + Defaults to "chain_iid". + + - "transformation_id": Stores transformation_id in struct_conn symmetry fields. + Preserves original chain_id. Requires AtomWorks to read. + - "chain_iid": Writes chain_iid as chain_id to make chains unique. + Better compatibility with external software. + + save_standard_annotations: Whether to serialize registered + :py:class:`~atomworks.io.utils.standard_annotations.base.StandardAnnotationBase` annotations + (conditions, ``atomize``, etc.) present on the structure. Defaults to True. + include_bonds: **DEPRECATED**. This parameter has no effect and will be removed in a future version. + ccd_entries: Optional dict mapping residue name to CCD template AtomArray for + ``chem_comp_atom`` and ``chem_comp_bond`` emission. Falls back to the + structure's ``_custom_ccd_registry``, then bundled CCD. Returns: - str: The file path where the CIF formatted structure was saved. + The absolute file path where the CIF formatted structure was saved. Raises: IOError: If there's an issue writing to the specified file path. + + See Also: + :py:class:`CIFWriteConfig` + :py:func:`to_cif_buffer` + :py:func:`to_cif_string` """ - # turn any relative path into an absolute path + # Handle deprecated parameter + if include_entity_poly is not None: + warnings.warn( + "Parameter 'include_entity_poly' is deprecated and will be removed in a future version. " + "Use 'include_entity_categories' instead.", + FutureWarning, + stacklevel=2, + ) + # If new parameter not explicitly set, use deprecated value + if include_entity_categories and not include_entity_poly: + include_entity_categories = include_entity_poly + + if include_bonds is not None: + warnings.warn( + "Parameter 'include_bonds' has been deprecated in Biotite and will be removed in a future version. " + "This parameter has no effect.", + FutureWarning, + stacklevel=2, + ) + + # Turn any relative path into an absolute path path = str(os.path.abspath(path)) file_name = os.path.basename(path) if file_type is None: - if str(path).endswith(".cif"): - file_type = "cif" - elif str(path).endswith(".cif.gz"): - file_type = "cif.gz" - elif str(path).endswith(".bcif"): - file_type = "bcif" - elif str(path).endswith(".bcif.gz"): - file_type = "bcif.gz" - else: - raise ValueError(f"Could not infer file type from path: {path}") + file_type = _infer_file_type_from_path(path) - file_obj = _to_cif_or_bcif( - structure, + # Build config from parameters + config = CIFWriteConfig( id=id or file_name, author=author, date=date, time=time, - include_entity_poly=include_entity_poly, + include_entity_categories=include_entity_categories, + include_chem_comp=include_chem_comp, include_nan_coords=include_nan_coords, - include_bonds=include_bonds, - extra_fields=extra_fields, + extra_fields=extra_fields if extra_fields else [], extra_categories=extra_categories, - _allow_ambiguous_bond_annotations=_allow_ambiguous_bond_annotations, - as_bcif=".bcif" in path, + chain_disambiguation=chain_disambiguation, + save_standard_annotations=save_standard_annotations, + ccd_entries=ccd_entries, ) + file_obj = _to_cif_or_bcif(structure, config, model_ids=model_ids, as_bcif="bcif" in file_type) return _to_cif_file(file_obj, path, file_type=file_type) @@ -955,34 +1606,6 @@ def to_pdb_string( return to_pdb_buffer(structure).getvalue() -def _filter_extra_fields(extra_fields: list[str], atom_site: pdbx.CIFCategory) -> list[str]: - """ - Filter the extra fields to only include fields that are actually present in the file. - """ - _translate_builtin_fields = { - "atom_id": "id", - "charge": "pdbx_formal_charge", - "b_factor": "B_iso_or_equiv", - "occupancy": "occupancy", - } - _fields_with_default = { - "label_entity_id", - "auth_seq_id", - } - - filtered_extra_fields = [] - for field in extra_fields: - if field in _fields_with_default: - filtered_extra_fields.append(field) - continue - if _translate_builtin_fields.get(field, field) in atom_site: - filtered_extra_fields.append(field) - else: - logger.warning(f"Field {field} not found in file, ignoring.") - - return filtered_extra_fields - - def find_files_by_extension(input_dir: Path, extension: str) -> list[Path]: """Recursively find files with the specified extension in a directory.""" files = [f for f in input_dir.rglob(f"*{extension}") if str(f).endswith(extension)] diff --git a/src/atomworks/io/utils/link_chemistry.py b/src/atomworks/io/utils/link_chemistry.py new file mode 100644 index 00000000..0be01b16 --- /dev/null +++ b/src/atomworks/io/utils/link_chemistry.py @@ -0,0 +1,1239 @@ +"""Build chemically valid products from authored inter-residue links. + +Link chemistry has three phases: + +1. Use the authored bond order, or infer a candidate order from both isolated CCD + reactants. CCD capacity limits inferred multiple bonds but does not veto a + candidate single-bond product. +2. Make the local leaving-group, bond-order, protonation, and charge changes needed + to turn those reactants into the linked product. +3. Reject the product unless every affected atom has a radical-free RDKit valence. +""" + +import contextlib +import functools +import logging +import os +from collections import defaultdict, deque +from typing import Literal + +import biotite.structure as struc +import networkx as nx +import numpy as np +from biotite.structure import AtomArray +from rdkit import Chem, rdBase +from rdkit.Chem import GetPeriodicTable + +from atomworks.constants import ( + BIOTITE_BOND_TYPE_TO_BOND_ORDER, + BOND_DISTANCE_THRESHOLD_CHNO, + BOND_DISTANCE_THRESHOLD_CHNOPS, + BOND_DISTANCE_THRESHOLD_OTHER, + CCD_MIRROR_PATH, + CHNO_ELEMENTS, + CHNOPS_ELEMENTS, + DEFAULT_VALENCE, + DO_NOT_MATCH_CCD, + HYDROGEN_LIKE_SYMBOLS, +) +from atomworks.enums import ChainType +from atomworks.io.utils.atom_array import ( + _find_bonded_hydrogens, + _get_bond_neighbors, + get_bond_degree_per_atom, +) +from atomworks.io.utils.ccd import _standard_ccd_only_cache, atom_array_from_ccd_code, get_polymerization_atoms + +logger = logging.getLogger(__name__) + + +_GLYCOSYLATION_ROLES = ( + "c-mannosylation", + "n-glycosylation", + "o-glycosylation", + "s-glycosylation", +) +_MAX_GLYCOSYLATION_DISTANCE = 2.4 +_LINK_PRODUCT_CHARGES = { + ("B", 0, 4): -1, + ("C", 1, 4): 0, + ("N", 0, 4): 1, + ("O", -1, 2): 0, + ("O", 0, 1): -1, + ("P", 0, 4): 1, + ("S", 0, 3): 1, + ("SE", 0, 1): -1, + ("SE", 0, 3): 1, +} + + +def filter_link_distances( + atom_array: AtomArray, + filtered: dict[str, np.ndarray], + idx1: np.ndarray, + idx2: np.ndarray, + valid: np.ndarray, + distance_policy: str, +) -> np.ndarray: + """Filter or diagnose candidate links using their authored distances.""" + n_rows = len(idx1) + conn_type_arr = filtered["conn_type_id"] + chains = [filtered["ptnr1_label_asym_id"], filtered["ptnr2_label_asym_id"]] + res_names_q = [filtered["ptnr1_label_comp_id"], filtered["ptnr2_label_comp_id"]] + atom_ids = [filtered["ptnr1_label_atom_id"], filtered["ptnr2_label_atom_id"]] + # --- Distance validation using pdbx_dist_value from struct_conn --- + pdbx_dist_value = filtered.get("pdbx_dist_value") + if distance_policy != "keep" and valid.any() and pdbx_dist_value is not None: + dists = np.full(n_rows, np.nan) + for i in range(n_rows): + with contextlib.suppress(ValueError, TypeError): + dists[i] = float(pdbx_dist_value[i]) + + expected_thresholds = _element_distance_thresholds(atom_array.element[idx1], atom_array.element[idx2]) + # Allow authored glycosylation through 2.4 A, e.g. the 2.211 A ASN-NAG link in PDB 2ODP. + roles = filtered.get("pdbx_role") + is_glycosylation = ( + np.isin(np.char.lower(np.char.strip(roles.astype(str))), _GLYCOSYLATION_ROLES) + if roles is not None + else np.zeros(n_rows, dtype=bool) + ) + is_carbohydrate = struc.filter_carbohydrates(atom_array) + is_glycosylation |= (conn_type_arr == "covale") & is_carbohydrate[idx1] & is_carbohydrate[idx2] + expected_thresholds[is_glycosylation] = BOND_DISTANCE_THRESHOLD_CHNO + allowed_thresholds = expected_thresholds.copy() + allowed_thresholds[is_glycosylation] = _MAX_GLYCOSYLATION_DISTANCE + + # Longer competing links can belong to another conformer, e.g. the PLP-LYS link in PDB 1RCQ. + is_long = valid & ~np.isnan(dists) & (dists > expected_thresholds) + exceeds_allowed = is_long & (dists > allowed_thresholds) + if np.any(is_long): + base = distance_policy.split("_")[0] if "_" in distance_policy else distance_policy + if base == "filter": + valid[exceeds_allowed] = False + + for k in np.where(is_long)[0]: + msg = ( + f"struct_conn bond {chains[0][k]}/{res_names_q[0][k]}/{atom_ids[0][k]}" + f" — {chains[1][k]}/{res_names_q[1][k]}/{atom_ids[1][k]}:" + f" pdbx_dist_value {dists[k]:.3f} A exceeds {expected_thresholds[k]:.1f} A threshold" + ) + if not exceeds_allowed[k]: + logger.warning( + "Long explicit glycosylation %s (keeping through %.1f A)", + msg, + allowed_thresholds[k], + ) + elif base == "raise": + raise ValueError(msg) + elif base == "filter": + logger.warning("Skipping %s", msg) + else: + logger.warning("Long struct_conn bond %s (keeping)", msg) + + return valid + + +@functools.cache +def _is_valid_rdkit_valence(element: str, charge: int, valence: int) -> bool: + """Test charge-dependent valence and radicals using explicit H as bond-order sum in a cached RDKit probe.""" + charge_spec = f"{charge:+d}" if charge else "" + with rdBase.BlockLogs(): + product = Chem.MolFromSmiles(f"[{element.title()}H{valence}{charge_spec}]") + return product is not None and product.GetAtomWithIdx(0).GetNumRadicalElectrons() == 0 + + +def _has_valid_rdkit_valence(atom_array: AtomArray, atom_idx: int) -> bool: + _, bond_types = atom_array.bonds.get_bonds(atom_idx) + valence = sum(BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(value), 1) for value in bond_types) + if "nhyd" in atom_array.get_annotation_categories(): + valence += int(atom_array.nhyd[atom_idx]) + return _is_valid_rdkit_valence( + str(atom_array.element[atom_idx]), + int(atom_array.charge[atom_idx]), + valence, + ) + + +def _valence_with_neutralization(element: str, reference_valence: int) -> int: + """Allow the reference or neutral default valence. + + For example, O(-) with reference valence 1 can form a second bond and become neutral ester oxygen. + """ + return max(reference_valence, GetPeriodicTable().GetDefaultValence(element.title())) + + +def infer_link_order(atom_array: AtomArray, atom1: int, atom2: int, *, allow_missing_templates: bool = False) -> int: + """Infer an unspecified link order; the caller preserves recognized explicit orders. + + Reuse the CCD order for an existing bond within the same residue. Otherwise, + start with the higher CCD leaving-bond order of the two partners (single if + neither declares one), then cap it by both partners' available capacity: + hydrogen/leaving-group displacement plus unused valence, including neutralization. + Thus O3'-P stays single in 1DPN, while unbonded selenium can accept a bond in 7ZCY. + + Unspecified single bonds remain candidates because CCD entries describe the + isolated reactants. Product cleanup must make the linked structure valid. + + When ``allow_missing_templates=True``, missing CCD definitions yield ``BondType.ANY`` + so coordinate-only parsing can preserve reported links without inventing their order. + + Raises: + ValueError: Required CCD template/atom/bond data are missing, or either + partner cannot support a bond under these rules. + """ + partners = [(str(atom_array.res_name[i]), str(atom_array.atom_name[i])) for i in (atom1, atom2)] + same_residue = all( + atom_array.get_annotation(key)[atom1] == atom_array.get_annotation(key)[atom2] + for key in ("chain_id", "res_id", "ins_code", "res_name", "transformation_id") + if key in atom_array.get_annotation_categories() + ) + # A leaving C=O can support a new C=N, e.g. the PLP-DLY link in PDB 1RCQ. + order = max((int(get_leaving_atom_bond_type(*p) or 1) for p in partners), default=1) + for res_name, atom_name in partners: + try: + template = atom_array_from_ccd_code(res_name, coords=None) + except ValueError as error: + if allow_missing_templates: + return int(struc.BondType.ANY) + raise ValueError(f"Cannot infer link order for {partners}: missing CCD template for {res_name}") from error + matches = np.flatnonzero(template.atom_name == atom_name) + if len(matches) != 1 or template.bonds is None: + raise ValueError( + f"Cannot infer link order for {partners}: missing CCD atom/bonds for {res_name}/{atom_name}" + ) + neighbors, types = template.bonds.get_bonds(int(matches[0])) + # PDB 1FYL repeats the intra-residue BRU C5-BR bond in struct_conn; it needs no additional valence. + if same_residue: + existing = types[template.atom_name[neighbors] == partners[1][1]] + if len(existing): + return int(existing[0]) + if order == 1: + continue + orders = np.array([BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(t), 1) for t in types]) + leaving = {n for group in get_leaving_atom_groups(template).get(atom_name, ()) for n in group} + retained = ~np.isin(template.element[neighbors], HYDROGEN_LIKE_SYMBOLS) & ~np.isin( + template.atom_name[neighbors], list(leaving) + ) + # Both partners must support the order: in PDB 1DPN, O3' gives up one H, so O3'-P stays single. + # Include unused valence: an unbonded atom need not displace anything, e.g. selenium in 7ZCY. + retained_valence = int(orders[retained].sum()) + element = template.element[matches[0]] + capacity = _valence_with_neutralization(element, int(orders.sum())) - retained_valence + if capacity < order and _is_valid_rdkit_valence( + element, int(template.charge[matches[0]]), retained_valence + order + ): + capacity = order + + order = min(order, capacity) + if order < 1: + raise ValueError( + f"Cannot infer link order for {partners}: retained heavy-atom bonds leave no valence; provide explicit product connectivity" + ) + return order + + +def _element_distance_thresholds( + elements1: np.ndarray, + elements2: np.ndarray, + chno_threshold: float = BOND_DISTANCE_THRESHOLD_CHNO, + chnops_threshold: float = BOND_DISTANCE_THRESHOLD_CHNOPS, + other_threshold: float = BOND_DISTANCE_THRESHOLD_OTHER, +) -> np.ndarray: + """Compute per-bond distance thresholds based on element pairs.""" + e1 = np.char.upper(elements1.astype(str)) + e2 = np.char.upper(elements2.astype(str)) + thresholds = np.full(len(e1), other_threshold) + is_chno = np.isin(e1, list(CHNO_ELEMENTS)) & np.isin(e2, list(CHNO_ELEMENTS)) + thresholds[is_chno] = chno_threshold + is_chnops = np.isin(e1, list(CHNOPS_ELEMENTS)) & np.isin(e2, list(CHNOPS_ELEMENTS)) + thresholds[is_chnops & ~is_chno] = chnops_threshold + return thresholds + + +def _has_existing_polymer_attachment(atom_array: AtomArray, atom1: int, atom2: int, atom_to_res: np.ndarray) -> bool: + """Return whether an authored bond already joins the proposed residue pair.""" + for atom, other in ((atom1, atom2), (atom2, atom1)): + neighbors, types = atom_array.bonds.get_bonds(atom) + residues = atom_to_res[neighbors[types != struc.BondType.COORDINATION]] + if atom_to_res[other] in residues: + return True + return False + + +def _validate_inferred_polymer_bond( + atom_array: AtomArray, atom1: int, atom2: int, degree: np.ndarray, leaving_groups: dict[str, dict] +) -> None: + """Check whether a proposed polymer bond can be added without internal bond rearrangement. + + Each endpoint must have free valence, an explicit/implicit H, or a CCD-declared + leaving group. Sequence alone cannot justify rearranging a saturated backbone + (e.g. SNN in 7P1D). Existing attachments are checked separately by the caller. + ``leaving_groups`` caches CCD lookups within the caller's operation only. + + Raises: + ValueError: An endpoint has neither free valence nor a hydrogen/declared + leaving group to displace. + """ + # Sequence does not authorize rearranging an already saturated backbone, e.g. SNN in 7P1D. + for idx in (atom1, atom2): + res_name = atom_array.res_name[idx] + if res_name not in leaving_groups: + leaving_groups[res_name] = get_chem_comp_leaving_atom_groups(res_name) + groups = leaving_groups[res_name] + expected = DEFAULT_VALENCE.get(atom_array.element[idx], 0) + atom_array.charge[idx] + has_h = ("nhyd" in atom_array.get_annotation_categories() and atom_array.nhyd[idx] > 0) or len( + _find_bonded_hydrogens(atom_array, int(idx)) + ) + if degree[idx] >= expected and not groups.get(atom_array.atom_name[idx]) and not has_h: + raise ValueError( + f"Cannot infer polymer bond at {atom_array.res_name[idx]}/{atom_array.atom_name[idx]}: " + "no free valence or declared leaving group" + ) + + +def add_polymer_bonds(atom_array: AtomArray) -> AtomArray: + """Add single bonds between consecutive polymer attachment sites. + + Leave arrays without bonds or chain-type annotations unchanged. Consider adjacent + residues only within the same chain/transformation, skipping residue-number gaps + greater than one and pairs whose attachment atoms cannot be identified or found. + + For each candidate pair: + - Preserve an existing attachment from either backbone site to the adjacent residue, + including noncanonical attachments (1XVK); do not add a second backbone link. + - Allow authored branches to coexist with the ordinary polymer bond; exact + product-valence validation later rejects unsupported chemistry. + - Require free valence, H, or a declared leaving group at both sites, including + sequence-defined caps (VAL-NH2 in 3N95 and unresolved ACE-SER in 7RCU). + + Merge accepted bonds into the input array and return it. Leaving-atom removal + and charge correction are handled later, not by this function. + + Raises: + ValueError: An endpoint has no capacity for the inferred bond. + """ + if atom_array.bonds is None or "chain_type" not in atom_array.get_annotation_categories(): + return atom_array + + has_tid = "transformation_id" in atom_array.get_annotation_categories() + res_starts = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + + # Pre-compute atom→residue mapping and valence for attachment checks. + sizes = np.diff(res_starts) + atom_to_res = np.repeat(np.arange(len(res_starts) - 1, dtype=np.intp), sizes) + degree = get_bond_degree_per_atom(atom_array) + + atom_names = atom_array.atom_name.tolist() + res_names = atom_array.res_name + chain_ids = atom_array.chain_id + res_ids = atom_array.res_id + transformation_ids = atom_array.transformation_id if has_tid else None + + def _find_atom(start: int, stop: int, name: str) -> int | None: + try: + return atom_names.index(name, start, stop) + except ValueError: + return None + + new_bonds = [] + # Keep reuse local: custom CCD entries can change between calls. + polymerization_atoms = functools.cache(get_polymerization_atoms) + leaving_groups = {} + for i in range(len(res_starts) - 2): + # Find candidate attachment atoms in consecutive residues of the same chain instance. + s1, e1, s2, e2 = res_starts[i], res_starts[i + 1], res_starts[i + 1], res_starts[i + 2] + if chain_ids[s1] != chain_ids[s2]: + continue + if has_tid and transformation_ids[s1] != transformation_ids[s2]: + continue + # Skip over genuine chain-break gaps (missing loops) + if int(res_ids[s2]) - int(res_ids[s1]) > 1: + continue + + atoms_1 = polymerization_atoms(res_names[s1], ChainType.as_enum(atom_array.chain_type[s1])) + atoms_2 = polymerization_atoms(res_names[s2], ChainType.as_enum(atom_array.chain_type[s2])) + if atoms_1[0] is None or atoms_2[1] is None: + continue + + idx_in = _find_atom(s2, e2, atoms_2[1]) + idx_out = _find_atom(s1, e1, atoms_1[0]) + if idx_in is None or idx_out is None: + continue + + # Existing backbone attachments take precedence; side-chain links do not (4AAH). + if _has_existing_polymer_attachment(atom_array, idx_out, idx_in, atom_to_res): + continue + + # Validate capacity for ordinary H/leaving-group removal. + _validate_inferred_polymer_bond(atom_array, idx_out, idx_in, degree, leaving_groups) + new_bonds.append((idx_out, idx_in, int(struc.BondType.SINGLE))) + + if new_bonds: + new_bond_arr = np.array(new_bonds, dtype=np.uint32) + atom_array.bonds = atom_array.bonds.merge(struc.BondList(atom_array.array_length(), new_bond_arr)) + + return atom_array + + +def _find_connected_components_after_removal(graph: nx.Graph, node_to_remove: int) -> list[list[int]]: + """Identifies connected components that would form after removing a node from a graph. + + The graph is not modified. Only components reachable from neighbors of the + removed node are returned; unrelated components are not included. + + Args: + graph: The input graph. + node_to_remove: The node to hypothetically remove. + + Returns: + List of lists containing node indices in each new component. + """ + # Only the removed atom's neighbours seed a traversal, and treating it as + # already seen keeps it out of them, so no subgraph has to be built per atom. + unvisited = set(graph.neighbors(node_to_remove)) + components = [] + while unvisited: + start = unvisited.pop() + seen = {node_to_remove, start} + queue = deque([start]) + component = [] + while queue: + node = queue.popleft() + component.append(node) + for neighbour in graph[node]: + if neighbour not in seen: + seen.add(neighbour) + queue.append(neighbour) + components.append(component) + unvisited -= set(component) + + return components + + +@_standard_ccd_only_cache(functools.cache) +def _get_chem_comp_leaving_atom_groups_cached( + ccd_code: str, ccd_mirror_path_str: str, mode: Literal["warn", "raise"] = "warn" +) -> dict[str, tuple[tuple[str, ...], ...]]: + """Internal cached implementation. Do not call directly.""" + # Skip CCD lookup for codes that shouldn't be matched against CCD (e.g., UNL, water-like codes) + if ccd_code in DO_NOT_MATCH_CCD: + if mode == "warn": + logger.debug(f"Skipping CCD lookup for `{ccd_code}` as it's in DO_NOT_MATCH_CCD") + return {} + + try: + chem_comp = atom_array_from_ccd_code(ccd_code, ccd_mirror_path_str, coords=None) + except (ValueError, AttributeError) as e: + if mode == "warn": + logger.warning(f"Failed to compute leaving groups for `{ccd_code}`: {e}") + elif mode == "raise": + raise ValueError(f"Failed to compute leaving groups for `{ccd_code}`: {e}") from e + return {} + + if "is_leaving_atom" not in chem_comp.get_annotation_categories(): + if mode == "warn": + logger.warning( + f"No 'is_leaving_atom' annotation found for `{ccd_code}`. " + "Cannot compute leaving groups, returning empty dictionary. " + "Check if your CCD mirror is up to date." + ) + elif mode == "raise": + raise ValueError( + f"No 'is_leaving_atom' annotation found for `{ccd_code}`. " + "Cannot compute leaving groups. Check if your CCD mirror is up to date." + ) + return {} + return get_leaving_atom_groups(chem_comp) + + +def get_leaving_atom_groups(chem_comp: struc.AtomArray) -> dict[str, tuple[tuple[str, ...], ...]]: + """Leaving atom groups of a component template (none without ``is_leaving_atom`` flags).""" + if "is_leaving_atom" not in chem_comp.get_annotation_categories(): + return {} + is_leaving_atom = chem_comp.get_annotation("is_leaving_atom") + if not any(is_leaving_atom): + return {} + + return dict( + _leaving_groups_from_topology( + tuple(chem_comp.atom_name), + tuple(chem_comp.element), + tuple(is_leaving_atom), + tuple(map(tuple, chem_comp.bonds.as_array()[:, :2].tolist())), + ) + ) + + +@functools.lru_cache(maxsize=2048) +def _leaving_groups_from_topology( + atom_name: tuple[str, ...], + element: tuple[str, ...], + leaving_flags: tuple[bool, ...], + bond_endpoints: tuple[tuple[int, int], ...], +) -> dict[str, tuple[tuple[str, ...], ...]]: + """Reuse leaving groups by chemical content, including for changing registry overrides. + + Bond orders and coordinates do not affect connectivity or leaving-group membership. + Callers must copy the returned dictionary before exposing it for mutation. + """ + leaving_atom_groups = defaultdict(list) + is_leaving_atom = np.asarray(leaving_flags) + bond_graph = nx.Graph() + bond_graph.add_edges_from(bond_endpoints) + for atom_idx in range(len(atom_name)): + # ... find the connected groups of atoms if the current atom were removed + connected_groups = _find_connected_components_after_removal(bond_graph, atom_idx) + + # A heavy leaving group carries its attached hydrogens with it. + for connected_group in connected_groups: + heavy_atoms: list[int] = list(filter(lambda x: element[x] != "H", connected_group)) + is_leaving_group = ( + all(is_leaving_atom[heavy_atoms]) if len(heavy_atoms) > 0 else all(is_leaving_atom[connected_group]) + ) + + if is_leaving_group: + leaving_atom_groups[atom_name[atom_idx]].append(sorted(connected_group)) + + # ... order members and groups by CCD declaration index so the result is stable + return { + parent: tuple(tuple(atom_name[idx] for idx in group) for group in sorted(groups, key=min)) + for parent, groups in leaving_atom_groups.items() + } + + +def get_chem_comp_leaving_atom_groups( + ccd_code: str, ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, mode: Literal["warn", "raise"] = "warn" +) -> dict[str, tuple[tuple[str, ...], ...]]: + """Compute the leaving atom groups for a given CCD entry. + + The returned dictionary maps the name of an atom to the groups of atoms that would + become disconnected if the atom were removed. Each unit of inter-residue bond order + displaces one group. + + Example: + >>> get_chem_comp_leaving_atom_groups("ALA") + {'N': (('H2',),), 'C': (('OXT', 'HXT'),), 'OXT': (('HXT',),)} + """ + # Normalize path and delegate to cached implementation (use empty string for None) + cache_key = os.fspath(ccd_mirror_path) if ccd_mirror_path else "" + return dict(_get_chem_comp_leaving_atom_groups_cached(ccd_code, cache_key, mode)) + + +def get_leaving_atom_bond_type(ccd_code: str, atom_name: str) -> struc.BondType | None: + """Return the bond type between ``atom_name`` and its CCD leaving atoms.""" + leaving_groups = get_chem_comp_leaving_atom_groups(ccd_code) + if atom_name not in leaving_groups: + return None + + try: + chem_comp = atom_array_from_ccd_code(ccd_code) + except (ValueError, AttributeError): + return None + + names = chem_comp.atom_name + matches = np.where(names == atom_name)[0] + if not len(matches): + return None + + atom_idx = int(matches[0]) + leaving_set = {name for group in leaving_groups[atom_name] for name in group} + rows = chem_comp.bonds.as_array() + rows = rows[(rows[:, 0] == atom_idx) | (rows[:, 1] == atom_idx)] + for a1, a2, bt in rows: + nb = a2 if a1 == atom_idx else a1 + if names[nb] in leaving_set and (bond_type := struc.BondType(int(bt))) != struc.BondType.SINGLE: + return bond_type + return None + + +def _check_bonds_in_direction( + bonds_array: np.ndarray, + leaving_atom_mask: np.ndarray, + parent_atom_mask: np.ndarray, + leaving_col: int, + other_col: int, + atom_array: struc.AtomArray, +) -> None: + """Check that leaving atoms at ``leaving_col`` only bond to valid atoms at ``other_col``.""" + is_leaving = leaving_atom_mask[bonds_array[:, leaving_col]] + bonds_with_leaving = bonds_array[is_leaving] + + if len(bonds_with_leaving) == 0: + return + + other_atoms = bonds_with_leaving[:, other_col] + # Valid bonds: to parent atoms OR to other leaving atoms + is_other_valid = parent_atom_mask[other_atoms] | leaving_atom_mask[other_atoms] + invalid_mask = ~is_other_valid + + if np.any(invalid_mask): + # Find first invalid bond for error message + invalid_bond = bonds_with_leaving[invalid_mask][0] + leaving_idx = invalid_bond[leaving_col] + other_idx = invalid_bond[other_col] + raise ValueError( + f"Leaving atom '{atom_array.atom_name[leaving_idx]}' in residue " + f"{atom_array.res_name[leaving_idx]} (index {leaving_idx}) is bonded to " + f"'{atom_array.atom_name[other_idx]}' (index {other_idx}), which is neither " + f"a parent atom making inter-residue bonds nor another leaving atom. " + f"Cannot safely remove." + ) + + +def _validate_leaving_atoms_have_no_unexpected_bonds( + atom_array: struc.AtomArray, + leaving_atom_mask: np.ndarray, + atoms_with_inter_bonds: np.ndarray, +) -> None: + """Validate that atoms marked for removal don't have unexpected bonds.""" + if atom_array.bonds is None or atom_array.bonds.get_bond_count() == 0: + return + + bonds_array = atom_array.bonds.as_array() + parent_atom_mask = np.zeros(len(atom_array), dtype=bool) + parent_atom_mask[atoms_with_inter_bonds] = True + + # Check both directions + _check_bonds_in_direction(bonds_array, leaving_atom_mask, parent_atom_mask, 0, 1, atom_array) + _check_bonds_in_direction(bonds_array, leaving_atom_mask, parent_atom_mask, 1, 0, atom_array) + + +def _get_inter_residue_bonds(atom_array: AtomArray) -> np.ndarray: + """Return inter-residue bonds excluding coordination.""" + if atom_array.bonds is None: + return np.empty((0, 3), dtype=int) + bonds = atom_array.bonds.as_array() + bonds = bonds[bonds[:, 2] != struc.BondType.COORDINATION] + identity = ["chain_id", "res_id", "ins_code"] + if "transformation_id" in atom_array.get_annotation_categories(): + identity.append("transformation_id") + is_inter = np.zeros(len(bonds), dtype=bool) + for field in identity: + values = atom_array.get_annotation(field) + is_inter |= values[bonds[:, 0]] != values[bonds[:, 1]] + return bonds[is_inter] + + +def get_inter_residue_atom_mask(atom_array: struc.AtomArray) -> np.ndarray: + """Get boolean mask indicating which atoms are involved in inter-residue bonds.""" + inter_bonds = _get_inter_residue_bonds(atom_array) + atom_mask = np.zeros(len(atom_array), dtype=bool) + atom_mask[np.unique(inter_bonds[:, :2])] = True + return atom_mask + + +def _is_overvalent(atom_array: struc.AtomArray, atom_idx: int, degree: np.ndarray) -> bool: + expected = DEFAULT_VALENCE.get(atom_array.element[atom_idx]) + if expected is not None and atom_array.element[atom_idx] != "C": + # Charge adjusts N/O valence but must not permit five bonds on carbon. + expected += atom_array.charge[atom_idx] + return expected is not None and degree[atom_idx] > expected + + +def _displace_hydrogens(atom_array: AtomArray, atom_idx: int, count: int, removed: set[int]) -> int: + """Detach H, preferring implicit and unresolved explicit atoms.""" + implicit = 0 + if "nhyd" in atom_array.get_annotation_categories(): + implicit = min(count, int(atom_array.nhyd[atom_idx])) + atom_array.nhyd[atom_idx] -= implicit + explicit = _find_bonded_hydrogens(atom_array, atom_idx) + if len(explicit): + explicit = explicit[np.argsort(np.isfinite(atom_array.coord[explicit]).all(axis=-1))] + explicit = explicit[: count - implicit] + for hydrogen in explicit: + atom_array.bonds.remove_bond(atom_idx, int(hydrogen)) + removed.add(int(hydrogen)) + return implicit + len(explicit) + + +def _lower_multiple_bond( + atom_array: AtomArray, + atom_idx: int, + degree: np.ndarray, + require_overvalent_partner: bool, + impacted: np.ndarray, +) -> bool: + """Lower one intra-residue C-C/C-N multiple bond created by an authored addition. + + Two product patterns are supported: + + 1. Both pi-bond atoms gained links, so both are overvalent and the bond order + drops by one (the thymine photodimer in 1N4E). + 2. Only ``atom_idx`` gained a link. With implicit H (``nhyd``), a linked N/O/S + donor transfers H to the other pi-bond atom and the bond order drops by one + (C=N in 8QIA, C≡N in 1U9X, and C=C in 2ZZ6). C≡C is not inferred. + + Return ``True`` only after applying one of these complete local products. + """ + partners, types = atom_array.bonds.get_bonds(atom_idx) + residue = struc.get_all_residue_positions(atom_array) + for partner, bond_type in zip(partners, types, strict=True): + partner = int(partner) + order = BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(bond_type), 1) + overvalent = _is_overvalent(atom_array, partner, degree) + if ( + order <= 1 + or atom_array.element[partner] not in ("C", "N") + or residue[partner] != residue[atom_idx] + or (require_overvalent_partner and not overvalent) + ): + continue + if not overvalent: + element = str(atom_array.element[partner]) + if (element == "C" and order != 2) or not _has_valid_rdkit_valence(atom_array, partner): + continue + donors = partners[(residue[partners] != residue[atom_idx]) & (types == struc.BondType.SINGLE)] + for donor in donors: + donor = int(donor) + if ( + "nhyd" not in atom_array.get_annotation_categories() + or atom_array.nhyd[donor] == 0 + or not _is_valid_rdkit_valence( + str(atom_array.element[donor]), int(atom_array.charge[donor]), int(degree[donor] - 1) + ) + ): + continue + atom_array.nhyd[donor] -= 1 + atom_array.nhyd[partner] += 1 + degree[donor] -= 1 + degree[partner] += 1 + impacted[donor] = True + break + else: + continue + atom_array.bonds.remove_bond(atom_idx, partner) + atom_array.bonds.add_bond(atom_idx, partner, struc.BondType(order - 1)) + degree[[atom_idx, partner]] -= 1 + impacted[[atom_idx, partner]] = True + return True + return False + + +def _maybe_fix_overvalent_carbon( + atom_array: AtomArray, atom_idx: int, degree: np.ndarray, removed: set[int], impacted: np.ndarray +) -> list[int]: + """Resolve one overvalent carbon after an authored link is added. + + The cases are tried in this order: + + 1. Displace H from the linked carbon (the ACE cap in 1J8Z). + 2. Replace a coordinate-missing terminal amide N while retaining C=O (the + ASN-LYS isopeptide in 6N0A). Resolved terminal atoms are never removed here. + 3. Treat attack on C=O as carbonyl addition: C=O becomes C-O(-) (1TQH). + 4. Treat attack on C=C/C=N/C≡N using :func:`_lower_multiple_bond` and donor-H + transfer (8QIA, 1U9X, and 2ZZ6). + + Explicit H are collected in ``removed``; a missing terminal group is returned + for the caller to delete after its bonds are detached. + """ + partners, types = atom_array.bonds.get_bonds(atom_idx) + if _displace_hydrogens(atom_array, atom_idx, 1, removed): + degree[atom_idx] -= 1 + return [] + + residue = struc.get_all_residue_positions(atom_array) + own = residue[partners] == residue[atom_idx] + heavy = ~np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS) + has_carbonyl = np.any(own & (atom_array.element[partners] == "O") & (types == struc.BondType.DOUBLE)) + if has_carbonyl and (~own & (types != struc.BondType.COORDINATION)).any(): + for partner in partners[own & (types == struc.BondType.SINGLE)]: + partner = int(partner) + neighbors, _ = atom_array.bonds.get_bonds(partner) + if ( + atom_array.element[partner] == "N" + and np.isnan(atom_array.coord[partner]).any() + and heavy[neighbors].sum() == 1 + ): + return [partner, *_find_bonded_hydrogens(atom_array, partner)] + + for oxygen, bond_type in zip(partners, types, strict=True): + if atom_array.element[oxygen] == "O" and bond_type == struc.BondType.DOUBLE: + oxygen = int(oxygen) + atom_array.bonds.remove_bond(atom_idx, oxygen) + atom_array.bonds.add_bond(atom_idx, oxygen, struc.BondType.SINGLE) + atom_array.charge[oxygen] = -1 + degree[[atom_idx, oxygen]] -= 1 + impacted[oxygen] = True + return [] + + _lower_multiple_bond(atom_array, atom_idx, degree, False, impacted) + return [] + + +def _restore_leaving_bond_orders_or_raise(atom_array: AtomArray) -> AtomArray: + """Restore bond order lost with a leaving group, or reject the product. + + If a double-bonded leaving atom is replaced by a single authored link, promote + a same-residue terminal O/S/N bond from single to double. The promoted atom + must be able to lose H or neutralize an existing -1 charge, as for TAF + phosphorus in 1DPN. Reject any bond-order deficit that remains afterward. + """ + inter_bonds = _get_inter_residue_bonds(atom_array) + orders = [BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(bt), 1) for bt in inter_bonds[:, 2]] + gained = np.bincount(inter_bonds[:, :2].ravel(), weights=np.repeat(orders, 2), minlength=len(atom_array)) + degree = get_bond_degree_per_atom(atom_array) + residue = struc.get_all_residue_positions(atom_array) + removed: set[int] = set() + leaving_bond_type = functools.cache(get_leaving_atom_bond_type) + for atom_idx in np.flatnonzero(gained): + key = (atom_array.res_name[atom_idx], atom_array.atom_name[atom_idx]) + lost = BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(leaving_bond_type(*key), 0) + if lost <= gained[atom_idx]: + continue + template = atom_array_from_ccd_code(key[0], coords=None) + expected = get_bond_degree_per_atom(template)[template.atom_name == key[1]].item() + for _ in range(min(int(lost - gained[atom_idx]), int(expected - degree[atom_idx]))): + neighbors, types = atom_array.bonds.get_bonds(int(atom_idx)) + candidates = [] + for neighbor in neighbors[ + (types == struc.BondType.SINGLE) + & (residue[neighbors] == residue[atom_idx]) + & np.isin(atom_array.element[neighbors], ("O", "S", "N")) + ]: + neighbor = int(neighbor) + bonded, _ = atom_array.bonds.get_bonds(neighbor) + heavy = bonded[~np.isin(atom_array.element[bonded], HYDROGEN_LIKE_SYMBOLS)] + has_h = bool( + ("nhyd" in atom_array.get_annotation_categories() and atom_array.nhyd[neighbor]) + or len(_find_bonded_hydrogens(atom_array, neighbor)) + ) + charge = 0 if atom_array.charge[neighbor] == -1 else int(atom_array.charge[neighbor]) + element = str(atom_array.element[neighbor]) + product_valence = int(degree[neighbor] + 1 - has_h) + if ( + heavy.tolist() == [atom_idx] + and (has_h or atom_array.charge[neighbor] == -1) + and _is_valid_rdkit_valence(element, charge, product_valence) + ): + product_state = (element, charge, product_valence) + candidates.append((product_state, neighbor, has_h, charge)) + if not candidates: + raise ValueError(f"Unsupported bond-order rearrangement at {key[0]}/{key[1]}") + if len({candidate[0] for candidate in candidates}) != 1: + raise ValueError(f"Ambiguous bond-order rearrangement at {key[0]}/{key[1]}") + _, neighbor, has_h, charge = min(candidates, key=lambda candidate: candidate[2]) + if has_h: + _displace_hydrogens(atom_array, neighbor, 1, removed) + atom_array.bonds.remove_bond(int(atom_idx), neighbor) + atom_array.bonds.add_bond(int(atom_idx), neighbor, struc.BondType.DOUBLE) + atom_array.charge[neighbor] = charge + degree[atom_idx] += 1 + degree[neighbor] += 1 - int(has_h) + if degree[atom_idx] < expected: + raise ValueError( + f"Unsupported bond-order rearrangement at {key[0]}/{key[1]}: " + "provide explicit product bond orders instead of inferring a replacement double bond" + ) + return atom_array[~np.isin(np.arange(len(atom_array)), list(removed))] if removed else atom_array + + +def _rebalance_terminal_heteroatoms( + atom_array: AtomArray, impacted: np.ndarray, degree: np.ndarray, removed: set[int] +) -> None: + """Repair two terminal-heteroatom products without changing heavy-atom coordinates. + + 1. A new substituent on P/S may require P/S=O to become P/S-O(-), as in 1CUL + and 1U4N. Prefer the least metal-coordinated equivalent oxygen. + 2. If an authored link uses a terminal O/S/N that was double-bonded in the CCD, + move that double bond to an equivalent single-bonded sibling, as in GLU OE1 + in 1PPV and ADP O1B in 2CGL. Equivalent candidates must have the same charge + and H state; otherwise no choice is made and final validation rejects it. + """ + residue = struc.get_all_residue_positions(atom_array) + for linked in np.flatnonzero(impacted): + linked = int(linked) + element = str(atom_array.element[linked]) + if _has_valid_rdkit_valence(atom_array, linked): + continue + neighbors, types = atom_array.bonds.get_bonds(linked) + + # Case 1: addition to a P/S center lowers one terminal oxo bond. + if element in ("P", "S"): + candidates = [] + for oxygen in neighbors[(types == struc.BondType.DOUBLE) & (atom_array.element[neighbors] == "O")]: + oxygen = int(oxygen) + oxygen_neighbors, oxygen_types = atom_array.bonds.get_bonds(oxygen) + covalent = oxygen_neighbors[oxygen_types != struc.BondType.COORDINATION] + if ( + residue[oxygen] == residue[linked] + and covalent.tolist() == [linked] + and _is_valid_rdkit_valence(element, int(atom_array.charge[linked]), int(degree[linked] - 1)) + ): + candidates.append((int(np.sum(oxygen_types == struc.BondType.COORDINATION)), oxygen)) + if candidates: + _, oxygen = min(candidates) + atom_array.bonds.remove_bond(linked, oxygen) + atom_array.bonds.add_bond(linked, oxygen, struc.BondType.SINGLE) + atom_array.charge[oxygen] = -1 + degree[[linked, oxygen]] -= 1 + impacted[oxygen] = True + continue + + # Case 2: a linked terminal heteroatom transfers its double bond to a sibling. + if element not in ("O", "S", "N"): + continue + candidates = [] + for center in neighbors[(types == struc.BondType.DOUBLE) & (residue[neighbors] == residue[linked])]: + center = int(center) + siblings, sibling_types = atom_array.bonds.get_bonds(center) + for sibling in siblings[ + (sibling_types == struc.BondType.SINGLE) + & (residue[siblings] == residue[linked]) + & (atom_array.element[siblings] == element) + ]: + sibling = int(sibling) + sibling_neighbors, sibling_neighbor_types = atom_array.bonds.get_bonds(sibling) + heavy_covalent = sibling_neighbors[ + (sibling_neighbor_types != struc.BondType.COORDINATION) + & ~np.isin(atom_array.element[sibling_neighbors], HYDROGEN_LIKE_SYMBOLS) + ] + if heavy_covalent.tolist() != [center]: + continue + explicit_h = _find_bonded_hydrogens(atom_array, sibling) + implicit_h = int(atom_array.nhyd[sibling]) if "nhyd" in atom_array.get_annotation_categories() else 0 + has_h = bool(implicit_h or len(explicit_h)) + charge = 0 if atom_array.charge[sibling] == -1 else int(atom_array.charge[sibling]) + if _is_valid_rdkit_valence(element, charge, int(degree[sibling] + 1 - has_h)): + signature = (int(atom_array.charge[sibling]), implicit_h, len(explicit_h)) + coordination = int(np.sum(sibling_neighbor_types == struc.BondType.COORDINATION)) + candidates.append((coordination, signature, center, sibling, has_h, charge)) + if not candidates: + continue + coordination = min(candidate[0] for candidate in candidates) + candidates = [candidate for candidate in candidates if candidate[0] == coordination] + if len({candidate[1] for candidate in candidates}) != 1: + continue + _, _, center, sibling, has_h, charge = candidates[0] + if has_h: + _displace_hydrogens(atom_array, sibling, 1, removed) + atom_array.bonds.remove_bond(linked, center) + atom_array.bonds.add_bond(linked, center, struc.BondType.SINGLE) + atom_array.bonds.remove_bond(center, sibling) + atom_array.bonds.add_bond(center, sibling, struc.BondType.DOUBLE) + atom_array.charge[sibling] = charge + degree[linked] -= 1 + degree[sibling] += 1 - int(has_h) + impacted[sibling] = True + + +def _remove_ccd_leaving_atoms( + atom_array: struc.AtomArray, + inter_bonds: np.ndarray, + preserve_atom_mask: np.ndarray | None = None, +) -> struc.AtomArray: + """Remove CCD-defined leaving atoms for atoms that form inter-residue bonds. + + Each unit of authored bond order consumes at most one CCD leaving group. + Groups already absent consume the budget first, then unprotected groups may be + removed unless they are themselves link endpoints. By default, finite-coordinate + atoms are protected. Canonical polymer bonds also consume their CCD leaving groups; + other protected atoms are retained. Thus 6W13 removes missing OP3 while + preserving resolved OP2, and internal peptide OXT atoms are removed. + """ + leaving_atom_mask = np.zeros(len(atom_array), dtype=bool) + has_nhyd = "nhyd" in atom_array.get_annotation_categories() + _h_first = frozenset(s[0] for s in HYDROGEN_LIKE_SYMBOLS) + atoms_with_inter_bonds = np.unique(inter_bonds[:, :2]) + inter_bond_mask = np.zeros(len(atom_array), dtype=bool) + inter_bond_mask[atoms_with_inter_bonds] = True + polymer_link_mask = np.zeros(len(atom_array), dtype=bool) + + polymerization_atoms = functools.cache(get_polymerization_atoms) + has_chain_type = "chain_type" in atom_array.get_annotation_categories() + for atom1, atom2, _ in inter_bonds: + atom1, atom2 = int(atom1), int(atom2) + sites = [] + for atom in (atom1, atom2): + chain_type = ChainType.as_enum(atom_array.chain_type[atom]) if has_chain_type else None + sites.append(polymerization_atoms(str(atom_array.res_name[atom]), chain_type)) + names = atom_array.atom_name + if (names[atom1], names[atom2]) in ((sites[0][0], sites[1][1]), (sites[0][1], sites[1][0])): + polymer_link_mask[[atom1, atom2]] = True + + def is_implicit_h(group: tuple[str, ...], present: set[str]) -> bool: + """Identify absent hydrogen-only groups tracked by nhyd, excluding explicit hydrogens.""" + return has_nhyd and present.isdisjoint(group) and all(n[0] in _h_first for n in group if n) + + # Pre-fetch CCD leaving info for every residue that makes an inter-residue bond. + unique_res_names = np.unique(atom_array.res_name[atoms_with_inter_bonds]) + leaving_cache = {rn: get_chem_comp_leaving_atom_groups(rn) for rn in unique_res_names} + # Each unit of link order displaces one leaving group. + orders = np.array([BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(bt), 1) for bt in inter_bonds[:, 2]]) + n_displaceable = np.bincount(inter_bonds[:, :2].ravel(), weights=np.repeat(orders, 2), minlength=len(atom_array)) + + # Build per-atom residue slices for O(1) lookup inside the loop. + _rs = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + sizes = np.diff(_rs) + atom_to_res_start = np.repeat(_rs[:-1], sizes) + atom_to_res_stop = np.repeat(_rs[1:], sizes) + if preserve_atom_mask is None: + preserve_atom_mask = np.isfinite(atom_array.coord).all(axis=-1) + else: + preserve_atom_mask = np.asarray(preserve_atom_mask, dtype=bool) + if preserve_atom_mask.shape != (len(atom_array),): + raise ValueError("preserve_atom_mask must match the AtomArray length") + current_res_start = -1 + + for atom_idx in atoms_with_inter_bonds: + groups = leaving_cache[atom_array.res_name[atom_idx]].get(atom_array.atom_name[atom_idx], ()) + if not groups: + continue + + res_start = int(atom_to_res_start[atom_idx]) + res_stop = int(atom_to_res_stop[atom_idx]) + # Inter-bond atoms are sorted: all parents in a residue can share these sets. + if res_start != current_res_start: + res_atom_names = atom_array.atom_name[res_start:res_stop] + present = set(res_atom_names) + preserved = set(res_atom_names[preserve_atom_mask[res_start:res_stop]]) + linked = set(res_atom_names[inter_bond_mask[res_start:res_stop]]) + current_res_start = res_start + still_attached = [g for g in groups if is_implicit_h(g, present) or not present.isdisjoint(g)] + removable = [ + group + for group in still_attached + if (polymer_link_mask[atom_idx] or preserved.isdisjoint(group)) and linked.isdisjoint(group) + ] + budget = max(0, int(n_displaceable[atom_idx]) - (len(groups) - len(still_attached))) + + for group in removable[:budget]: + leaving_atom_mask[res_start:res_stop] |= ( + res_atom_names == group[0] if len(group) == 1 else np.isin(res_atom_names, list(group)) + ) + # Implicit H are counted in nhyd rather than present as atoms; keep the + # counter consistent with the removal. + if is_implicit_h(group, present): + atom_array.nhyd[atom_idx] = max(0, int(atom_array.nhyd[atom_idx]) - len(group)) + + if np.any(leaving_atom_mask): + # Sanity check: leaving atoms must only bond to their parent or each other. + _validate_leaving_atoms_have_no_unexpected_bonds(atom_array, leaving_atom_mask, atoms_with_inter_bonds) + atom_array = atom_array[~leaving_atom_mask] + + return atom_array + + +def _resolve_overvalent_atoms(atom_array: AtomArray) -> tuple[AtomArray, np.ndarray]: + """Apply product repairs in dependency order and return every changed atom. + + Coupled pi-bond additions are handled before single-endpoint carbon repairs; + terminal resonance/oxo products come next; finally H or missing terminal groups + are displaced. Deferring deletion until the end keeps atom indices stable while + bond orders and valence totals are updated. + """ + impacted = get_inter_residue_atom_mask(atom_array) + inter_atoms = np.flatnonzero(impacted) + carbons = inter_atoms[atom_array.element[inter_atoms] == "C"] + other_atoms = inter_atoms[atom_array.element[inter_atoms] != "C"] + degree = get_bond_degree_per_atom(atom_array) + removed = set() + for idx in carbons: + if _is_overvalent(atom_array, int(idx), degree): + _lower_multiple_bond(atom_array, int(idx), degree, True, impacted) + _rebalance_terminal_heteroatoms(atom_array, impacted, degree, removed) + for idx in [*carbons, *other_atoms]: + if idx in removed: + continue + element = atom_array.element[idx] + if element == "S" and degree[idx] == 3 and _displace_hydrogens(atom_array, int(idx), 1, removed): + degree[idx] -= 1 + continue + if not _is_overvalent(atom_array, idx, degree): + continue + displace = [] + if element == "C": + displace = _maybe_fix_overvalent_carbon(atom_array, idx, degree, removed, impacted) + elif element in ("N", "O"): + # Displace only enough H to accommodate the added bond order. + excess = int(degree[idx] - DEFAULT_VALENCE[element] - atom_array.charge[idx]) + _displace_hydrogens(atom_array, int(idx), excess, removed) + for gone in displace: + neighbors, _ = atom_array.bonds.get_bonds(int(gone)) + for neighbor in neighbors: + atom_array.bonds.remove_bond(int(gone), int(neighbor)) + removed.add(int(gone)) + degree = get_bond_degree_per_atom(atom_array) + if not removed: + return atom_array, impacted + keep = ~np.isin(np.arange(len(atom_array)), list(removed)) + return atom_array[keep], impacted[keep] + + +def build_link_product( + atom_array: struc.AtomArray, + preserve_atom_mask: np.ndarray | None = None, +) -> tuple[struc.AtomArray, np.ndarray]: + """Build local products for authored inter-residue bonds. + + Remove CCD leaving groups, restore any bond order they supplied, and apply the + supported local product transformations. Charge normalization and final RDKit + validation happen later in :func:`resolve_link_chemistry`. + + Args: + atom_array: Structure containing the authored links. + preserve_atom_mask: Atoms that must not be removed as leaving groups. + Finite-coordinate atoms are protected when omitted. + + Returns: + ``(atom_array, impacted_mask)`` where ``impacted_mask`` marks atoms + involved in inter-residue bonds or changed by their repairs, suitable for charge-correction. + + Raises: + ValueError: If a leaving atom is bonded to atoms other than its parent + or fellow leaving atoms, or supported corrections leave an atom overvalent. + """ + inter_bonds = _get_inter_residue_bonds(atom_array) + if len(inter_bonds) == 0: + return atom_array, np.zeros(len(atom_array), dtype=bool) + + atom_array = _remove_ccd_leaving_atoms(atom_array, inter_bonds, preserve_atom_mask) + atom_array = _restore_leaving_bond_orders_or_raise(atom_array) + atom_array, impacted = _resolve_overvalent_atoms(atom_array) + return atom_array, impacted + + +def apply_link_product_charges(atom_array: AtomArray, to_update: np.ndarray) -> AtomArray: + """Apply only explicit formal-charge transitions implied by link products. + + The table covers tetrahedral borate, tetravalent N/P, three-coordinate S/Se, + one-coordinate selenolate, neutralized ester O, deprotonated terminal O, and + neutralization of a pre-existing C(+) after it reaches valence four. O(+) is + handled separately: three-coordinate O is accepted only when it has no O-O + bond. No generic ``bond_degree - default_valence`` charge inference is used. + """ + if ( + "nhyd" not in atom_array.get_annotation_categories() + and not np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS).any() + ): + logger.warning("Neither hydrogens nor nhyd annotation present. Cannot fix formal charges.") + return atom_array + + indices = np.flatnonzero(to_update) + degree = get_bond_degree_per_atom(atom_array)[indices] + # An isolated link endpoint can indicate missing CCD connectivity, e.g. the UNL lipid in PDB 8CUY. + lone = indices[(degree == 1) & get_inter_residue_atom_mask(atom_array)[indices]] + if len(lone): + starts = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + sizes = np.diff(starts) + multi_atom = lone[sizes[np.searchsorted(starts, lone, side="right") - 1] > 1] + if len(multi_atom): + idx = multi_atom[0] + raise ValueError( + f"Atom {idx} ({atom_array.element[idx]}, atom_name={atom_array.atom_name[idx]}, " + f"res_name={atom_array.res_name[idx]}, chain_id={atom_array.chain_id[idx]}, " + f"res_id={atom_array.res_id[idx]}) has inter-residue bonds but no " + "intra-residue bonds — usually a missing CCD template. Ensure the residue is not an " + "unknown ligand (UNL) or otherwise lacking bonding information." + ) + + for idx, valence in zip(indices, degree, strict=True): + key = (str(atom_array.element[idx]).upper(), int(atom_array.charge[idx]), int(valence)) + if key in _LINK_PRODUCT_CHARGES: + atom_array.charge[idx] = _LINK_PRODUCT_CHARGES[key] + elif key == ("O", 0, 3): + neighbors, types = atom_array.bonds.get_bonds(int(idx)) + covalent = neighbors[types != struc.BondType.COORDINATION] + if not np.any(atom_array.element[covalent] == "O"): + atom_array.charge[idx] = 1 + return atom_array + + +def _has_amide_bond(atom_array: AtomArray, n_idx: int, bonds_arr: np.ndarray) -> bool: + """Check if nitrogen at ``n_idx`` is part of an N-C(=O) amide pattern.""" + n_neighbors = _get_bond_neighbors(bonds_arr, n_idx) + for c_idx in n_neighbors[atom_array.element[n_neighbors] == "C"]: + c_neighbors = _get_bond_neighbors(bonds_arr, c_idx) + for o_idx in c_neighbors[atom_array.element[c_neighbors] == "O"]: + bond_mask = ((bonds_arr[:, 0] == c_idx) & (bonds_arr[:, 1] == o_idx)) | ( + (bonds_arr[:, 0] == o_idx) & (bonds_arr[:, 1] == c_idx) + ) + if bond_mask.any() and bonds_arr[bond_mask][0, 2] == int(struc.BondType.DOUBLE): + return True + return False + + +def _normalize_linked_amides( + atom_array: struc.AtomArray, + to_update: np.ndarray | None = None, +) -> tuple[struc.AtomArray, np.ndarray]: + """Neutralize linked amide nitrogens and preserve the affected-atom mask.""" + # Amide formation removes the excess N-H, e.g. LYS NZ linked to DHS in PDB 1QFE. + if to_update is None: + to_update = get_inter_residue_atom_mask(atom_array) + if not np.any(to_update): + return atom_array, to_update + + bonds_arr = atom_array.bonds.as_array() + n_mask = (atom_array.element == "N") & (atom_array.charge == 1) & to_update + + h_to_remove = set() + for n_idx in np.where(n_mask)[0]: + if not _has_amide_bond(atom_array, n_idx, bonds_arr): + continue + # Neutralization requires an actual removable H, not just a charge adjustment. + if not _displace_hydrogens(atom_array, int(n_idx), 1, h_to_remove): + raise ValueError(f"Cannot neutralize amide nitrogen {n_idx}: no removable hydrogen") + atom_array.charge[n_idx] -= 1 + + if h_to_remove: + keep = np.ones(len(atom_array), dtype=bool) + keep[list(h_to_remove)] = False + atom_array = atom_array[keep] + to_update = to_update[keep] + + return atom_array, to_update + + +def correct_charged_amide_nitrogens( + atom_array: struc.AtomArray, + to_update: np.ndarray | None = None, +) -> struc.AtomArray: + """Neutralize linked amide nitrogens, removing the excess H.""" + return _normalize_linked_amides(atom_array, to_update)[0] + + +def _validate_link_valence(atom_array: AtomArray, impacted: np.ndarray) -> None: + """Reject repaired atoms without a possible radical-free RDKit valence.""" + degree = get_bond_degree_per_atom(atom_array) + has_nhyd = "nhyd" in atom_array.get_annotation_categories() + for idx in np.flatnonzero(impacted): + if _has_valid_rdkit_valence(atom_array, int(idx)): + continue + # Coordinate-only inputs may omit both H atoms and nhyd. In that case an + # under-valent heavy-atom graph is valid if implicit H can complete it. + if not has_nhyd and any( + _is_valid_rdkit_valence(str(atom_array.element[idx]), int(atom_array.charge[idx]), int(degree[idx] + n_h)) + for n_h in range(1, 5) + ): + continue + raise ValueError( + f"Unresolved link valence at {atom_array.chain_id[idx]}/{atom_array.res_id[idx]}/" + f"{atom_array.res_name[idx]}/{atom_array.atom_name[idx]} " + f"(element={atom_array.element[idx]}, charge={atom_array.charge[idx]}, bond-order sum={degree[idx]:g})" + ) + + +def resolve_link_chemistry(atom_array: AtomArray, preserve_atom_mask: np.ndarray | None = None) -> AtomArray: + """Build, normalize, and validate products for authored links. + + The phases are deliberately explicit: build the linked product, apply only + supported charge/protonation changes, then validate every affected atom. Raise + rather than guessing when these local rules do not produce a valid product. + + Args: + atom_array: Structure containing the authored links. + preserve_atom_mask: Atoms that must not be removed as leaving groups. + Finite-coordinate atoms are protected when omitted. + """ + atom_array, impacted = build_link_product(atom_array, preserve_atom_mask) + if not impacted.any(): + return atom_array + atom_array = apply_link_product_charges(atom_array, impacted) + atom_array, impacted = _normalize_linked_amides(atom_array, impacted) + _validate_link_valence(atom_array, impacted) + return atom_array + + +# Compatibility names retained for existing callers. +resolve_leaving_atoms = build_link_product +correct_formal_charges_for_specified_atoms = apply_link_product_charges diff --git a/src/atomworks/io/utils/non_rcsb.py b/src/atomworks/io/utils/non_rcsb.py index bf67f9c5..f7ca0710 100644 --- a/src/atomworks/io/utils/non_rcsb.py +++ b/src/atomworks/io/utils/non_rcsb.py @@ -1,19 +1,16 @@ -""" -Utility functions for handling non-RCSB CIF files. +"""Utility functions for handling non-RCSB CIF files. + Such files do not follow the standard CIF format and thus may require special handling. """ __all__ = [ "get_identity_assembly_gen_category", "get_identity_op_expr_category", - "initialize_chain_info_from_atom_array", ] import logging from collections.abc import Sequence -import numpy as np -from biotite.structure import AtomArray from biotite.structure.io.pdbx import CIFCategory from atomworks.constants import ( @@ -25,13 +22,32 @@ ) from atomworks.enums import ChainType from atomworks.io.utils.ccd import get_chem_comp_type -from atomworks.io.utils.selection import get_residue_starts -from atomworks.io.utils.sequence import get_1_from_3_letter_code logger = logging.getLogger("atomworks.io") -def infer_chain_type_from_ccd_codes(ccd_code_seq: Sequence[str]) -> ChainType: +def infer_chain_type_from_three_letter(ccd_code_seq: Sequence[str]) -> ChainType: + """Infer chain type from three-letter CCD code arrays. + + Used for parsed structure data where residues are represented as CCD codes. + Assigns chain type based on the residue chem types of the provided CCD codes. + + Args: + ccd_code_seq: List of three-letter CCD codes (e.g., ``["ALA", "CYS", "ASP"]``). + + Returns: + Inferred chain type enum value. + + Examples: + >>> infer_chain_type_from_three_letter(["ALA", "CYS", "ASP"]) + + >>> infer_chain_type_from_three_letter(["DA", "DT", "DG", "DC"]) + + + See Also: + :py:func:`~atomworks.io.tools.fasta.infer_chain_type_from_one_letter` - + For sequence notation (one-letter format). + """ chain_type_counts = dict.fromkeys( [ "aa_like", @@ -44,9 +60,9 @@ def infer_chain_type_from_ccd_codes(ccd_code_seq: Sequence[str]) -> ChainType: 0, ) - # ... infer the chain type based on each residue for res_name in ccd_code_seq: chem_comp = get_chem_comp_type(res_name, mode="warn") + # Increment the count for the appropriate chain type category # (All amino acid-like chem types are considered "aa_like") if chem_comp in AA_LIKE_CHEM_TYPES: @@ -56,22 +72,24 @@ def infer_chain_type_from_ccd_codes(ccd_code_seq: Sequence[str]) -> ChainType: chain_type_counts[ChainType.POLYPEPTIDE_D] += 1 elif chem_comp in POLYPEPTIDE_L_CHEM_TYPES: chain_type_counts[ChainType.POLYPEPTIDE_L] += 1 + # (We differentiate between RNA and DNA) elif chem_comp in RNA_LIKE_CHEM_TYPES: chain_type_counts[ChainType.RNA] += 1 elif chem_comp in DNA_LIKE_CHEM_TYPES: chain_type_counts[ChainType.DNA] += 1 + # (All other chem types are considered non-polymer) else: chain_type_counts[ChainType.NON_POLYMER] += 1 # WARNING: The following logic is heuristic, and may fail in cases of multiple residues types within a chain. - # ... if we have both RNA and DNA, set the chain type to RNA/DNA hybrid + # If we have both RNA and DNA, set the chain type to RNA/DNA hybrid if chain_type_counts[ChainType.RNA] > 0 and chain_type_counts[ChainType.DNA] > 0: chain_type = ChainType.DNA_RNA_HYBRID - # ... if we have proteins, set to either L- or D-polypeptide, depending on the counts + # If we have proteins, set to either L- or D-polypeptide, depending on the counts elif chain_type_counts[ChainType.POLYPEPTIDE_L] > 0 or chain_type_counts[ChainType.POLYPEPTIDE_D] > 0: # ... if we have equal or more L-polypeptides than D-polypeptides in the chain, set to L-polypeptide if chain_type_counts[ChainType.POLYPEPTIDE_L] >= chain_type_counts[ChainType.POLYPEPTIDE_D]: @@ -81,7 +99,7 @@ def infer_chain_type_from_ccd_codes(ccd_code_seq: Sequence[str]) -> ChainType: elif chain_type_counts[ChainType.POLYPEPTIDE_L] < chain_type_counts[ChainType.POLYPEPTIDE_D]: chain_type = ChainType.POLYPEPTIDE_D - # ... if we only have "aa_like", default to "polypeptide(L)" + # If we only have "aa_like", default to "polypeptide(L)" elif ( chain_type_counts["aa_like"] > 0 and chain_type_counts[ChainType.POLYPEPTIDE_L] == 0 @@ -95,7 +113,7 @@ def infer_chain_type_from_ccd_codes(ccd_code_seq: Sequence[str]) -> ChainType: # ... if we have DNA, set to polydeoxyribonucleotide elif chain_type_counts[ChainType.DNA] > 0: chain_type = ChainType.DNA - # ... otherwise set to non-polymer, if we have non-polymer residues + # Otherwise, set to non-polymer (if we have non-polymer residues) elif chain_type_counts[ChainType.NON_POLYMER] > 0: chain_type = ChainType.NON_POLYMER else: @@ -104,97 +122,6 @@ def infer_chain_type_from_ccd_codes(ccd_code_seq: Sequence[str]) -> ChainType: return chain_type -def initialize_chain_info_from_atom_array( - atom_array: AtomArray, - infer_chain_type: bool = True, - infer_chain_sequences: bool = True, - use_chain_iids: bool = False, -) -> dict: - """Infer specified information (chain type and sequence information) from an AtomArray. - - Required when the chain type or polymer sequence information is not explicitly provided in the CIF file. - Such situations may arise when the CIF file is not from the RCSB PDB database (e.g., distillation). - - WARNING: Use this function for computationally predicted structures or for inference; do not use for files from the RCSB PDB database. - - In particular, this function adds the following information to the chain_info_dict: - - The RCSB entity ID for each chain (e.g., 1, 2, 3, etc.), if present in the AtomArray (under the entity_id atom site label) - - The unprocessed one-letter entity canonical and non-canonical sequences. - - (Optionally) A boolean flag indicating whether the chain is a polymer. - - (Optionally) The chain type as an IntEnum (e.g., polypeptide(L), non-polymer, etc.) - - (Optionally) The residue IDs and residue names, inferred from the AtomArray. - - Args: - atom_array (AtomArray): The AtomArray object to infer chain information from. - infer_chain_type (bool, optional): Whether to infer the chain type from the AtomArray. Defaults to True. - infer_chain_sequences (bool, optional): Whether to infer the chain sequences from the AtomArray. Defaults to True. - use_chain_iids (bool, optional): Whether to use the chain_iid annotation rather than the chain_id. Defaults to False. - - Returns: - dict: A dictionary containing the specified chain information as keys - """ - logger.info( - "Could not read ChainType from CIF file, inferring from AtomArray (ensure this is the correct behavior)!" - ) - chain_info_dict = {} - - _res_starts = get_residue_starts(atom_array) - if use_chain_iids: - chain_identifiers = atom_array.chain_iid[_res_starts] - else: - chain_identifiers = atom_array.chain_id[_res_starts] - res_ids = atom_array.res_id[_res_starts] - res_names = atom_array.res_name[_res_starts] - hetero = atom_array.hetero[_res_starts] - - for chain_identifier in np.unique(chain_identifiers): - if not chain_identifier: - raise ValueError( - 'Chain identifier is empty! Please ensure that in your input file, each chain has a unique identifier (e.g., `label_asym_id` in a CIF file cannot be "").' - ) - - is_in_chain = chain_identifiers == chain_identifier - seq = res_names[is_in_chain] - - # ... EDGE CASE: If all atoms are "HETATM", override the chain type to non-polymer - if np.all(hetero[is_in_chain]): - chain_type = ChainType.NON_POLYMER - else: - chain_type = infer_chain_type_from_ccd_codes(seq) - - processed_entity_non_canonical_sequence = "".join( - get_1_from_3_letter_code(ccd_code, chain_type, use_closest_canonical=False) for ccd_code in seq - ) - processed_entity_canonical_sequence = "".join( - get_1_from_3_letter_code(ccd_code, chain_type, use_closest_canonical=True) for ccd_code in seq - ) - - chain_info_dict[chain_identifier] = {} - - if "label_entity_id" in atom_array.get_annotation_categories(): - # (Entity ID is the same for all atoms in the chain) - atom_level_chain_identifiers = atom_array.chain_iid if use_chain_iids else atom_array.chain_id - representative_entity_id = atom_array.label_entity_id[atom_level_chain_identifiers == chain_identifier][0] - if representative_entity_id not in [".", "?"]: - chain_info_dict[chain_identifier]["rcsb_entity"] = int(representative_entity_id) - - if infer_chain_type: - chain_info_dict[chain_identifier]["chain_type"] = chain_type - chain_info_dict[chain_identifier]["is_polymer"] = chain_type.is_polymer() - - if infer_chain_sequences: - chain_info_dict[chain_identifier]["res_id"] = list(res_ids[is_in_chain]) - chain_info_dict[chain_identifier]["res_name"] = list(res_names[is_in_chain]) - chain_info_dict[chain_identifier]["processed_entity_non_canonical_sequence"] = ( - processed_entity_non_canonical_sequence - ) - chain_info_dict[chain_identifier]["processed_entity_canonical_sequence"] = ( - processed_entity_canonical_sequence - ) - - return chain_info_dict - - def get_identity_op_expr_category() -> CIFCategory: return CIFCategory.deserialize( """_pdbx_struct_oper_list.id 1 diff --git a/src/atomworks/io/utils/query.py b/src/atomworks/io/utils/query.py index 5bc29ec3..116085b9 100644 --- a/src/atomworks/io/utils/query.py +++ b/src/atomworks/io/utils/query.py @@ -1,6 +1,9 @@ import ast import operator +import re from collections.abc import Callable +from functools import reduce +from itertools import product from types import MappingProxyType from typing import Any @@ -25,8 +28,21 @@ class QueryExpression: Select bonded atoms in specific residues: >>> expr = QueryExpression("has_bonds() & (res_name in ['ALA', 'GLY', 'VAL'])") + + Combine path-selection syntax with predicates via ``sel('...')``: + >>> expr = QueryExpression("sel('[A,B]/ALA') & (x > 0)") + + Combine derived StandardAnnotation masks using their exported shortcuts: + >>> expr = QueryExpression('sa_mask("C_CRD") & sa_mask("C_IDX")') + + Expand a residue-wise reduction back to atoms: + >>> expr = QueryExpression("apply_and_spread('residue', atom_name == 'CA', 'any')") """ + # Functions that take one string-like argument, as opposed to the + # zero-argument predicates below (``has_bonds()`` etc.). + STRING_ARG_FUNCTIONS = frozenset({"sa_mask", "sel"}) + # Map string operators to functions OPS = MappingProxyType( { @@ -51,6 +67,16 @@ class QueryExpression: ast.USub: operator.neg, } ) + REDUCTIONS = MappingProxyType( + { + "all": np.all, + "any": np.any, + "max": np.max, + "mean": np.mean, + "min": np.min, + "sum": np.sum, + } + ) def __init__(self, expr: str) -> None: """Initialize QueryExpression with a query string. @@ -139,11 +165,56 @@ def _build_functions(atom_array: AtomArray) -> dict[str, Callable]: Dictionary mapping function names to callable functions. """ functions = { + "apply_and_spread": lambda level, data, reduction: QueryExpression._apply_and_spread( + atom_array, level, data, reduction + ), "has_nan_coord": lambda: QueryExpression._has_nan_coord(atom_array), "has_bonds": lambda: QueryExpression._has_bonds(atom_array), + "sa_mask": lambda name: QueryExpression._standard_annotation_mask(atom_array, name), + # Bridge into the path-selection DSL: ``sel('A/ALA/1/[CA,CB]')`` returns a mask. + # Non-raising on empty match so it composes inside boolean expressions. + "sel": lambda selection_str: AtomSelectionStack.from_query(selection_str).get_mask( + atom_array, raise_on_empty=False + ), } return functions + @staticmethod + def _apply_and_spread(atom_array: AtomArray, level: str, data: Any, reduction: str) -> np.ndarray: + """Reduce query data at a structural level and spread it back to atoms.""" + from atomworks.io.utils.standard_annotations.base import Level + + if not isinstance(level, str): + raise TypeError("apply_and_spread() level must be a string") + if not isinstance(reduction, str): + raise TypeError("apply_and_spread() reduction must be a string") + if reduction not in QueryExpression.REDUCTIONS: + raise ValueError( + f"Unknown apply_and_spread() reduction '{reduction}'. " + f"Expected one of {sorted(QueryExpression.REDUCTIONS)}." + ) + data = np.asarray(data) + if data.ndim == 0 or len(data) != atom_array.array_length(): + raise ValueError( + "apply_and_spread() data must have one value per atom; " + f"got shape {data.shape} for {atom_array.array_length()} atoms." + ) + return Level(level).apply_and_spread(atom_array, data, QueryExpression.REDUCTIONS[reduction]) + + @staticmethod + def _standard_annotation_mask(atom_array: AtomArray, name: str) -> np.ndarray: + """Derive a registered StandardAnnotation mask without storing it.""" + # Lazy import avoids a cycle through StandardAnnotation defaults that use query utilities. + from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS + + sa_cls = STANDARD_ANNOTATIONS.get(name) + if sa_cls.n_body != 1: + raise ValueError( + f"sa_mask('{name}') requires a one-body standard annotation, but " + f"`{sa_cls.full_name}` is {sa_cls.n_body}-body and has no per-atom mask." + ) + return sa_cls.mask(atom_array) + @staticmethod def _has_nan_coord(atom_array: AtomArray) -> np.ndarray: """Check if atom has NaN coordinates. @@ -166,7 +237,7 @@ def _has_bonds(atom_array: AtomArray) -> np.ndarray: Returns: Boolean numpy array indicating which atoms are involved in bonds. """ - if not hasattr(atom_array, "bonds"): + if atom_array.bonds is None: return np.zeros(atom_array.array_length(), dtype=bool) _bonded_idxs = np.unique(atom_array.bonds.as_array()[:, :2]) return np.isin(np.arange(atom_array.array_length()), _bonded_idxs) @@ -200,7 +271,7 @@ def _ensure_bool_array(mask: Any, expected_length: int) -> np.ndarray: # Check length if len(mask) != expected_length: raise ValueError( - f"Query resulted in mask of length {len(mask)}, but AtomArray has length {expected_length}" + f"Query resulted in mask of length {len(mask)}, " f"but AtomArray has length {expected_length}" ) return mask @@ -315,19 +386,33 @@ def _eval_node( elif isinstance(node, ast.Call): # Handle function calls - if isinstance(node.func, ast.Name): - func_name = node.func.id - if func_name in functions: - # Call the function (no arguments supported for now) - if node.args or node.keywords: - raise ValueError(f"Function '{func_name}' does not accept arguments") - result = functions[func_name]() - # Ensure it returns a boolean array of correct length - return self._ensure_bool_array(result, atom_array.array_length()) - else: - raise NameError(f"Function '{func_name}' is not defined") - else: + if not isinstance(node.func, ast.Name): raise ValueError("Complex function calls not supported") + func_name = node.func.id + if func_name not in functions: + raise NameError(f"Function '{func_name}' is not defined") + + if func_name == "apply_and_spread": + if len(node.args) != 3 or node.keywords: + raise ValueError("Function 'apply_and_spread' expects level, data, and reduction arguments") + level = self._eval_node(node.args[0], namespace, functions, atom_array) + data = self._eval_node(node.args[1], namespace, functions, atom_array) + reduction = self._eval_node(node.args[2], namespace, functions, atom_array) + return functions[func_name](level, data, reduction) + elif func_name in self.STRING_ARG_FUNCTIONS: + if len(node.args) != 1 or node.keywords: + raise ValueError(f"Function '{func_name}' expects a single argument") + argument = self._eval_node(node.args[0], namespace, functions, atom_array) + if not isinstance(argument, str): + raise TypeError(f"Function '{func_name}' argument must evaluate to a string") + result = functions[func_name](argument) + else: + # Zero-argument predicates (has_bonds(), has_nan_coord()). + if node.args or node.keywords: + raise ValueError(f"Function '{func_name}' does not accept arguments") + result = functions[func_name]() + # Ensure it returns a boolean array of correct length + return self._ensure_bool_array(result, atom_array.array_length()) elif isinstance(node, ast.Name): if node.id in namespace: @@ -392,3 +477,408 @@ def idxs(atom_array: AtomArray | AtomArrayStack, expr: str) -> np.ndarray: """ querier = QueryExpression(expr) return querier.idxs(atom_array) + + +# --------------------------------------------------------------------------- +# Path-selection DSL +# +# A terse, path-like selection language over five ordered fields: +# CHAIN_ID / RES_NAME / RES_ID / ATOM_NAME / TRANSFORMATION_ID +# +# It complements the :py:class:`QueryExpression` language above and is bridged +# into it via the ``sel('...')`` function, e.g. ``arr.query("sel('[A,B]/ALA') & (x > 0)")``. +# --------------------------------------------------------------------------- + + +class AtomSelection: + """A single-valued selection of atoms in a molecular structure. + + A selection is specified by ``chain_id``, ``res_name``, ``res_id``, ``atom_name``, + and (optionally) ``transformation_id``. Each field is either an exact value or the + wildcard ``"*"`` (match anything). Fields are combined with logical AND. + + For example: + - specifying only ``chain_id`` selects all atoms in that chain + - specifying ``chain_id`` and ``res_name`` selects all atoms of that residue type in that chain + - specifying only ``atom_name`` selects all atoms with that name, in any chain/residue + + For multi-valued selections (lists, ranges, unions), use :py:class:`AtomSelectionStack`. + """ + + def __init__( + self, + chain_id: str = "*", + res_name: str = "*", + res_id: int | str = "*", + atom_name: str = "*", + transformation_id: int | str = "*", + ): + self.chain_id = chain_id + self.res_name = res_name + self.atom_name = atom_name + self.res_id = int(res_id) if res_id != "*" else res_id + self.transformation_id = str(transformation_id) + + def __str__(self) -> str: + parts = [self.chain_id, self.res_name, str(self.res_id), self.atom_name, str(self.transformation_id)] + + # Remove trailing '*' values + while parts and parts[-1] == "*": + parts.pop() + + return "/".join(parts) + + def __repr__(self) -> str: + return str(self) + + def __eq__(self, other: Any) -> bool: + if isinstance(other, str): + # Convert the string to an AtomSelection for comparison + other = self.from_selection_str(other) + + if not isinstance(other, AtomSelection): + return False + + return ( + self.chain_id == other.chain_id + and self.res_name == other.res_name + and self.res_id == other.res_id + and self.atom_name == other.atom_name + and self.transformation_id == other.transformation_id + ) + + @classmethod + def from_selection_str(cls, selection_string: str) -> "AtomSelection": + """Create a selection from ``CHAIN/RES_NAME/RES_ID/ATOM/TRANSFORM`` syntax. + + ``"*"`` acts as a wildcard for any field; trailing fields may be omitted and default to ``"*"``. + Bracket-list syntax (``[...]``) is not accepted here - use :py:meth:`AtomSelectionStack.from_query` + or the query ``sel('...')`` function for multi-valued selections. + + Examples: + >>> AtomSelection.from_selection_str("A/ALA/1/CA") + A/ALA/1/CA + >>> AtomSelection.from_selection_str("*/ALA/*/CB") + */ALA/*/CB + """ + selection = parse_selection_string(selection_string) + return cls( + chain_id=selection.chain_id, + res_name=selection.res_name, + res_id=selection.res_id, + atom_name=selection.atom_name, + transformation_id=selection.transformation_id, + ) + + @classmethod + def from_pymol_str(cls, pymol_string: str) -> "AtomSelection": + """Create a selection from a PyMOL atom label ``CHAIN/RES_NAME`RES_ID/ATOM``. + + Such strings are produced by clicking an atom/residue in PyMOL, e.g. ``"A/ASP`37/OD2"``. + ``"*"`` may be used as a wildcard. ``transformation_id`` is not supported by PyMOL strings. + """ + selection = parse_pymol_string(pymol_string) + return cls( + chain_id=selection.chain_id, + res_name=selection.res_name, + res_id=selection.res_id, + atom_name=selection.atom_name, + ) + + def get_mask(self, atom_array: AtomArray, raise_on_empty: bool = True) -> np.ndarray: + """Create a boolean mask for this selection on ``atom_array``. + + Args: + raise_on_empty: If ``True`` (default), raise :py:class:`ValueError` when the selection + matches no atoms. Set ``False`` for union/composition contexts (used by ``sel('...')``). + """ + return get_mask_from_atom_selection(atom_array, self, raise_on_empty=raise_on_empty) + + def get_idxs(self, atom_array: AtomArray) -> np.ndarray: + """Get the indices of atoms selected by this AtomSelection.""" + return np.where(self.get_mask(atom_array))[0] + + +def parse_selection_string(selection_string: str) -> AtomSelection: + """Parse a ``CHAIN/RES_NAME/RES_ID/ATOM/TRANSFORM`` string into an :py:class:`AtomSelection`. + + ``"*"`` acts as a wildcard for any field. Trailing fields may be omitted and default to ``"*"``. + + Raises: + ValueError: If bracket-list syntax ``[...]`` is used (multi-valued selections must go through + :py:meth:`AtomSelectionStack.from_query`). + """ + if "[" in selection_string or "]" in selection_string: + raise ValueError( + f"Bracket-list syntax '[...]' is not supported for single selections: {selection_string!r}. " + "Use AtomSelectionStack.from_query(...) or the query sel('...') function instead." + ) + granularity_tiers = ["chain_id", "res_name", "res_id", "atom_name", "transformation_id"] + values = selection_string.split("/") + + # Create a dictionary with available tiers and values + selection_dict = {tier: value for tier, value in zip(granularity_tiers, values, strict=False) if value != "*"} + + return AtomSelection(**selection_dict) + + +def parse_pymol_string(pymol_string: str) -> AtomSelection: + """Parse a PyMOL ``CHAIN/RES_NAME`RES_ID/ATOM`` string into an :py:class:`AtomSelection`. + + Wildcards (``"*"``) are supported; ``transformation_id`` is not. + """ + # Replace backtick with slash to standardize the format + standardized_string = pymol_string.replace("`", "/") + return parse_selection_string(standardized_string) + + +def get_mask_from_selection_string(atom_array: AtomArray, selection_string: str) -> np.ndarray: + """Create a boolean mask from a ``CHAIN/RES_NAME/RES_ID/ATOM/TRANSFORM`` selection string. + + ``"*"`` acts as a wildcard for any field. + """ + return get_mask_from_atom_selection(atom_array, parse_selection_string(selection_string)) + + +def get_mask_from_atom_selection( + atom_array: AtomArray, atom_selection: AtomSelection, raise_on_empty: bool = True +) -> np.ndarray: + """Create a boolean mask from an :py:class:`AtomSelection`. + + Args: + raise_on_empty: If ``True`` (default), raise :py:class:`ValueError` when no atoms match. + """ + mask = np.ones(atom_array.array_length(), dtype=bool) + + # ``"*"`` is the wildcard; an empty/omitted string field is treated as wildcard too (this also + # avoids touching a possibly-absent ``transformation_id`` annotation on a trailing-slash select). + # ``res_id`` is the exception: it is an int and ``0`` is a real residue id, so it must compare + # against the sentinel directly rather than via truthiness (``0`` is falsy). + if atom_selection.chain_id and atom_selection.chain_id != "*": + mask &= atom_array.chain_id == atom_selection.chain_id + + if atom_selection.res_name and atom_selection.res_name != "*": + mask &= atom_array.res_name == atom_selection.res_name + + if atom_selection.res_id != "*": + mask &= atom_array.res_id == atom_selection.res_id + + if atom_selection.atom_name and atom_selection.atom_name != "*": + mask &= atom_array.atom_name == atom_selection.atom_name + + if atom_selection.transformation_id and atom_selection.transformation_id != "*": + mask &= atom_array.transformation_id == atom_selection.transformation_id + + if raise_on_empty and not np.any(mask): + raise ValueError(f"No atoms found for selection: {atom_selection}") + + return mask + + +class AtomSelectionStack: + """A union (logical OR) of :py:class:`AtomSelection` objects. + + Enables a single string to select multiple atoms/segments via + :py:meth:`from_query` (extended syntax with ``[...]`` lists and ranges) or + :py:meth:`from_contig` (contiguous residue ranges). + """ + + def __init__(self, selections: list[AtomSelection]): + self.selections = selections + + @classmethod + def from_contig(cls, contig: str) -> "AtomSelectionStack": + """Create a stack from contiguous residue ranges like ``"A1-2, B3-10"``.""" + # First define a regex that matches the elements of the contig string + CONTIG_REGEX = re.compile(r"([A-Za-z]+)(\d+)-(\d+)") # noqa + selections = [] + for selection in contig.replace(" ", "").split(","): + match = CONTIG_REGEX.match(selection) + if not match: + raise ValueError(f"Invalid contig string: {selection}") + chain_id, start, stop = match.groups() + # Create a new AtomSelection for each match + for i in range(int(start), int(stop) + 1): + # Create a new AtomSelection for each residue in the range + atom_selection = AtomSelection(chain_id=chain_id, res_id=i) + selections.append(atom_selection) + return cls(selections) + + @classmethod + def from_query(cls, query: str | list[str]) -> "AtomSelectionStack": + """Create a stack from the extended path-selection syntax. + + Grammar (fields in order ``CHAIN/RES_NAME/RES_ID/ATOM/TRANSFORM``, trailing fields + default to ``"*"``): + + - Each field is a scalar (``A``), the wildcard ``*``, or a bracket list ``[a, b, ...]``. + - Inside a bracket list, each item is a scalar or - for ``res_id`` only - an inclusive + range ``lo-hi`` (e.g. ``[1-5, 9, 12-14]``). Ranges must be bracketed; a bare ``5-10`` + is not a range. + - Multiple whole tokens, separated by top-level commas (or given as a ``list[str]``), + are unioned. Commas inside ``[...]`` are part of the list, not token separators. + + Examples: + >>> AtomSelectionStack.from_query("[A,B]/ALA/1/[CA,CB]") # 4 selections, unioned + >>> AtomSelectionStack.from_query("A/*/[5-10]") # residues 5..10 in chain A + >>> AtomSelectionStack.from_query("A/*/[5-10], B/*/[3-8]") # different range per chain + """ + tokens = cls._parse_query_tokens(query) + selections: list[AtomSelection] = [] + for token in tokens: + field_values = cls._parse_token_fields(token) + selections.extend(cls._build_selections_from_fields(field_values)) + return cls(selections) + + @staticmethod + def _split_top_level(string: str, sep: str) -> list[str]: + """Split ``string`` on ``sep`` only at bracket depth 0 (commas inside ``[...]`` are kept).""" + parts: list[str] = [] + buf: list[str] = [] + depth = 0 + for ch in string: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == sep and depth == 0: + parts.append("".join(buf)) + buf = [] + else: + buf.append(ch) + parts.append("".join(buf)) + return parts + + @classmethod + def _parse_query_tokens(cls, query: str | list[str]) -> list[str]: + """Split query input into individual (bracket-aware) union tokens.""" + items = [query] if isinstance(query, str) else list(query) + raw = [tok for item in items for tok in cls._split_top_level(item, ",")] + return [tok.strip() for tok in raw if tok.strip()] + + @classmethod + def _parse_token_fields(cls, token: str) -> dict[str, list[Any]]: + """Parse a single token into per-field option lists.""" + parts = token.split("/") + while len(parts) < 5: + parts.append("*") + chain_val, res_name_val, res_id_val, atom_name_val, trans_id_val = parts[:5] + return { + "chain_id": cls._parse_field_value(chain_val, is_res_id=False), + "res_name": cls._parse_field_value(res_name_val, is_res_id=False), + "res_id": cls._parse_field_value(res_id_val, is_res_id=True), + "atom_name": cls._parse_field_value(atom_name_val, is_res_id=False), + "transformation_id": cls._parse_field_value(trans_id_val, is_res_id=False), + } + + @classmethod + def _parse_field_value(cls, value: str, *, is_res_id: bool = False) -> list[Any]: + """Parse a single field into a list of options (expanding ``[...]`` lists and ranges).""" + v = value.strip() + if v in ("*", ""): + return ["*"] + if "[" in v or "]" in v: + if not (v.startswith("[") and v.endswith("]")): + raise ValueError(f"Malformed bracket list in selection field: {v!r}") + items = [item.strip() for item in v[1:-1].split(",") if item.strip()] + if not items: + raise ValueError(f"Empty bracket list in selection field: {v!r}") + options: list[Any] = [] + for item in items: + options.extend(cls._expand_item(item, is_res_id=is_res_id)) + return options + # Bare value: scalar only (ranges must be wrapped in brackets) + return cls._scalar_option(v, is_res_id=is_res_id) + + @classmethod + def _expand_item(cls, item: str, *, is_res_id: bool = False) -> list[Any]: + """Expand a single bracket-list item (scalar, wildcard, or res_id range) into options.""" + if item == "*": + return ["*"] + if is_res_id: + match = re.fullmatch(r"(-?\d+)-(-?\d+)", item) + if match: + start_i, stop_i = int(match.group(1)), int(match.group(2)) + step = 1 if start_i <= stop_i else -1 + return list(range(start_i, stop_i + step, step)) + return cls._scalar_option(item, is_res_id=is_res_id) + + @staticmethod + def _scalar_option(value: str, *, is_res_id: bool = False) -> list[Any]: + """Coerce a single scalar field value into a one-element option list.""" + if is_res_id: + try: + return [int(value)] + except ValueError: + raise ValueError( + f"Invalid res_id {value!r}: ranges must be wrapped in brackets, e.g. '[5-10]'." + ) from None + return [value] + + @classmethod + def _build_selections_from_fields(cls, field_values: dict[str, list[Any]]) -> list[AtomSelection]: + """Build the Cartesian product of per-field options into individual selections.""" + return [ + AtomSelection(chain_id=c, res_name=r, res_id=i, atom_name=a, transformation_id=t) + for c, r, i, a, t in product( + field_values["chain_id"], + field_values["res_name"], + field_values["res_id"], + field_values["atom_name"], + field_values["transformation_id"], + ) + ] + + def get_mask(self, atom_array: AtomArray | AtomArrayStack, raise_on_empty: bool = True) -> np.ndarray: + """Create a boolean mask by unioning (logical OR) all member selections. + + Args: + raise_on_empty: Passed through to each member selection. If ``True`` (default), + a member matching no atoms raises :py:class:`ValueError`. ``sel('...')`` sets this + to ``False`` so unions degrade gracefully. + """ + if not self.selections: + return np.zeros(atom_array.array_length(), dtype=bool) + masks = [selection.get_mask(atom_array, raise_on_empty=raise_on_empty) for selection in self.selections] + return reduce(np.logical_or, masks) + + def get_center_of_mass(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: + """Return the center of mass of the selected atoms.""" + mask = self.get_mask(atom_array) + if not np.any(mask): + raise ValueError("No atoms selected by the AtomSelectionStack.") + + if isinstance(atom_array, AtomArray): + return atom_array.coord[mask].mean(axis=0) + elif isinstance(atom_array, AtomArrayStack): + return atom_array.coord[:, mask].mean(axis=1) + else: + raise ValueError(f"Cannot get center of mass for {type(atom_array)}!") + + def get_principal_components(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: + """Return principal axes (eigenvectors) of the selected atoms via SVD. + + Returns: + ``(3, 3)`` array for :py:class:`~biotite.structure.AtomArray`. + ``(n_models, 3, 3)`` array for :py:class:`~biotite.structure.AtomArrayStack`. + """ + mask = self.get_mask(atom_array) + if not np.any(mask): + raise ValueError("No atoms selected by the AtomSelectionStack.") + + if isinstance(atom_array, AtomArray): + coords = atom_array.coord[mask] # (N_atoms, 3) + coords_centered = coords - coords.mean(axis=0) + # SVD for principal axes + _, _, vh = np.linalg.svd(coords_centered, full_matrices=False) + return vh.T # (3, 3), columns are principal axes + elif isinstance(atom_array, AtomArrayStack): + coords = atom_array.coord[:, mask, :] # (n_models, N_atoms, 3) + pcs = [] + for model_coords in coords: + model_centered = model_coords - model_coords.mean(axis=0) + _, _, vh = np.linalg.svd(model_centered, full_matrices=False) + pcs.append(vh.T) # (3, 3) + return np.stack(pcs, axis=0) # (n_models, 3, 3) + else: + raise ValueError(f"Cannot get principal components for {type(atom_array)}!") diff --git a/src/atomworks/io/utils/scatter.py b/src/atomworks/io/utils/scatter.py index 66f222cc..54a8b79f 100644 --- a/src/atomworks/io/utils/scatter.py +++ b/src/atomworks/io/utils/scatter.py @@ -2,6 +2,9 @@ import biotite.structure as struc import numpy as np +from jaxtyping import Int, Shaped + +Array = np.ndarray # Group-wise operations (= non-contiguous or contiguous groups) ------------------------------ @@ -160,6 +163,30 @@ def apply_and_spread_group_wise(group: np.ndarray, data: np.ndarray, func: Calla return spread_group_wise(group, aggregated) +def safe_scatter( + source_values: Shaped[Array, "s *rest"], # noqa: F821 + mapping: Int[Array, "s"], # noqa: F821 + target: Shaped[Array, "t *rest"], # noqa: F821 +) -> Shaped[Array, "t *rest"]: # noqa: F821 + """Scatter ``source_values`` onto a copy of ``target``: source ``i`` writes to ``target[mapping[i]]``, + ``mapping[i] == -1`` drops it. A string ``target`` dtype is widened so long labels are not truncated. + + >>> safe_scatter(np.array([10, 20, 30]), np.array([2, -1, 0]), np.zeros(4, dtype=int)) + array([30, 0, 10, 0]) + """ + if len(source_values) != len(mapping): + raise ValueError( + f"source_values ({len(source_values)}) and mapping ({len(mapping)}) must have the same length." + ) + if target.dtype.kind in "US": + out = target.astype(np.result_type(target.dtype, np.asarray(source_values).dtype)) + else: + out = target.copy() + matched = mapping >= 0 + out[mapping[matched]] = source_values[matched] + return out + + # Segment-wise operations (= contiguous groups) -------------------------------- def get_segments(*arrays: np.ndarray, add_exclusive_stop: bool = False) -> np.ndarray: """ @@ -208,10 +235,49 @@ def get_segments(*arrays: np.ndarray, add_exclusive_stop: bool = False) -> np.nd apply_segment_wise = struc.segments.apply_segment_wise spread_segment_wise = struc.segments.spread_segment_wise +NUMPY_REDUCE_FUNCS = { + np.any: np.logical_or.reduceat, + np.all: np.logical_and.reduceat, + np.maximum: np.maximum.reduceat, + np.minimum: np.minimum.reduceat, + np.max: np.maximum.reduceat, + np.min: np.minimum.reduceat, + np.add: np.add.reduceat, + np.sum: np.add.reduceat, +} -def apply_and_spread_segment_wise(segment: np.ndarray, data: np.ndarray, func: Callable) -> np.ndarray: - """ - Apply a segment-wise reduction and broadcast the result back to all original positions. + +def apply_and_spread_segment_wise( + segment: np.ndarray, + data: np.ndarray, + func: Callable, + axis: int | None = None, +) -> np.ndarray: + """Apply a segment-wise reduction and broadcast the result back to all original positions. + + Uses :py:obj:`NUMPY_REDUCE_FUNCS` for common numpy ufuncs when ``axis`` is ``None``. + + Args: + segment: 1D array of segment start/stop indices (with exclusive stop appended). + data: The input data array. + func: The function to apply to each segment. + axis: The axis along which to apply the function. If ``None``, the function + is applied to the entire segment. + + Returns: + A new array with the same shape as ``data``, where the result of the + function applied to each segment has been spread across the elements + of that segment. + + Example: + >>> import numpy as np + >>> segment = np.array([0, 3, 6]) + >>> data = np.array([1, 2, 3, 4, 5, 6]) + >>> apply_and_spread_segment_wise(segment, data, np.sum) + array([ 6, 6, 6, 15, 15, 15]) """ - aggregated = apply_segment_wise(segment, data, func) + if func in NUMPY_REDUCE_FUNCS and axis is None: + aggregated = NUMPY_REDUCE_FUNCS[func](data, segment[:-1]) + else: + aggregated = apply_segment_wise(segment, data, func, axis) return spread_segment_wise(segment, aggregated) diff --git a/src/atomworks/io/utils/selection.py b/src/atomworks/io/utils/selection.py index c8575861..0112bd29 100644 --- a/src/atomworks/io/utils/selection.py +++ b/src/atomworks/io/utils/selection.py @@ -1,28 +1,25 @@ -"""Tools for atom and segment selection on ``AtomArray`` and ``AtomArrayStack``. +"""Segment slicing and annotation helpers for ``AtomArray`` and ``AtomArrayStack``. -Provides helpers to compute segment boundaries and apply expressive selection syntax to structures. +For the atom-selection string languages (the ``sel('...')`` / path-selection DSL and the +pandas-like query language), see :py:mod:`atomworks.io.utils.query`. Key public objects: -- :py:class:`~atomworks.io.utils.selection.AtomSelection` -- :py:class:`~atomworks.io.utils.selection.AtomSelectionStack` - :py:class:`~atomworks.io.utils.selection.SegmentSlice` +- :py:func:`~atomworks.io.utils.selection.get_residue_starts` See individual docstrings for usage and examples. """ -__all__ = ["AtomSelection", "AtomSelectionStack", "annot_start_stop_idxs", "get_annotation", "get_residue_starts"] +__all__ = ["annot_start_stop_idxs", "get_annotation", "get_residue_starts"] -import re from abc import ABC, abstractmethod -from functools import reduce -from itertools import product from typing import Any, Literal import biotite.structure as struc import numpy as np from biotite.structure import AtomArray, AtomArrayStack -from atomworks.io.utils.atom_array_plus import AtomArrayPlus +import atomworks.io.utils.atom_array_plus as aap from atomworks.io.utils.scatter import get_segments @@ -76,7 +73,7 @@ def get_residue_starts(atom_array: AtomArray | AtomArrayStack, add_exclusive_sto .. _Biotite get_residue_starts: https://github.com/biotite-dev/biotite/blob/231eefed334e1d3509c1b7cb3f2bfd71d4b0eeb0/src/biotite/structure/residues.py#L35 """ - _annots_to_check = ["chain_id", "res_name", "res_id", "ins_code", "transformation_id"] + _annots_to_check = ["chain_id", "res_id", "ins_code", "transformation_id"] existing_annots = atom_array.get_annotation_categories() annots_to_check = [annot for annot in _annots_to_check if annot in existing_annots] return annot_start_stop_idxs(atom_array, annots=annots_to_check, add_exclusive_stop=add_exclusive_stop) @@ -87,17 +84,17 @@ def _validate_n_body_and_type(atom_array: AtomArray | AtomArrayStack, n_body: in Args: atom_array: Structure to validate. - n_body: Annotation dimensionality (1 or 2). + n_body: Annotation dimensionality (0, 1 or 2). operation: Description used in error messages. Raises: - ValueError: If ``n_body > 1`` but ``atom_array`` is not ``AtomArrayPlus`` or ``AtomArrayStack``. - NotImplementedError: If ``n_body`` is not 1 or 2. + ValueError: If ``n_body`` is not 1 but ``atom_array`` cannot hold such annotations. + NotImplementedError: If ``n_body`` is not 0, 1 or 2. """ - if n_body > 1 and not isinstance(atom_array, (AtomArrayPlus | AtomArrayStack)): + if n_body != 1 and not isinstance(atom_array, aap._AtomArrayPlusBase): raise ValueError(f"Cannot {operation} with n_body={n_body} on non-AtomArrayPlus!") - if n_body not in (1, 2): + if n_body not in (0, 1, 2): raise NotImplementedError(f"Cannot {operation} with n_body={n_body}!") @@ -106,12 +103,14 @@ def get_annotation( ) -> np.ndarray: """Return an annotation array if present, otherwise ``default``. - If ``n_body`` is ``None``, the dimensionality is auto-detected by probing 1D then 2D annotation categories. + If ``n_body`` is ``None``, the dimensionality is auto-detected by probing 1D, then 2D, then 0D + annotation categories. Args: atom_array: Structure to query. annot: Annotation category name. - n_body: 1 for 1D annotations, 2 for 2D annotations; auto-detected if ``None``. + n_body: 0 for a system-wide value, 1 for 1D annotations, 2 for 2D annotations; + auto-detected if ``None``. default: Value to return if the annotation is missing. Defaults to ``None``. Returns: @@ -121,14 +120,14 @@ def get_annotation( _validate_n_body_and_type(atom_array, n_body, f"get annotation for {annot}") else: # Auto-detect annotation dimensionality if n_body not specified - for body in (1, 2): + for body in (1, 2, 0): if annot in get_annotation_categories(atom_array, n_body=body): return get_annotation(atom_array, annot, n_body=body) - if n_body == 1 and annot in atom_array.get_annotation_categories(): + if n_body is not None and annot in get_annotation_categories(atom_array, n_body=n_body): + if isinstance(atom_array, aap._AtomArrayPlusBase): + return atom_array.get_annotation(annot, n_body=n_body) return atom_array.get_annotation(annot) - elif n_body == 2 and annot in atom_array.get_annotation_2d_categories(): - return atom_array.get_annotation_2d(annot) return default @@ -138,31 +137,18 @@ def get_annotation_categories(atom_array: AtomArray | AtomArrayStack, n_body: in Args: atom_array: Structure to query. - n_body: ``1`` for 1D, ``2`` for 2D, or ``"all"`` for both. + n_body: ``0`` for a system-wide value, ``1`` for 1D, ``2`` for 2D, or ``"all"`` for all three. Returns: Names of available annotation categories for the requested dimensionality. """ - # Map n_body to the corresponding method name - n_body_to_method = { - 1: "get_annotation_categories", - 2: "get_annotation_2d_categories", - } - if n_body == "all": - categories = [] - for method_name in n_body_to_method.values(): - if hasattr(atom_array, method_name): - categories.extend(getattr(atom_array, method_name)()) - return categories - elif n_body in n_body_to_method: - method_name = n_body_to_method[n_body] - if hasattr(atom_array, method_name): - return getattr(atom_array, method_name)() - else: - return [] - else: - return [] + return [name for body in (0, 1, 2) for name in get_annotation_categories(atom_array, n_body=body)] + if n_body == 1: + return atom_array.get_annotation_categories() + if n_body in (0, 2) and isinstance(atom_array, aap._AtomArrayPlusBase): + return atom_array.get_annotation_categories(n_body=n_body) + return [] class SegmentSlice(ABC): @@ -241,427 +227,3 @@ class ChainIdxSlice(SegmentSlice): def _get_segment_bounds(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: return struc.get_chain_starts(atom_array, add_exclusive_stop=True) - - -class AtomSelection: - """Represent a selection of atoms in a molecular structure.""" - - def __init__( - self, - chain_id: str = "*", - res_name: str = "*", - res_id: int | str = "*", - atom_name: str = "*", - transformation_id: int | str = "*", - ): - """Initialize a selection. - - Args: - chain_id: Chain identifier or ``"*"`` for any. Defaults to ``"*"``. - res_name: Residue name or ``"*"`` for any. Defaults to ``"*"``. - res_id: Residue index (integer) or ``"*"`` for any. Defaults to ``"*"``. - atom_name: Atom name or ``"*"`` for any. Defaults to ``"*"``. - transformation_id: Transformation id or ``"*"`` for any. Defaults to ``"*"``. - """ - self.chain_id = chain_id - self.res_name = res_name - self.atom_name = atom_name - self.res_id = int(res_id) if res_id != "*" else res_id - self.transformation_id = str(transformation_id) - - def __str__(self) -> str: - parts = [self.chain_id, self.res_name, str(self.res_id), self.atom_name, str(self.transformation_id)] - - # Remove trailing '*' values - while parts and parts[-1] == "*": - parts.pop() - - return "/".join(parts) - - def __repr__(self) -> str: - return str(self) - - def __eq__(self, other: Any) -> bool: - if isinstance(other, str): - # Convert the string to an AtomSelection for comparison - other = self.from_selection_str(other) - - if not isinstance(other, AtomSelection): - return False - - return ( - self.chain_id == other.chain_id - and self.res_name == other.res_name - and self.res_id == other.res_id - and self.atom_name == other.atom_name - and self.transformation_id == other.transformation_id - ) - - @classmethod - def from_selection_str(cls, selection_string: str) -> "AtomSelection": - """Create a selection from ``CHAIN/RES/RESID/ATOM/TRANSFORM`` syntax. - - ``"*"`` acts as a wildcard for any field. Trailing fields may be omitted - and default to ``"*"``. - - Examples: - >>> # Selects the CA atom of the ALA residue at chain A, residue index 1 - >>> AtomSelection.from_selection_str("A/ALA/1/CA") - - >>> # Selects the CB atom of the ALA residue in any chain at any residue index - >>> AtomSelection.from_selection_str("*/ALA/*/CB") - - >>> # Selects all atoms of the ALA residue at chain A - >>> AtomSelection.from_selection_str("A/ALA/") - - >>> # Selects the CA atom of the ALA residue at chain A, residue index 1, transformation index 1 - >>> AtomSelection.from_selection_str("A/ALA/1/CA/1") - - """ - selection = parse_selection_string(selection_string) - return cls( - chain_id=selection.chain_id, - res_name=selection.res_name, - res_id=selection.res_id, - atom_name=selection.atom_name, - transformation_id=selection.transformation_id, - ) - - @classmethod - def from_pymol_str(cls, pymol_string: str) -> "AtomSelection": - """Create a selection from a PyMOL atom label string. - - PyMOL strings are of the form ``CHAIN/RES`RESID/ATOM`` and do not support - ``transformation_id``. ``"*"`` may be used as a wildcard. - - PyMOL strings do not support transformation_id. - - We introduce to default PyMOL syntax the "*" operator as a wildcard to select all atoms in a given granularity. - - Example: - >>> # Selects the OD2 atom of the ASP residue at chain A, residue index 37 - >>> AtomSelection.from_pymol_str("A/ASP`37/OD2") - """ - selection = parse_pymol_string(pymol_string) - return cls( - chain_id=selection.chain_id, - res_name=selection.res_name, - res_id=selection.res_id, - atom_name=selection.atom_name, - ) - - def get_mask(self, atom_array: AtomArray) -> np.ndarray: - """Create a boolean mask using this AtomSelection on an AtomArray.""" - return get_mask_from_atom_selection(atom_array, self) - - def get_idxs(self, atom_array: AtomArray) -> np.ndarray: - """Get the indices of atoms selected by this AtomSelection.""" - return np.where(self.get_mask(atom_array))[0] - - -def parse_selection_string(selection_string: str) -> AtomSelection: - """Parse ``CHAIN/RES/RESID/ATOM/TRANSFORM`` into an :py:class:`AtomSelection`. - - ``"*"`` acts as a wildcard for any field. Trailing fields may be omitted - and default to ``"*"``. - - See Also: - :py:meth:`~atomworks.io.utils.selection.AtomSelection.from_selection_str` - """ - granularity_tiers = ["chain_id", "res_name", "res_id", "atom_name", "transformation_id"] - values = selection_string.split("/") - - # Create a dictionary with available tiers and values - selection_dict = {tier: value for tier, value in zip(granularity_tiers, values, strict=False) if value != "*"} - - return AtomSelection(**selection_dict) - - -def parse_pymol_string(pymol_string: str) -> AtomSelection: - """Parse a PyMOL string ``CHAIN/RES`RESID/ATOM`` into an :py:class:`AtomSelection`. - - PyMOL selection strings are of the form: CHAIN_ID/RES_NAME`RES_ID/ATOM_NAME - - PyMOL selection strings do not support transformation_id. - - See Also: - :py:meth:`~atomworks.io.utils.selection.AtomSelection.from_pymol_str` - """ - # Replace backtick with slash to standardize the format - standardized_string = pymol_string.replace("`", "/") - return parse_selection_string(standardized_string) - - -def get_mask_from_selection_string(atom_array: AtomArray, selection_string: str) -> np.ndarray: - """Create a boolean mask from an AtomArray sequence selection string. - - Selection strings follow ``CHAIN/RES/RESID/ATOM/TRANSFORM`` with ``"*"`` as a - wildcard for any field. Trailing fields may be omitted. - - Example: - >>> atom_array = AtomArray(...) - >>> mask = get_mask_from_selection_string(atom_array, "A/ALA/1/CA") - [False, True, False, False, ...] - - See Also: - :py:func:`~atomworks.io.utils.selection.parse_selection_string` - """ - return get_mask_from_atom_selection(atom_array, parse_selection_string(selection_string)) - - -def get_mask_from_atom_selection(atom_array: AtomArray, atom_selection: AtomSelection) -> np.ndarray: - """Create a boolean mask from an :py:class:`AtomSelection`. - - See Also: - :py:func:`~atomworks.io.utils.selection.parse_selection_string` - """ - # TODO: Refactor using AtomArray query syntax - mask = np.ones(atom_array.array_length(), dtype=bool) - - # ... add the masks - if atom_selection.chain_id and atom_selection.chain_id != "*": - mask &= atom_array.chain_id == atom_selection.chain_id - - if atom_selection.res_name and atom_selection.res_name != "*": - mask &= atom_array.res_name == atom_selection.res_name - - if atom_selection.res_id and atom_selection.res_id != "*": - mask &= atom_array.res_id == atom_selection.res_id - - if atom_selection.atom_name and atom_selection.atom_name != "*": - mask &= atom_array.atom_name == atom_selection.atom_name - - if atom_selection.transformation_id and atom_selection.transformation_id != "*": - mask &= atom_array.transformation_id == atom_selection.transformation_id - - if not np.any(mask): - raise ValueError(f"No atoms found for selection: {atom_selection}") - - return mask - - -class AtomSelectionStack: - """Manage multiple :py:class:`AtomSelection` objects as a unioned query. - - Supports ranges and comma-separated tokens via :py:meth:`from_query` and - contiguous ranges via :py:meth:`from_contig`. - - See Also: - :py:meth:`~atomworks.io.utils.selection.AtomSelectionStack.from_query`, - :py:meth:`~atomworks.io.utils.selection.AtomSelectionStack.from_contig` - """ - - def __init__(self, selections: list[AtomSelection]): - """Initialize a stack of selections. - - Args: - selections: Sequence of selections to be unioned. - """ - self.selections = selections - - @classmethod - def from_contig(cls, contig: str) -> "AtomSelectionStack": - """Create a stack from contiguous residue ranges. - - Contig strings specify inclusive residue index ranges, e.g. ``"A1-2"`` - or ``"A1-2, B3-10"``. - - Args: - contig: Contiguous residue selection string like ``"A1-2, B3-10"``. - - Examples: - >>> # Selects residues 1..2 in chain A - >>> AtomSelectionStack.from_contig("A1-2") - >>> # Selects residues 1..2 in chain A and 3..10 in chain B - >>> AtomSelectionStack.from_contig("A1-2, B3-10") - - See Also: - :py:meth:`~atomworks.io.utils.selection.AtomSelectionStack.from_query` - """ - # First define a regex that matches the elements of the contig string - CONTIG_REGEX = re.compile(r"([A-Za-z]+)(\d+)-(\d+)") # noqa - selections = [] - for selection in contig.replace(" ", "").split(","): - match = CONTIG_REGEX.match(selection) - if not match: - raise ValueError(f"Invalid contig string: {selection}") - chain_id, start, stop = match.groups() - # Create a new AtomSelection for each match - for i in range(int(start), int(stop) + 1): - # Create a new AtomSelection for each residue in the range - atom_selection = AtomSelection(chain_id=chain_id, res_id=i) - selections.append(atom_selection) - return cls(selections) - - @classmethod - def from_query(cls, query: str | list[str]) -> "AtomSelectionStack": - """Create a stack from extended query syntax with ranges. - - Extended syntax overview: - - **Chains**: ``A`` (all atoms in chain A), ``A/ALA`` (all ALA in chain A) - - **Ranges (``res_id`` only)**: ``A/*/5-10`` selects residues 5..10 in chain A - - Grammar per field (``CHAIN/RES/RESID/ATOM/TRANSFORM``): - - ``"*"`` wildcard - - Exact value, e.g. ``"A"``, ``"ALA"``, ``"CA"`` - - Range (``res_id`` only): ``"5-10"`` (inclusive) - - Notes: - - Fields are in order: CHAIN_ID/RES_NAME/RES_ID/ATOM_NAME/TRANSFORMATION_ID - - Wildcard is "*". Missing trailing fields default to "*". - - Multiple comma-separated tokens are combined by union. - - Multiple tokens may be provided as a comma-separated string or ``list[str]``. - - Examples: - >>> # Selects residues 5..10 in chain A - >>> AtomSelectionStack.from_query("A/*/5-10") - >>> # Selects residues 5..10 in chain A and 3..10 in chain B - >>> AtomSelectionStack.from_query("A/*/5-10, B/*/3-10") - >>> # Selects residues 5..10 in chain A and 3..10 in chain B - >>> AtomSelectionStack.from_query(["A/*/5-10", "B/*/3-10"]) - """ - tokens = cls._parse_query_tokens(query) - selections: list[AtomSelection] = [] - - for token in tokens: - field_values = cls._parse_token_fields(token) - token_selections = cls._build_selections_from_fields(field_values) - selections.extend(token_selections) - - return cls(selections) - - @classmethod - def _parse_query_tokens(cls, query: str | list[str]) -> list[str]: - """Parse query input into individual tokens.""" - if isinstance(query, str): - return [tok.strip() for tok in query.split(",") if tok.strip()] - else: - return [tok.strip() for tok in query if tok and tok.strip()] - - @classmethod - def _parse_token_fields(cls, token: str) -> dict[str, list[Any]]: - """Parse a single token into field values.""" - parts = token.split("/") - - # Ensure five fields with '*' defaults - while len(parts) < 5: - parts.append("*") - chain_val, res_name_val, res_id_val, atom_name_val, trans_id_val = parts[:5] - - return { - "chain_id": cls._parse_field_value(chain_val, is_res_id=False), - "res_name": cls._parse_field_value(res_name_val, is_res_id=False), - "res_id": cls._parse_field_value(res_id_val, is_res_id=True), - "atom_name": cls._parse_field_value(atom_name_val, is_res_id=False), - "transformation_id": cls._parse_field_value(trans_id_val, is_res_id=False), - } - - @classmethod - def _parse_field_value(cls, value: str, *, is_res_id: bool = False) -> list[Any]: - """Parse a field value into a list of options. - - For ``res_id``, values are integers; for others, strings. - """ - v = value.strip() - if v == "*" or v == "": - return ["*"] - - return cls._extract_field_options(v, is_res_id=is_res_id) - - @classmethod - def _extract_field_options(cls, value: str, *, is_res_id: bool = False) -> list[Any]: - """Extract options from a field value (ranges or scalars).""" - # Range syntax: 5-10 (res_id only) - if is_res_id and re.fullmatch(r"-?\d+-?\d+", value): - start_s, stop_s = value.split("-", 1) - start_i, stop_i = int(start_s), int(stop_s) - step = 1 if start_i <= stop_i else -1 - return list(range(start_i, stop_i + step, step)) - - # Scalar value - return [int(value)] if is_res_id and value not in ("*", "") else [value] - - @classmethod - def _build_selections_from_fields(cls, field_values: dict[str, list[Any]]) -> list[AtomSelection]: - """Build selections from parsed field values, expanding sets and ranges.""" - # Extract field values directly as lists - chain_vals = field_values["chain_id"] - resn_vals = field_values["res_name"] - resi_vals = field_values["res_id"] - atom_vals = field_values["atom_name"] - tran_vals = field_values["transformation_id"] - - # Build selections via Cartesian product - selections = [ - AtomSelection( - chain_id=c if c != "*" else "*", - res_name=r if r != "*" else "*", - res_id=i if i != "*" else "*", - atom_name=a if a != "*" else "*", - transformation_id=t if t != "*" else "*", - ) - for c, r, i, a, t in product(chain_vals, resn_vals, resi_vals, atom_vals, tran_vals) - ] - - return selections - - def get_mask(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Create a boolean mask by unioning all selections.""" - if not self.selections: - return np.zeros(atom_array.array_length(), dtype=bool) - - masks = [selection.get_mask(atom_array) for selection in self.selections] - return reduce(np.logical_or, masks) - - def get_center_of_mass(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Return the center of mass of the selected atoms. - - Returns: - For :py:class:`~biotite.structure.AtomArray`: ``(3,)`` array. - For :py:class:`~biotite.structure.AtomArrayStack`: ``(n_models,)`` array of means. - - Raises: - ValueError: If no atoms are selected. - """ - mask = self.get_mask(atom_array) - if not np.any(mask): - raise ValueError("No atoms selected by the AtomSelectionStack.") - - if isinstance(atom_array, AtomArray): - return atom_array.coord[mask].mean(axis=0) - elif isinstance(atom_array, AtomArrayStack): - return atom_array.coord[:, mask].mean(axis=1) - else: - raise ValueError(f"Cannot get center of mass for {type(atom_array)}!") - - def get_principal_components(self, atom_array: AtomArray | AtomArrayStack) -> np.ndarray: - """Return principal axes (eigenvectors) of the selected atoms via SVD. - - Returns: - ``(3, 3)`` array for :py:class:`~biotite.structure.AtomArray`. - ``(n_models, 3, 3)`` array for :py:class:`~biotite.structure.AtomArrayStack`. - - Raises: - ValueError: If no atoms are selected. - """ - mask = self.get_mask(atom_array) - if not np.any(mask): - raise ValueError("No atoms selected by the AtomSelectionStack.") - - if isinstance(atom_array, AtomArray): - coords = atom_array.coord[mask] # (N_atoms, 3) - coords_centered = coords - coords.mean(axis=0) - # SVD for principal axes - _, _, vh = np.linalg.svd(coords_centered, full_matrices=False) - return vh.T # (3, 3), columns are principal axes - elif isinstance(atom_array, AtomArrayStack): - coords = atom_array.coord[:, mask, :] # (n_models, N_atoms, 3) - pcs = [] - for model_coords in coords: - model_centered = model_coords - model_coords.mean(axis=0) - _, _, vh = np.linalg.svd(model_centered, full_matrices=False) - pcs.append(vh.T) # (3, 3) - return np.stack(pcs, axis=0) # (n_models, 3, 3) - else: - raise ValueError(f"Cannot get principal components for {type(atom_array)}!") diff --git a/src/atomworks/io/utils/sequence.py b/src/atomworks/io/utils/sequence.py index 869ff045..6df0cac3 100644 --- a/src/atomworks/io/utils/sequence.py +++ b/src/atomworks/io/utils/sequence.py @@ -7,13 +7,20 @@ import functools import logging +from collections import Counter +from collections.abc import Sequence import numpy as np import toolz from atomworks.constants import ( + AA_LIKE_CHEM_TYPES, + DNA_LIKE_CHEM_TYPES, GAP, GAP_ONE_LETTER, + POLYPEPTIDE_D_CHEM_TYPES, + POLYPEPTIDE_L_CHEM_TYPES, + RNA_LIKE_CHEM_TYPES, STANDARD_AA, STANDARD_DNA, STANDARD_NA, @@ -28,6 +35,7 @@ from atomworks.io.utils.ccd import ( aa_chem_comps, chem_comp_to_one_letter, + get_chem_comp_type, na_chem_comps, ) @@ -87,6 +95,7 @@ def dna_chem_comp_1to3() -> dict[str, str]: return {val: key for key, val in na_chem_comp_3to1().items() if key in STANDARD_DNA} +@functools.cache def get_1_from_3_letter_code( res_name: str, chain_type: ChainType, @@ -122,6 +131,7 @@ def get_1_from_3_letter_code( return "X" +@functools.cache def get_3_from_1_letter_code( letter: str, chain_type: ChainType, @@ -192,3 +202,120 @@ def is_standard_aa_not_glycine(ccd_code_array: np.ndarray) -> np.ndarray: def is_protein_unknown(ccd_code_array: np.ndarray) -> np.ndarray: return np.asarray(ccd_code_array) == UNKNOWN_AA + + +def infer_chain_type_from_three_letter(ccd_code_seq: Sequence[str]) -> ChainType: + """Infer chain type from three-letter CCD code arrays. + + Used for parsed structure data where residues are represented as CCD codes. + Assigns chain type based on the residue chem types of the provided CCD codes. + + Args: + ccd_code_seq: List of three-letter CCD codes (e.g., ``["ALA", "CYS", "ASP"]``). + + Returns: + Inferred chain type enum value. + + Examples: + >>> infer_chain_type_from_three_letter(["ALA", "CYS", "ASP"]) + + >>> infer_chain_type_from_three_letter(["DA", "DT", "DG", "DC"]) + + + See Also: + :py:func:`~atomworks.io.tools.fasta.infer_chain_type_from_one_letter` - + For sequence notation (one-letter format). + """ + chain_type_counts = dict.fromkeys( + [ + "aa_like", + ChainType.POLYPEPTIDE_D, + ChainType.POLYPEPTIDE_L, + ChainType.DNA, + ChainType.RNA, + ChainType.NON_POLYMER, + ], + 0, + ) + + # Classify each distinct component once, preserving its contribution to chain composition. + for res_name, count in Counter(ccd_code_seq).items(): + chem_comp = get_chem_comp_type(res_name, mode="warn") + + # Increment the count for the appropriate chain type category + # (All amino acid-like chem types are considered "aa_like") + if chem_comp in AA_LIKE_CHEM_TYPES: + chain_type_counts["aa_like"] += count + # (We further differentiate between L- and D-polypeptides) + if chem_comp in POLYPEPTIDE_D_CHEM_TYPES: + chain_type_counts[ChainType.POLYPEPTIDE_D] += count + elif chem_comp in POLYPEPTIDE_L_CHEM_TYPES: + chain_type_counts[ChainType.POLYPEPTIDE_L] += count + + # (We differentiate between RNA and DNA) + elif chem_comp in RNA_LIKE_CHEM_TYPES: + chain_type_counts[ChainType.RNA] += count + elif chem_comp in DNA_LIKE_CHEM_TYPES: + chain_type_counts[ChainType.DNA] += count + + # (All other chem types are considered non-polymer) + else: + chain_type_counts[ChainType.NON_POLYMER] += count + + # WARNING: The following logic is heuristic, and may fail in cases of multiple residues types within a chain. + + # If we have both RNA and DNA, set the chain type to RNA/DNA hybrid + if chain_type_counts[ChainType.RNA] > 0 and chain_type_counts[ChainType.DNA] > 0: + chain_type = ChainType.DNA_RNA_HYBRID + + # If we have proteins, set to either L- or D-polypeptide, depending on the counts + elif chain_type_counts[ChainType.POLYPEPTIDE_L] > 0 or chain_type_counts[ChainType.POLYPEPTIDE_D] > 0: + # ... if we have equal or more L-polypeptides than D-polypeptides in the chain, set to L-polypeptide + if chain_type_counts[ChainType.POLYPEPTIDE_L] >= chain_type_counts[ChainType.POLYPEPTIDE_D]: + chain_type = ChainType.POLYPEPTIDE_L + + # ... if we have more D-polypeptides than L-polypeptides, set to D-polypeptide + elif chain_type_counts[ChainType.POLYPEPTIDE_L] < chain_type_counts[ChainType.POLYPEPTIDE_D]: + chain_type = ChainType.POLYPEPTIDE_D + + # If we only have "aa_like", default to "polypeptide(L)" + elif ( + chain_type_counts["aa_like"] > 0 + and chain_type_counts[ChainType.POLYPEPTIDE_L] == 0 + and chain_type_counts[ChainType.POLYPEPTIDE_D] == 0 + ): + chain_type = ChainType.POLYPEPTIDE_L + + # ... if we have RNA, set to polyribonucleotide + elif chain_type_counts[ChainType.RNA] > 0: + chain_type = ChainType.RNA + # ... if we have DNA, set to polydeoxyribonucleotide + elif chain_type_counts[ChainType.DNA] > 0: + chain_type = ChainType.DNA + # Otherwise, set to non-polymer (if we have non-polymer residues) + elif chain_type_counts[ChainType.NON_POLYMER] > 0: + chain_type = ChainType.NON_POLYMER + else: + raise ValueError(f"Could not infer chain type from residue names: {ccd_code_seq}") + + return chain_type + + +def convert_to_one_letter_sequences( + res_names: list[str], + chain_type: ChainType, +) -> tuple[str, str]: + """Convert 3-letter residue codes to 1-letter (non_canonical, canonical) sequences. + + Args: + res_names: List of 3-letter residue codes. + chain_type: Chain type (e.g., protein, RNA, DNA). + + Returns: + Tuple of (non_canonical_sequence, canonical_sequence) where: + - non_canonical keeps modified residues as-is (e.g., MSE → X) + - canonical maps to closest standard residue (e.g., MSE → M) + """ + non_canonical = "".join(get_1_from_3_letter_code(rn, chain_type, use_closest_canonical=False) for rn in res_names) + canonical = "".join(get_1_from_3_letter_code(rn, chain_type, use_closest_canonical=True) for rn in res_names) + return non_canonical, canonical diff --git a/src/atomworks/io/utils/standard_annotations/__init__.py b/src/atomworks/io/utils/standard_annotations/__init__.py new file mode 100644 index 00000000..ec3bbf58 --- /dev/null +++ b/src/atomworks/io/utils/standard_annotations/__init__.py @@ -0,0 +1,14 @@ +"""Condition system for molecular design and annotation management.""" + +from atomworks.io.utils.standard_annotations import definitions +from atomworks.io.utils.standard_annotations.base import ( + NO_DEFAULT, + STANDARD_ANNOTATIONS, + StandardAnnotationBase, +) + +# --- alias table --- +S_ATM = STANDARD_ANNOTATIONS.atomize +S_SEGMIN = STANDARD_ANNOTATIONS.expsegmin +S_SEGMAX = STANDARD_ANNOTATIONS.expsegmax +# -------------------- diff --git a/src/atomworks/io/utils/standard_annotations/base.py b/src/atomworks/io/utils/standard_annotations/base.py new file mode 100644 index 00000000..8dd0c6ba --- /dev/null +++ b/src/atomworks/io/utils/standard_annotations/base.py @@ -0,0 +1,640 @@ +""" +StandardAnnotation system with: + - a ``StandardAnnotationBase`` abstract base class for typed, defaulted, registry-driven + annotations on ``AtomArray`` objects + - metaclasses ``StandardAnnotationMeta`` that handles auto-registration and attribute pre-computation + - accessor singleton ``STANDARD_ANNOTATIONS`` +""" + +from __future__ import annotations + +from abc import ABC, ABCMeta +from collections.abc import Callable +from enum import Enum, StrEnum +from typing import Any, ClassVar, Literal + +import numpy as np +from biotite.structure import AtomArray + +from atomworks.io.utils import scatter +from atomworks.io.utils.atom_array_plus import AnnotationList2D +from atomworks.io.utils.selection import ( + _validate_n_body_and_type, + get_annotation, + get_annotation_categories, + get_residue_starts, +) + +__all__ = [ + "NO_DEFAULT", + "STANDARD_ANNOTATIONS", + "AnnotationRegistryAccessor", + "StandardAnnotationBase", +] + +MAX_TEXT_ANNOTATION_WIDTH = 64 + + +class _DefaultValue(Enum): + MISSING = "no uniform default" + + +NO_DEFAULT = _DefaultValue.MISSING +"""Sentinel for annotations without a structure-independent scalar fill value.""" + + +class Level(StrEnum): + """ + Level-based hierarchy for describing information in a molecular structure. + + Used for example to specify the level at which a `StandardAnnotation` applies. + """ + + ATOM = "atom" + TOKEN = "token" + RESIDUE = "residue" + CHAIN = "chain" + MOLECULE = "molecule" + SYSTEM = "system" + + def _get_segment_or_group(self, atom_array: AtomArray) -> tuple[str, np.ndarray]: + if self == Level.ATOM: + raise ValueError("Apply and spread does not make sense for atom level.") + if self == Level.TOKEN: + try: + from atomworks.ml.utils.token import get_token_starts + except ImportError: + raise ImportError( + "`atomworks.ml` module not found. All token-level functionality requires `atomworks.ml`, " + "so `Level.TOKEN` is not supported when it is unavailable." + ) from None + return "segment", get_token_starts(atom_array, add_exclusive_stop=True) + elif self == Level.RESIDUE: + return "segment", get_residue_starts(atom_array, add_exclusive_stop=True) + elif self == Level.CHAIN: + chain_key = "chain_iid" if "chain_iid" in atom_array.get_annotation_categories() else "chain_id" + return "group", atom_array.get_annotation(chain_key) + elif self == Level.MOLECULE: + return "group", atom_array.get_annotation("molecule_id") + elif self == Level.SYSTEM: + return "group", np.ones(atom_array.array_length(), dtype=np.int32) + else: + raise ValueError(f"Invalid level: {self}") + + def apply(self, atom_array: AtomArray, data: np.ndarray, func: Callable[[AtomArray], Any]) -> Any: + strategy, grouping = self._get_segment_or_group(atom_array) + if strategy == "segment": + return scatter.apply_segment_wise(grouping, data, func) + elif strategy == "group": + return scatter.apply_group_wise(grouping, data, func) + else: + raise ValueError(f"Invalid strategy: {strategy}") + + def spread(self, atom_array: AtomArray, data: np.ndarray) -> np.ndarray: + strategy, grouping = self._get_segment_or_group(atom_array) + if strategy == "segment": + return scatter.spread_segment_wise(grouping, data) + elif strategy == "group": + return scatter.spread_group_wise(grouping, data) + else: + raise ValueError(f"Invalid strategy: {strategy}") + + def apply_and_spread( + self, + atom_array: AtomArray, + data: np.ndarray, + func: Callable[[np.ndarray], Any], + ) -> np.ndarray: + """ + Apply a function to the data and spread the result back to the original positions. + """ + + strategy, grouping = self._get_segment_or_group(atom_array) + if strategy == "segment": + return scatter.apply_and_spread_segment_wise(grouping, data, func) + elif strategy == "group": + return scatter.apply_and_spread_group_wise(grouping, data, func) + else: + raise ValueError(f"Invalid strategy: {strategy}") + + +class StandardAnnotationMeta(ABCMeta): + """Metaclass for all standard annotations. + + Registers concrete subclasses into a shared registry and pre-computes + the derived canonical storage name (``full_name``). + + The registry ``StandardAnnotationMeta._registry`` is shared across all + subclasses. This enforces name uniqueness across ALL standard annotations. + """ + + _registry: ClassVar[dict[str, type[StandardAnnotationBase]]] = {} + + @classmethod + def _registered_names(cls) -> dict[str, type[StandardAnnotationBase]]: + """Return every public name already claimed by a registered annotation.""" + return { + candidate: registered_cls + for registered_name, registered_cls in cls._registry.items() + for candidate in ( + registered_name, + registered_cls.full_name, + *registered_cls.aliases, + ) + } + + def __new__(meta, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs): # noqa: N804 + annotation_name = namespace.get("name") + if annotation_name: + # Validate required attributes + required_attrs = ["name", "n_body", "level", "dtype"] + for attr in required_attrs: + assert attr in namespace, f"StandardAnnotations must define the {attr} attribute." + + assert ( + "_" not in annotation_name + ), "StandardAnnotations must not contain underscores in their names (reserved for internal use)." + + declared_aliases = namespace.get("aliases", ()) + if isinstance(declared_aliases, str) or not isinstance(declared_aliases, list | tuple): + raise TypeError("StandardAnnotation aliases must be a list or tuple of strings.") + aliases = tuple(declared_aliases) + if any(not isinstance(alias, str) or not alias for alias in aliases): + raise TypeError("StandardAnnotation aliases must be non-empty strings.") + if len(set(aliases)) != len(aliases): + raise ValueError(f"StandardAnnotation '{annotation_name}' declares duplicate aliases.") + + if "alias" in namespace: + raise TypeError( + "StandardAnnotation alias= is no longer supported; override get_full_name() for a custom " + "canonical name or use aliases= for accepted legacy names." + ) + + cls = super().__new__(meta, name, bases, namespace, **kwargs) + + if annotation_name: + cls.aliases = aliases + cls.enum = cls.dtype if isinstance(cls.dtype, type) and issubclass(cls.dtype, Enum) else None + if isinstance(cls.default_value, Enum) and cls.default_value is not NO_DEFAULT: + cls.default_value = cls.default_value.value + + if "is_symmetric" not in namespace: + assert cls.n_body <= 1, "Multi-body StandardAnnotations must define the is_symmetric attribute." + cls.is_symmetric = True + + cls.level = Level(cls.level) + assert ( + cls.n_body != 0 or cls.level == Level.SYSTEM + ), "0-body StandardAnnotations must use level=Level.SYSTEM." + + # Eagerly compute and cache the canonical storage name. + cls.full_name = cls.get_full_name() + + if cls.full_name in cls.aliases: + raise ValueError( + f"StandardAnnotation '{annotation_name}' aliases its canonical name '{cls.full_name}'." + ) + + claimed = StandardAnnotationMeta._registered_names() + for candidate in (annotation_name, cls.full_name, *cls.aliases): + if existing := claimed.get(candidate): + raise ValueError( + f"StandardAnnotation name or alias '{candidate}' is already registered to " + f"{existing.__name__}." + ) + + # Register in shared registry + StandardAnnotationMeta._registry[annotation_name] = cls + + return cls + + def __repr__(self) -> str: # noqa: N804 + try: + if self.n_body > 1: + return ( + f"{self.__name__}(name={self.name}, n_body={self.n_body}, " + f"level={self.level}, is_symmetric={self.is_symmetric})" + ) + else: + return f"{self.__name__}(name={self.name}, n_body={self.n_body}, level={self.level})" + except AttributeError: + # NOTE: This is a fallback for the abstract base class to ensure that methods like `__mro__` continue to work + return super().__repr__() + + +class StandardAnnotationBase(ABC, metaclass=StandardAnnotationMeta): + """Abstract base class for all standard annotations. + + A standard annotation is a typed, defaulted annotation with mask semantics + that can be stored on an :py:class:`~biotite.structure.AtomArray` and + serialized/deserialized from CIF files. + + Attributes: + (required on concrete subclasses) + name: Registry name. No underscores allowed. + n_body: Number of bodies (0 for a single system-wide value, 1 for per-atom/residue, + 2 for pairwise). + level: Level at which the annotation applies. + dtype: Semantic scalar type for the annotation values. Enum subclasses + describe closed categorical domains. + + (optional) + aliases: Alternative field names accepted when reading legacy data. + is_symmetric: Whether pairwise annotation is symmetric. Required if ``n_body > 1``. + value_shape: Per-item shape, excluding the atom or pair axis. Scalars use ``()``. + default_value: Scalar fill value for a uniform default, or :py:data:`NO_DEFAULT`. + Enum members are normalized to their stored scalar values at class creation. + Structure-dependent and sparse defaults override :py:meth:`default_annotation`. + + (computed by metaclass) + full_name: Canonical persisted field name. + enum: Enum class from ``dtype``, or ``None`` for open-ended scalar types. + """ + + _prefix: ClassVar[str] = "annotation" + + # --- Attributes to be defined by concrete subclasses --- + name: ClassVar[str] + n_body: ClassVar[int] + level: ClassVar[Level] + dtype: ClassVar[np.dtype | type[np.generic] | type[float] | type[int] | type[str] | type[bool] | type[Enum]] + aliases: ClassVar[tuple[str, ...]] = () + is_symmetric: ClassVar[bool] # only needs to be set if n_body > 1 + value_shape: ClassVar[tuple[int, ...]] = () + default_value: ClassVar[Any] = NO_DEFAULT + + # --- Pre-computed attributes (set by metaclass) --- + full_name: ClassVar[str] + enum: ClassVar[type[Enum] | None] + + def __init__(self): + """DO NOT INSTANTIATE THIS CLASS. Use its class methods directly.""" + raise RuntimeError( + f"{self.__class__.__name__} is a definitional class and should not be instantiated. " + "Use its class methods directly." + ) + + @classmethod + def _resolve_level(cls, level: Level | str | None) -> Level: + """Resolves the level to a Level enum.""" + return Level(level if level is not None else cls.level) + + @classmethod + def _resolve_n_body(cls, n_body: int | None) -> int: + """Resolves the number of bodies to an integer.""" + return n_body if n_body is not None else cls.n_body + + # --- Name generation methods --- + @classmethod + def get_full_name(cls) -> str: + """Returns the full name of the StandardAnnotation at a given body order and level.""" + return f"{cls._prefix}_{cls.name}_{cls.n_body}_{cls.level}" + + @classmethod + def storage_dtype(cls) -> np.dtype: + """Return the NumPy dtype used to store annotation values.""" + if isinstance(cls.dtype, type) and issubclass(cls.dtype, Enum): + return np.asarray([member.value for member in cls.dtype]).dtype + dtype = np.dtype(cls.dtype) + if cls.n_body != 0 and dtype.kind in ("U", "S") and dtype.itemsize == 0: + return np.dtype((dtype.type, MAX_TEXT_ANNOTATION_WIDTH)) + return dtype + + @classmethod + def get_feature_name( + cls, + n_body: int | None = None, + level: Level | str | None = None, + suffix: str = "", + ) -> str: + """Returns the feature name at a given body order and level. + + Args: + n_body: The body order to get the feature name for. + level: The level to get the feature name for. + suffix: An optional suffix to add to the feature name to allow + flexibility for adding multiple different features for a single StandardAnnotation. + + Returns: + The feature name of the StandardAnnotation. This will be of the form: + `feature-___` + """ + level, n_body = cls._resolve_level(level), cls._resolve_n_body(n_body) + suffix_str = f"-{suffix}" if suffix and not suffix.startswith("-") else suffix + return f"feature{suffix_str}_{cls.name}_{n_body}_{level}" + + @classmethod + def has_annotation(cls, atom_array: AtomArray) -> bool: + """Check if the value annotation for this StandardAnnotation is present on the AtomArray.""" + categories = get_annotation_categories(atom_array, n_body=cls.n_body) + return any(name in categories for name in (cls.full_name, *cls.aliases)) + + @staticmethod + def _values_equal(first: np.ndarray, second: np.ndarray) -> bool: + first, second = np.asarray(first), np.asarray(second) + equal_nan = np.issubdtype(first.dtype, np.inexact) and np.issubdtype(second.dtype, np.inexact) + return np.array_equal(first, second, equal_nan=equal_nan) + + @classmethod + def _annotations_equal( + cls, + first: np.ndarray | AnnotationList2D, + second: np.ndarray | AnnotationList2D, + ) -> bool: + if isinstance(first, AnnotationList2D) and isinstance(second, AnnotationList2D): + return np.array_equal(first.pairs, second.pairs) and cls._values_equal(first.values, second.values) + if isinstance(first, AnnotationList2D) or isinstance(second, AnnotationList2D): + return False + return cls._values_equal(first, second) + + @classmethod + def _read_existing(cls, atom_array: AtomArray) -> np.ndarray | AnnotationList2D | None: + """Read the canonical field or one alias, rejecting conflicting duplicates.""" + existing = [ + (name, value) + for name in (cls.full_name, *cls.aliases) + if (value := get_annotation(atom_array, name, n_body=cls.n_body)) is not None + ] + if not existing: + return None + first_name, first = existing[0] + for other_name, other in existing[1:]: + if not cls._annotations_equal(first, other): + raise ValueError( + f"Conflicting fields '{first_name}' and '{other_name}' both resolve to " + f"StandardAnnotation '{cls.name}'." + ) + return first + + @classmethod + def default_annotation(cls, atom_array: AtomArray) -> np.ndarray | AnnotationList2D: + """Fill a system or one-body annotation, or override for structure-dependent/sparse defaults.""" + if cls.default_value is NO_DEFAULT or cls.n_body not in (0, 1): + raise NotImplementedError(f"StandardAnnotation `{cls.name}` must implement default_annotation().") + shape = () if cls.n_body == 0 else (atom_array.array_length(), *cls.value_shape) + dtype = cls.storage_dtype() + if dtype.kind in ("U", "S") and dtype.itemsize == 0: + dtype = np.asarray(cls.default_value, dtype=dtype).dtype + return np.full(shape, cls.default_value, dtype=dtype) + + @classmethod + def mask_from_annotation(cls, annotation: np.ndarray | AnnotationList2D) -> np.ndarray | AnnotationList2D: + """Derive this StandardAnnotation's mask from its value annotation.""" + if np.issubdtype(cls.storage_dtype(), np.bool_): + return annotation + raise NotImplementedError( + f"StandardAnnotation `{cls.name}` (class `{cls.__name__}`) does not define mask_from_annotation()." + ) + + # --- Core Functionality --- + @classmethod + def mask( + cls, + atom_array: AtomArray, + default: Any | Literal["generate", "raise"] = "generate", + ) -> np.ndarray | AnnotationList2D: + """Derive a mask from the persisted or generated value annotation. + + Args: + atom_array: The AtomArray to get the mask from. + default: Behavior when the value annotation is absent. If ``"generate"``, + derive from :meth:`default_annotation`; if ``"raise"``, raise. + If any other value, that value is returned. + + Returns: + The mask for this StandardAnnotation. + + Raises: + ValueError: If the annotation is absent and ``default`` is ``"raise"``. + """ + annotation = cls._read_existing(atom_array) + if annotation is None: + if isinstance(default, str) and default == "generate": + annotation = cls.default_annotation(atom_array) + elif isinstance(default, str) and default == "raise": + raise ValueError(f"AtomArray is missing {cls.n_body}-body annotation `{cls.full_name}`.") + else: + return default + return cls.mask_from_annotation(annotation) + + @classmethod + def annotation( + cls, + atom_array: AtomArray, + default: Any | Literal["generate", "raise"] = "generate", + ) -> np.ndarray | AnnotationList2D: + """Gets an annotation from an AtomArray, falling back to a generated default. + + Args: + atom_array: The AtomArray to get the annotation from. + default: The default value to return if the annotation is not found. + If ``"generate"``, the default annotation is generated and returned. + If ``"raise"``, a ValueError is raised if the annotation is not found. + If any other value, that value is returned. + + Returns: + The annotation for this StandardAnnotation. + + Raises: + ValueError: If the annotation is not found and ``default`` is ``"raise"``. + """ + annotation = cls._read_existing(atom_array) + + if annotation is None: + if isinstance(default, str) and default == "generate": + annotation = cls.default_annotation(atom_array) + elif isinstance(default, str) and default == "raise": + raise ValueError(f"AtomArray is missing {cls.n_body}-body annotation `{cls.full_name}`.") + else: + annotation = default + + return annotation + + @classmethod + def annotation_and_mask_at_declared_level(cls, atom_array: AtomArray) -> tuple[np.ndarray, np.ndarray]: + """Return values and active masks with one entry per segment at the StandardAnnotation's Level. + + System annotations return one entry; 2d annotations return all entries in the AnnotationList2D. + """ + annotation = cls.annotation(atom_array, default="generate") + mask = cls.mask(atom_array, default="generate") + if cls.n_body == 0: + return np.asarray(annotation).reshape(1), np.asarray(mask, dtype=bool).reshape(1) + if isinstance(annotation, AnnotationList2D): + if not isinstance(mask, AnnotationList2D): + raise TypeError(f"Two-body annotation `{cls.name}` returned a one-body mask.") + return np.asarray(annotation.values), np.asarray(mask.values, dtype=bool) + if isinstance(mask, AnnotationList2D): + raise TypeError(f"One-body annotation `{cls.name}` returned a two-body mask.") + + n_atoms = atom_array.array_length() + if cls.level == Level.ATOM or n_atoms == 0: + indices = np.arange(n_atoms) + else: + indices = cls.level.apply(atom_array, np.arange(n_atoms), lambda values: values[0]) + + return np.asarray(annotation)[indices], np.asarray(mask, dtype=bool)[indices] + + @classmethod + def set_annotation(cls, atom_array: AtomArray, *args, **annotation_kwargs) -> None: + """Set the annotation, synchronizing existing aliases and enforcing declared pairwise symmetry.""" + if cls.n_body in (0, 1): + if len(args) == 0: + array = annotation_kwargs.pop("array") + elif len(args) == 1: + array = args[0] + else: + raise ValueError(f"Only one argument is allowed for {cls.n_body}-body annotations. Got {len(args)}.") + assert len(annotation_kwargs) == 0, "Unexpected annotation keyword arguments." + + if cls.n_body == 0: + _validate_n_body_and_type(atom_array, cls.n_body, f"set {cls.full_name}") + array = np.asarray(array, dtype=cls.storage_dtype()) + atom_array.set_annotation(cls.full_name, array, n_body=0) + else: + atom_array.set_annotation(cls.full_name, array) + + categories = get_annotation_categories(atom_array, n_body=cls.n_body) + for alias in cls.aliases: + if alias in categories: + if cls.n_body == 0: + atom_array.set_annotation(alias, array, n_body=0) + else: + atom_array.del_annotation(alias) + atom_array.set_annotation(alias, array) + + elif cls.n_body == 2: + _validate_n_body_and_type(atom_array, cls.n_body, f"set {cls.full_name}") + + if len(args) == 1: + assert isinstance(args[0], AnnotationList2D), "Only AnnotationList2D is allowed for 2-body annotations." + annot = args[0] + elif len(args) == 0: + annot = AnnotationList2D(atom_array.array_length(), **annotation_kwargs) + else: + raise ValueError(f"Only one argument is allowed for 2-body annotations. Got {len(args)} arguments.") + + if cls.is_symmetric: + annot = annot.symmetrized() + atom_array.set_annotation(cls.full_name, annot, n_body=2) + categories = get_annotation_categories(atom_array, n_body=2) + for alias in cls.aliases: + if alias in categories: + atom_array.set_annotation(alias, annot, n_body=2) + else: + raise NotImplementedError("Currently only 0-body, 1-body and 2-body annotations are supported.") + + @classmethod + def is_valid(cls, atom_array: AtomArray) -> bool: + """ + Check if the annotation is consistent at the relevant level. + (i.e. do all atoms/tokens/residues/chains/molecules/systems have the same value for the StandardAnnotation?) + + Returns: + bool: True if valid, False otherwise. + """ + if cls.n_body == 0 or cls.level == Level.ATOM: + return True + + if cls.n_body == 1: + is_same = lambda x: np.all(x == x[0]) if len(x) > 0 else True # noqa: E731 + is_annotation_valid = cls.level.apply(atom_array, cls.annotation(atom_array), is_same) + return np.all(is_annotation_valid) + + # TODO: Implement n-body aggregation + return True + + +class AnnotationRegistryAccessor: + """Base class for annotation registry accessor singletons. + + Provides dynamic attribute-based and subscript access to a registry dict, + plus utilities for querying field names by body order. + + Subclasses must override :py:attr:`_registry` to return the appropriate + :py:class:`StandardAnnotationMeta` registry dict. + """ + + _entry_kind: ClassVar[str] = "standard annotation" + + @property + def _registry(self) -> dict[str, type[StandardAnnotationBase]]: + raise NotImplementedError + + def __getattr__(self, name: str) -> type[StandardAnnotationBase]: + """Dynamically retrieves an entry from the registry.""" + name = name.replace("_", "-") + return self.__getitem__(name) + + def __getitem__(self, name: str) -> type[StandardAnnotationBase]: + """Retrieves an entry by its registry name.""" + try: + return self._registry[name] + except KeyError: + raise AttributeError( + f"No {self._entry_kind} named '{name}' is registered. " f"Available {self._entry_kind}s: {self.list()}" + ) from None + + def list(self) -> list[str]: + """Returns a list of registry names of all registered entries.""" + return list(self._registry.keys()) + + def __iter__(self): + """Iterate over all registered classes.""" + for name in self.list(): + yield self[name] + + def get_valid_full_names(self) -> frozenset[str]: + """Returns the set of full names of all registered entries.""" + return frozenset(cls.full_name for cls in self) + + def get(self, name: str) -> type[StandardAnnotationBase]: + """Retrieve an entry by registry name, canonical field name, or alias.""" + if name in self._registry: + return self._registry[name] + return self.from_full_name(name) + + def from_full_name(self, full_name: str) -> type[StandardAnnotationBase]: + """Resolve a canonical full name or accepted alias to a registered class.""" + for cls in self: + if full_name in (cls.full_name, *cls.aliases): + return cls + raise KeyError(f"No {self._entry_kind} with full_name '{full_name}' found.") + + def get_field_names(self, n_body: int | Literal["all"] = "all", *, include_aliases: bool = False) -> list[str]: + """Return persisted StandardAnnotation field names for the given body order. + + Args: + n_body: Body order to filter by, or ``"all"`` for no filtering. + Defaults to ``"all"``. + include_aliases: Include accepted legacy read names. + + Returns: + Deduplicated list of field names. + """ + names: list[str] = [] + for cls in self: + if n_body != "all" and cls.n_body != n_body: + continue + names.append(cls.full_name) + if include_aliases: + names.extend(cls.aliases) + return list(dict.fromkeys(names)) + + +class StandardAnnotationAccessor(AnnotationRegistryAccessor): + """Provides dynamic, attribute-based access to all registered standard annotations. + + Iterates ``StandardAnnotationMeta._registry``, which also includes subclasses of + :py:class:`StandardAnnotationBase`. + """ + + @property + def _registry(self) -> dict[str, type[StandardAnnotationBase]]: + return StandardAnnotationMeta._registry + + def __repr__(self) -> str: + return f"StandardAnnotations({self.list()})" + + +STANDARD_ANNOTATIONS = StandardAnnotationAccessor() diff --git a/src/atomworks/io/utils/standard_annotations/definitions.py b/src/atomworks/io/utils/standard_annotations/definitions.py new file mode 100644 index 00000000..c301f4f7 --- /dev/null +++ b/src/atomworks/io/utils/standard_annotations/definitions.py @@ -0,0 +1,85 @@ +"""Non-condition standard annotations. + +Defines standard annotations that benefit from the registry-driven save/load +and default-generation pattern, but are NOT model-input conditions. +""" + +import numpy as np +from biotite.structure import AtomArray + +from atomworks.io.utils.standard_annotations.base import Level, StandardAnnotationBase + + +class ExpandableSegmentMinLength(StandardAnnotationBase): + """Per-residue minimum length for expandable segment sentinels. + + A value of ``-1`` indicates the residue is not an expandable segment sentinel. + Values ``>= 0`` indicate the sentinel's minimum realized segment length. + """ + + name = "expsegmin" + n_body = 1 + level = Level.RESIDUE + dtype = int + aliases = ("S_SEGMIN",) + default_value = -1 + + @classmethod + def mask_from_annotation(cls, annotation: np.ndarray) -> np.ndarray: + return annotation != -1 + + +class ExpandableSegmentMaxLength(StandardAnnotationBase): + """Per-residue maximum length for expandable segment sentinels. + + A value of ``-1`` indicates the residue is not an expandable segment sentinel. + Values ``>= 0`` indicate the sentinel's maximum realized segment length. + """ + + name = "expsegmax" + n_body = 1 + level = Level.RESIDUE + dtype = int + aliases = ("S_SEGMAX",) + default_value = -1 + + @classmethod + def mask_from_annotation(cls, annotation: np.ndarray) -> np.ndarray: + return annotation != -1 + + +class Atomize(StandardAnnotationBase): + """Per-atom flag indicating whether a residue should be treated atomistically. + + Unlike standard polymer residues (which are tokenized at the residue level), + atomized residues are represented at the individual atom level. This includes + non-polymers and non-canonical polymer residues by default. + + The canonical field name remains ``"atomize"`` for backward compatibility, + so ``atom_array.atomize`` continues to work. + """ + + name = "atomize" + n_body = 1 + level = Level.ATOM + dtype = bool + aliases = ("S_ATM",) + + @classmethod + def get_full_name(cls) -> str: + return "atomize" + + @classmethod + def default_annotation(cls, atom_array: AtomArray) -> np.ndarray: + """Default: True for non-polymers and non-canonical polymer residues.""" + # Lazy to avoid a module-load cycle with `annotator` + from atomworks.io.utils.annotator import ensure_annotations + + ensure_annotations(atom_array, "is_polymer", "is_standard_aa", "is_standard_rna", "is_standard_dna") + is_standard_polymer = atom_array.is_polymer & ( + atom_array.is_standard_aa + | atom_array.is_standard_rna + | atom_array.is_standard_dna + | ExpandableSegmentMinLength.mask(atom_array, default="generate") + ) + return ~is_standard_polymer diff --git a/src/atomworks/io/utils/standard_annotations/serialization.py b/src/atomworks/io/utils/standard_annotations/serialization.py new file mode 100644 index 00000000..4ad896c2 --- /dev/null +++ b/src/atomworks/io/utils/standard_annotations/serialization.py @@ -0,0 +1,411 @@ +"""Serialization and deserialization helpers for StandardAnnotation objects. + +These helpers are used by :py:func:`~atomworks.io.utils.io_utils._to_cif_or_bcif` +(writing) and by :py:func:`~atomworks.io.parser.parse` (reading) to persist all +registered :py:class:`~atomworks.io.utils.standard_annotations.base.StandardAnnotationBase` +subclasses into CIF extra-category blocks and restore them on load. + +**Serialization tiers**: + +- **0-body** annotations (a single system-wide value): written once in their own CIF + category, as a single row with no ``idx`` columns. +- **Scalar 1-body** annotations (shape ``(n_atoms,)``): written to ``atom_site`` + via biotite's ``extra_fields`` mechanism, like any regular ``AtomArray`` + annotation. +- **Nonscalar 1-body** annotations (shape ``(n_atoms, k)``) and + **2-body** annotations (``AnnotationList2D``): written as CIF extra-categories + using a shared ``idx0, [idx1,] val0, val1, …`` format. For nonscalar 1-body + ``idx0 = [0, 1, …, n_atoms-1]`` (one index column); for 2-body ``idx0`` and + ``idx1`` hold the pair atom indices (two index columns). Raises + :py:exc:`ValueError` for 2-body annotations if the row count exceeds + ``_MAX_MULTI_BODY_ROWS`` +""" + +import warnings +from typing import Literal + +import numpy as np +from biotite.structure import AtomArray, AtomArrayStack +from biotite.structure.io import pdbx + +from atomworks.io.utils.atom_array_plus import ( + AnnotationList2D, + AtomArrayPlus, + AtomArrayPlusStack, + as_atom_array_plus, +) +from atomworks.io.utils.selection import get_annotation, get_annotation_categories +from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS +from atomworks.io.utils.standard_annotations.base import StandardAnnotationBase + +# Maximum number of rows to save for multi-body AtomArrayPlus annotations (e.g. pairwise distances) +_MAX_MULTI_BODY_ROWS = 10_000 + +# This is a closed compatibility list for reading legacy mask fields; new +# annotations must persist values. +_LEGACY_MASK_FIELDS = { + "sequence": "mask_sequence_1_residue", + "coordinate": "mask_coordinate_1_atom", + "index": "mask_index_1_residue", +} + +# --------------------------------------------------------------------------- +# Low-level CIF dict helpers +# --------------------------------------------------------------------------- + + +def _atleast_2d_last(arr: np.ndarray) -> np.ndarray: + """Same as np.atleast_2d but adds a dimension at the end instead of the beginning.""" + arr = np.asarray(arr) + if arr.ndim == 0: + return arr.reshape(1, 1) + elif arr.ndim == 1: + return np.expand_dims(arr, axis=-1) + else: + return arr + + +def _idxs_values_to_cif_dict(idxs: np.ndarray, values: np.ndarray) -> dict: + """Convert index and value arrays to a CIF category dict.""" + idxs = _atleast_2d_last(idxs) # (N, n_body) + values = _atleast_2d_last(values) # (N, n_values) + return {f"idx{i}": idxs[:, i] for i in range(idxs.shape[1])} | { + f"val{i}": values[:, i] for i in range(values.shape[1]) + } + + +def _annotation_to_cif_idxs_values( + annotation: np.ndarray | AnnotationList2D, + n_atoms: int, +) -> tuple[np.ndarray, np.ndarray]: + """Extract ``(idxs, values)`` from an annotation for CIF serialization. + + For :py:class:`~atomworks.io.utils.atom_array_plus.AnnotationList2D` (2-body): + returns ``(pairs, values)``. Scalars use one row with no index columns. + + For ``np.ndarray`` (nonscalar 1-body): returns ``(arange(n_atoms), annotation)``. + """ + if isinstance(annotation, AnnotationList2D): + return annotation.pairs, annotation.values + elif annotation.ndim == 0: + return np.empty((1, 0), dtype=int), annotation + else: + return np.arange(n_atoms), annotation + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +def _detect_standard_annotation_categories( + cif_block: pdbx.CIFBlock, +) -> dict[str, type[StandardAnnotationBase]]: + """Scan a CIF block and return found StandardAnnotation categories. + + Returns: + Dict mapping category names to their registered StandardAnnotation. + """ + classes_by_field = { + field: annotation_cls + for annotation_cls in STANDARD_ANNOTATIONS + for field in (annotation_cls.full_name, *annotation_cls.aliases) + } + return {key: classes_by_field[key] for key in cif_block if key in classes_by_field} + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def _get_available_standard_annotations( + atom_array: AtomArray, + n_body: int | Literal["all"] = "all", +) -> list[type[StandardAnnotationBase]]: + """Return registered StandardAnnotations present on ``atom_array``.""" + return [ + sa_cls + for sa_cls in STANDARD_ANNOTATIONS + if (n_body == "all" or sa_cls.n_body == n_body) and sa_cls.has_annotation(atom_array) + ] + + +def _canonicalize_standard_annotation_aliases(atom_array: AtomArray) -> None: + """Materialize canonical fields on a serialization copy without deleting aliases.""" + for annotation_cls in _get_available_standard_annotations(atom_array): + if annotation_cls.full_name not in get_annotation_categories(atom_array, n_body=annotation_cls.n_body): + annotation_cls.set_annotation(atom_array, annotation_cls.annotation(atom_array, default="raise")) + + +def _get_scalar_1body_sa_names(atom_array: AtomArray) -> list[str]: + """Return names of 1-body StandardAnnotations on ``atom_array`` that should go into ``atom_site``. + + Nonscalar 1-body annotation values are excluded (they go into a CIF + extra-category instead). + + Args: + atom_array: The structure whose annotations are inspected. + + Returns: + Annotation names suitable for passing as ``extra_fields`` to + :py:func:`~biotite.structure.io.pdbx.set_structure`. + """ + names: list[str] = [] + for sa_cls in _get_available_standard_annotations(atom_array, n_body=1): + annotation = sa_cls.annotation(atom_array) + if annotation.ndim == 1: + names.append(sa_cls.full_name) + return names + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + + +def _serialize_standard_annotation_categories(atom_array: AtomArray) -> dict[str, dict]: + """Serialize 0-body, nonscalar 1-body and 2-body annotations to CIF extra-category dicts. + + Scalar 1-body annotations are excluded here; they are written to ``atom_site`` + via :py:func:`_get_scalar_1body_sa_names` instead. + + Args: + atom_array: The structure to read annotations from. + + Returns: + Dict suitable for passing as ``extra_categories`` to + :py:func:`~atomworks.io.utils.io_utils._write_categories_to_block`. + + Raises: + ValueError: If a 2-body annotation has more than ``MAX_MULTI_BODY_ROWS`` pairs. + """ + extra_categories: dict[str, dict] = {} + n_atoms = atom_array.array_length() + + for annotation_cls in _get_available_standard_annotations(atom_array): + annotation = annotation_cls.annotation(atom_array) + + if annotation_cls.n_body == 1 and annotation.ndim == 1: + continue # Scalar 1-body annotations are saved in atom_site extra_fields + if annotation_cls.n_body == 2 and len(annotation.pairs) > _MAX_MULTI_BODY_ROWS: + raise ValueError( + f"StandardAnnotation '{annotation_cls.full_name}' would write {len(annotation.pairs)} rows " + f"(pairs), exceeding the limit of {_MAX_MULTI_BODY_ROWS}. " + ) + + idxs, values = _annotation_to_cif_idxs_values(annotation, n_atoms) + extra_categories[annotation_cls.full_name] = _idxs_values_to_cif_dict(idxs, values) + + return extra_categories + + +# --------------------------------------------------------------------------- +# Deserialization +# --------------------------------------------------------------------------- + + +def _deserialize_standard_annotation_cif_category( + cif_category: pdbx.CIFCategory, + dtype: np.dtype, + annotation_cls: type[StandardAnnotationBase], + atom_array: AtomArray, + model_num: int | None = None, +) -> np.ndarray | AnnotationList2D | None: + """Deserialize one CIF extra-category into its annotation value. + + System annotations use their registered body order; otherwise, ``idx*`` columns determine the layout: + + - **0-body annotation** → one scalar value with no index columns. + - **1 index column** → 1-body: ``val*`` columns are scattered into a + default-filled array of shape ``(n_atoms[, k])`` using ``idx0`` as the + atom-index selector. + - **2 index columns** → 2-body: ``idx0`` and ``idx1`` form pair indices; + an :py:class:`~atomworks.io.utils.atom_array_plus.AnnotationList2D` is + constructed and returned. + + When ``model_num`` is provided and a ``model_num`` column exists in the + category, only rows matching that model are used. System values must agree across a stack. + """ + # Filter rows by model_num if present in the category + row_mask = None + if model_num is not None and "model_num" in cif_category: + model_col = cif_category["model_num"].as_array(int) + row_mask = model_col == model_num + if not row_mask.any(): + return + + idxs = [cif_category[k].as_array(int) for k in sorted(k for k in cif_category if k.startswith("idx"))] + vals = [] + for key in sorted(k for k in cif_category if k.startswith("val")): + column = cif_category[key] + vals.append(column.as_array(str) == "True" if np.issubdtype(dtype, np.bool_) else column.as_array(dtype)) + if not vals: + raise ValueError("CIF category has no 'val' columns.") + + if row_mask is not None: + idxs = [idx[row_mask] for idx in idxs] + vals = [val[row_mask] for val in vals] + + val_array = np.stack(vals, axis=1) if len(vals) > 1 else vals[0] + n_atoms = atom_array.array_length() + + if annotation_cls.n_body == 0: + if idxs: + raise ValueError("0-body CIF categories must not contain index columns.") + if val_array.ndim != 1 or not len(val_array): + raise ValueError("0-body CIF categories must contain scalar values.") + if len(val_array) != 1 and "model_num" not in cif_category: + raise ValueError("0-body CIF categories must contain exactly one row without model_num.") + if not np.array_equal(val_array, np.full_like(val_array, val_array[0]), equal_nan=val_array.dtype.kind in "fc"): + raise ValueError("Conflicting system values across models; select one model with ParseConfig(model=...).") + return np.asarray(val_array[0], dtype=dtype) + elif len(idxs) <= 1: + # 1-body: scatter val columns into a full-length array. + idx_array = idxs[0] if idxs else np.arange(n_atoms) + full = np.asarray(annotation_cls.default_annotation(atom_array), dtype=dtype).copy() + expected_shape = (n_atoms,) if val_array.ndim == 1 else (n_atoms,) + val_array.shape[1:] + if full.shape != expected_shape: + raise ValueError( + f"StandardAnnotation '{annotation_cls.full_name}' default has shape {full.shape}, " + f"but its CIF values require {expected_shape}." + ) + full[idx_array] = val_array.astype(dtype) + return full + else: + # 2-body: reconstruct AnnotationList2D from pair index columns. + pairs = np.stack(idxs, axis=1) + value = val_array.astype(dtype) + return AnnotationList2D(n_atoms=n_atoms, pairs=pairs, values=value) + + +def _handle_legacy_standard_annotations(atom_array: AtomArrayPlus | AtomArrayPlusStack) -> None: + """Restore legacy masks and normalize integer Index values to booleans. + + Sequence and Coordinate retain saved targets where the legacy mask is active, + falling back to the structure when targets are absent or sequence targets are + unspecified. Inactive positions receive defaults. Legacy fields are removed after conversion. + """ + for annotation_cls in STANDARD_ANNOTATIONS: + legacy_name = _LEGACY_MASK_FIELDS.get(annotation_cls.name) + if legacy_name is None: + continue + legacy_mask = get_annotation(atom_array, legacy_name, n_body=annotation_cls.n_body) + + if annotation_cls.name == "index": + canonical = get_annotation(atom_array, annotation_cls.full_name, n_body=1) + if canonical is not None and np.asarray(canonical).dtype.kind != "b": + annotation = np.asarray(legacy_mask, dtype=bool) if legacy_mask is not None else canonical != -1 + atom_array.del_annotation(annotation_cls.full_name) + annotation_cls.set_annotation(atom_array, annotation) + elif canonical is None and legacy_mask is not None: + annotation_cls.set_annotation(atom_array, np.asarray(legacy_mask, dtype=bool)) + if legacy_mask is not None: + atom_array.del_annotation(legacy_name) + continue + + if legacy_mask is None: + continue + legacy_mask = np.asarray(legacy_mask, dtype=bool) + source = annotation_cls.annotation(atom_array, default=None) + if source is None: + ground_truth_value = getattr(annotation_cls, "ground_truth_value", None) + if ground_truth_value is None: + raise ValueError(f"Legacy mask restoration is not defined for '{annotation_cls.name}'.") + source = ground_truth_value(atom_array) + if source is None: + raise ValueError(f"Legacy mask restoration is not defined for '{annotation_cls.name}'.") + elif annotation_cls.name == "sequence": + missing_target = legacy_mask & (source == annotation_cls.default_value) + source = np.where(missing_target, annotation_cls.ground_truth_value(atom_array), source) + annotation = annotation_cls.default_annotation(atom_array) + annotation = annotation.astype(np.result_type(annotation.dtype, source.dtype)) + if isinstance(atom_array, AtomArrayPlusStack) and source.shape == ( + atom_array.stack_depth(), + *annotation.shape, + ): + if atom_array.stack_depth() == 1: + annotation[legacy_mask] = source[0, legacy_mask] + annotation_cls.set_annotation(atom_array, annotation) + else: + per_model = np.broadcast_to(annotation, source.shape).copy() + per_model[:, legacy_mask] = source[:, legacy_mask] + atom_array.set_per_stack_annotation(annotation_cls.full_name, per_model) + elif source.shape == annotation.shape: + annotation[legacy_mask] = source[legacy_mask] + annotation_cls.set_annotation(atom_array, annotation) + else: + raise ValueError( + f"Cannot restore StandardAnnotation '{annotation_cls.name}' from legacy mask field " + f"'{legacy_name}': target values have shape {source.shape}, but the annotation " + f"requires shape {annotation.shape}." + ) + atom_array.del_annotation(legacy_name) + + +def _deserialize_standard_annotations( + atom_array: AtomArray | AtomArrayStack, + cif_block: pdbx.CIFBlock, + model_num: int | None = None, + *, + restore_legacy: bool = True, +) -> AtomArrayPlus | AtomArrayPlusStack: + """Reconstruct all StandardAnnotations from a CIF block onto an AtomArray. + + Args: + atom_array: The AtomArray (or stack) to annotate. + cif_block: The CIF block containing the serialized annotations. + model_num: When provided, filters SA category rows to only those + matching this CIF model ID. Without it, system values must agree across models. + restore_legacy: Reconstruct annotation values from legacy mask fields and the structure. + Parsers wait until assembly transforms have produced the final coordinate frame. + + Returns: + The atom array promoted to :py:class:`AtomArrayPlus` with all found + StandardAnnotations restored. + + Raises: + ValueError: If a system annotation is malformed or conflicts across models. + """ + if isinstance(atom_array, AtomArray): + atom_array = as_atom_array_plus(atom_array) + elif isinstance(atom_array, AtomArrayStack): + atom_array = AtomArrayPlusStack.from_atom_array_stack(atom_array) + else: + raise ValueError(f"AtomArray or AtomArrayStack expected, got {type(atom_array)}") + + category_values: dict[type[StandardAnnotationBase], tuple[str, np.ndarray | AnnotationList2D]] = {} + for key, annotation_cls in _detect_standard_annotation_categories(cif_block).items(): + try: + value = _deserialize_standard_annotation_cif_category( + cif_block[key], annotation_cls.storage_dtype(), annotation_cls, atom_array, model_num=model_num + ) + except (ValueError, KeyError) as e: + if annotation_cls.n_body == 0: + raise ValueError(f"Cannot deserialize system annotation '{key}': {e}") from e + warnings.warn(f"Failed to deserialize StandardAnnotation '{key}': {e}", stacklevel=2) + continue + if value is None: + continue + existing = annotation_cls.annotation(atom_array, default=None) + if existing is not None and not annotation_cls._annotations_equal(existing, value): + raise ValueError( + f"Conflicting atom_site fields and CIF category '{key}' for StandardAnnotation '{annotation_cls.name}'." + ) + if annotation_cls in category_values: + previous_key, previous_value = category_values[annotation_cls] + if not annotation_cls._annotations_equal(previous_value, value): + raise ValueError( + f"Conflicting CIF categories '{previous_key}' and '{key}' both resolve to " + f"StandardAnnotation '{annotation_cls.name}'." + ) + else: + category_values[annotation_cls] = (key, value) + + for annotation_cls, (_, value) in category_values.items(): + annotation_cls.set_annotation(atom_array, value) + + if restore_legacy: + _handle_legacy_standard_annotations(atom_array) + + return atom_array diff --git a/src/atomworks/io/utils/standardize.py b/src/atomworks/io/utils/standardize.py new file mode 100644 index 00000000..24ba2a46 --- /dev/null +++ b/src/atomworks/io/utils/standardize.py @@ -0,0 +1,185 @@ +"""Standardization utilities for structural files.""" + +import logging +import os +from typing import Literal + +import numpy as np +from biotite.structure import AtomArray + +from atomworks.constants import ( + CCD_MIRROR_PATH, + DO_NOT_MATCH_CCD, + HYDROGEN_LIKE_SYMBOLS, + STANDARD_POLYMER_RESIDUES, +) +from atomworks.io.utils.ccd import get_atom_names_for_residue, get_available_ccd_codes +from atomworks.io.utils.selection import get_residue_starts + +logger = logging.getLogger(__name__) + +_CANONICAL_RESIDUES: frozenset[str] = frozenset(STANDARD_POLYMER_RESIDUES) + + +def _handle_unmatched_atom( + atom_name: str, + res_name: str, + atom_array: AtomArray, + atom_idx: int, + start_idx: int, + on_mismatch: Literal["filter", "warn", "raise"], + on_mismatch_non_heavy: Literal["filter", "warn", "raise"] | None, + atoms_to_keep: np.ndarray, +) -> None: + """Handle an atom that matches neither standard nor alternative names.""" + # Determine atom type and select appropriate policy + is_non_heavy = atom_array.element[atom_idx] in HYDROGEN_LIKE_SYMBOLS + policy = on_mismatch_non_heavy if (is_non_heavy and on_mismatch_non_heavy is not None) else on_mismatch + atom_type_str = "non-heavy atom" if is_non_heavy else "heavy atom" + + # Get residue context for error message + chain_id = atom_array.chain_id[start_idx] + res_id = atom_array.res_id[start_idx] + msg = ( + f"Atom '{atom_name}' in residue {res_name} " + f"(chain {chain_id}, res_id {res_id}) matches neither " + f"standard nor alternative CCD names" + ) + + # Apply policy + if policy == "raise": + raise ValueError( + f"{msg}. If this is a custom component, provide its chem_comp_atom and chem_comp_bond definitions." + ) + elif policy == "filter": + logger.warning(f"{msg} - removing {atom_type_str} " f"(will be re-added by add_missing_atoms if valid)") + atoms_to_keep[atom_idx] = False + elif policy == "warn": + logger.warning(msg) + + +def standardize_atom_names( + atom_array: AtomArray, + on_mismatch: Literal["filter", "warn", "raise"] = "raise", + on_mismatch_non_heavy: Literal["filter", "warn", "raise"] | None = None, + ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH, + non_canonical_only: bool = True, +) -> AtomArray: + """Standardize atom names to CCD canonical naming. + + Processes each residue, attempting to match atoms using: + 1. Standard CCD atom names + 2. Alternative CCD atom names (if available), converting to standard + 3. Handling unmatched atoms according to ``on_mismatch`` policy + + NOTE: If we use are using a custom CCD registry, that entry will + be considered "standard" (even if its names don't match the canonical + CCD). + + Args: + atom_array: Structure with potentially non-standard atom names. + on_mismatch: How to handle atoms matching neither standard nor alternative. + Applied to heavy atoms, and to non-heavy atoms if ``on_mismatch_non_heavy`` + is not specified: + - ``"raise"``: Raise ``ValueError`` on first unmatched atom (default) + - ``"filter"``: Remove unmatched atoms and log warning. + - ``"warn"``: Keep unmatched atoms and log warning + on_mismatch_non_heavy: Optional separate policy for non-heavy atoms + (H, D, T isotopes). If ``None`` (default), uses ``on_mismatch`` value. + ccd_mirror_path: Path to local CCD mirror. + non_canonical_only: If ``True`` (default), only standardize non-canonical residues. + Canonical residues (standard 20 amino acids, standard RNA/DNA nucleotides) + will be skipped. Defaults to ``True`` for efficiency. + + Returns: + AtomArray with standardized names. May be shorter than input if + ``on_mismatch="filter"`` removes atoms. + """ + # Normalize + ccd_mirror_path_str = str(ccd_mirror_path or "") + + available_ccds = get_available_ccd_codes(ccd_mirror_path_str) + + # Pre-fetch atom name data for all unique residues + unique_res_names = np.unique(atom_array.res_name) + res_name_to_atom_names = {} + for res_name in unique_res_names: + if non_canonical_only and res_name in _CANONICAL_RESIDUES: + continue + if res_name not in DO_NOT_MATCH_CCD and res_name in available_ccds: + template_data = get_atom_names_for_residue(res_name, ccd_mirror_path_str) + if template_data[0]: # Check if std_names_set is not empty + res_name_to_atom_names[res_name] = template_data + + atoms_to_keep = np.ones(len(atom_array), dtype=bool) + + # Loop over residue + _res_start_ends = get_residue_starts(atom_array, add_exclusive_stop=True) + _res_starts, _res_ends = _res_start_ends[:-1], _res_start_ends[1:] + for start_idx, end_idx in zip(_res_starts, _res_ends, strict=False): + res_name = atom_array.res_name[start_idx] + + # Skip if we don't have template data for this residue + if res_name not in res_name_to_atom_names: + continue + + # Get pre-fetched template data: standard names, alt names, and mapping alt -> standard + std_names_set, alt_names_set, alt_to_std = res_name_to_atom_names[res_name] + + # Check if all atoms already match standard names - if so, skip processing this residue + residue_atom_names = atom_array.atom_name[start_idx:end_idx] + if std_names_set.issuperset(residue_atom_names): + continue + + # Decide if we should match by standard atom names or alternative atom IDs (if available) + match_by = "atom_name" + if alt_names_set: # We have alternative names available from the CCD + n_matches_std = sum(1 for name in residue_atom_names if name in std_names_set) + n_matches_alt = sum(1 for name in residue_atom_names if name in alt_names_set) + + if n_matches_alt > n_matches_std: + match_by = "alt_atom_id" + # Log message about using alternative atom IDs for this residue (since this isn't typical) + chain_id = atom_array.chain_id[start_idx] + res_id = atom_array.res_id[start_idx] + logger.info(f"Residue {res_name} (chain {chain_id}, res_id {res_id}): using alternative atom IDs") + + # Process each atom in the residue + for j, atom_name in enumerate(residue_atom_names): + atom_idx = start_idx + j + matched = False + + # Match and standardize atom name + if match_by == "atom_name": + if atom_name in std_names_set: + matched = True + elif atom_name in alt_to_std: + atom_array.atom_name[atom_idx] = alt_to_std[atom_name] + matched = True + else: # match_by == "alt_atom_id" + if atom_name in alt_names_set and atom_name in alt_to_std: + atom_array.atom_name[atom_idx] = alt_to_std[atom_name] + matched = True + elif atom_name in std_names_set: + matched = True + + # Handle unmatched atoms + if not matched: + _handle_unmatched_atom( + atom_name, + res_name, + atom_array, + atom_idx, + start_idx, + on_mismatch, + on_mismatch_non_heavy, + atoms_to_keep, + ) + + # Apply filter + if not np.all(atoms_to_keep): + n_removed = np.sum(~atoms_to_keep) + logger.info(f"Filtered {n_removed} atoms with non-standard names") + atom_array = atom_array[atoms_to_keep] + + return atom_array diff --git a/src/atomworks/io/utils/testing.py b/src/atomworks/io/utils/testing.py index 582abd3d..5671c37b 100644 --- a/src/atomworks/io/utils/testing.py +++ b/src/atomworks/io/utils/testing.py @@ -1,19 +1,25 @@ """Utility functions for writings tests for AtomArray objects.""" -__all__ = ["assert_same_atom_array"] - +import functools import io import os from collections.abc import Iterable +from typing import Any, Literal import biotite.structure as struc import numpy as np from biotite.database import rcsb -from biotite.structure.atoms import AtomArray, AtomArrayStack +from biotite.structure import AtomArray, AtomArrayStack +from biotite.structure.info import standardize_order +from rdkit import Chem -import atomworks.io.utils.bonds as cb from atomworks.constants import PDB_MIRROR_PATH +from atomworks.io.utils.atom_array import subset_to_first_transformation +from atomworks.io.utils.atom_array_plus import AnnotationList2D, as_atom_array_plus +from atomworks.io.utils.bonds import remap_intra_residue_coordination_bonds from atomworks.io.utils.scatter import apply_group_wise, apply_segment_wise +from atomworks.io.utils.selection import get_annotation, get_annotation_categories, get_residue_starts +from atomworks.io.utils.sequence import convert_to_one_letter_sequences def get_pdb_path(pdbid: str, mirror_path: str | os.PathLike = PDB_MIRROR_PATH) -> str: @@ -99,123 +105,177 @@ def is_same_in_group(groups: np.ndarray, data: np.ndarray) -> np.ndarray: return is_group_data_same -def is_monotonic_increasing(arr: np.ndarray, strict: bool = True) -> bool: - """Check if an array is monotonically increasing. +def _get_atom_array_stats(arr: AtomArray) -> str: + msg = f"AtomArray: {len(arr)} atoms, {struc.get_residue_count(arr)} residues, {struc.get_chain_count(arr)} chains\n" + msg += f"\t... unique chain ids: {np.unique(arr.chain_id)}\n" + msg += f"\t... unique residue ids: {np.unique(arr.res_id)}\n" + msg += f"\t... unique atom types: {np.unique(arr.atom_name)}\n" + msg += f"\t... unique elements: {np.unique(arr.element)}\n" + return msg - Args: - arr (np.ndarray): Array to check. - """ - if strict: - return np.all(np.diff(arr) > 0) - else: - return np.all(np.diff(arr) >= 0) +def _cast_annotation_to_common_dtype(annot1: AtomArray, annot2: AtomArray) -> tuple[np.ndarray, np.ndarray]: + """Cast annotations to common dtype for comparison. -def is_monotonic_decreasing(arr: np.ndarray, strict: bool = True) -> bool: - """Check if an array is monotonically decreasing. + Rules: + - Integer types → int64 + - Float types → float64 + - String types → str_ (no change) + - Bool types → bool (no change) Args: - arr (np.ndarray): Array to check. + annot1: First annotation. + annot2: Second annotation + annotation: Annotation name to cast. + + Returns: + Tuple of casted annotations from annot1 and annot2. """ - if strict: - return np.all(np.diff(arr) < 0) + + # Check if both are numeric + if np.issubdtype(annot1.dtype, np.integer) and np.issubdtype(annot2.dtype, np.integer): + return annot1.astype(np.int64), annot2.astype(np.int64) + elif np.issubdtype(annot1.dtype, np.floating) and np.issubdtype(annot2.dtype, np.floating): + return annot1.astype(np.float64), annot2.astype(np.float64) else: - return np.all(np.diff(arr) <= 0) + # Return as-is for strings, bools, etc. + return annot1, annot2 -def is_increasing_without_gaps(arr: np.ndarray, strict: bool = True) -> bool: - """Check if an array is monotonically increasing without gaps. +def _sort_bond_array(bonds: np.ndarray) -> np.ndarray: + """Sort bond array lexicographically for deterministic comparison.""" + if len(bonds) == 0: + return bonds - Args: - arr (np.ndarray): Array to check. - """ - if strict: - return np.all(np.diff(arr) == 0) - else: - return np.all((np.diff(arr) == 0) | (np.diff(arr) == 1)) + bonds = bonds.copy() + # Normalize: smaller index first + mask = bonds[:, 0] > bonds[:, 1] + bonds[mask, :2] = bonds[mask, 1::-1] -def has_annotation(arr: AtomArray, annotation: str | list[str]) -> bool: - """Check if an AtomArray has an annotation. + # Sort lexicographically + sort_idx = np.lexsort((bonds[:, 2], bonds[:, 1], bonds[:, 0])) + return bonds[sort_idx] - Args: - arr: AtomArray to check. - annotation: Annotation(s) to check for. - """ - existing_annotations = frozenset(["coord", *arr.get_annotation_categories()]) - if isinstance(annotation, str): - return annotation in existing_annotations - else: - return set(annotation).issubset(existing_annotations) +def _reorder_by_canonical_graph(atom_array: AtomArray) -> AtomArray: + """Reorder atoms within residues using canonical graph ranking.""" + # Reorder atoms within all residues by canonical ranking + reorder_indices = np.arange(len(atom_array)) -def _get_atom_array_stats(arr: AtomArray) -> str: - msg = f"AtomArray: {len(arr)} atoms, {struc.get_residue_count(arr)} residues, {struc.get_chain_count(arr)} chains\n" - msg += f"\t... unique chain ids: {np.unique(arr.chain_id)}\n" - msg += f"\t... unique residue ids: {np.unique(arr.res_id)}\n" - msg += f"\t... unique atom types: {np.unique(arr.atom_name)}\n" - msg += f"\t... unique elements: {np.unique(arr.element)}\n" - return msg + # Get residue boundaries + res_start_stops = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + res_starts, res_stops = res_start_stops[:-1], res_start_stops[1:] + for start, stop in zip(res_starts, res_stops, strict=False): + residue = atom_array[start:stop] -def assert_same_atom_array( - arr1: AtomArray | AtomArrayStack, - arr2: AtomArray | AtomArrayStack, + # Skip if no bonds - we cannot define a canonical ordering + if residue.bonds is None or len(residue.bonds.as_array()) == 0: + continue + + # Get canonical ordering for this residue + try: + # ... create a simplified molecule with only connectivity (no stereochemistry or charge) + mol_simplified = Chem.RWMol() + + # Add atoms (without charges or stereochemistry, which can be ambiguous) + for element in residue.element: + atom = Chem.Atom(element) + atom.SetNoImplicit(True) # Don't add implicit hydrogens + mol_simplified.AddAtom(atom) + + # Add bonds (all as single bonds to ignore bond type differences) + bond_array = residue.bonds.as_array() + for bond_idx in range(len(bond_array)): + atom1, atom2 = bond_array[bond_idx, :2] + mol_simplified.AddBond(int(atom1), int(atom2), Chem.BondType.SINGLE) + + # Get canonical ordering + mol_simplified = mol_simplified.GetMol() + canonical_ranks = Chem.CanonicalRankAtoms(mol_simplified, breakTies=True) + local_order = np.argsort(canonical_ranks) + + # Update global reorder indices + reorder_indices[start:stop] = start + local_order + except Exception: + # If conversion fails, keep original order for this residue + continue + + return atom_array[reorder_indices] + + +def _compare_annotation_arrays( + annot1: np.ndarray, + annot2: np.ndarray, + annotation_name: str, + cast_to_common_dtype: bool, + _n_mismatches_to_show: int, +) -> None: + if cast_to_common_dtype: + annot1, annot2 = _cast_annotation_to_common_dtype(annot1, annot2) + + # Check if the arrays contain floating-point numbers (in which case, we allow NaN == NaN) + if np.issubdtype(annot1.dtype, np.floating) and np.issubdtype(annot2.dtype, np.floating): + arrays_equal = np.array_equal(annot1, annot2, equal_nan=True) + else: + arrays_equal = np.array_equal(annot1, annot2, equal_nan=False) + + if not arrays_equal: + mismatch_idxs = np.where(annot1 != annot2)[0] + msg = ( + f"Annotation {annotation_name} does not match at {len(mismatch_idxs)} indices. First few mismatches:" + "\n" + ) + for idx in mismatch_idxs[:_n_mismatches_to_show]: + msg += f"\t{idx}: {annot1[idx]} != {annot2[idx]}\n" + if idx >= _n_mismatches_to_show: + break + raise AssertionError(msg) + + +def _assert_same_atom_array( + arr1: AtomArray, + arr2: AtomArray, compare_coords: bool = True, compare_bonds: bool = True, compare_box: bool = False, - annotations_to_compare: list[str] | None = None, + annotations_to_compare: list[str] | Literal["arr1"] | None = None, enforce_order: bool = True, compare_bond_order: bool = True, + cast_to_common_dtype: bool = False, _n_mismatches_to_show: int = 5, ) -> None: - """Asserts that two AtomArray objects are equal. + """Asserts that two AtomArray objects are equal. Does not accept AtomArrayPlus Args: arr1 (AtomArray): The first AtomArray to compare. arr2 (AtomArray): The second AtomArray to compare. - compare_coords (bool, optional): Whether to compare coordinates. Defaults to True. - compare_bonds (bool, optional): Whether to compare bonds. Defaults to True. - compare_box (bool, optional): Whether to compare the box attribute. Defaults to False. - annotations_to_compare (list[str] | None, optional): List of annotation categories to compare. - Defaults to None, in which case all annotations are compared. - enforce_order (bool, optional): Whether to enforce the order of the atoms. Defaults to True. - NOTE: Enforcing order is much faster; use False only when strictly necessary. - compare_bond_order (bool, optional): Whether to compare bond order. Defaults to True. - _n_mismatches_to_show (int, optional): Number of mismatches to show. Defaults to 20. - - WARNING: If AtomArrayStack objects are passed, only the first array is compared. + **kwargs: See documentation in ``assert_same_atom_array`` Raises: AssertionError: If the AtomArray objects are not equal. """ - assert isinstance( - arr1, AtomArray | AtomArrayStack - ), f"arr1 is not an AtomArray or AtomArrayStack but has type {type(arr1)}" - assert isinstance( - arr2, AtomArray | AtomArrayStack - ), f"arr2 is not an AtomArray or AtomArrayStack but has type {type(arr2)}" + assert isinstance(arr1, AtomArray), f"arr1 is not an AtomArray but has type {type(arr1)}" + assert isinstance(arr2, AtomArray), f"arr2 is not an AtomArray but has type {type(arr2)}" # Copy both arrays to avoid modifying the original arrays arr1 = arr1.copy() arr2 = arr2.copy() - # If the input is a stack, only compare the first array - if isinstance(arr1, AtomArrayStack): - arr1 = arr1[0] - if isinstance(arr2, AtomArrayStack): - arr2 = arr2[0] + # Promote to AtomArrayPlus for unified handling + arr1 = as_atom_array_plus(arr1) + arr2 = as_atom_array_plus(arr2) # Compare lengths, down to the residue-level if necessary - if len(arr1) != len(arr2): - msg = "AtomArrays are not the same length!\n" + if arr1.array_length() != arr2.array_length(): + msg = "AtomArrays are not the same shape!\n" # Find the chains that are different lengths for chain_id in np.unique(arr1.chain_id): arr1_chain_aa = arr1[arr1.chain_id == chain_id] arr2_chain_aa = arr2[arr2.chain_id == chain_id] - if len(arr1_chain_aa) != len(arr2_chain_aa): + if arr1_chain_aa.array_length() != arr2_chain_aa.array_length(): msg += f"+--------- Mismatches for chain: {chain_id} -----------+\n" # Find the residues that are different lengths for res_id in np.unique(arr1_chain_aa.res_id): @@ -223,7 +283,7 @@ def assert_same_atom_array( arr2_res_aa = arr2_chain_aa[arr2_chain_aa.res_id == res_id] # Give an informative error message - if len(arr1_res_aa) != len(arr2_res_aa): + if arr1_res_aa.array_length() != arr2_res_aa.array_length(): msg += f"Mismatch at residue {res_id}:\n" msg += f"\tarr1: {_get_atom_array_stats(arr1_res_aa)}\n" msg += f"\tarr2: {_get_atom_array_stats(arr2_res_aa)}\n" @@ -235,10 +295,14 @@ def assert_same_atom_array( arr1.coord.shape == arr2.coord.shape ), f"Coord shapes do not match: {arr1.coord.shape} != {arr2.coord.shape}" if not np.allclose(arr1.coord, arr2.coord, equal_nan=True, atol=1e-3, rtol=1e-3): - mismatch_idxs = np.where(arr1.coord != arr2.coord)[0] - msg = f"Coords do not match at {len(mismatch_idxs)} indices. First few mismatches:" + "\n" - for idx in mismatch_idxs[:_n_mismatches_to_show]: - msg += f"\t{idx}: {arr1.coord[idx]} != {arr2.coord[idx]}\n" + # Use np.isclose with equal_nan=True to correctly identify mismatches + # This handles NaN coordinates properly (NaN == NaN with equal_nan=True) + mismatch_mask = ~np.isclose(arr1.coord, arr2.coord, equal_nan=True, atol=1e-3, rtol=1e-3) + # Find atoms (rows) with any coordinate component mismatch + mismatch_atom_idxs = np.where(np.any(mismatch_mask, axis=1))[0] + msg = f"Coords do not match for {len(mismatch_atom_idxs)} atoms. First few mismatches:" + "\n" + for idx in mismatch_atom_idxs[:_n_mismatches_to_show]: + msg += f"\tAtom {idx}: {arr1.coord[idx]} != {arr2.coord[idx]}\n" raise AssertionError(msg) # Not returned by `get_annotation_categories` @@ -248,131 +312,184 @@ def assert_same_atom_array( else: assert np.array_equal(arr1._box, arr2._box, equal_nan=True) - if annotations_to_compare is None: - arr1_annotation_keys = arr1.get_annotation_categories() - arr2_annotation_keys = arr2.get_annotation_categories() - missing_in_arr1 = set(arr2_annotation_keys) - set(arr1_annotation_keys) + if annotations_to_compare is None or annotations_to_compare == "arr1": + arr1_annotation_keys = get_annotation_categories(arr1, n_body="all") + arr2_annotation_keys = get_annotation_categories(arr2, n_body="all") + + if annotations_to_compare is None: + missing_in_arr1 = set(arr2_annotation_keys) - set(arr1_annotation_keys) + assert len(missing_in_arr1) == 0, f"Annotations missing in arr1: {missing_in_arr1}" + missing_in_arr2 = set(arr1_annotation_keys) - set(arr2_annotation_keys) - assert len(missing_in_arr1) == 0, f"Annotations missing in arr1: {missing_in_arr1}" assert len(missing_in_arr2) == 0, f"Annotations missing in arr2: {missing_in_arr2}" + annotations_to_compare = arr1_annotation_keys - if enforce_order: - # Compare annotations directly - for annotation in annotations_to_compare: - if annotation not in arr1.get_annotation_categories(): - raise AssertionError(f"Annotation {annotation} not in arr1.") - if annotation not in arr2.get_annotation_categories(): - raise AssertionError(f"Annotation {annotation} not in arr2.") - - # Check if the arrays contain floating-point numbers (in which case, we allow NaN == NaN) - if np.issubdtype(arr1.get_annotation(annotation).dtype, np.floating) and np.issubdtype( - arr2.get_annotation(annotation).dtype, np.floating - ): - arrays_equal = np.array_equal( - arr1.get_annotation(annotation), arr2.get_annotation(annotation), equal_nan=True - ) - else: - arrays_equal = np.array_equal( - arr1.get_annotation(annotation), arr2.get_annotation(annotation), equal_nan=False - ) + if not enforce_order: + # Check for unsupported mode: comparing bond order without atom names is ambiguous + if "atom_name" not in annotations_to_compare and compare_bond_order: + raise ValueError( + "Cannot compare bond order with enforce_order=False when atom_name is not being compared. " + "Either set enforce_order=True, compare_bond_order=False, or include 'atom_name' in annotations_to_compare." + ) - if not arrays_equal: - mismatch_idxs = np.where(arr1.get_annotation(annotation) != arr2.get_annotation(annotation))[0] - msg = ( - f"Annotation {annotation} does not match at {len(mismatch_idxs)} indices. First few mismatches:" - + "\n" - ) - for idx in mismatch_idxs[:_n_mismatches_to_show]: - msg += ( - f"\t{idx}: {arr1.get_annotation(annotation)[idx]} != {arr2.get_annotation(annotation)[idx]}\n" - ) - if idx >= _n_mismatches_to_show: - break - raise AssertionError(msg) - else: - # Convert annotations to a sorted list of tuples and compare (order-invariant) - def convert_atom_array_to_sorted_tuples(arr: AtomArray, annotations: list[str]) -> list[tuple]: - atoms = [] - for atom in arr: - atom_info = [(annotation, atom.__getattr__(annotation)) for annotation in annotations] - atoms.append(tuple(sorted(atom_info))) - return sorted(atoms) - - arr1_atoms_sorted = convert_atom_array_to_sorted_tuples(arr1, annotations_to_compare) - arr2_atoms_sorted = convert_atom_array_to_sorted_tuples(arr2, annotations_to_compare) - - if arr1_atoms_sorted != arr2_atoms_sorted: - msg = "Annotations do not match. First few mismatches:\n" - for idx, atom in enumerate(set(arr1_atoms_sorted).symmetric_difference(set(arr2_atoms_sorted))): - msg += f"\t{idx}: {atom}\n" - if idx >= _n_mismatches_to_show: - break - raise AssertionError(msg) + # If atom_name is being compared, we can trust the ordering - use standard CCD ordering + if "atom_name" in annotations_to_compare: + arr1 = arr1[standardize_order(arr1)] + arr2 = arr2[standardize_order(arr2)] - if compare_bonds: - assert arr1.bonds is not None, "arr1.bonds is None" - assert arr2.bonds is not None, "arr2.bonds is None" + # If atom_name is NOT being compared, use canonical graph ordering if bonds available + elif compare_bonds and arr1.bonds is not None and arr2.bonds is not None: + # Reorder all residues using canonical graph ranking + arr1 = _reorder_by_canonical_graph(arr1) + arr2 = _reorder_by_canonical_graph(arr2) + else: + raise ValueError( + "Cannot compare AtomArrays without enforcing order when atom_name is not being compared and bonds are not available. " + ) + + # Compare annotations directly + for annotation in annotations_to_compare: + if annotation not in get_annotation_categories(arr1, n_body="all"): + raise AssertionError(f"Annotation {annotation} not in arr1.") + if annotation not in get_annotation_categories(arr2, n_body="all"): + raise AssertionError(f"Annotation {annotation} not in arr2.") + + comparison_fn = functools.partial( + _compare_annotation_arrays, + annotation_name=annotation, + cast_to_common_dtype=cast_to_common_dtype, + _n_mismatches_to_show=_n_mismatches_to_show, + ) - # TODO: Switch to using the `convert_bond_type` method once we upgrade to Biotite v1.4.0 - # structure.bonds.convert_bond_type(struc.bonds.BondType.COORDINATION, struc.bonds.BondType.SINGLE) - mask_1 = arr1.bonds._bonds[:, 2] == struc.bonds.BondType.COORDINATION - arr1.bonds._bonds[mask_1, 2] = struc.bonds.BondType.SINGLE + annot1 = get_annotation(arr1, annotation) + annot2 = get_annotation(arr2, annotation) + is_pairwise = isinstance(annot1, AnnotationList2D) + assert is_pairwise == isinstance( + annot2, AnnotationList2D + ), f"Annotation {annotation} has different body orders." + if is_pairwise: + comparison_fn(annot1.pairs, annot2.pairs) + comparison_fn(annot1.values, annot2.values) + else: + assert annot1.shape == annot2.shape, f"Annotation {annotation} has different shapes." + comparison_fn(np.atleast_1d(annot1), np.atleast_1d(annot2)) - mask_2 = arr2.bonds._bonds[:, 2] == struc.bonds.BondType.COORDINATION - arr2.bonds._bonds[mask_2, 2] = struc.bonds.BondType.SINGLE + if compare_bonds: + arr1_has_bonds = arr1.bonds is not None and len(arr1.bonds.as_array()) > 0 + arr2_has_bonds = arr2.bonds is not None and len(arr2.bonds.as_array()) > 0 + + if not arr1_has_bonds and not arr2_has_bonds: + # Both have no bonds (None or empty) — equivalent + pass + elif arr1_has_bonds != arr2_has_bonds: + raise AssertionError( + f"Bond mismatch: arr1 has {'bonds' if arr1_has_bonds else 'no bonds'}, " + f"arr2 has {'bonds' if arr2_has_bonds else 'no bonds'}" + ) + else: + # Intra-residue COORDINATION cannot survive CIF round-trip + arr1 = remap_intra_residue_coordination_bonds(arr1) + arr2 = remap_intra_residue_coordination_bonds(arr2) - if enforce_order: - # Compare bond arrays directly + if arr1_has_bonds and arr2_has_bonds: bonds1 = arr1.bonds.as_array() bonds2 = arr2.bonds.as_array() + if not enforce_order: + # Compare bonds with sorting + bonds1 = _sort_bond_array(bonds1) + bonds2 = _sort_bond_array(bonds2) + if not compare_bond_order: bonds1 = bonds1[:, :2] bonds2 = bonds2[:, :2] + if not np.array_equal(bonds1, bonds2): - mismatch_idxs = np.where(bonds1 != bonds2)[0] + mismatch_idxs = np.where(np.any(bonds1 != bonds2, axis=1))[0] msg = f"Bonds do not match at {len(mismatch_idxs)} indices. First few mismatches:" + "\n" for idx in mismatch_idxs[:_n_mismatches_to_show]: msg += f"\t{idx}: {bonds1[idx]} != {bonds2[idx]}\n" raise AssertionError(msg) - else: - # Check graph isomorphisms, labeling nodes with element - arr1_hash = cb.hash_atom_array( - arr1, annotations=["element"], bond_order=compare_bond_order, cast_aromatic_bonds_to_same_type=True - ) - arr2_hash = cb.hash_atom_array( - arr2, annotations=["element"], bond_order=compare_bond_order, cast_aromatic_bonds_to_same_type=True - ) - assert arr1_hash == arr2_hash, f"Graph hashes do not match: {arr1_hash} != {arr2_hash}" -def has_ambiguous_annotation_set( - atom_array: AtomArray, - annotation_set: Iterable = ["chain_id", "res_id", "res_name", "atom_name", "ins_code"], -) -> bool: - """Detect whether a given set of annotations is insufficient to distinguish - all atoms in the input AtomArray. +def assert_same_atom_array_or_stack( + arr1: AtomArray | AtomArrayStack, + arr2: AtomArray | AtomArrayStack, + compare_coords: bool = True, + compare_bonds: bool = True, + compare_box: bool = False, + annotations_to_compare: list[str] | Literal["arr1"] | None = None, + enforce_order: bool = True, + compare_bond_order: bool = True, + cast_to_common_dtype: bool = False, + _n_mismatches_to_show: int = 5, +) -> None: + """Asserts that two AtomArray or AtomArrayStack objects are equal. - For example, this is used to detect ambiguous annotation of the structure - that would lead to loss of information when writing out the structure. + Args: + arr1 (AtomArray): The first AtomArray or AtomArrayStack to compare. + arr2 (AtomArray): The second AtomArray or AtomArrayStack to compare. + compare_coords (bool, optional): Whether to compare coordinates. Defaults to True. + compare_bonds (bool, optional): Whether to compare bonds. Defaults to True. + compare_box (bool, optional): Whether to compare the box attribute. Defaults to False. + annotations_to_compare (list[str] | Literal["arr1"] | None, optional): + List of annotation categories to compare, or "arr1" to compare on all annotations in arr1. + Defaults to None, in which case all annotations are compared. + enforce_order (bool, optional): Whether to enforce the order of the atoms. Defaults to True. + NOTE: Enforcing order is much faster; use False only when strictly necessary. + compare_bond_order (bool, optional): Whether to compare bond order. Defaults to True. + cast_to_common_dtype (bool, optional): Whether to cast numeric annotations to a common + dtype before comparison. Useful for tests where dtype preservation is not guaranteed + (e.g., CIF roundtrips). Defaults to False. + _n_mismatches_to_show (int, optional): Number of mismatches to show. Defaults to 5. - This happens because the `struct_conn` category distinguishes bonds - between different atoms based on the 5-tuple: - (chain_id, res_id, res_name, atom_name, ins_code) + Raises: + AssertionError: If the AtomArray or AtomArrayStack objects are not equal. + """ + assert isinstance( + arr1, AtomArray | AtomArrayStack + ), f"arr1 is not an AtomArray or AtomArrayStack but has type {type(arr1)}" + assert isinstance( + arr2, AtomArray | AtomArrayStack + ), f"arr2 is not an AtomArray or AtomArrayStack but has type {type(arr2)}" - To properly save bonds with a structure, make sure that all atoms - have unique 5-tuples. + # Copy both arrays to avoid modifying the original arrays + arr1 = arr1.copy() + arr2 = arr2.copy() - Args: - atom_array (AtomArray): The atom array to check for ambiguous annotations. - annotation_set (Iterable, optional): The set of annotations to check for ambiguity. - Defaults to ["chain_id", "res_id", "res_name", "atom_name", "ins_code"], which is relevant for determining possible bond ambiguity. + # Ensure input types match + if arr1.shape != arr2.shape: + raise AssertionError(f"arr1 shape {arr1.shape} does not match arr2 shape {arr2.shape}.") + + # Call the appropriate number of single-AtomArray comparison based on the input type + comparison_kwargs = { + "compare_coords": compare_coords, + "compare_bonds": compare_bonds, + "compare_box": compare_box, + "annotations_to_compare": annotations_to_compare, + "enforce_order": enforce_order, + "compare_bond_order": compare_bond_order, + "cast_to_common_dtype": cast_to_common_dtype, + "_n_mismatches_to_show": _n_mismatches_to_show, + } + + if isinstance(arr1, AtomArray): + _assert_same_atom_array(arr1, arr2, **comparison_kwargs) + else: + for i in range(arr1.stack_depth()): + _assert_same_atom_array(arr1[i], arr2[i], **comparison_kwargs) - Returns: - bool: True if ambiguous annotations are detected, False otherwise. +def has_ambiguous_annotation_set( + atom_array: AtomArray, + annotation_set: Iterable[str] = ("chain_id", "res_id", "res_name", "atom_name", "ins_code"), +) -> bool: + """Detect whether a given set of annotations is insufficient to distinguish all atoms. + + Used to detect ambiguous annotations that would lose information on CIF write, + since ``struct_conn`` distinguishes bonds by the 5-tuple + ``(chain_id, res_id, res_name, atom_name, ins_code)``. """ - # Create a structured array with the 5-tuple elements identifier_dtypes = [ ( annotation, @@ -384,16 +501,124 @@ def has_ambiguous_annotation_set( ] structured_array = np.empty(atom_array.array_length(), dtype=identifier_dtypes) - for category in identifier_dtypes: - name, dtype = category + for name, _dtype in identifier_dtypes: structured_array[name] = ( atom_array.get_annotation(name) if name in atom_array.get_annotation_categories() else ["."] * atom_array.array_length() ) - # Use numpy's unique function with return_counts=True to find duplicates _, counts = np.unique(structured_array, return_counts=True) - - # If any count is greater than 1, we have ambiguous annotations return np.any(counts > 1) + + +def assert_same_annotation_cardinality( + arr1: AtomArray, + arr2: AtomArray, + annotations: list[str], +) -> None: + """Assert annotations have the same number of unique values in both arrays. + + Args: + arr1: First atom array. + arr2: Second atom array. + annotations: List of annotation names to check cardinality for. + """ + for annot in annotations: + # Check annotation exists in both arrays + if annot not in arr1.get_annotation_categories(): + raise AssertionError(f"Annotation '{annot}' not in arr1") + if annot not in arr2.get_annotation_categories(): + raise AssertionError(f"Annotation '{annot}' not in arr2") + + # Get unique value counts + n_unique_1 = len(np.unique(arr1.get_annotation(annot))) + n_unique_2 = len(np.unique(arr2.get_annotation(annot))) + + # Compare cardinality + if n_unique_1 != n_unique_2: + raise AssertionError( + f"Annotation '{annot}' has different cardinality: " + f"{n_unique_1} unique values in arr1 vs {n_unique_2} in arr2" + ) + + +def verify_atom_array_chain_info_consistency( + chain_info: dict[str, Any], + atom_array: AtomArray, + verify_sequences: bool = True, +) -> None: + """Verify that atom array and chain_info are consistent. + + Args: + chain_info: The chain_info dictionary. + atom_array: The processed atom array. + verify_sequences: Whether to verify 1-letter sequences match. Defaults to ``True``. + + Raises: + AssertionError: If inconsistencies are detected between atom array and chain_info. + """ + atom_array = subset_to_first_transformation(atom_array) + chain_info = chain_info.copy() + + # Make residue start mask + res_start_mask = np.zeros(atom_array.array_length(), dtype=bool) + res_starts = get_residue_starts(atom_array) + res_start_mask[res_starts] = True + + chain_ids = np.unique(atom_array.chain_id) + for chain_id in chain_ids: + # Check that chain is present + if chain_id not in chain_info: + raise AssertionError(f"Chain ID {chain_id} found in atom array but not in chain_info") + + # Get stored res_names from chain_info + stored_res_names = chain_info[chain_id].get("res_name", []) + if not stored_res_names: + continue + + # Get observed res_names from atom array (at residue starts only) + chain_mask = atom_array.chain_id == chain_id + chain_res_start_mask = chain_mask & res_start_mask + observed_res_names = [str(res_name) for res_name in atom_array.res_name[chain_res_start_mask]] + + if sorted(observed_res_names) != sorted(stored_res_names): + raise AssertionError( + f"Chain {chain_id}: residue name multiset mismatch — residues may be non-contiguous\n" + f" Observed: {observed_res_names}\n" + f" Stored: {stored_res_names}" + ) + + # Compare: must match exactly (this function only runs when add_missing_atoms=True) + if observed_res_names != stored_res_names: + raise AssertionError( + f"Chain {chain_id}: res_name order mismatch\n" + f" Observed: {observed_res_names}\n" + f" Stored: {stored_res_names}" + ) + + # Verify 1-letter sequences match (optional) + if verify_sequences: + chain_type = chain_info[chain_id].get("chain_type") + if chain_type: + # Use utility that returns both sequences in one call + computed_non_canonical, computed_canonical = convert_to_one_letter_sequences( + stored_res_names, chain_type + ) + + stored_canonical = chain_info[chain_id].get("processed_entity_canonical_sequence", "") + stored_non_canonical = chain_info[chain_id].get("processed_entity_non_canonical_sequence", "") + + if computed_canonical != stored_canonical: + raise AssertionError( + f"Chain {chain_id}: Canonical sequence mismatch\n" + f" Computed: {computed_canonical}\n" + f" Stored: {stored_canonical}" + ) + + if computed_non_canonical != stored_non_canonical: + raise AssertionError( + f"Chain {chain_id}: Non-canonical sequence mismatch\n" + f" Computed: {computed_non_canonical}\n" + f" Stored: {stored_non_canonical}" + ) diff --git a/src/atomworks/io/utils/visualize.py b/src/atomworks/io/utils/visualize.py index a9dd6cc1..8264c41d 100644 --- a/src/atomworks/io/utils/visualize.py +++ b/src/atomworks/io/utils/visualize.py @@ -17,7 +17,7 @@ from biotite.structure.io import mol, pdb, pdbx from atomworks.constants import ATOMIC_NUMBER_TO_ELEMENT, METAL_ELEMENTS -from atomworks.io.utils.io_utils import read_any, to_cif_string +from atomworks.io.utils.io_utils import CIFWriteConfig, read_any, to_cif_string logger = logging.getLogger("atomworks.io") @@ -74,9 +74,13 @@ def view( - structure (AtomArray): The atomic structure to be visualized. - zoom_to_selection (dict[str, int | str] | None, optional): A dictionary specifying the selection to zoom into. Defaults to None. Here are some examples: + - `{'serial': 35}` - will zoom to the atom with index 35 in the atom array + - `{'chain': 'A', 'resi': 35}` - will zoom to the residue id 35 in chain A - - `{'chain': 'C'} - will zoom to the entire chain C + + - `{'chain': 'C'}` - will zoom to the entire chain C + !WARNING! If the selection is wrong, the visualization will be empty. - show_hover (bool, optional): Whether to enable hover functionality to display atom details. Defaults to True. @@ -107,8 +111,7 @@ def view( # Convert the structure to a temporary CIF string for interacting with py3Dmol _tmp_cif_str = to_cif_string( structure, - _allow_ambiguous_bond_annotations=True, - include_entity_poly=False, + config=CIFWriteConfig(include_entity_categories=False), ) # ... add the structure model to the view in mmCIF format view.addModel(_tmp_cif_str, "structure", format="mmcif") @@ -317,12 +320,11 @@ def view_pymol( format = "bcif" if as_bcif else "cif" buffer = to_cif_string( structure, - id=id, - _allow_ambiguous_bond_annotations=True, - include_entity_poly=True, - include_nan_coords=False, - include_bonds=True, - extra_fields=[], + config=CIFWriteConfig( + id=id, + include_entity_categories=False, + include_nan_coords=False, + ), as_bcif=as_bcif, ) elif isinstance(structure, pdbx.CIFFile | pdb.PDBFile | mol.SDFile | pdbx.CIFBlock): diff --git a/src/atomworks/ml/conditions/__init__.py b/src/atomworks/ml/conditions/__init__.py index 7874d534..56257058 100644 --- a/src/atomworks/ml/conditions/__init__.py +++ b/src/atomworks/ml/conditions/__init__.py @@ -1,7 +1,11 @@ -"""Condition system for molecular design and annotation management.""" +"""Condition system for StandardAnnotations that are intended to be passed to ML models.""" -from atomworks.ml.conditions.base import CONDITIONS, ConditionBase -from atomworks.ml.conditions.conditions import * # noqa: F403 +from atomworks.io.utils.standard_annotations.base import NO_DEFAULT +from atomworks.ml.conditions import definitions +from atomworks.ml.conditions.base import ( + CONDITIONS, + ConditionBase, +) # --- alias table --- Condition = CONDITIONS @@ -13,3 +17,15 @@ C_CTR = CONDITIONS.c_terminus C_NTR = CONDITIONS.n_terminus # -------------------- + +# Re-importing for backwards compatibility +# isort: split # these imports must remain after the alias table above +import sys # noqa: E402 + +import atomworks.ml.conditions.definitions as conditions # noqa: E402 +from atomworks.io.utils.standard_annotations.base import Level # noqa: E402 +from atomworks.ml.utils import annotator # noqa: E402 + +# Ensure that re-imported modules are registered properly +sys.modules[__name__ + ".annotator"] = annotator +sys.modules[__name__ + ".conditions"] = conditions diff --git a/src/atomworks/ml/conditions/base.py b/src/atomworks/ml/conditions/base.py index f04b25cb..685848d1 100644 --- a/src/atomworks/ml/conditions/base.py +++ b/src/atomworks/ml/conditions/base.py @@ -1,167 +1,64 @@ """ -Condition system with: - - an abstract base class (`ConditionBase`) for sharing general functionality across conditions - - a metaclass that serves as a registry for all concrete conditions (`ConditionMeta`) that - automatically registers all concrete conditions and sets their attributes - - a dynamic accessor (`ConditionAccessor`) that provides easy access to all registered conditions +Condition system which subclasses StandardAnnotation, providing a separate registry for +StandardAnnotations typically provided as conditions to ML models. """ -from abc import ABC, ABCMeta, abstractmethod -from collections.abc import Callable -from enum import StrEnum +from __future__ import annotations + +import logging from typing import Any, ClassVar, Literal import numpy as np from biotite.structure import AtomArray -from atomworks.io.utils import scatter -from atomworks.io.utils.atom_array_plus import AnnotationList2D -from atomworks.io.utils.selection import get_annotation, get_residue_starts -from atomworks.ml.utils.token import get_token_starts - -__all__ = ["CONDITIONS", "ConditionBase"] +from atomworks.io.utils.scatter import safe_scatter +from atomworks.io.utils.selection import get_annotation_categories +from atomworks.io.utils.standard_annotations.base import ( + AnnotationRegistryAccessor, + StandardAnnotationBase, + StandardAnnotationMeta, +) +logger = logging.getLogger("atomworks.ml") -class Level(StrEnum): - """ - Level-hierarchy for describing information in a molecular structure. - - Used for example to specify the level at which a `Condition` applies. - """ +__all__ = [ + "CONDITIONS", + "ConditionBase", + "ConditionConflictError", +] - ATOM = "atom" - TOKEN = "token" - RESIDUE = "residue" - CHAIN = "chain" - MOLECULE = "molecule" - SYSTEM = "system" - - def _get_segment_or_group(self, atom_array: "AtomArray") -> tuple[str, np.ndarray]: - if self == Level.ATOM: - raise ValueError("Apply and spread does not make sense for atom level.") - if self == Level.TOKEN: - return "segment", get_token_starts(atom_array, add_exclusive_stop=True) - elif self == Level.RESIDUE: - return "segment", get_residue_starts(atom_array, add_exclusive_stop=True) - elif self == Level.CHAIN: - chain_key = "chain_iid" if "chain_iid" in atom_array.get_annotation_categories() else "chain_id" - return "group", atom_array.get_annotation(chain_key) - elif self == Level.MOLECULE: - return "group", atom_array.get_annotation("molecule_id") - elif self == Level.SYSTEM: - return "group", np.ones(atom_array.array_length(), dtype=np.int32) - else: - raise ValueError(f"Invalid level: {self}") - - def apply(self, atom_array: "AtomArray", data: np.ndarray, func: "Callable[[AtomArray], Any]") -> "Any": - strategy, grouping = self._get_segment_or_group(atom_array) - if strategy == "segment": - return scatter.apply_segment_wise(grouping, data, func) - elif strategy == "group": - return scatter.apply_group_wise(grouping, data, func) - else: - raise ValueError(f"Invalid strategy: {strategy}") - - def spread(self, atom_array: "AtomArray", data: np.ndarray) -> np.ndarray: - strategy, grouping = self._get_segment_or_group(atom_array) - if strategy == "segment": - return scatter.spread_segment_wise(grouping, data) - elif strategy == "group": - return scatter.spread_group_wise(grouping, data) - else: - raise ValueError(f"Invalid strategy: {strategy}") - - def apply_and_spread( - self, - atom_array: "AtomArray", - data: np.ndarray, - func: "Callable[[np.ndarray], Any]", - ) -> np.ndarray: - """ - Apply a function to the data and spread the result back to the original positions. - """ - strategy, grouping = self._get_segment_or_group(atom_array) - if strategy == "segment": - return scatter.apply_and_spread_segment_wise(grouping, data, func) - elif strategy == "group": - return scatter.apply_and_spread_group_wise(grouping, data, func) - else: - raise ValueError(f"Invalid strategy: {strategy}") +class ConditionConflictError(Exception): + """A ``transfer`` target position is already conditioned to a value conflicting with the incoming one.""" -class ConditionMeta(ABCMeta): - """ - Metaclass that combines ABC functionality with auto-registration. +class ConditionMeta(StandardAnnotationMeta): + """Metaclass for model-input conditions. - It registers all concrete (non-abstract) subclasses into a central - registry and pre-computes derived class attributes like 'full_name'. + Inherits all validation and registration from :py:class:`StandardAnnotationMeta`. + Additionally registers concrete subclasses into ``_condition_registry``, + a subset of ``StandardAnnotationMeta._registry``. """ - _registry: ClassVar[dict[str, type["ConditionBase"]]] = {} + _condition_registry: ClassVar[dict[str, type[ConditionBase]]] = {} def __new__(meta, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs): # noqa: N804 - # Create the class as usual cls = super().__new__(meta, name, bases, namespace, **kwargs) - # Register the class if it's a concrete implementation - # We check for 'name' to ensure it's a condition intended for registration. - condition_name = namespace.get("name") - if condition_name: - # Ensure the `name` is valid - if "_" in condition_name: - raise ValueError( - f"Condition `{condition_name}` (class `{cls.__name__}`) must not contain underscores in its name." - " This is reserved for internal use." - ) - - # Ensure the following attributes are set - if "n_body" not in namespace: - raise ValueError( - f"Condition `{condition_name}` (class `{cls.__name__}`) must have an n_body attribute." - ) - if "level" not in namespace: - raise ValueError(f"Condition `{condition_name}` (class `{cls.__name__}`) must have a level attribute.") - if "is_symmetric" not in namespace and (cls.n_body > 1): - raise ValueError( - f"Condition `{condition_name}` (class `{cls.__name__}`) must have an is_symmetric attribute if n_body > 1." - ) - else: - cls.is_symmetric = True - - if condition_name in meta._registry: - raise ValueError(f"Condition with name '{condition_name}' is already registered.") - - # Ensure the level is a Level enum - cls.level = Level(cls.level) - - # --- Eagerly compute and set properties on the class itself --- - if "is_mask" in namespace: - cls.is_mask = namespace["is_mask"] - else: - cls.is_mask = np.issubdtype(cls.dtype, bool) - - cls.full_name = cls.get_full_name() - cls.mask_name = cls.get_mask_name() - - # --- Register the class --- - meta._registry[condition_name] = cls + annotation_name = namespace.get("name") + if annotation_name: + cls.has_ground_truth = cls.ground_truth_value.__func__ is not ConditionBase.ground_truth_value.__func__ + # Also register in condition-specific registry + ConditionMeta._condition_registry[annotation_name] = cls return cls - def __repr__(self) -> str: # noqa: N804 - try: - if self.n_body > 1: - return f"{self.__name__}(name={self.name}, n_body={self.n_body}, level={self.level}, is_symmetric={self.is_symmetric})" - else: - return f"{self.__name__}(name={self.name}, n_body={self.n_body}, level={self.level})" - except AttributeError: - # NOTE: This is a fallback for the abstract base class to ensure that methods like `__mro__` continue to work - return super().__repr__() +class ConditionBase(StandardAnnotationBase, metaclass=ConditionMeta): + """Abstract base class for all model-input conditions. -class ConditionBase(ABC, metaclass=ConditionMeta): - """Abstract base class for all conditions. Any condition must be a subclass of this class. + A condition is a :py:class:`StandardAnnotationBase` that is semantically + a model input (e.g., sequence, coordinates, chain pairing). Attributes: (required) @@ -171,356 +68,133 @@ class ConditionBase(ABC, metaclass=ConditionMeta): (optional) is_symmetric: Whether the condition is symmetric. Only - applies if `n_body > 1`. Otherwise always `True`. - is_mask: Whether the condition itself is a mask. In this case the - `full_name` is the same as the `mask_name`. - + applies if ``n_body > 1``. Otherwise always ``True``. (derived) full_name: The full, systematic name of the condition of the form - `condition_{name}_{n_body}_{level}`. - mask_name: The mask, systematic name of the condition of the form - `mask_{name}_{n_body}_{level}`. + ``condition_{name}_{n_body}_{level}``. + has_ground_truth: Whether the condition overrides :py:meth:`ground_truth_value`. """ - # --- Attributes to be defined by all concrete subclasses --- - name: ClassVar[str] - n_body: ClassVar[int] - level: ClassVar[Level] - dtype: ClassVar[np.dtype | float | int | str | bool] - is_symmetric: ClassVar[bool] # only needs to be set if n_body > 1 - - # --- Pre-computed attributes (set by metaclass) --- - full_name: ClassVar[str] - mask_name: ClassVar[str] - is_mask: ClassVar[bool] - - def __init__(self): - """DO NOT INSTANTIATE THIS CLASS. Use its class methods directly. Init will throw a RuntimeError.""" - # Enforce that these classes are not for instantiation - raise RuntimeError( - f"{self.__class__.__name__} is a definitional class and should not be instantiated. " - "Use its class methods directly." - ) - - @classmethod - def _resolve_level(cls, level: Level | str | None) -> Level: - """Resolves the level to a Level enum.""" - return Level(level if level is not None else cls.level) - - @classmethod - def _resolve_n_body(cls, n_body: int | None) -> int: - """Resolves the number of bodies to an integer.""" - return n_body if n_body is not None else cls.n_body - - # --- name generation methods --- - @classmethod - def get_full_name(cls) -> str: - """Returns the full name of the condition at a given body order and level.""" - if cls.is_mask: - # ... re-route to the mask name generation for masks - return cls.get_mask_name() - - return f"condition_{cls.name}_{cls.n_body}_{cls.level}" + _prefix: ClassVar[str] = "condition" + has_ground_truth: ClassVar[bool] = False @classmethod - def get_mask_name( - cls, - n_body: int | None = None, - level: Level | str | None = None, - ) -> str: - """Returns the mask name of the condition at a given body order and level. - - By default (i.e. if `n_body` and `level` are not provided), n_body and level - are resolved to the default values for the condition. - - Args: - n_body: The number of bodies involved in the condition. - level: The level at which the condition applies. - - Returns: - The mask name of the condition. - - Example: - For a 2-body distance condition which applies at the `ATOM` level, the - default mask is a 2-body atom-level mask `mask_distance_2_atom`. In the future, - we may implement automatic derivation of lower-body masks from higher-body masks. - I.e. the 1-body token-level mask `mask_distance_1_token` could be derived from the default - """ - level, n_body = cls._resolve_level(level), cls._resolve_n_body(n_body) - return f"mask_{cls.name}_{n_body}_{level}" + def ground_truth_value(cls, atom_array: AtomArray) -> np.ndarray | None: + """The condition's target read off the structure, or ``None`` if it is not structure-derived.""" @classmethod - def get_feature_name( - cls, - n_body: int | None = None, - level: Level | str | None = None, - suffix: str = "", - ) -> str: - """Returns the feature name of the condition at a given body order and level. - - Args: - n_body: The body order to get the feature name for. - level: The level to get the feature name for. - suffix: An optional suffix to add to the feature name to allow - flexibility for adding multiple different features for a single condition. + def set_annotation_from_ground_truth(cls, atom_array: AtomArray, mask: np.ndarray) -> None: + """Set ground truth at selected atoms and defaults everywhere else, in place. - Returns: - The feature name of the condition. This will be of the form: - `feature-___` + ``mask`` must be a boolean array of shape ``(n_atoms,)``, including for + residue-level conditions. Only one-body conditions with ground truth are + supported. Values come from the current structure, replacing any saved + targets; existing aliases are synchronized by :py:meth:`set_annotation`. """ - level, n_body = cls._resolve_level(level), cls._resolve_n_body(n_body) - suffix_str = f"-{suffix}" if suffix and not suffix.startswith("-") else suffix - return f"feature{suffix_str}_{cls.name}_{n_body}_{level}" - - # --- Abstract Default Generation & Validation Methods --- - @classmethod - @abstractmethod - def default_mask(cls, atom_array: AtomArray) -> np.ndarray: - """Generates the default mask for the condition.""" - raise NotImplementedError(f"Condition `{cls.name}` (class `{cls.__name__}`) does not have a default mask.") - - @classmethod - @abstractmethod - def default_annotation(cls, atom_array: AtomArray) -> np.ndarray: - if cls.is_mask: - return cls.default_mask(atom_array) + if cls.n_body != 1 or not cls.has_ground_truth: + raise ValueError(f"{cls.name} must be a one-body condition with a ground-truth value.") + mask = np.asarray(mask) + if mask.dtype != np.bool_ or mask.shape != (atom_array.array_length(),): + raise ValueError( + f"Expected a boolean mask of shape ({atom_array.array_length()},), " + f"got {mask.shape} with dtype {mask.dtype}." + ) - raise NotImplementedError( - f"Condition `{cls.name}` (class `{cls.__name__}`) does not have a default annotation." - ) + annotation = cls.default_annotation(atom_array).copy() + if mask.any(): + ground_truth = cls.ground_truth_value(atom_array) + if ground_truth is None: + raise ValueError(f"{cls.name} returned no ground-truth value.") + annotation[mask] = ground_truth[mask] + cls.set_annotation(atom_array, annotation) - # --- Core Functionality --- @classmethod - def mask( + def transfer( cls, - atom_array: AtomArray, - n_body: int | None = None, - level: Level | str | None = None, + source: AtomArray, + target: AtomArray, + source_indices: np.ndarray, + target_indices: np.ndarray, *, - default: Any | Literal["generate", "raise"] = "generate", - ) -> np.ndarray: - """Gets a mask from an AtomArray, falling back to a generated default. + on_mismatch: Literal["overwrite", "warn", "raise"] = "raise", + ) -> None: + """Copy this condition from ``source[source_indices]`` onto + ``target[target_indices]``, pair by pair -- the two index arrays are equal length and aligned. Args: - atom_array: The AtomArray to get the mask from. - n_body: The number of bodies involved in the condition. - level: The level at which the condition applies. - default: The default value to return if the mask is not found. - If `"generate"`, the default mask is generated and returned. - If `"raise"`, a ValueError is raised if the mask is not found. - If any other value, that value is returned. - - Returns: - The mask for the condition. - - Raises: - ValueError: If the mask is not found and `default` is `"raise"`. + source: AtomArray to read the condition annotation from. + target: AtomArray written in place. + source_indices: Source atoms to read, aligned with ``target_indices``. + target_indices: Target atoms to write, aligned with ``source_indices``. + on_mismatch: When a target position is already set to a *different* value -- ``"raise"`` + (default, :py:class:`ConditionConflictError`), ``"warn"`` (log + overwrite), ``"overwrite"``. + + Empty indices are a no-op; the write is atomic (``target`` is untouched if the call raises). + ``target_indices`` are assumed unique -- duplicates resolve last-write-wins and are not + conflict-checked. """ - level, n_body = cls._resolve_level(level), cls._resolve_n_body(n_body) - mask_name = cls.get_mask_name(n_body, level) - - mask = get_annotation(atom_array, mask_name, n_body=n_body) + source_indices = np.asarray(source_indices) + target_indices = np.asarray(target_indices) + if len(source_indices) != len(target_indices): + raise ValueError( + f"{cls.full_name}.transfer needs equal-length, aligned source/target indices; " + f"got {len(source_indices)} and {len(target_indices)}." + ) + if len(source_indices) == 0: + return + + incoming = cls.annotation(source, default="generate")[source_indices] + target_annotation = cls.annotation(target, default="generate") + if on_mismatch != "overwrite": + already = cls.mask_from_annotation(target_annotation)[target_indices] + if already.any() and not np.array_equal(target_annotation[target_indices][already], incoming[already]): + res_ids = np.unique(target.res_id[target_indices][already]).tolist() + existing = target_annotation[target_indices][already] + msg = f"{cls.full_name} conflict at target res_id {res_ids}: {existing!r} vs incoming {incoming[already]!r}" + if on_mismatch == "raise": + raise ConditionConflictError(msg) + logger.warning(msg) + + # Commit only after everything that can raise has succeeded. + cls.set_annotation(target, safe_scatter(incoming, target_indices, target_annotation)) + + +class ConditionAccessor(AnnotationRegistryAccessor): + """Provides dynamic, attribute-based access to all registered conditions. + + Iterates ``ConditionMeta._condition_registry``, which contains only + :py:class:`ConditionBase` subclasses (not other standard annotations). + """ - if mask is None: - # TODO: Enable default generation for levels & n_body other than the default - default = "raise" if (level != cls.level) | (n_body != cls.n_body) else default + _entry_kind: ClassVar[str] = "condition" - if default == "generate": - mask = cls.default_mask(atom_array) - elif default == "raise": - raise ValueError(f"AtomArray is missing {n_body}-body annotation `{mask_name}`.") - else: - mask = default + @property + def _registry(self) -> dict[str, type[ConditionBase]]: + return ConditionMeta._condition_registry - return mask + def fill_missing_conditions_with_defaults(self, atom_array: AtomArray) -> AtomArray: + """Fill all missing condition annotations with their defaults. - @classmethod - def annotation( - cls, - atom_array: AtomArray, - default: Any | Literal["generate", "raise"] = "generate", - ) -> np.ndarray: - """Gets an annotation from an AtomArray, falling back to a generated default. + Iterates all registered conditions and generates default annotation values + for any that are not already present on the AtomArray. Args: - atom_array: The AtomArray to get the annotation from. - default: The default value to return if the annotation is not found. - If `"generate"`, the default annotation is generated and returned. - If `"raise"`, a ValueError is raised if the annotation is not found. - If any other value, that value is returned. - - Returns: - The annotation for the condition. - - Raises: - ValueError: If the annotation is not found and `default` is `"raise"`. - """ - annotation = get_annotation(atom_array, cls.full_name, n_body=cls.n_body) - - if annotation is None: - if default == "generate": - annotation = cls.default_annotation(atom_array) - elif default == "raise": - raise ValueError(f"AtomArray is missing {cls.n_body}-body annotation `{cls.full_name}`.") - else: - annotation = default - - return annotation - - @classmethod - def set_mask( - cls, - atom_array: AtomArray, - mask: np.ndarray | AnnotationList2D, - n_body: int | None = None, - level: Level | None = None, - ) -> None: - """Sets the mask for the condition on the AtomArray.""" - level, n_body = cls._resolve_level(level), cls._resolve_n_body(n_body) - mask_name = cls.get_mask_name(n_body, level) - if n_body == 1: - if not np.issubdtype(mask.dtype, bool): - raise ValueError(f"Mask `{mask_name}` for `{cls.name}` must be a boolean array. Got {mask.dtype}.") - atom_array.set_annotation(mask_name, mask) - elif n_body == 2: - if cls.is_symmetric: - mask = mask.symmetrized() - n_atoms = mask.n_atoms - pairs = mask.pairs - values = mask.values - if n_atoms != atom_array.array_length(): - raise ValueError( - f"Mask `{mask_name}` for `{cls.name}` must have {atom_array.array_length()} atoms. Got {n_atoms}." - ) - if len(pairs) and ((pairs.max() >= n_atoms) or (pairs.min() < 0)): - raise ValueError( - f"Mask `{mask_name}` for `{cls.name}` must have pairs within the range [0, {n_atoms}). Got min={min(pairs)}, max={max(pairs)}." - ) - if not np.issubdtype(pairs.dtype, np.integer): - raise ValueError(f"Mask `{mask_name}` for `{cls.name}` must have pairs as integers. Got {pairs.dtype}.") - if not np.issubdtype(values.dtype, np.bool_): - raise ValueError(f"Mask `{mask_name}` for `{cls.name}` must be a boolean array. Got {mask.dtype}.") - atom_array.set_annotation_2d(mask_name, pairs, values) - else: - raise NotImplementedError( - f"Currently only 1-body and 2-body conditions are supported. Got {n_body}-body condition." - ) - - @classmethod - def set_annotation(cls, atom_array: AtomArray, *args, **annotation_kwargs) -> None: - """Sets the annotation for the condition on the AtomArray. Dynamically dispatches to the correct body order.""" - if cls.n_body == 1: - # ... handle correct argument logic - if len(args) == 0: - array = annotation_kwargs.pop("array") # biotite .set_annotation expects a single array argument - elif len(args) == 1: - array = args[0] - else: - raise ValueError(f"Only one argument is allowed for 1-body conditions. Got {len(args)} arguments.") - assert len(annotation_kwargs) == 0, "No keyword arguments are allowed for 1-body conditions." - - # ... set the annotation - atom_array.set_annotation(cls.full_name, array) - - elif cls.n_body == 2: - if not hasattr(atom_array, "_annot_2d"): - raise ValueError("AtomArray must have 2D annotations to set 2D annotation.") - - # ... handle correct argument logic - if len(args) == 1: - assert isinstance(args[0], AnnotationList2D), "Only AnnotationList2D is allowed for 2-body conditions." - annot = args[0] - pairs, values = annot.pairs, annot.values - annotation_kwargs = {"pairs": pairs, "values": values} - elif len(args) == 0: - pass - else: - raise ValueError(f"Only one argument is allowed for 2-body conditions. Got {len(args)} arguments.") - - # ... set the annotation - atom_array.set_annotation_2d(cls.full_name, **annotation_kwargs) - else: - raise NotImplementedError("Currently only 1-body and 2-body conditions are supported.") - - @classmethod - def is_valid(cls, atom_array: AtomArray) -> bool: - """ - Check if the condition's annotation (and mask, if not is_mask) is consistent at the relevant level. - (i.e. do all atoms/tokens/residues/chains/molecules/systems have the same value for the condition?) + atom_array: The AtomArray to fill. Modified in-place and returned. Returns: - bool: True if valid, False otherwise. + The atom array with all missing condition defaults applied. """ - if cls.level == Level.ATOM: - return True - - if cls.n_body == 1: - is_same = lambda x: np.all(x == x[0]) if len(x) > 0 else True # noqa: E731 - is_annotation_valid = cls.level.apply(atom_array, cls.annotation(atom_array), is_same) - if not cls.is_mask: - is_mask_valid = cls.level.apply(atom_array, cls.mask(atom_array), is_same) - is_annotation_valid &= is_mask_valid - return np.all(is_annotation_valid) - - # TODO: Implement n-body aggregation - return True - - -class ConditionAccessor: - """ - Provides dynamic, attribute-based access to all registered conditions. - No manual updates needed when new conditions are added. - """ + all_annotation_categories = set(get_annotation_categories(atom_array, n_body="all")) + for condition_cls in self: + if not any(name in all_annotation_categories for name in (condition_cls.full_name, *condition_cls.aliases)): + default_annotation = condition_cls.annotation(atom_array, default="generate") + condition_cls.set_annotation(atom_array, default_annotation) - def __getattr__(self, name: str) -> type[ConditionBase]: - """Dynamically retrieves a condition class from the registry.""" - # replace any underscores in the name with hyphens - name = name.replace("_", "-") - return self.__getitem__(name) - - def __getitem__(self, name: str) -> type[ConditionBase]: - """Retrieves a condition by its string name.""" - try: - return ConditionMeta._registry[name] - except KeyError: - raise AttributeError( - f"No condition named '{name}' is registered. Available conditions: {self.list()}" - ) from None - - def list(self) -> list[str]: - """Returns a list of names of all registered conditions.""" - return list(ConditionMeta._registry.keys()) - - def __iter__(self): - """Iterate over all registered condition classes.""" - for condition_name in self.list(): - yield self[condition_name] - - def get_valid_full_names(self) -> set[str]: - """Returns the set of systematic full names of all registered conditions.""" - return frozenset(self[condition].full_name for condition in self.list()) - - def get_valid_mask_names(self) -> set[str]: - """Returns the set of systematic mask names of all registered conditions.""" - return frozenset(self[condition].mask_name for condition in self.list()) - - def get(self, name: str) -> type[ConditionBase]: - """Retrieves a condition by its string name.""" - return self.__getitem__(name) - - def from_mask_name(self, mask_name: str) -> type[ConditionBase]: - """Resolves a systematic mask name to a condition class.""" - condition_name = mask_name.split("_")[1] - return self.__getitem__(condition_name) - - def from_full_name(self, full_name: str) -> type[ConditionBase]: - """Resolves a systematic full name to a condition class.""" - condition_name = full_name.split("_")[1] - return self.__getitem__(condition_name) + return atom_array def __repr__(self) -> str: return f"Conditions({self.list()})" -# Singleton instance for easy, clean access +# Singleton instances for easy, clean access CONDITIONS = ConditionAccessor() diff --git a/src/atomworks/ml/conditions/conditions.py b/src/atomworks/ml/conditions/conditions.py deleted file mode 100644 index 35e3c007..00000000 --- a/src/atomworks/ml/conditions/conditions.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Concrete implementations of common molecular design conditions.""" - -import numpy as np -from biotite.structure import AtomArray -from sympy.sets.sets import true - -from atomworks.constants import MASKED -from atomworks.io.utils.atom_array import apply_and_spread -from atomworks.io.utils.atom_array_plus import AnnotationList2D -from atomworks.ml.conditions.annotator import ensure_annotations -from atomworks.ml.conditions.base import ConditionBase, Level - - -class Sequence(ConditionBase): - name = "sequence" - n_body = 1 - level = Level.RESIDUE - dtype = str - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> np.ndarray: - if cls.full_name in atom_array.get_annotation_categories(): - # ... if annotation exists, derive the mask from it - return cls.annotation(atom_array, default="raise") != MASKED - else: - # ... otherwise, default to an empty mask - return np.zeros(atom_array.array_length(), dtype=bool) - - @classmethod - def default_annotation(cls, atom_array: AtomArray) -> np.ndarray: - seq = np.full(atom_array.array_length(), fill_value=MASKED) - if cls.mask_name in atom_array.get_annotation_categories(): - # ... if mask exists, use it to get the sequence - seq_mask = cls.mask(atom_array, default="raise") - seq[seq_mask] = atom_array.res_name[seq_mask] - return seq - - -class Coordinate(ConditionBase): - name = "coordinate" - n_body = 1 - level = Level.ATOM - dtype = float - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> np.ndarray: - if cls.full_name in atom_array.get_annotation_categories(): - # ... if annotation exists, derive the mask from it - return np.isfinite(cls.annotation(atom_array, default="raise")).any(axis=1) - else: - # ... otherwise, default to an empty mask - return np.zeros(atom_array.array_length(), dtype=bool) - - @classmethod - def default_annotation(cls, atom_array: AtomArray) -> np.ndarray: - coords = np.full((atom_array.array_length(), 3), fill_value=np.nan) - if cls.mask_name in atom_array.get_annotation_categories(): - # ... if mask exists, use it to get the coordinates - mask = cls.mask(atom_array, default="raise") - coords[mask] = atom_array.coord[mask] - return coords - - -class Index(ConditionBase): - name = "index" - n_body = 1 - level = Level.RESIDUE - dtype = int - MASKED_INDEX = -1 - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> np.ndarray: - if cls.full_name in atom_array.get_annotation_categories(): - # ... if annotation exists, derive the mask from it - return cls.annotation(atom_array, default="raise") != cls.MASKED_INDEX - else: - # ... otherwise, default to a full mask (all indices leaked) - return np.ones(atom_array.array_length(), dtype=bool) - - @classmethod - def default_annotation(cls, atom_array: AtomArray) -> np.ndarray: - ensure_annotations(atom_array, "within_chain_res_idx") - indices = atom_array.get_annotation("within_chain_res_idx").copy() - if cls.mask_name in atom_array.get_annotation_categories(): - # ... if mask exists, use it to get the indices - mask = cls.mask(atom_array, default="raise") - indices[~mask] = cls.MASKED_INDEX - return indices - - -class Distance(ConditionBase): - name = "distance" - n_body = 2 - level = Level.ATOM - is_symmetric = True - dtype = float - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> AnnotationList2D: - annotation = cls.annotation(atom_array, default="generate") - return AnnotationList2D( - n_atoms=atom_array.array_length(), - pairs=annotation.pairs, - values=annotation.values > 0, - ) - - @classmethod - def default_annotation(cls, atom_array: AtomArray) -> AnnotationList2D: - return AnnotationList2D( - n_atoms=atom_array.array_length(), - pairs=np.array([], dtype=int), - values=np.array([], dtype=cls.dtype), - ) - - -class NTerminus(ConditionBase): - name = "n-terminus" - n_body = 1 - level = Level.RESIDUE - is_mask = True - dtype = bool - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> np.ndarray: - ensure_annotations(atom_array, "is_polymer", "is_chain_start", "is_res_start", "within_chain_res_idx") - - # ... get indicator atoms for N-terminus atoms - is_n_terminus = atom_array.is_polymer & (atom_array.within_chain_res_idx == 0) & atom_array.is_chain_start - - # ... spread to full residue - residue_segments = np.concatenate([np.where(atom_array.is_res_start)[0], [atom_array.array_length()]]) - is_n_terminus = apply_and_spread(residue_segments, is_n_terminus, np.any) - - return is_n_terminus - - -class CTerminus(ConditionBase): - name = "c-terminus" - n_body = 1 - level = Level.RESIDUE - is_mask = true - dtype = bool - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> np.ndarray: - ensure_annotations(atom_array, "is_polymer", "is_chain_start", "is_res_start", "within_chain_res_idx") - - # ... get indicator atoms for C-terminus atoms - annotations = atom_array.get_annotation_categories() - chain_ids = atom_array.chain_iid if "chain_iid" in annotations else atom_array.chain_id - # ... find max within_chain_res_idx for each chain - is_max_within_chain_res_idx = np.zeros(atom_array.array_length(), dtype=bool) - for chain_id in np.unique(chain_ids): - is_this_chain = chain_ids == chain_id - max_chain_idx = np.max(atom_array.get_annotation("within_chain_res_idx")[is_this_chain]) - is_max_idx = atom_array.get_annotation("within_chain_res_idx") == max_chain_idx - is_max_within_chain_res_idx |= is_this_chain & is_max_idx - - # ... spread to full residue - is_c_terminus = is_max_within_chain_res_idx & atom_array.is_polymer - residue_segments = np.concatenate([np.where(atom_array.is_res_start)[0], [atom_array.array_length()]]) - is_c_terminus = apply_and_spread(residue_segments, is_c_terminus, np.any) - - return is_c_terminus - - -class Chain(ConditionBase): - name = "chain" - n_body = 2 - level = Level.CHAIN - is_symmetric = True - dtype = bool - - @classmethod - def default_mask(cls, atom_array: AtomArray) -> AnnotationList2D: - annotations = atom_array.get_annotation_categories() - chain_iid_annotation = "chain_iid" if "chain_iid" in annotations else "chain_id" - chain_instance = atom_array.get_annotation(chain_iid_annotation) - is_same_chain = chain_instance[None, :] == chain_instance[:, None] - pairs = np.stack(np.where(is_same_chain), axis=0).T - values = np.ones(pairs.shape[0], dtype=bool) - return AnnotationList2D(atom_array.array_length(), pairs=pairs, values=values) diff --git a/src/atomworks/ml/conditions/definitions.py b/src/atomworks/ml/conditions/definitions.py new file mode 100644 index 00000000..4f91a2ad --- /dev/null +++ b/src/atomworks/ml/conditions/definitions.py @@ -0,0 +1,156 @@ +"""Concrete implementations of common molecular design conditions.""" + +import numpy as np +from biotite.structure import AtomArray +from jaxtyping import Bool, Float, Shaped + +from atomworks.constants import MASKED +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.atom_array_plus import AnnotationList2D +from atomworks.io.utils.scatter import apply_and_spread_segment_wise +from atomworks.io.utils.selection import get_residue_starts +from atomworks.io.utils.standard_annotations.base import Level +from atomworks.ml.conditions.base import ConditionBase + +Array = np.ndarray + + +class Sequence(ConditionBase): + name = "sequence" + n_body = 1 + level = Level.RESIDUE + dtype = str + aliases = ("C_SEQ",) + default_value = MASKED + + @classmethod + def mask_from_annotation(cls, annotation: np.ndarray) -> np.ndarray: + return annotation != MASKED + + @classmethod + def ground_truth_value(cls, atom_array: AtomArray) -> Shaped[Array, "n_atoms"]: # noqa: F821 + # The conditioned identity is the residue name. + return atom_array.res_name + + +class Coordinate(ConditionBase): + name = "coordinate" + n_body = 1 + level = Level.ATOM + dtype = float + aliases = ("C_CRD",) + value_shape = (3,) + default_value = np.nan + + @classmethod + def mask_from_annotation(cls, annotation: np.ndarray) -> np.ndarray: + return np.isfinite(annotation).any(axis=1) + + @classmethod + def ground_truth_value(cls, atom_array: AtomArray) -> Float[Array, "n_atoms 3"]: # noqa: F722 + # The conditioned target is the atom's coordinates. + return atom_array.coord + + +class Index(ConditionBase): + name = "index" + n_body = 1 + level = Level.RESIDUE + dtype = bool + aliases = ("C_IDX",) + default_value = True + + +class Distance(ConditionBase): + name = "distance" + n_body = 2 + level = Level.ATOM + is_symmetric = True + dtype = float + aliases = ("C_DIS",) + + @classmethod + def mask_from_annotation(cls, annotation: AnnotationList2D) -> AnnotationList2D: + return AnnotationList2D( + n_atoms=annotation.n_atoms, + pairs=annotation.pairs, + values=annotation.values > 0, + ) + + @classmethod + def default_annotation(cls, atom_array: AtomArray) -> AnnotationList2D: + return AnnotationList2D( + n_atoms=atom_array.array_length(), + pairs=np.array([], dtype=int), + values=np.array([], dtype=cls.storage_dtype()), + ) + + +class NTerminus(ConditionBase): + name = "n-terminus" + n_body = 1 + level = Level.RESIDUE + dtype = bool + aliases = ("mask_n-terminus_1_residue", "C_NTR") + + @classmethod + def default_annotation(cls, atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 + ensure_annotations(atom_array, "is_polymer", "is_chain_start", "is_res_start", "within_chain_res_idx") + + # ... get indicator atoms for N-terminus atoms + is_n_terminus = atom_array.is_polymer & (atom_array.within_chain_res_idx == 0) & atom_array.is_chain_start + + # ... spread to full residue + residue_segments = get_residue_starts(atom_array, add_exclusive_stop=True) + is_n_terminus = apply_and_spread_segment_wise(residue_segments, is_n_terminus, np.any) + + return is_n_terminus + + +class CTerminus(ConditionBase): + name = "c-terminus" + n_body = 1 + level = Level.RESIDUE + dtype = bool + aliases = ("mask_c-terminus_1_residue", "C_CTR") + + @classmethod + def default_annotation(cls, atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F821 + ensure_annotations(atom_array, "is_polymer", "is_chain_start", "is_res_start", "within_chain_res_idx") + + # ... get indicator atoms for C-terminus atoms + annotations = atom_array.get_annotation_categories() + chain_ids = atom_array.chain_iid if "chain_iid" in annotations else atom_array.chain_id + # ... find max within_chain_res_idx for each chain + is_max_within_chain_res_idx = np.zeros(atom_array.array_length(), dtype=bool) + for chain_id in np.unique(chain_ids): + is_this_chain = chain_ids == chain_id + max_chain_idx = np.max(atom_array.get_annotation("within_chain_res_idx")[is_this_chain]) + is_max_idx = atom_array.get_annotation("within_chain_res_idx") == max_chain_idx + is_max_within_chain_res_idx |= is_this_chain & is_max_idx + + # ... spread to full residue + is_c_terminus = is_max_within_chain_res_idx & atom_array.is_polymer + residue_segments = get_residue_starts(atom_array, add_exclusive_stop=True) + is_c_terminus = apply_and_spread_segment_wise(residue_segments, is_c_terminus, np.any) + + return is_c_terminus + + +class Chain(ConditionBase): + name = "chain" + n_body = 2 + level = Level.CHAIN + is_symmetric = True + dtype = bool + aliases = ("mask_chain_2_chain", "C_CHA") + + @classmethod + def default_annotation(cls, atom_array: AtomArray) -> AnnotationList2D: + annotations = atom_array.get_annotation_categories() + chain_iid_annotation = "chain_iid" if "chain_iid" in annotations else "chain_id" + chain_instance = atom_array.get_annotation(chain_iid_annotation) + is_same_chain = chain_instance[None, :] == chain_instance[:, None] + pairs = np.stack(np.where(is_same_chain), axis=0).T + values = np.ones(pairs.shape[0], dtype=bool) + return AnnotationList2D(atom_array.array_length(), pairs=pairs, values=values) diff --git a/src/atomworks/ml/datasets/README.md b/src/atomworks/ml/datasets/README.md index 0e6ab3a1..bc0d4e53 100644 --- a/src/atomworks/ml/datasets/README.md +++ b/src/atomworks/ml/datasets/README.md @@ -27,7 +27,7 @@ dataset = FileDataset.from_directory( ```python from atomworks.ml.datasets import PandasDataset -from atomworks.ml.datasets.loaders import create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.transforms.base import Compose from atomworks.ml.transforms.crop import CropSpatialLikeAF3 @@ -35,8 +35,8 @@ from atomworks.ml.transforms.crop import CropSpatialLikeAF3 dataset = PandasDataset( data="path/to/metadata.parquet", name="interfaces", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"] + loader=create_structure_loader( + column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]} ), transform=Compose([ CropSpatialLikeAF3(crop_size=384), @@ -47,6 +47,23 @@ dataset = PandasDataset( ## Core Concepts +### MetadataIndex + +`MetadataIndex` is a shared component that handles parquet/DataFrame loading, filtering, and ID-mapping for all dataset backends. Every dataset that uses tabular metadata delegates to a `MetadataIndex` instance, ensuring consistent filtering and ID lookup behavior. + +```python +from atomworks.ml.datasets import MetadataIndex + +index = MetadataIndex( + data="metadata.parquet", + name="my_dataset", + id_column="example_id", + filters=["resolution < 2.5"], +) +print(len(index)) # Number of filtered examples +print(index.get_example_id(0)) # First example's ID +``` + ### The Three-Step Pipeline All AtomWorks datasets follow a consistent three-step process: @@ -104,7 +121,7 @@ dataset = FileDataset.from_directory( #### `PandasDataset` -For tabular datasets stored as DataFrames or Parquet/CSV files. +For tabular datasets stored as DataFrames or Parquet/CSV files. Delegates all metadata, filtering, and ID logic to a `MetadataIndex` internally. ```python dataset = PandasDataset( @@ -125,6 +142,25 @@ dataset = PandasDataset( **ID-Based Access:** Set an `id_column` to enable `dataset.id_to_idx()` and `idx_to_id()` methods. +#### `AseDBDataset` + +For molecular structures stored in ASE LMDB databases. Requires a metadata parquet file mapping example IDs to LMDB indices. + +```python +from atomworks.ml.datasets.ase_dataset import AseDBDataset +from atomworks.ml.datasets.loaders.ase import create_ase_loader + +dataset = AseDBDataset( + lmdb_path="/data/omol25/", + name="omol25", + data="omol25_metadata.parquet", + id_column="example_id", + lmdb_idx_column="lmdb_idx", + filters=["num_atoms < 200"], + loader=create_ase_loader(per_atom_properties=["forces"]), +) +``` + ### Loader Functions Loaders are functions that convert dataset-specific raw data into a standard format for Transforms. @@ -134,10 +170,10 @@ Loaders are functions that convert dataset-specific raw data into a standard for Use loader factory functions to create loaders with common patterns: ```python -from atomworks.ml.datasets.loaders import create_base_loader, create_loader_with_query_pn_units +from atomworks.ml.datasets.loaders import create_structure_loader # Basic loader for simple datasets -loader = create_base_loader( +loader = create_structure_loader( example_id_colname="example_id", path_colname="structure_path", assembly_id_colname="assembly_id", @@ -148,8 +184,8 @@ loader = create_base_loader( ) # Loader with query pn_units (for cropping) -loader = create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], # For interfaces +loader = create_structure_loader( + column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]}, base_path="/data/pdb", extension=".cif.gz" ) diff --git a/src/atomworks/ml/datasets/__init__.py b/src/atomworks/ml/datasets/__init__.py index 93b7bf25..d6f2f488 100644 --- a/src/atomworks/ml/datasets/__init__.py +++ b/src/atomworks/ml/datasets/__init__.py @@ -2,21 +2,25 @@ # HACK: Re-export all dataset classes for backward compatibility # In the future, we will just import from their respective modules (e.g., from .pandas_dataset import PandasDataset) -from .base import ExampleIDMixin, MolecularDataset +from .base import ExampleIDProtocol, MolecularDataset from .concat_dataset import ConcatDatasetWithID, FallbackDatasetWrapper, get_row_and_index_by_example_id from .file_dataset import FileDataset -from .pandas_dataset import PandasDataset, StructuralDatasetWrapper +from .metadata import ArrowMetadataIndex, MetadataIndex, MetadataIndexProtocol, SequentialMetadataIndex +from .pandas_dataset import PandasDataset logger = logging.getLogger("datasets") __all__ = [ + "ArrowMetadataIndex", "ConcatDatasetWithID", - "ExampleIDMixin", + "ExampleIDProtocol", "FallbackDatasetWrapper", "FileDataset", + "MetadataIndex", + "MetadataIndexProtocol", "MolecularDataset", "PandasDataset", - "StructuralDatasetWrapper", + "SequentialMetadataIndex", "get_row_and_index_by_example_id", "logger", ] diff --git a/src/atomworks/ml/datasets/ase_dataset.py b/src/atomworks/ml/datasets/ase_dataset.py new file mode 100644 index 00000000..43745324 --- /dev/null +++ b/src/atomworks/ml/datasets/ase_dataset.py @@ -0,0 +1,139 @@ +"""Dataset for loading molecular structures from ASE LMDB databases.""" + +import logging +import os +import warnings +from collections.abc import Callable +from os import PathLike +from pathlib import Path +from typing import Any + +from atomworks.ml.datasets.metadata import MetadataIndexProtocol +from atomworks.ml.datasets.sharded_dataset import ShardedDataset + +try: + import ase.db + from ase_db_backends.aselmdb import LMDBDatabase # noqa: F401 +except ImportError: + warnings.warn( + "ASE and ase-db-backends are required for AseDBDataset. " "Install with: uv pip install ase ase-db-backends", + ImportWarning, + stacklevel=2, + ) + ase = None + +logger = logging.getLogger(__name__) + + +class AseDBDataset(ShardedDataset): + """Dataset for loading molecular structures from ASE LMDB databases. + + Uses memory-efficient lazy shard loading - only one shard is kept + open at a time (~50MB), instead of all shards (~50MB * num_shards). + """ + + def __init__( + self, + *, + lmdb_path: str, + name: str, + loader: Callable | None = None, + data: str | PathLike | None = None, + id_column: str = "example_id", + lmdb_idx_column: str = "lmdb_idx", + filters: list[str] | None = None, + columns_to_load: list[str] | None = None, + transform: Callable | None = None, + save_failed_examples_to_dir: str | Path | None = None, + memory_map: bool = False, + metadata: MetadataIndexProtocol | None = None, + ): + """Initialize AseDBDataset. + + Args: + lmdb_path: Path to the ASE LMDB database directory. + name: Descriptive name for this dataset. + loader: Optional callable to process ``(atoms_row, global_idx, metadata_row)`` + into a data dict. + data: Optional path to a parquet/CSV file with metadata for filtering. + id_column: Column name for human-readable example IDs. + lmdb_idx_column: Column name containing integer LMDB indices. + filters: Optional list of pandas query strings to filter metadata. + columns_to_load: Optional list of columns to load from metadata file. + transform: Transform pipeline to apply to loaded data. + save_failed_examples_to_dir: Optional directory to save failed examples. + memory_map: If ``True``, use :class:`ArrowMetadataIndex` for metadata. + metadata: Optional pre-built metadata index. + """ + # ASE-specific shard state + self._current_db = None + self._current_ids: list | None = None + self._owner_pid = os.getpid() + + super().__init__( + shard_path=lmdb_path, + shard_extension=".aselmdb", + name=name, + loader=loader, + data=data, + id_column=id_column, + idx_column=lmdb_idx_column, + filters=filters, + columns_to_load=columns_to_load, + transform=transform, + save_failed_examples_to_dir=save_failed_examples_to_dir, + memory_map=memory_map, + metadata=metadata, + ) + + def __getstate__(self) -> dict[str, Any]: + """Serialize configuration without live LMDB handles or changing the parent reader.""" + state = self.__dict__.copy() + state.update(_current_db=None, _current_ids=None, _current_shard_idx=None) + return state + + def _ensure_shard_loaded(self, shard_idx: int) -> None: + """Reopen inherited handles in each DataLoader worker process.""" + if self._owner_pid != os.getpid(): + if self._current_db is not None: + # ASE close() accesses a property that otherwise reopens on a PID change. + self._current_db._env_pid = os.getpid() + self.close() + self._owner_pid = os.getpid() + super()._ensure_shard_loaded(shard_idx) + + def _get_shard_count(self, path: str) -> int: + """Return the number of entries in an ASE shard.""" + db = ase.db.connect(path, type="aselmdb", readonly=True, use_lock_file=False) + try: + return len(db.ids) + finally: + db.close() + + def _open_shard(self, shard_idx: int) -> None: + """Open an ASE database shard.""" + path = self._filepaths[shard_idx] + self._current_db = ase.db.connect(path, type="aselmdb", readonly=True, use_lock_file=False) + self._current_ids = self._current_db.ids + logger.debug(f"Loaded shard {shard_idx}: {Path(path).name}") + + def _close_shard(self) -> None: + """Close the currently open ASE shard.""" + if self._current_db is not None: + try: + self._current_db.close() + except Exception as e: + logger.warning(f"Failed to close shard {self._current_shard_idx}: {e}") + self._current_db = None + self._current_ids = None + + def _fetch_entry(self, local_idx: int) -> Any: + """Fetch an atoms_row from the current ASE shard.""" + actual_shard_len = len(self._current_ids) + if local_idx >= actual_shard_len: + raise IndexError( + f"Local index {local_idx} exceeds shard bounds. " + f"Shard {self._current_shard_idx} has {actual_shard_len} entries." + ) + lmdb_row_id = self._current_ids[local_idx] + return self._current_db.get(id=lmdb_row_id) diff --git a/src/atomworks/ml/datasets/base.py b/src/atomworks/ml/datasets/base.py index aafb222e..f2e6635c 100644 --- a/src/atomworks/ml/datasets/base.py +++ b/src/atomworks/ml/datasets/base.py @@ -1,14 +1,15 @@ """Base classes for AtomWorks molecular datasets.""" +import copy import logging import os import socket import time -from abc import ABC, abstractmethod from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, Protocol, runtime_checkable +import numpy as np from torch.utils.data import Dataset from atomworks.ml.transforms.base import TransformedDict @@ -18,48 +19,21 @@ logger = logging.getLogger("datasets") -class ExampleIDMixin(ABC): - """Mixin providing example ID functionality to a Dataset. +@runtime_checkable +class ExampleIDProtocol(Protocol): + """Structural interface for datasets that support ID-based access.""" - Provides methods for converting between example IDs and indices, and checking - if an example ID exists in the dataset. - """ - - @abstractmethod def __contains__(self, example_id: str) -> bool: - """Check if the dataset contains the example ID. - - Args: - example_id: The ID to check for. - - Returns: - True if the ID exists in the dataset. - """ - pass + """Check if the dataset contains the example ID.""" + ... - @abstractmethod def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: - """Convert example ID(s) to index(es). - - Args: - example_id: Single ID or list of IDs to convert. - - Returns: - Corresponding index or list of indices. - """ - pass - - @abstractmethod - def idx_to_id(self, idx: int | list[int]) -> str | list[str]: - """Convert index(es) to example ID(s). - - Args: - idx: Single index or list of indices to convert. + """Convert example ID(s) to index(es).""" + ... - Returns: - Corresponding ID or list of IDs. - """ - pass + def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: + """Convert index(es) to example ID(s).""" + ... class MolecularDataset(Dataset): @@ -67,6 +41,7 @@ class MolecularDataset(Dataset): Handles Transform pipelines and loader functionality for molecular data. Subclasses implement :meth:`__getitem__` with their own data access patterns. + Dictionary samples include ``dataset_name`` after transforms finish. """ def __init__( @@ -128,6 +103,13 @@ def _apply_loader(self, raw_data: Any) -> Any: return data + def _with_dataset_name(self, data: Any) -> Any: + """Attach source identity to a shallow copy, preserving transform history.""" + if isinstance(data, dict): + data = copy.copy(data) + data["dataset_name"] = self.name + return data + def _apply_transform(self, data: Any, example_id: str | None = None, idx: int | None = None) -> Any: """Apply the Transform pipeline with error handling and debugging support. @@ -145,7 +127,7 @@ def _apply_transform(self, data: Any, example_id: str | None = None, idx: int | Exception: Any exception from the transform pipeline is re-raised. """ if self.transform is None: - return data + return self._with_dataset_name(data) # Generate default example_id from idx and dataset name if not provided if example_id is None and idx is not None: @@ -159,7 +141,7 @@ def _apply_transform(self, data: Any, example_id: str | None = None, idx: int | # Capture RNG state for reproducibility before applying Transforms rng_state_dict = capture_rng_states(include_cuda=False) data = self.transform(data) - return data + return self._with_dataset_name(data) except KeyboardInterrupt: # Always re-raise keyboard interrupts diff --git a/src/atomworks/ml/datasets/concat_dataset.py b/src/atomworks/ml/datasets/concat_dataset.py index 83b70265..ff561ab5 100644 --- a/src/atomworks/ml/datasets/concat_dataset.py +++ b/src/atomworks/ml/datasets/concat_dataset.py @@ -7,7 +7,7 @@ from torch.utils.data import ConcatDataset, Dataset -from .base import ExampleIDMixin +from .base import ExampleIDProtocol logger = logging.getLogger("datasets") @@ -15,18 +15,16 @@ class ConcatDatasetWithID(ConcatDataset): """Equivalent to :class:`torch.utils.data.ConcatDataset` but allows accessing examples by ID. - Provides ID-based access across multiple datasets that implement :class:`ExampleIDMixin`. + Provides ID-based access across multiple datasets that implement :class:`ExampleIDProtocol`. """ - # TODO: Do I need all of these _raise_if etc. etc. here? Can I just check that the wrapped datasets inherit somehow from ExampleIDMixin? + datasets: list[ExampleIDProtocol] - datasets: list[ExampleIDMixin] - - def __init__(self, datasets: list[ExampleIDMixin]): + def __init__(self, datasets: list[ExampleIDProtocol]): """Initialize ConcatDatasetWithID. Args: - datasets: List of datasets that implement ExampleIDMixin. + datasets: List of datasets that implement ExampleIDProtocol. """ super().__init__(datasets) @@ -161,7 +159,7 @@ def get_dataset_by_id(self, example_id: str) -> Dataset: return self.get_dataset_by_idx(idx) -def get_row_and_index_by_example_id(dataset: ExampleIDMixin, example_id: str) -> dict: +def get_row_and_index_by_example_id(dataset: ExampleIDProtocol, example_id: str) -> dict: """Retrieve a row and its index from a nested dataset structure by its example ID. Args: @@ -180,7 +178,8 @@ def get_row_and_index_by_example_id(dataset: ExampleIDMixin, example_id: str) -> dataset = dataset.get_dataset_by_idx(_local_idx) _local_idx = dataset.id_to_idx(example_id) - row = dataset.data.loc[example_id] + idx_local = dataset.id_to_idx(example_id) + row = dataset.metadata.get_row(idx_local) return {"row": row, "index": idx} @@ -222,7 +221,7 @@ def __getitem__(self, idxs: tuple[int, ...]) -> Any: for i, idx in enumerate(idxs): dataset = self.dataset if i == 0 else self.fallback_dataset - dataset_name = "Primary dataset" if i == 0 else f"Fallback {i}/{len(idxs) - 1}" + dataset_name = "Primary dataset" if i == 0 else f"Fallback {i}/{len(idxs)-1}" try: return dataset[idx] @@ -238,11 +237,11 @@ def __getitem__(self, idxs: tuple[int, ...]) -> Any: # Log fallback attempt if not the last one if i < len(idxs) - 1: - logger.warning(f"({dataset_name}): Trying fallback index {idxs[i + 1]}.{example_id}") + logger.warning(f"({dataset_name}): Trying fallback index {idxs[i+1]}.{example_id}") # All attempts failed logger.error( - f"(Exceeded all {len(idxs) - 1} fallbacks. Training will crash now. Errors: {error_list} for examples: {example_id_list})" + f"(Exceeded all {len(idxs)-1} fallbacks. Training will crash now. Errors: {error_list} for examples: {example_id_list})" ) raise RuntimeError(f"All attempts failed for indices {idxs}. See error_list for details.") from ExceptionGroup( "All fallback attempts failed", error_list diff --git a/src/atomworks/ml/datasets/file_dataset.py b/src/atomworks/ml/datasets/file_dataset.py index c28e395d..8af95eb9 100644 --- a/src/atomworks/ml/datasets/file_dataset.py +++ b/src/atomworks/ml/datasets/file_dataset.py @@ -7,14 +7,14 @@ from atomworks.ml.utils.io import scan_directory -from .base import ExampleIDMixin, MolecularDataset +from .base import MolecularDataset def _always_true(x: PathLike) -> bool: return True -class FileDataset(MolecularDataset, ExampleIDMixin): +class FileDataset(MolecularDataset): """Dataset that loads molecular data from individual files. Each file represents one example in the dataset. If creating a dataset from a diff --git a/src/atomworks/ml/datasets/lmdb_dataset.py b/src/atomworks/ml/datasets/lmdb_dataset.py new file mode 100644 index 00000000..d9cc6f81 --- /dev/null +++ b/src/atomworks/ml/datasets/lmdb_dataset.py @@ -0,0 +1,151 @@ +"""Dataset for loading molecular structures from raw LMDB databases.""" + +import logging +import warnings +from collections.abc import Callable +from os import PathLike +from pathlib import Path + +from atomworks.ml.datasets.loaders.cif import create_structure_loader +from atomworks.ml.datasets.metadata import MetadataIndexProtocol +from atomworks.ml.datasets.sharded_dataset import ShardedDataset +from atomworks.ml.utils.io import _decompress + +try: + import lmdb +except ImportError: + warnings.warn( + "lmdb library is required for LmdbDataset. Install it with: uv pip install lmdb", + ImportWarning, + stacklevel=2, + ) + lmdb = None + + +def _default_key_encoder(i: int) -> bytes: + """Encode an integer index as 8-byte little-endian bytes.""" + return i.to_bytes(8, "little") + + +logger = logging.getLogger(__name__) + + +class LmdbDataset(ShardedDataset): + """Dataset for loading raw bytes from LMDB databases. + + Values are raw bytes (typically CIF, optionally zstd/gzip compressed). + Supports the same metadata filtering as :class:`PandasDataset`, etc. + """ + + def __init__( + self, + *, + lmdb_path: str, + name: str, + shard_extension: str = ".ciflmdb", + key_encoder: Callable[[int], bytes] | None = None, + loader: Callable | None = None, + data: str | PathLike | None = None, + id_column: str = "example_id", + lmdb_idx_column: str = "lmdb_idx", + filters: list[str] | None = None, + columns_to_load: list[str] | None = None, + parser_args: dict | None = None, + transform: Callable | None = None, + save_failed_examples_to_dir: str | Path | None = None, + memory_map: bool = False, + metadata: MetadataIndexProtocol | None = None, + ): + """Initialize LmdbDataset. + + Args: + lmdb_path: Path to the LMDB database directory or single file. + name: Descriptive name for this dataset. + shard_extension: File extension used to discover shards in a directory. + Defaults to ``".ciflmdb"``. + key_encoder: Callable that converts an integer index to the LMDB key bytes. + Defaults to 8-byte little-endian encoding (OMOL format). + loader: Optional callable to process ``(raw_bytes, global_idx, metadata_row)`` + into a data dict. Defaults to + :py:func:`~atomworks.ml.datasets.loaders.cif.create_structure_loader` + when ``shard_extension`` is ``".ciflmdb"``. Must be provided explicitly + for other extensions. + data: Optional path to a parquet/CSV file with metadata for filtering. + id_column: Column name for human-readable example IDs. + lmdb_idx_column: Column name containing integer LMDB indices. + filters: Optional list of pandas query strings to filter metadata. + columns_to_load: Optional list of columns to load from metadata file. + parser_args: Optional parser arguments passed to the default CIF bytes loader. + transform: Transform pipeline to apply to loaded data. + save_failed_examples_to_dir: Optional directory to save failed examples. + memory_map: If ``True``, use :class:`ArrowMetadataIndex` for metadata. + metadata: Optional pre-built metadata index. + """ + # LMDB-specific shard state (set early so __del__ cleanup works) + self._current_env = None + self._current_txn = None + + if loader is None: + if shard_extension == ".ciflmdb": + loader = create_structure_loader(storage="bytes", parser_args=parser_args) + else: + raise ValueError( + f"No default loader for shard extension '{shard_extension}'. Please provide an explicit loader." + ) + + self._key_encoder = key_encoder or _default_key_encoder + + super().__init__( + shard_path=lmdb_path, + shard_extension=shard_extension, + name=name, + loader=loader, + data=data, + id_column=id_column, + idx_column=lmdb_idx_column, + filters=filters, + columns_to_load=columns_to_load, + transform=transform, + save_failed_examples_to_dir=save_failed_examples_to_dir, + memory_map=memory_map, + metadata=metadata, + ) + + def _get_shard_count(self, path: str) -> int: + """Return the number of entries in an LMDB shard.""" + env = lmdb.open(path, subdir=False, readonly=True, lock=False) + try: + with env.begin() as txn: + return txn.stat()["entries"] + finally: + env.close() + + def _open_shard(self, shard_idx: int) -> None: + """Open an LMDB shard.""" + path = self._filepaths[shard_idx] + self._current_env = lmdb.open(path, subdir=False, readonly=True, lock=False) + self._current_txn = self._current_env.begin() + logger.debug(f"Loaded shard {shard_idx}: {Path(path).name}") + + def _close_shard(self) -> None: + """Close the currently open LMDB shard.""" + if self._current_txn is not None: + try: + self._current_txn.abort() + except Exception as e: + logger.warning(f"Failed to abort txn for shard {self._current_shard_idx}: {e}") + self._current_txn = None + if self._current_env is not None: + try: + self._current_env.close() + except Exception as e: + logger.warning(f"Failed to close shard {self._current_shard_idx}: {e}") + self._current_env = None + + def _fetch_entry(self, local_idx: int) -> bytes: + """Fetch and decompress an entry from the current LMDB shard.""" + key = self._key_encoder(local_idx) + raw = self._current_txn.get(key) + if raw is None: + raise KeyError(f"Key {local_idx} not found in shard {self._current_shard_idx}") + return _decompress(raw) diff --git a/src/atomworks/ml/datasets/loaders/__init__.py b/src/atomworks/ml/datasets/loaders/__init__.py index dad80b03..f9e481b2 100644 --- a/src/atomworks/ml/datasets/loaders/__init__.py +++ b/src/atomworks/ml/datasets/loaders/__init__.py @@ -6,12 +6,20 @@ from .cif import ( create_base_loader, + create_blob_cif_loader, + create_cif_bytes_loader, create_loader_with_interfaces_and_pn_units_to_score, create_loader_with_query_pn_units, + create_structure_loader, ) +from .materials import create_ase_materials_loader __all__ = [ + "create_ase_materials_loader", "create_base_loader", + "create_blob_cif_loader", + "create_cif_bytes_loader", "create_loader_with_interfaces_and_pn_units_to_score", "create_loader_with_query_pn_units", + "create_structure_loader", ] diff --git a/src/atomworks/ml/datasets/loaders/ase.py b/src/atomworks/ml/datasets/loaders/ase.py new file mode 100644 index 00000000..85f0edfb --- /dev/null +++ b/src/atomworks/ml/datasets/loaders/ase.py @@ -0,0 +1,171 @@ +"""Loader for ASE datasets (e.g., from XYZ files) into AtomWorks format.""" + +import functools +import logging +from collections.abc import Callable +from typing import Any, Literal + +import numpy as np +from biotite.structure import BondType + +from atomworks.io.parser import parse_atom_array +from atomworks.io.tools.rdkit import atom_array_from_rdkit, atom_array_to_rdkit +from atomworks.io.transforms.atom_array import ( + get_coarse_graph_as_nodes_and_edges, + get_connected_nodes, +) +from atomworks.io.utils.ase_conversions import ase_to_atom_array +from atomworks.io.utils.atom_array import annotate_and_remove_hydrogens +from atomworks.io.utils.chain import create_chain_id_generator + +logger = logging.getLogger(__name__) + + +def _ase_loader_function( + raw_data: tuple, + per_atom_properties: list[str], + global_properties: list[str], + hydrogen_policy: Literal["keep", "remove"], +) -> dict[str, Any]: + """ASE loader function (picklable when used with functools.partial).""" + # Unpack the tuple (metadata_row available but unused for now) + atoms_row, global_idx, _metadata_row = raw_data + + # Convert ASE row to atoms object + atoms = atoms_row.toatoms() + + # Update atoms.info with any data from the row + if isinstance(atoms_row.data, dict): + atoms.info.update(atoms_row.data) + + # Convert to Biotite AtomArray + atom_array = ase_to_atom_array(atoms) + + # Add bonds, hybridization, chirality, etc. via RDKit + mol = atom_array_to_rdkit( + atom_array, + infer_bonds=True, + timeout_seconds=2, + hydrogen_policy="keep", + system_charge=atoms.info.get("charge", 0), + ) + atom_array = atom_array_from_rdkit(mol, remove_hydrogens=False) + + if hydrogen_policy == "remove": + # Set nhyd annotation and remove hydrogens + atom_array = annotate_and_remove_hydrogens(atom_array, increment=False) + + # Create unique atom IDs and assign chain IDs and residue IDs based on connectivity + atom_array.set_annotation("atom_id", np.arange(atom_array.array_length())) + # Exclude coordination bonds so metal ions and ligands get separate chains + connected_atoms = get_connected_nodes( + *get_coarse_graph_as_nodes_and_edges(atom_array, "atom_id", exclude_bond_types={BondType.COORDINATION}) + ) + + # Assign chain IDs based on connected components + chain_id_gen = create_chain_id_generator() + for connected_atom in connected_atoms: + chain_letter = next(chain_id_gen) + res_number = 1 + element_counts = {} + + for atom_id in connected_atom: + atom_array.chain_id[atom_id] = chain_letter + atom_array.res_id[atom_id] = res_number + atom_array.res_name[atom_id] = f"{chain_letter}:{res_number}" + + element = atom_array.element[atom_id] + element_counts[element] = element_counts.get(element, 0) + 1 + atom_name = f"{element}{element_counts[element]}" + atom_array.atom_name[atom_id] = atom_name + + # Extract per-atom properties BEFORE parse_atom_array + for prop in per_atom_properties: + if prop in ("numbers", "positions"): + continue + + if prop in atoms.arrays: + if not hasattr(atom_array, prop): + prop_data = atoms.arrays[prop] + if len(prop_data) == atom_array.array_length(): + atom_array.set_annotation(prop, prop_data) + else: + logger.warning( + f"Property '{prop}' found in atoms.arrays but length mismatch " + f"({len(prop_data)} vs {atom_array.array_length()} atoms). " + f"Skipping — this can happen when hydrogens are removed." + ) + elif prop in atoms.info: + prop_data = atoms.info[prop] + if not hasattr(atom_array, prop): + if hasattr(prop_data, "__len__") and len(prop_data) == atom_array.array_length(): + atom_array.set_annotation(prop, np.array(prop_data)) + else: + logger.warning( + f"Property '{prop}' found in atoms.info but not compatible as per-atom " + f"(length {len(prop_data) if hasattr(prop_data, '__len__') else 'scalar'} vs {atom_array.array_length()} atoms)" + ) + else: + logger.warning( + f"Requested per-atom property '{prop}' not found for example {global_idx}. " + f"Available in atoms.arrays: {list(atoms.arrays.keys())}, " + f"available in atoms.info: {list(atoms.info.keys())}" + ) + + # Parse atom array + data = parse_atom_array( + atom_array, + add_missing_atoms=False, + remove_waters=False, + remove_ccds=None, + hydrogen_policy="keep", + ) + + # Extract the processed AtomArray + atom_array = data["assemblies"]["1"][0] + data["atom_array"] = atom_array + + # Extract global properties + for prop in global_properties: + if prop in atoms.info: + data["extra_info"][prop] = atoms.info[prop] + elif prop in atoms_row: + data["extra_info"][prop] = atoms_row[prop] + else: + logger.warning( + f"Requested global property '{prop}' not found in atoms.info for example {global_idx}. " + f"Available properties: {list(atoms.info.keys())}" + ) + + return data + + +def create_ase_loader( + per_atom_properties: list[str] | None = None, + global_properties: list[str] | None = None, + hydrogen_policy: Literal["keep", "remove"] = "keep", +) -> Callable: + """Factory function that creates a picklable loader for ASE LMDB datasets. + + The loader processes ASE atoms_row objects into AtomWorks-compatible dictionaries + with atom_array and extra_info fields. The ``example_id`` is set by the dataset's + ``__getitem__`` method from the :py:class:`~atomworks.ml.datasets.metadata.MetadataIndex`. + + Args: + per_atom_properties: List of per-atom properties to extract from atoms.arrays + as AtomArray annotations (e.g., forces, charges, spins) + global_properties: List of global properties to extract from atoms.info + and/or atoms_row into extra_info dict (e.g., energy, charge, spin) + hydrogen_policy: Whether to keep or remove explicit hydrogens. When "remove", + explicit hydrogens are removed and their count is stored in the ``nhyd`` + annotation. + + Returns: + A picklable loader function (via functools.partial) for multiprocessing. + """ + return functools.partial( + _ase_loader_function, + per_atom_properties=per_atom_properties or [], + global_properties=global_properties or [], + hydrogen_policy=hydrogen_policy, + ) diff --git a/src/atomworks/ml/datasets/loaders/cif.py b/src/atomworks/ml/datasets/loaders/cif.py index 0173867a..142b5144 100644 --- a/src/atomworks/ml/datasets/loaders/cif.py +++ b/src/atomworks/ml/datasets/loaders/cif.py @@ -1,43 +1,111 @@ """CIF-based dataset loaders.""" import functools -from collections.abc import Callable +import io +import json +import warnings +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Any +from typing import Any, Literal import pandas as pd +from atomworks.io.config import ParseConfig from atomworks.io.parser import STANDARD_PARSER_ARGS, parse +from atomworks.io.utils.io_utils import infer_pdb_file_type +from atomworks.ml.utils.blob_store import BlobIndex, BlobStore from .base import _construct_metadata_hierarchy, _construct_structure_path +ColumnMapping = Mapping[str, str | Sequence[str] | None] -def _load_structure_from_path(path: Path, assembly_id: str, parser_args: dict | None = None) -> dict[str, Any]: - """Load structure from file path using the CIF parser, merging with STANDARD_PARSER_ARGS.""" - # Merge STANDARD_PARSER_ARGS with parser_args (parser_args takes precedence) - merged_args = {**STANDARD_PARSER_ARGS, **(parser_args or {})} - result_dict = parse( - filename=path, - build_assembly=(assembly_id,), - **merged_args, - ) - return result_dict + +def _warn_deprecated(name: str, replacement: str) -> None: + warnings.warn(f"{name} is deprecated; use {replacement}", DeprecationWarning, stacklevel=3) + + +def _normalize_altloc_seed(value: Any) -> int | None: + """Convert a scalar metadata seed to a Python integer, preserving missing values.""" + return None if pd.isna(value) else int(value) + + +def _apply_column_mapping( + result: dict[str, Any], + row: pd.Series | None, + column_mapping: ColumnMapping | None, +) -> dict[str, Any]: + """Promote selected metadata columns into named loader outputs.""" + if not column_mapping: + return result + if row is None: + raise ValueError("column_mapping requires a metadata row") + + collisions = result.keys() & column_mapping.keys() + if collisions: + raise ValueError(f"column_mapping cannot replace loader outputs: {sorted(collisions)}") + + for output_name, source_columns in column_mapping.items(): + if source_columns is None: + result[output_name] = None + continue + columns = [source_columns] if isinstance(source_columns, str) else list(source_columns) + result[output_name] = ( + row[columns[0]] if isinstance(source_columns, str) else [row[column] for column in columns] + ) + for column in columns: + result["extra_info"].pop(column, None) + return result + + +def _resolve_parse_config( + parser_args: ParseConfig | dict | None, + assembly_id: str, + source: Path | io.BytesIO | io.StringIO | Callable[[], io.StringIO], + file_type: str | None = None, + altloc_seed: int | None = None, +) -> ParseConfig: + """Resolve ``parser_args`` to a :py:class:`ParseConfig`, stamping ``build_assembly`` from ``assembly_id`` for CIF/bCIF inputs. + + Accepts a ``ParseConfig`` (preferred) or a legacy dict of kwargs (merged + with :py:data:`STANDARD_PARSER_ARGS` via :py:meth:`ParseConfig.from_dict`). + + When ``altloc_seed`` is provided, altloc selection is switched to + ``"random_clash_aware"`` with the given seed for deterministic sampling. + """ + if not isinstance(parser_args, ParseConfig): + # Legacy dict support: merge with STANDARD_PARSER_ARGS and convert to ParseConfig + parser_args = ParseConfig.from_dict({**STANDARD_PARSER_ARGS, **(parser_args or {})}) + + if file_type is not None: + # Overwrite file_type in parser_args if explicitly provided (e.g. for CIF bytes loader) + parser_args = parser_args.replace(file_type=file_type) + + if (parser_args.file_type or infer_pdb_file_type(source)) in ("cif", "bcif", "mmjson"): + parser_args = parser_args.replace(build_assembly=(assembly_id,)) + + if altloc_seed is not None: + # For CIF inputs, if altloc_seed is provided, switch to random_clash_aware altloc selection with the given seed + parser_args = parser_args.replace(altloc="random_clash_aware", altloc_seed=altloc_seed) + + return parser_args def _base_loader_function( row: pd.Series, - example_id_colname: str, - path_colname: str, - assembly_id_colname: str | None, - attrs: dict, - base_path: str, - extension: str, - sharding_pattern: str | None, - parser_args: dict | None, + example_id_colname: str = "example_id", + path_colname: str = "path", + assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, + attrs: dict | None = None, + base_path: str = "", + extension: str = "", + sharding_pattern: str | None = None, + parser_args: ParseConfig | dict | None = None, + column_mapping: ColumnMapping | None = None, ) -> dict[str, Any]: """Base loader function (picklable when used with functools.partial).""" # Prepare loader-specific attributes - loader_attrs = attrs.copy() + loader_attrs = (attrs or {}).copy() if base_path and "base_path" not in loader_attrs: loader_attrs["base_path"] = base_path if extension and "extension" not in loader_attrs: @@ -46,23 +114,27 @@ def _base_loader_function( extra_info = _construct_metadata_hierarchy(row, loader_attrs) assembly_id = row[assembly_id_colname] if assembly_id_colname is not None and assembly_id_colname in row else "1" + altloc_seed = _normalize_altloc_seed(row[altloc_seed_colname]) if altloc_seed_colname is not None else None + path = _construct_structure_path( row[path_colname], extra_info.get("base_path"), extra_info.get("extension"), sharding_pattern ) - result_dict = _load_structure_from_path(path, assembly_id, parser_args) + result_dict = parse(path, config=_resolve_parse_config(parser_args, assembly_id, path, altloc_seed=altloc_seed)) # Remove used columns from extra_info exclude_cols = ( [example_id_colname, path_colname] + ([assembly_id_colname] if assembly_id_colname else []) + + ([altloc_seed_colname] if altloc_seed_colname else []) + ["base_path", "extension"] ) extra_info = {k: v for k, v in extra_info.items() if k not in exclude_cols} - return { + result = { "example_id": row[example_id_colname], "path": path, "assembly_id": assembly_id, + "altloc_seed": altloc_seed, "extra_info": extra_info, "atom_array": result_dict["assemblies"][assembly_id][0], "atom_array_stack": result_dict["assemblies"][assembly_id], @@ -70,44 +142,29 @@ def _base_loader_function( "ligand_info": result_dict["ligand_info"], "metadata": result_dict["metadata"], } + return _apply_column_mapping(result, row, column_mapping) def create_base_loader( example_id_colname: str = "example_id", path_colname: str = "path", assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, attrs: dict | None = None, base_path: str = "", extension: str = "", sharding_pattern: str | None = None, - parser_args: dict | None = None, + parser_args: ParseConfig | dict | None = None, ) -> Callable[[pd.Series], dict[str, Any]]: - """Factory function that creates a picklable base loader for AtomWorks datasets. - - Args: - example_id_colname: Name of column containing unique example identifiers - path_colname: Name of column containing paths to structure files - assembly_id_colname: Optional column name containing assembly IDs. - If None, assembly_id defaults to "1" for all examples. - attrs: Additional attributes to merge with highest precedence into the metadata hierarchy - (and ultimately included in the output dictionary's "extra_info" key). - base_path: Base path to prepend to file paths if not included in path column - extension: File extension to add/replace if not included in path column - sharding_pattern: Pattern for how files are organized in subdirectories, if not specified in the path - - "/1:2/": Use characters 1-2 for first directory level - - "/1:2/0:2/": Use chars 1-2 for first dir, then chars 0-2 for second dir - - None: No sharding (default) - parser_args: Optional dictionary of arguments to pass to the CIF parser when loading the structure file. - - Returns: - A picklable loader function (via functools.partial) for multiprocessing. - """ - return functools.partial( - _base_loader_function, + """Deprecated; use :func:`create_structure_loader`.""" + _warn_deprecated("create_base_loader", 'create_structure_loader(storage="filesystem")') + return create_structure_loader( + storage="filesystem", example_id_colname=example_id_colname, path_colname=path_colname, assembly_id_colname=assembly_id_colname, - attrs=attrs or {}, + altloc_seed_colname=altloc_seed_colname, + attrs=attrs, base_path=base_path, extension=extension, sharding_pattern=sharding_pattern, @@ -115,127 +172,233 @@ def create_base_loader( ) -def _loader_with_query_pn_units_function( - row: pd.Series, - base_loader: Callable, - pn_unit_iid_colnames: list[str], -) -> dict[str, Any]: - """Loader with query pn_units (picklable when used with functools.partial).""" - result = base_loader(row) - result["extra_info"] = {k: v for k, v in result["extra_info"].items() if k not in pn_unit_iid_colnames} - query_pn_unit_iids = [row[colname] for colname in pn_unit_iid_colnames] - result["query_pn_unit_iids"] = query_pn_unit_iids - return result - - def create_loader_with_query_pn_units( example_id_colname: str = "example_id", path_colname: str = "path", pn_unit_iid_colnames: str | list[str] | None = None, assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, base_path: str = "", extension: str = "", sharding_pattern: str | None = None, attrs: dict | None = None, - parser_args: dict | None = None, + parser_args: ParseConfig | dict | None = None, ) -> Callable[[pd.Series], dict[str, Any]]: - """Factory function that creates a picklable loader for pipelines with query pn_units (chains). - - For instance, in the interfaces dataset, each sampled row contains two pn_unit instance IDs - that should be included in the cropped structure. - - Examples: - Interfaces dataset: - >>> loader = create_loader_with_query_pn_units( - ... pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], assembly_id_colname="assembly_id" - ... ) + """Deprecated; use :func:`create_structure_loader` with ``column_mapping``.""" + _warn_deprecated("create_loader_with_query_pn_units", "create_structure_loader(column_mapping=...)") - Chains dataset: - >>> loader = create_loader_with_query_pn_units( - ... pn_unit_iid_colnames="pn_unit_iid", base_path="/data/structures", extension=".cif.gz" - ... ) - """ - # Normalize pn_unit_iid_colnames to list format if isinstance(pn_unit_iid_colnames, str): pn_unit_iid_colnames = [pn_unit_iid_colnames] pn_unit_iid_colnames = pn_unit_iid_colnames or [] - # Create base loader with common parameters - base_loader = create_base_loader( + return create_structure_loader( + storage="filesystem", example_id_colname=example_id_colname, path_colname=path_colname, assembly_id_colname=assembly_id_colname, + altloc_seed_colname=altloc_seed_colname, attrs=attrs, base_path=base_path, extension=extension, sharding_pattern=sharding_pattern, parser_args=parser_args, + column_mapping={"query_pn_unit_iids": pn_unit_iid_colnames}, ) - return functools.partial( - _loader_with_query_pn_units_function, - base_loader=base_loader, - pn_unit_iid_colnames=pn_unit_iid_colnames, - ) - - -def _loader_with_interfaces_and_pn_units_to_score_function( - row: pd.Series, - base_loader: Callable, - interfaces_to_score_colname: str | None, - pn_units_to_score_colname: str | None, -) -> dict[str, Any]: - """Loader with scoring info (picklable when used with functools.partial).""" - result = base_loader(row) - exclude_cols = [interfaces_to_score_colname, pn_units_to_score_colname] - result["extra_info"] = {k: v for k, v in result["extra_info"].items() if k not in exclude_cols} - - interfaces_to_score = row[interfaces_to_score_colname] if interfaces_to_score_colname is not None else None - pn_units_to_score = row[pn_units_to_score_colname] if pn_units_to_score_colname is not None else None - - result.update( - { - "interfaces_to_score": interfaces_to_score, - "pn_units_to_score": pn_units_to_score, - } - ) - return result - def create_loader_with_interfaces_and_pn_units_to_score( example_id_colname: str = "example_id", path_colname: str = "path", assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, interfaces_to_score_colname: str | None = "interfaces_to_score", pn_units_to_score_colname: str | None = "pn_units_to_score", base_path: str = "", extension: str = "", sharding_pattern: str | None = None, attrs: dict | None = None, - parser_args: dict | None = None, + parser_args: ParseConfig | dict | None = None, ) -> Callable[[pd.Series], dict[str, Any]]: - """Factory function that creates a picklable loader for validation datasets with scoring information. + """Deprecated; use :func:`create_structure_loader` with ``column_mapping``.""" + _warn_deprecated( + "create_loader_with_interfaces_and_pn_units_to_score", "create_structure_loader(column_mapping=...)" + ) - Example: - >>> loader = create_loader_with_interfaces_and_pn_units_to_score( - ... interfaces_to_score_colname="interfaces_to_score", pn_units_to_score_colname="pn_units_to_score" - ... ) - """ - # Create base loader with common parameters - base_loader = create_base_loader( + return create_structure_loader( + storage="filesystem", example_id_colname=example_id_colname, path_colname=path_colname, assembly_id_colname=assembly_id_colname, + altloc_seed_colname=altloc_seed_colname, attrs=attrs, base_path=base_path, extension=extension, sharding_pattern=sharding_pattern, parser_args=parser_args, + column_mapping={ + "interfaces_to_score": interfaces_to_score_colname, + "pn_units_to_score": pn_units_to_score_colname, + }, + ) + + +def _cif_bytes_loader_function( + raw_data: tuple, + parser_args: ParseConfig | dict | None = None, + assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, + column_mapping: ColumnMapping | None = None, + cache_key: str | None = None, +) -> dict[str, Any]: + """Loader for CIF bytes (picklable when used with functools.partial).""" + cif_bytes, global_idx, metadata_row = raw_data + + # Extract assembly_id from metadata row when available + assembly_id = "1" + if metadata_row is not None and assembly_id_colname is not None and assembly_id_colname in metadata_row.index: + assembly_id = str(metadata_row[assembly_id_colname]) + + altloc_seed = None + if metadata_row is not None and altloc_seed_colname is not None and altloc_seed_colname in metadata_row.index: + altloc_seed = _normalize_altloc_seed(metadata_row[altloc_seed_colname]) + + source = cif_bytes if callable(cif_bytes) else io.StringIO(cif_bytes.decode("utf-8")) + result = parse( + source, + config=_resolve_parse_config(parser_args, assembly_id, source, file_type="cif", altloc_seed=altloc_seed), + cache_key=cache_key, + ) + # Build extra_info from metadata row + extra_info: dict[str, Any] = {} + if metadata_row is not None: + extra_info = _construct_metadata_hierarchy(metadata_row, {}) + # Remove columns already represented in the output + exclude_cols = {"example_id"} + if assembly_id_colname: + exclude_cols.add(assembly_id_colname) + extra_info = {k: v for k, v in extra_info.items() if k not in exclude_cols} + + result = { + "assembly_id": assembly_id, + "extra_info": extra_info, + "atom_array": result["assemblies"][assembly_id][0], + "atom_array_stack": result["assemblies"][assembly_id], + "chain_info": result["chain_info"], + "ligand_info": result["ligand_info"], + "metadata": result["metadata"], + } + return _apply_column_mapping(result, metadata_row, column_mapping) + + +def create_cif_bytes_loader( + parser_args: ParseConfig | dict | None = None, + assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, +) -> Callable: + """Deprecated; use :func:`create_structure_loader` with ``storage="bytes"``.""" + _warn_deprecated("create_cif_bytes_loader", 'create_structure_loader(storage="bytes")') + return create_structure_loader( + storage="bytes", + parser_args=parser_args, + assembly_id_colname=assembly_id_colname, + altloc_seed_colname=altloc_seed_colname, ) - return functools.partial( - _loader_with_interfaces_and_pn_units_to_score_function, - base_loader=base_loader, - interfaces_to_score_colname=interfaces_to_score_colname, - pn_units_to_score_colname=pn_units_to_score_colname, + +def _blob_cif_loader_function( + row: pd.Series, + store: BlobStore, + index: BlobIndex, + id_column: str, + parser_args: ParseConfig | dict | None = None, + assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, + example_id_colname: str | None = None, + column_mapping: ColumnMapping | None = None, +) -> dict[str, Any]: + """Fetch a structure record from the blob store and retain the sampled example identity.""" + example_id_colname = example_id_colname or id_column + if id_column in row.index: + record_id = row[id_column] + elif id_column == example_id_colname: + record_id = row.name + else: + raise KeyError(f"Record ID column {id_column!r} is missing from the metadata row") + if example_id_colname in row.index: + example_id = row[example_id_colname] + elif id_column == example_id_colname: + example_id = row.name + else: + raise KeyError(f"Example ID column {example_id_colname!r} is missing from the metadata row") + location = index.lookup(str(record_id)) + out = _cif_bytes_loader_function( + (lambda: io.StringIO(store.get_bytes(*location).decode("utf-8")), None, row), + parser_args, + assembly_id_colname, + altloc_seed_colname, + column_mapping, + cache_key=json.dumps(["blob", store.data_dir, *location]), ) + if id_column != example_id_colname: + out["extra_info"].pop(id_column, None) + out["example_id"] = example_id + return out + + +def create_blob_cif_loader( + blob_dir: str, + *, + endpoint_url: str | None = None, + example_id_colname: str = "example_id", + record_id_colname: str | None = None, + assembly_id_colname: str | None = "assembly_id", + altloc_seed_colname: str | None = None, + parser_args: ParseConfig | dict | None = None, +) -> Callable[[pd.Series], dict[str, Any]]: + """Deprecated; use :func:`create_structure_loader` with ``storage="blob"``.""" + _warn_deprecated("create_blob_cif_loader", 'create_structure_loader(storage="blob")') + return create_structure_loader( + storage="blob", + blob_dir=blob_dir, + endpoint_url=endpoint_url, + example_id_colname=example_id_colname, + record_id_colname=record_id_colname, + parser_args=parser_args, + assembly_id_colname=assembly_id_colname, + altloc_seed_colname=altloc_seed_colname, + ) + + +def create_structure_loader( + storage: Literal["filesystem", "bytes", "blob"] = "filesystem", + *, + column_mapping: ColumnMapping | None = None, + **kwargs: Any, +) -> Callable: + """Create a structure loader with consistent metadata projection across storage backends. + + ``column_mapping`` maps output names to one metadata column or an ordered sequence of columns. + Remaining arguments are forwarded to the selected backend factory. + Blob loaders check the parsed cache before fetching records; shard paths must be immutable + and globally identify their contents. Row metadata is applied after caching. + """ + if storage in {"filesystem", "bytes"}: + loader = _base_loader_function if storage == "filesystem" else _cif_bytes_loader_function + return functools.partial(loader, column_mapping=column_mapping, **kwargs) + + if storage == "blob": + blob_dir = kwargs.pop("blob_dir").rstrip("/") + endpoint_url = kwargs.pop("endpoint_url", None) + example_id_colname = kwargs.pop("example_id_colname", "example_id") + record_id_colname = kwargs.pop("record_id_colname", None) or example_id_colname + return functools.partial( + _blob_cif_loader_function, + store=BlobStore(f"{blob_dir}/data", endpoint_url=endpoint_url), + index=BlobIndex(f"{blob_dir}/index.parquet", id_column=record_id_colname), + id_column=record_id_colname, + example_id_colname=example_id_colname, + column_mapping=column_mapping, + **kwargs, + ) + + raise ValueError(f"Unsupported structure storage: {storage!r}") diff --git a/src/atomworks/ml/datasets/loaders/materials.py b/src/atomworks/ml/datasets/loaders/materials.py new file mode 100644 index 00000000..65e85d20 --- /dev/null +++ b/src/atomworks/ml/datasets/loaders/materials.py @@ -0,0 +1,110 @@ +"""Periodic materials loaders for the existing ASE dataset interface.""" + +import functools +from collections.abc import Callable, Mapping +from typing import Any + +import numpy as np + + +def _space_group_label(value: Any) -> int | None: + """Read a numeric source label without estimating symmetry from coordinates.""" + if value is None: + return None + try: + number = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Space-group labels must be integer numbers from 1 to 230, got {value!r}") from exc + if (isinstance(value, float | np.floating) and value != number) or not 1 <= number <= 230: + raise ValueError(f"Space-group labels must be integer numbers from 1 to 230, got {value!r}") + return number + + +def ase_atoms_to_material_dict( + atoms: Any, + *, + metadata: Mapping[str, Any] | None = None, + wrap_fractional_coordinates: bool = False, +) -> dict[str, Any]: + """Return periodic geometry and explicit source symmetry labels from ASE Atoms. + + Coordinates and lattice lengths are in Angstroms; angles are in degrees. + Fractional coordinates follow ASE's row-vector convention. Wrapping applies + only to periodic axes. A full-rank cell is required, including for slabs. + ``space_group`` and ``parent_space_group`` are separate input metadata labels, + not symmetry estimates. Prototype labels remain in ``extra_info`` verbatim. + """ + from ase import Atoms + + if not isinstance(atoms, Atoms): + raise TypeError(f"Expected ASE Atoms, got {type(atoms).__name__}") + cell = np.asarray(atoms.cell, dtype=float) + if not np.isfinite(cell).all() or np.linalg.matrix_rank(cell) != 3: + raise ValueError("Materials fractional coordinates require a finite, full-rank cell") + source_metadata = dict(metadata or {}) + lengths = np.asarray(atoms.cell.lengths()) + angles = np.asarray(atoms.cell.angles()) + return { + "fractional_coordinates": atoms.get_scaled_positions(wrap=wrap_fractional_coordinates), + "cartesian_coordinates": atoms.get_positions(), + "lattice_vectors": cell.copy(), + "lattice_lengths": lengths, + "lattice_angles": angles, + "cell_parameters": np.concatenate([lengths, angles]), + "cell_volume": float(atoms.cell.volume), + "pbc": np.asarray(atoms.pbc, dtype=bool).copy(), + "atomic_numbers": atoms.get_atomic_numbers(), + "chemical_symbols": np.asarray(atoms.get_chemical_symbols()), + "space_group": _space_group_label(source_metadata.get("space_group")), + "parent_space_group": _space_group_label(source_metadata.get("parent_space_group")), + "extra_info": source_metadata, + } + + +def _load_material( + raw_data: tuple, + *, + wrap_fractional_coordinates: bool, + include_atom_array: bool, + keep_ase_atoms: bool, +) -> dict[str, Any]: + """Convert an ASE dataset row without molecular bond inference or preparation.""" + row, global_index, metadata_row = raw_data + atoms = row.toatoms(add_additional_information=True) + metadata = {**dict(row.key_value_pairs), **dict(row.data)} + result = ase_atoms_to_material_dict( + atoms, metadata=metadata, wrap_fractional_coordinates=wrap_fractional_coordinates + ) + result.update(source_row_id=row.id, global_index=global_index, metadata_row=metadata_row) + for name in ("energy", "forces", "stress"): + value = row.get(name) + if value is not None: + result[name] = value + if include_atom_array: + from atomworks.io.utils.ase_conversions import ase_to_atom_array + + result["atom_array"] = ase_to_atom_array(atoms) + if keep_ase_atoms: + result["atoms"] = atoms + return result + + +def create_ase_materials_loader( + *, + wrap_fractional_coordinates: bool = False, + include_atom_array: bool = False, + keep_ase_atoms: bool = True, +) -> Callable[[tuple], dict[str, Any]]: + """Create a picklable periodic-materials loader for :class:`AseDBDataset`. + + Consumes ``(atoms_row, global_index, metadata_row)``. Dataset metadata indexing + supplies ``example_id``. Optional AtomArrays preserve the cell, PBC and ASE + initial charges; they do not infer bonds or assign formal charges. They are + coordinate containers, not structures prepared for molecular ML transforms. + """ + return functools.partial( + _load_material, + wrap_fractional_coordinates=wrap_fractional_coordinates, + include_atom_array=include_atom_array, + keep_ase_atoms=keep_ase_atoms, + ) diff --git a/src/atomworks/ml/datasets/metadata.py b/src/atomworks/ml/datasets/metadata.py new file mode 100644 index 00000000..e258012f --- /dev/null +++ b/src/atomworks/ml/datasets/metadata.py @@ -0,0 +1,400 @@ +"""Metadata index for filtering and ID-mapping in molecular datasets.""" + +import logging +from os import PathLike +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +import numpy as np +import pandas as pd +import pyarrow as pa +import pyarrow.compute as pc +import pyarrow.feather as feather + +from atomworks.common import as_list +from atomworks.constants import NA_VALUES +from atomworks.ml.utils.io import build_feather_once, job_scoped_feather_path, read_csv, read_parquet_with_metadata + +logger = logging.getLogger("datasets") + + +@runtime_checkable +class MetadataIndexProtocol(Protocol): + """Structural interface shared by all metadata index backends.""" + + def __len__(self) -> int: ... + def __contains__(self, example_id: str) -> bool: ... + def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: ... + def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: ... + def get_row(self, idx: int) -> pd.Series: ... + def get_column_values(self, column: str) -> np.ndarray: ... + def get_example_id(self, idx: int) -> str: ... + + +class MetadataIndex: + """Parquet/DataFrame metadata with filtering and ID lookup. + + Single source of truth for example IDs across all dataset backends. + The ``id_column`` is set as the pandas index for O(1) ``.loc`` lookups. + """ + + def __init__( + self, + *, + data: pd.DataFrame | PathLike, + name: str, + id_column: str = "example_id", + filters: list[str] | None = None, + columns_to_load: list[str] | None = None, + load_kwargs: dict | None = None, + ): + """Initialize MetadataIndex. + + Args: + data: Either a pandas DataFrame or path to a CSV/Parquet file. + name: Descriptive name for logging. + id_column: Column to use as the index for example ID lookups. + filters: Independent row-wise pandas predicates evaluated on the input data. + columns_to_load: Optional list of columns to load from file. + load_kwargs: Additional keyword arguments for pandas read functions. + """ + if isinstance(data, PathLike | str): + data = _load_from_path(data, columns_to_load, **(load_kwargs or {})) + + assert id_column in data.columns, f"Column {id_column} not found. Available: {list(data.columns)}" + + if filters: + data = _apply_filters(data, filters, name) + + self.name = name + data.set_index(id_column, inplace=True, drop=False, verify_integrity=True) + self.data: pd.DataFrame = data + + def __len__(self) -> int: + return len(self.data) + + def __contains__(self, example_id: str) -> bool: + return example_id in self.data.index + + def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: + """Convert example ID(s) to positional index(es).""" + if np.isscalar(example_id): + return self.data.index.get_loc(example_id) + return [self.data.index.get_loc(eid) for eid in example_id] + + def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: + """Convert positional index(es) to example ID(s).""" + _return_single = False + if np.isscalar(idx) or (isinstance(idx, np.ndarray) and idx.shape == ()): + _return_single = True + idx = idx.item() if isinstance(idx, np.ndarray) else idx + idx = slice(idx, idx + 1) + ids = self.data.iloc[idx].index.values + return ids[0] if _return_single else ids + + def get_row(self, idx: int) -> pd.Series: + """Get metadata row by positional index.""" + return self.data.iloc[idx] + + def get_column_values(self, column: str) -> np.ndarray: + """Get values of a column as numpy array.""" + return np.array(self.data[column]) + + def get_example_id(self, idx: int) -> str: + """Get example ID by positional index.""" + return str(self.data.iloc[idx].name) + + +class ArrowMetadataIndex: + """Memory-mapped Arrow metadata with the same interface as :class:`MetadataIndex`. + + Converts the DataFrame to an uncompressed feather file on local storage and + reads it back as a memory-mapped :class:`pyarrow.Table`, keeping Python heap + usage minimal. ID lookups are O(N) — acceptable because they are not on the + DataLoader hot path. + """ + + def __init__( + self, + *, + data: pd.DataFrame | PathLike, + name: str, + id_column: str = "example_id", + filters: list[str] | None = None, + columns_to_load: list[str] | None = None, + load_kwargs: dict | None = None, + local_drive_mount: str = "/tmp", + job_id_env_var: str = "SLURM_JOB_ID", + ): + """Initialize ArrowMetadataIndex. + + Args: + data: Either a pandas DataFrame or path to a CSV/Parquet file. + name: Descriptive name for logging. + id_column: Column to use as the ID column. + filters: Independent row-wise pandas predicates evaluated on the input data. + columns_to_load: Optional list of columns to load from file. + load_kwargs: Additional keyword arguments for pandas read functions. + local_drive_mount: Root directory for feather files. + job_id_env_var: Environment variable used to namespace feather files + per job (e.g. ``"SLURM_JOB_ID"``). + """ + self._id_column = id_column + self.name = name + self.feather_path = _build_feather_index( + data=data, + name=name, + id_column=id_column, + filters=filters, + columns_to_load=columns_to_load, + load_kwargs=load_kwargs, + local_drive_mount=local_drive_mount, + job_id_env_var=job_id_env_var, + ) + self.data: pa.Table = feather.read_table(self.feather_path, memory_map=True) + assert id_column in self.data.column_names, f"Column {id_column} not found. Available: {self.data.column_names}" + + def __getstate__(self) -> dict: + """Pickle-friendly: store path instead of the full table.""" + return { + "feather_path": self.feather_path, + "_id_column": self._id_column, + "name": self.name, + } + + def __setstate__(self, state: dict) -> None: + """Restore from pickle by re-opening the feather file.""" + self.feather_path = state["feather_path"] + self._id_column = state["_id_column"] + self.name = state["name"] + self.data = feather.read_table(self.feather_path, memory_map=True) + + def __len__(self) -> int: + return self.data.num_rows + + def __contains__(self, example_id: str) -> bool: + return pc.index(self.data.column(self._id_column), example_id).as_py() != -1 + + def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: + """Convert example ID(s) to positional index(es). O(N) per call.""" + col = self.data.column(self._id_column) + if np.isscalar(example_id): + idx = pc.index(col, example_id).as_py() + if idx == -1: + raise KeyError(example_id) + return idx + mask = pc.is_in(col, value_set=pa.array(example_id)) + matching_rows = np.where(mask.to_numpy())[0] + matching_ids = col.take(matching_rows).to_pylist() + id_to_row = {str(v): int(r) for v, r in zip(matching_ids, matching_rows, strict=False)} + return [id_to_row[eid] for eid in example_id] + + def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: + """Convert positional index(es) to example ID(s).""" + col = self.data.column(self._id_column) + if np.isscalar(idx) or (isinstance(idx, np.ndarray) and idx.shape == ()): + idx = idx.item() if isinstance(idx, np.ndarray) else idx + return str(col[idx].as_py()) + return np.array([str(col[i].as_py()) for i in idx]) + + def get_row(self, idx: int) -> pd.Series: + """Get metadata row by positional index.""" + return self.data.slice(idx, 1).to_pandas().iloc[0] + + def get_column_values(self, column: str) -> np.ndarray: + """Get values of a column as numpy array.""" + return self.data.column(column).to_numpy(zero_copy_only=False) + + def get_example_id(self, idx: int) -> str: + """Get example ID by positional index.""" + return str(self.data.column(self._id_column)[idx].as_py()) + + +class SequentialMetadataIndex: + """Identity metadata mapping ``"0"`` … ``"n-1"`` to sequential indices. + + Same interface as :class:`MetadataIndex` / :class:`ArrowMetadataIndex` + for datasets that don't need an external metadata file. + """ + + def __init__(self, *, n_entries: int, idx_column: str): + self._n = n_entries + self._idx_column = idx_column + + def __len__(self) -> int: + return self._n + + def __contains__(self, example_id: str) -> bool: + try: + return 0 <= int(example_id) < self._n + except (ValueError, TypeError): + return False + + def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: + """Convert string index(es) to int.""" + if np.isscalar(example_id): + idx = int(example_id) + if not 0 <= idx < self._n: + raise KeyError(example_id) + return idx + return [self.id_to_idx(eid) for eid in example_id] + + def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: + """Convert int index(es) to string.""" + if np.isscalar(idx) or (isinstance(idx, np.ndarray) and idx.shape == ()): + return str(idx.item() if isinstance(idx, np.ndarray) else idx) + return np.array([str(i) for i in idx]) + + def get_column_values(self, column: str) -> np.ndarray: + """Return sequential indices for ``idx_column``.""" + if column != self._idx_column: + raise KeyError(f"Column {column!r} not available without a metadata file") + return np.arange(self._n) + + def get_row(self, idx: int) -> pd.Series: + """Not available — ``SequentialMetadataIndex`` has no row data.""" + raise NotImplementedError( + "SequentialMetadataIndex has no row data. " "Provide a metadata file to use get_row()." + ) + + def get_example_id(self, idx: int) -> str: + return str(idx) + + +def _build_feather_index( + *, + data: pd.DataFrame | PathLike | str, + name: str, + id_column: str, + filters: list[str] | None, + columns_to_load: list[str] | None, + load_kwargs: dict | None, + local_drive_mount: str, + job_id_env_var: str, +) -> str: + """Build once (under a lock) and return the path to a memory-mappable feather index.""" + # Key by name *and* content (filters, columns, source): one name can mean different rows. + source_key = str(data) if isinstance(data, PathLike | str) else None # in-memory frame: name only + feather_path = job_scoped_feather_path( + name, + local_drive_mount=local_drive_mount, + job_id_env_var=job_id_env_var, + content_key=( + source_key, + id_column, + filters, + "cumulative_masks", + columns_to_load, + sorted((load_kwargs or {}).items()), + ), + ) + + def build() -> pa.Table: + # Read the source only here (feather missing) — not once per rank. + df = _load_from_path(data, columns_to_load, **(load_kwargs or {})) if isinstance(data, PathLike | str) else data + assert id_column in df.columns, f"Column {id_column} not found. Available: {list(df.columns)}" + if filters: + df = _apply_filters(df, filters, name) + try: + import torch.distributed as dist + + rank = dist.get_rank() if dist.is_available() and dist.is_initialized() else "N/A" + except ImportError: + rank = "N/A" + logger.info(f"Rank {rank}: Converting {name} to feather at {feather_path}") + return pa.Table.from_pandas(df) + + return build_feather_once(feather_path, build) + + +def _load_from_path( + path: PathLike | str, + columns_to_load: list[str] | None = None, + **load_kwargs: Any, +) -> pd.DataFrame: + """Load DataFrame from a CSV or Parquet file (local path or ``s3://`` URL).""" + suffix = Path(str(path)).suffix + if columns_to_load is not None: + columns_to_load = as_list(columns_to_load) + if suffix == ".csv": + return read_csv(path, usecols=columns_to_load, keep_default_na=False, na_values=NA_VALUES, **load_kwargs) + elif suffix == ".parquet": + return read_parquet_with_metadata(path, columns=columns_to_load, **load_kwargs) + else: + raise ValueError(f"Unsupported file type: {suffix}") + + +def _apply_filters( + data: pd.DataFrame, + filters: list[str], + name: str, +) -> pd.DataFrame: + """Combine independent row-wise masks with cumulative waterfall counts, then select rows once.""" + initial_count = len(data) + filtered_count = initial_count + keep = np.ones(initial_count, dtype=bool) + filter_results: list[tuple[str, int]] = [] + + for query in filters: + original_count = filtered_count + mask = data.eval(query) + if not isinstance(mask, pd.Series) or not pd.api.types.is_bool_dtype(mask.dtype): + raise ValueError(f"Dataset filters require a boolean row-wise predicate, got {query!r}.") + keep &= mask.to_numpy(dtype=bool, na_value=False) + filtered_count = int(keep.sum()) + + if filtered_count == 0: + raise ValueError(f"Query '{query}' on dataset {name} removed all rows.") + + rows_removed = original_count - filtered_count + filter_results.append((query, rows_removed)) + + _log_filter_summary(name, initial_count, filtered_count, filter_results) + return data.loc[keep] + + +def _log_filter_summary( + name: str, + initial_count: int, + final_count: int, + filter_results: list[tuple[str, int]], + max_width: int = 120, +) -> None: + """Log a waterfall summary of all applied filters.""" + for query, removed in filter_results: + if removed == 0: + logger.warning(f"Query '{query}' on dataset {name} did not remove any rows.") + + total_removed = initial_count - final_count + total_pct = (total_removed / initial_count) * 100 if initial_count > 0 else 0.0 + + header = f" {name}: {initial_count:,} \u2192 {final_count:,} rows ({total_pct:.1f}% removed) " + + bar_width = 22 + # inner_width = max_width - 2 (for border chars) + inner_width = max_width - 2 + # Format: " " + # 2 + query + 1 + 10 + 1 + 7 + 1 + bar + 2(padding) = inner_width + max_query_width = inner_width - 2 - 1 - 10 - 1 - 7 - 1 - bar_width - 2 + + filter_lines: list[str] = [] + for query, removed in filter_results: + pct = (removed / initial_count) * 100 if initial_count > 0 else 0.0 + filled = round(pct / 100 * bar_width) + bar = "\u2588" * filled + "\u2591" * (bar_width - filled) + count_str = f"-{removed:,}" + pct_str = f"({pct:.1f}%)" + if len(query) > max_query_width: + query = query[: max_query_width - 3] + "..." + filter_lines.append(f" {query:<{max_query_width}s} {count_str:>10s} {pct_str:>7s} {bar}") + + top = f"\u250c{header:\u2500<{inner_width}}\u2510" + bottom = f"\u2514{'\u2500' * inner_width}\u2518" + empty = f"\u2502{' ' * inner_width}\u2502" + + lines = [top, empty] + for fl in filter_lines: + lines.append(f"\u2502{fl:<{inner_width}}\u2502") + lines += [empty, bottom] + + logger.info("\n" + "\n".join(lines)) diff --git a/src/atomworks/ml/datasets/pandas_dataset.py b/src/atomworks/ml/datasets/pandas_dataset.py index 7f44515c..2c1d7585 100644 --- a/src/atomworks/ml/datasets/pandas_dataset.py +++ b/src/atomworks/ml/datasets/pandas_dataset.py @@ -1,7 +1,6 @@ """Pandas DataFrame-based dataset implementation.""" import logging -import warnings from collections.abc import Callable from os import PathLike from pathlib import Path @@ -9,21 +8,19 @@ import numpy as np import pandas as pd +import pyarrow as pa -from atomworks.common import as_list -from atomworks.constants import NA_VALUES -from atomworks.ml.utils.io import read_parquet_with_metadata - -from .base import ExampleIDMixin, MolecularDataset +from .base import MolecularDataset +from .metadata import ArrowMetadataIndex, MetadataIndex, MetadataIndexProtocol logger = logging.getLogger("datasets") -class PandasDataset(MolecularDataset, ExampleIDMixin): +class PandasDataset(MolecularDataset): """Dataset for tabular data stored as pandas DataFrames. - Inherits all functionality from :class:`MolecularDataset` with additional - DataFrame-specific features for filtering and ID-based access. + Delegates all metadata, filtering, and ID-mapping logic to + :py:class:`~atomworks.ml.datasets.metadata.MetadataIndex`. """ def __init__( @@ -31,7 +28,7 @@ def __init__( *, data: pd.DataFrame | PathLike, name: str, - id_column: str | None = "example_id", + id_column: str = "example_id", filters: list[str] | None = None, columns_to_load: list[str] | None = None, # MolecularDataset parameters @@ -39,6 +36,9 @@ def __init__( loader: Callable | None = None, save_failed_examples_to_dir: str | Path | None = None, load_kwargs: dict | tuple | None = None, + # Metadata options + memory_map: bool = False, + metadata: MetadataIndexProtocol | None = None, ): """Initialize PandasDataset. @@ -47,10 +47,10 @@ def __init__( the tabular data. Each row represents one example. name: Descriptive name for this dataset. Used for debugging and some downstream functions when using nested datasets. - id_column: Optional column name to use as the DataFrame index for - example ID lookups. If provided, this column will be set as the index. - filters: Optional list of pandas query strings to filter the data. - Applied in order during initialization. + id_column: Column name to use as the DataFrame index for + example ID lookups. + filters: Independent row-wise pandas predicates evaluated on the input data. + Masks are combined in order for waterfall logging; rows are selected once. columns_to_load: Optional list of column names to load when reading from a file. If None, all columns are loaded. Can dramatically reduce memory usage and load time if loading from a columnar format like Parquet. @@ -60,14 +60,19 @@ def __init__( will be saved for debugging. Includes RNG state and error information. load_kwargs: Additional keyword arguments passed to pandas' read functions (read_csv, read_parquet) when loading from file. + memory_map: If ``True``, use :class:`ArrowMetadataIndex` for memory-mapped + metadata storage instead of in-memory pandas. Reduces heap usage for + large datasets. + metadata: Optional pre-built metadata index. If provided, used directly + instead of constructing a new index. Takes precedence over ``memory_map``. Examples: Load from DataFrame: >>> df = pd.DataFrame({"path": [...], "label": [...]}) >>> dataset = PandasDataset(data=df, name="my_dataset") - Load from file with filtering: - >>> dataset = PandasDataset(data="data.csv", name="filtered_dataset", filters=["label > 0", "path.str.contains('.pdb')"]) + Load from file with memory-mapped metadata: + >>> dataset = PandasDataset(data="data.parquet", name="big_dataset", memory_map=True) """ super().__init__( name=name, @@ -76,50 +81,39 @@ def __init__( save_failed_examples_to_dir=save_failed_examples_to_dir, ) - # Load data from path if needed - if isinstance(data, PathLike | str): - data = self._load_from_path(data, columns_to_load, **(load_kwargs or {})) - self.data = data - - # Apply filters - self.filters = filters - self._already_filtered = False - if filters: - self._apply_filters(filters) - self._already_filtered = True - - # Set index column if specified - if id_column is not None: - assert id_column in self.data.columns, f"Column {id_column} not found in dataset." - self.data.set_index(id_column, inplace=True, drop=False, verify_integrity=True) + if metadata is not None: + self._metadata = metadata + else: + # Normalize load_kwargs to dict + _load_kwargs = dict(load_kwargs) if isinstance(load_kwargs, tuple) else load_kwargs + + _index_cls = ArrowMetadataIndex if memory_map else MetadataIndex + self._metadata = _index_cls( + data=data, + name=name, + id_column=id_column, + filters=filters, + columns_to_load=columns_to_load, + load_kwargs=_load_kwargs, + ) - def _load_from_path( - self, path: PathLike | str, columns_to_load: list[str] | None = None, **load_kwargs: Any - ) -> pd.DataFrame: - """Load data from file path. + @property + def metadata(self) -> MetadataIndexProtocol: + """The metadata index.""" + return self._metadata - Args: - path: Path to the file to load. - columns_to_load: Optional list of column names to load. - **load_kwargs: Additional arguments for pandas read functions. + @property + def data(self) -> pd.DataFrame | pa.Table: + """The underlying metadata table. - Returns: - Loaded DataFrame. + Returns a :class:`pd.DataFrame` for :class:`MetadataIndex` or a + :class:`pa.Table` for :class:`ArrowMetadataIndex`. Raises: - ValueError: If file type is unsupported. + AttributeError: If the metadata backend has no ``data`` attribute + (e.g. :class:`SequentialMetadataIndex`). """ - path = Path(path) - # Convert OmegaConf ListConfig to plain list if needed - if columns_to_load is not None: - columns_to_load = as_list(columns_to_load) - if path.suffix == ".csv": - data = pd.read_csv(path, usecols=columns_to_load, keep_default_na=False, na_values=NA_VALUES, **load_kwargs) - elif path.suffix == ".parquet": - data = read_parquet_with_metadata(path, columns=columns_to_load, **load_kwargs) - else: - raise ValueError(f"Unsupported file type: {path.suffix}") - return data + return self._metadata.data def __getitem__(self, idx: int) -> Any: """Get an example by index, applying specified loader and Transforms. @@ -130,178 +124,23 @@ def __getitem__(self, idx: int) -> Any: Returns: Transformed data from the row. """ - raw_data = self.data.iloc[idx] - example_id = self._get_example_id(idx) + raw_data = self.metadata.get_row(idx) + example_id = self.metadata.get_example_id(idx) data = self._apply_loader(raw_data) return self._apply_transform(data, example_id=example_id, idx=idx) def __len__(self) -> int: """Return the number of rows in the dataset.""" - return len(self.data) + return len(self.metadata) def __contains__(self, example_id: str) -> bool: """Check if the dataset contains the example ID.""" - return example_id in self.data.index - - def _id_to_index_single(self, example_id: str) -> int: - """Convert single example ID to index.""" - return self.data.index.get_loc(example_id) - - def _id_to_index_multiple(self, example_ids: list[str]) -> list[int]: - """Convert multiple example IDs to indices.""" - idxs = np.arange(len(self.data)) - return [idxs[self.data.index.get_loc(example_id)] for example_id in example_ids] + return example_id in self.metadata def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: """Convert an example ID to the corresponding local index.""" - if np.isscalar(example_id): - return self._id_to_index_single(example_id) - elif isinstance(example_id, list | np.ndarray | tuple): - return self._id_to_index_multiple(example_id) - else: - raise ValueError(f"Invalid type for example_id: {type(example_id)}") + return self.metadata.id_to_idx(example_id) def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: """Convert a local index to the corresponding example ID.""" - _return_single = False - if np.isscalar(idx) or (isinstance(idx, np.ndarray) and idx.shape == ()): - _return_single = True - idx = idx.item() if isinstance(idx, np.ndarray) else idx - idx = slice(idx, idx + 1) - ids = self.data.iloc[idx].index.values - return ids[0] if _return_single else ids - - def _apply_filters(self, filters: list[str]) -> pd.DataFrame: - """Apply filters to the data based on the provided list of query strings. - - For documentation on pandas query syntax, see: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.query.html - - Args: - filters: List of query strings to apply to the data. - - Raises: - ValueError: If the data is not initialized or if a query removes all rows. - Warning: If a query does not remove any rows. - - Example: - >>> queries = [ - >>> "deposition_date < '2020-01-01'", - >>> "resolution < 2.5 and ~method.str.contains('NMR')", - >>> "cluster.notnull()", - >>> "method in ['X-RAY_DIFFRACTION', 'ELECTRON_MICROSCOPY']" - >>> ] - >>> dataset = PandasDataset(data="data.csv", name="filtered_dataset", filters=queries) - """ - assert not self._already_filtered, "Filters cannot be applied after initialization." - - # Apply queries one by one, confirming the impact of each - for query in filters: - self._apply_query(query) - - def _apply_query(self, query: str) -> None: - """Apply a single query to the data. - - Args: - query: The pandas query string to apply. - """ - # Filter using query and validate impact - original_num_rows = len(self.data) - self.data = self.data.query(query) - filtered_num_rows = len(self.data) - self._validate_filter_impact(query, original_num_rows, filtered_num_rows) - - def _validate_filter_impact(self, query: str, original_num_rows: int, filtered_num_rows: int) -> None: - """Validate the impact of the filter. - - Args: - query: The query that was applied. - original_num_rows: Number of rows before filtering. - filtered_num_rows: Number of rows after filtering. - - Raises: - ValueError: If the query removes all rows. - """ - rows_removed = original_num_rows - filtered_num_rows - percent_removed = (rows_removed / original_num_rows) * 100 - percent_remaining = (filtered_num_rows / original_num_rows) * 100 - - if filtered_num_rows == original_num_rows: - logger.warning(f"Query '{query}' on dataset {self.name} did not remove any rows.") - elif filtered_num_rows == 0: - raise ValueError(f"Query '{query}' on dataset {self.name} removed all rows.") - else: - logger.info( - f"\n+-------------------------------------------+\n" - f"Query '{query}' on dataset {self.name}:\n" - f" - Started with: {original_num_rows:,} rows\n" - f" - Removed: {rows_removed:,} rows ({percent_removed:.2f}%)\n" - f" - Remaining: {filtered_num_rows:,} rows ({percent_remaining:.2f}%)\n" - f"+-------------------------------------------+\n" - ) - - def _get_example_id(self, idx: int) -> str: - """Get example ID from index - returns the index value from the DataFrame. - - Args: - idx: The index of the row. - - Returns: - The index value as a string. - """ - return str(self.data.iloc[idx].name) # .name gets the index value - - -# Backwards Compatibility -# TODO: Deprecate -def StructuralDatasetWrapper( # noqa: N802 - dataset_parser: Callable, - transform: Callable | None = None, - dataset: PandasDataset | None = None, - cif_parser_args: dict | None = None, - save_failed_examples_to_dir: str | Path | None = None, - **kwargs, -) -> PandasDataset: - """Backwards-compatible wrapper for the deprecated StructuralDatasetWrapper. - - This function is deprecated and will be removed in a future version. - Use :class:`PandasDataset` with the appropriate loader function instead. - - Args: - dataset_parser: The dataset parser to use (e.g., PNUnitsDFParser, InterfacesDFParser). - transform: Transform pipeline to apply to loaded data. - dataset: The underlying PandasDataset containing the tabular data. - cif_parser_args: Arguments to pass to the CIF parser. - save_failed_examples_to_dir: Directory to save failed examples for debugging. - **kwargs: Additional arguments passed to PandasDataset. - - Returns: - PandasDataset instance configured with the deprecated parameters. - - Raises: - ValueError: If dataset parameter is required but not provided. - """ - from atomworks.ml.datasets.parsers import load_example_from_metadata_row - - warnings.warn( - "StructuralDatasetWrapper is deprecated. Use PandasDataset with a loader function instead. " - "See atomworks.ml.datasets.loaders for functional alternatives to dataset parsers.", - DeprecationWarning, - stacklevel=2, - ) - - if dataset is None: - raise ValueError("dataset parameter is required for StructuralDatasetWrapper") - - # Create loader from deprecated parameters - def loader(row: pd.Series) -> dict[str, Any]: - return load_example_from_metadata_row(row, dataset_parser, cif_parser_args=cif_parser_args or {}) - - # Create a new PandasDataset with the loader - return PandasDataset( - data=dataset.data, - name=dataset.name if hasattr(dataset, "name") else "structural_dataset", - transform=transform, - loader=loader, - save_failed_examples_to_dir=save_failed_examples_to_dir, - **kwargs, - ) + return self.metadata.idx_to_id(idx) diff --git a/src/atomworks/ml/datasets/parsers/base.py b/src/atomworks/ml/datasets/parsers/base.py index 74e7bf43..cca983b7 100644 --- a/src/atomworks/ml/datasets/parsers/base.py +++ b/src/atomworks/ml/datasets/parsers/base.py @@ -5,7 +5,9 @@ import pandas as pd from atomworks.io import parse +from atomworks.io.config import ParseConfig from atomworks.io.parser import STANDARD_PARSER_ARGS +from atomworks.io.utils.io_utils import infer_pdb_file_type class MetadataRowParser(ABC): @@ -108,11 +110,12 @@ def load_example_from_metadata_row( merged_cif_parser_args = {**STANDARD_PARSER_ARGS, **cif_parser_args} # Use the parse function with the merged CIF parser arguments - result_dict = parse( - filename=parsed_row["path"], - build_assembly=(parsed_row["assembly_id"],), # Convert list to tuple (make hashable) - **merged_cif_parser_args, - ) + inferred_file_type = merged_cif_parser_args.get("file_type") or infer_pdb_file_type(parsed_row["path"]) + if inferred_file_type in ("cif", "bcif"): + config = ParseConfig(**merged_cif_parser_args, build_assembly=(parsed_row["assembly_id"],)) + else: + config = ParseConfig(**merged_cif_parser_args) + result_dict = parse(parsed_row["path"], config=config) # Combine the PDB output and the parsed output into our clean representation data = { diff --git a/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py b/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py index 20fa2727..0b2095b6 100644 --- a/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py +++ b/src/atomworks/ml/datasets/parsers/default_metadata_row_parsers.py @@ -1,6 +1,7 @@ """MetadataRowParser implementations for chain- and interface-based datasets.""" import re +import warnings from collections.abc import Sequence from pathlib import Path from typing import Any @@ -84,10 +85,11 @@ def find_existing_file_path( class PNUnitsDFParser(MetadataRowParser): - # TODO: Deprecate in favor of GenericDFParser - """Parser for pn_units DataFrame rows. + .. deprecated:: 2.1.1 + Use :class:`GenericDFParser`. + In addition to standard fields (example_id, path), this parser also includes: - The query pn_unit instance ID, which is used to center the crop. - The assembly ID, which is used to load the correct assembly from the CIF file. @@ -108,6 +110,7 @@ def __init__( path_template: Template string for path construction (perfect for Hydra configs). Can be a single template or a list/tuple of templates corresponding to the base_dir(s) and file_extension(s). Example: "{base_dir}/{pdb_id[1:3]}/{pdb_id}{file_extension}" (default) """ + warnings.warn("PNUnitsDFParser is deprecated; use GenericDFParser", DeprecationWarning, stacklevel=2) self.base_dirs = [Path(bd) for bd in as_list(base_dir)] self.file_extensions = as_list(file_extension) self.path_templates = as_list(path_template) @@ -136,10 +139,11 @@ def _parse(self, row: pd.Series) -> dict[str, Any]: class InterfacesDFParser(MetadataRowParser): - # TODO: Deprecate in favor of GenericDFParser - """Parser for interfaces DataFrame rows. + .. deprecated:: 2.1.1 + Use :class:`GenericDFParser`. + In addition to standard fields (example_id, path), this parser also includes: - The two query pn_unit instance IDs, as a list, which are used to sample the interface during cropping. - The assembly ID, which is used to load the correct assembly from the CIF file. @@ -160,6 +164,7 @@ def __init__( path_template: Template string for path construction (perfect for Hydra configs). Can be a single template or a list/tuple of templates corresponding to the base_dir(s) and file_extension(s). Example: "{base_dir}/{pdb_id[1:3]}/{pdb_id}{file_extension}" (default) """ + warnings.warn("InterfacesDFParser is deprecated; use GenericDFParser", DeprecationWarning, stacklevel=2) self.base_dirs = [Path(bd) for bd in as_list(base_dir)] self.file_extensions = as_list(file_extension) self.path_templates = as_list(path_template) @@ -226,6 +231,7 @@ class GenericDFParser(MetadataRowParser): - Any additional key-value pairs specified by the ``attrs`` parameter - All unused dataframe columns (i.e., those not used for example_id, path, query_pn_unit_iids, or assembly_id) - Dataset-level attributes (if present), found in the ``attrs`` attribute of the Dataframe (or Series) + For example, the "extra_info" key could contain information about which chain(s) to score during validation, metadata for specific metrics, etc. Note: diff --git a/src/atomworks/ml/datasets/sharded_dataset.py b/src/atomworks/ml/datasets/sharded_dataset.py new file mode 100644 index 00000000..61ff6cef --- /dev/null +++ b/src/atomworks/ml/datasets/sharded_dataset.py @@ -0,0 +1,241 @@ +"""Base class for sharded molecular datasets (LMDB, ASE, etc.).""" + +import logging +from abc import abstractmethod +from collections.abc import Callable +from glob import glob +from os import PathLike +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pyarrow as pa + +from atomworks.ml.datasets.base import MolecularDataset +from atomworks.ml.datasets.metadata import ( + ArrowMetadataIndex, + MetadataIndex, + MetadataIndexProtocol, + SequentialMetadataIndex, +) + +logger = logging.getLogger(__name__) + + +class ShardedDataset(MolecularDataset): + """Base class for sharded molecular datasets. + + Owns all shared logic: metadata setup, shard discovery, offset calculation, + lazy shard loading, ``__getitem__`` skeleton, and context manager protocol. + + Subclasses implement four abstract methods to handle format-specific I/O: + :meth:`_get_shard_count`, :meth:`_open_shard`, :meth:`_close_shard`, + and :meth:`_fetch_entry`. + """ + + def __init__( + self, + *, + shard_path: str, + shard_extension: str, + name: str, + loader: Callable | None = None, + data: str | PathLike | None = None, + id_column: str = "example_id", + idx_column: str = "lmdb_idx", + filters: list[str] | None = None, + columns_to_load: list[str] | None = None, + transform: Callable | None = None, + save_failed_examples_to_dir: str | Path | None = None, + memory_map: bool = False, + metadata: MetadataIndexProtocol | None = None, + ): + """Initialize ShardedDataset. + + Args: + shard_path: Path to the shard directory or single shard file. + shard_extension: File extension used to discover shards in a directory. + name: Descriptive name for this dataset. + loader: Optional callable to process ``(entry, global_idx, metadata_row)`` + into a data dict. + data: Optional path to a parquet/CSV file with metadata for filtering. + id_column: Column name for human-readable example IDs. + idx_column: Column name containing integer shard indices. + filters: Optional list of pandas query strings to filter metadata. + columns_to_load: Optional list of columns to load from metadata file. + transform: Transform pipeline to apply to loaded data. + save_failed_examples_to_dir: Optional directory to save failed examples. + memory_map: If ``True``, use :class:`ArrowMetadataIndex` for metadata. + metadata: Optional pre-built metadata index. + """ + super().__init__( + name=name, + loader=loader, + transform=transform, + save_failed_examples_to_dir=save_failed_examples_to_dir, + ) + + self.shard_path = Path(shard_path) + self._shard_extension = shard_extension + + # Discover shard files + self._filepaths = self._discover_shards(self.shard_path) + self._num_shards = len(self._filepaths) + logger.info(f"Found {self._num_shards} shard(s) at {self.shard_path}") + + # Auto-detect shard sizes and build cumulative offset array + shard_counts = [self._get_shard_count(p) for p in self._filepaths] + self._shard_offsets = np.zeros(self._num_shards + 1, dtype=np.int64) + self._shard_offsets[1:] = np.cumsum(shard_counts) + self._total_entries = int(self._shard_offsets[-1]) + logger.info(f"Total entries across {self._num_shards} shard(s): {self._total_entries:,}") + + # Lazy shard state — only one shard open at a time + self._current_shard_idx: int | None = None + + # Metadata index + if metadata is not None: + self._metadata = metadata + elif data is not None: + _index_cls = ArrowMetadataIndex if memory_map else MetadataIndex + self._metadata = _index_cls( + data=data, + name=name, + id_column=id_column, + filters=filters, + columns_to_load=columns_to_load, + ) + else: + self._metadata = SequentialMetadataIndex( + n_entries=self._total_entries, + idx_column=idx_column, + ) + + self._shard_indices = self._metadata.get_column_values(idx_column).astype(np.int64) + logger.info(f"Dataset contains {len(self._metadata):,} examples") + + # ------------------------------------------------------------------ + # Abstract methods — subclasses implement these + # ------------------------------------------------------------------ + + def _discover_shards(self, path: Path) -> list[str]: + """Discover shard files matching ``self._shard_extension``.""" + if path.is_file(): + return [str(path)] + if path.is_dir(): + filepaths = sorted(glob(str(path / f"*{self._shard_extension}"))) + if not filepaths: + raise ValueError(f"No {self._shard_extension} files found in directory: {path}") + return filepaths + raise ValueError(f"Path does not exist: {path}") + + @abstractmethod + def _get_shard_count(self, path: str) -> int: + """Return the number of entries in a single shard file.""" + ... + + @abstractmethod + def _open_shard(self, shard_idx: int) -> None: + """Open the shard at ``shard_idx``, storing handles on ``self``.""" + ... + + @abstractmethod + def _close_shard(self) -> None: + """Close the currently open shard and reset handles on ``self``.""" + ... + + @abstractmethod + def _fetch_entry(self, local_idx: int) -> Any: + """Fetch the raw entry at ``local_idx`` within the currently open shard.""" + ... + + # ------------------------------------------------------------------ + # Concrete shared methods + # ------------------------------------------------------------------ + + @property + def metadata(self) -> MetadataIndexProtocol: + """The metadata index.""" + return self._metadata + + @property + def data(self) -> pd.DataFrame | pa.Table: + """The underlying metadata table.""" + return self._metadata.data + + def _ensure_shard_loaded(self, shard_idx: int) -> None: + """Load a shard if not already loaded, closing previous shard.""" + if self._current_shard_idx == shard_idx: + return + self._close_shard() + self._open_shard(shard_idx) + self._current_shard_idx = shard_idx + + def __len__(self) -> int: + """Return the number of examples in the dataset.""" + return len(self._metadata) + + def __getitem__(self, idx: int) -> Any: + """Load and transform an example by index.""" + dataset_len = len(self) + if idx < 0: + idx = dataset_len + idx + if idx < 0 or idx >= dataset_len: + raise IndexError(f"Index {idx} out of range for dataset with {dataset_len} examples") + + global_idx = int(self._shard_indices[idx]) + example_id = self._metadata.get_example_id(idx) + + # Find which shard this index belongs to + shard_idx = int(np.searchsorted(self._shard_offsets, global_idx, side="right") - 1) + local_idx = global_idx - int(self._shard_offsets[shard_idx]) + + if shard_idx >= self._num_shards or shard_idx < 0: + raise IndexError(f"Global index {global_idx} exceeds dataset bounds ({self._total_entries})") + + self._ensure_shard_loaded(shard_idx) + entry = self._fetch_entry(local_idx) + + # Fetch metadata row (shared logic) + try: + metadata_row = self._metadata.get_row(idx) + except NotImplementedError: + metadata_row = None + + data = self._apply_loader((entry, global_idx, metadata_row)) + + if isinstance(data, dict): + data["example_id"] = example_id + + return self._apply_transform(data, example_id=example_id, idx=idx) + + def __contains__(self, example_id: str) -> bool: + """Check if the dataset contains the example ID.""" + return example_id in self._metadata + + def id_to_idx(self, example_id: str | list[str]) -> int | list[int]: + """Convert example ID(s) to dataset index(es).""" + return self._metadata.id_to_idx(example_id) + + def idx_to_id(self, idx: int | list[int]) -> str | np.ndarray: + """Convert dataset index(es) to example ID(s).""" + return self._metadata.idx_to_id(idx) + + def close(self) -> None: + """Explicitly close database connection.""" + self._close_shard() + self._current_shard_idx = None + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> bool: + """Context manager exit with automatic cleanup.""" + self.close() + return False + + def __del__(self): + """Close database connections on deletion.""" + self.close() diff --git a/src/atomworks/ml/encoding_definitions.py b/src/atomworks/ml/encoding_definitions.py index 04a08deb..1003b148 100644 --- a/src/atomworks/ml/encoding_definitions.py +++ b/src/atomworks/ml/encoding_definitions.py @@ -3,7 +3,7 @@ import copy from collections.abc import Sequence from dataclasses import dataclass -from functools import cached_property, lru_cache +from functools import cached_property from itertools import cycle from logging import getLogger @@ -23,6 +23,7 @@ STANDARD_RNA, UNKNOWN_AA, UNKNOWN_DNA, + UNKNOWN_ELEMENT, UNKNOWN_RNA, ) from atomworks.io.utils.ccd import get_chem_comp_type @@ -108,55 +109,6 @@ def __post_init__(self): assert unknown_token in self.token_atoms, f"Unknown token {unknown_token} not defined in `token_atoms`." assert chemcomp_type in CHEM_COMP_TYPES, f"Unknown chemcomp type {chemcomp_type}." - # Set function to resolve unknown tokens. - # NOTE: This is set here to use caching. - @lru_cache(maxsize=10000) - def resolve_unknown_token_name(token_name: str | int, token_is_atom: bool) -> str: - assert isinstance( - token_name, str | int | np.integer - ), f"Expected `token_name` to be a string or int, but got {type(token_name)}: token_name={token_name}, token_is_atom={token_is_atom}." - - # Case 1: Token is known & valid - if token_name in self.token_atoms: - # ... escape - return token_name - - # Case 2: Token is unknown atom - if token_is_atom: - # ... for unknown atoms - if UNKNOWN_ELEMENT_TOKEN not in self.token_atoms: - # ... ensure that the `UNKNOWN_ELEMENT_TOKEN` is in the encoding - raise KeyError( - f"Encountered unknown atom token `{token_name}` which is not in the encoding, " - f"but the `UNKNOWN_ELEMENT_TOKEN` (`{UNKNOWN_ELEMENT_TOKEN}`) is also not in the encoding." - ) - return UNKNOWN_ELEMENT_TOKEN - - # Case 3: Token is unknown residue - if exists(self.chemcomp_type_to_unknown): - # ... try to resolve which unknown residue token to use based on the chemical component type - chem_type = get_chem_comp_type(token_name) - if chem_type not in self.chemcomp_type_to_unknown: - raise KeyError( - f"Could not resolve unknown residue token name: `{token_name}`, " - f"chemcomp_type: `{chem_type}` not in `encoding.chemcomp_type_to_unknown`." - "You will either have to:\n" - "(1) filter out this token before encoding,\n" - "(2) use an encoding that contains a `chemcomp_type_to_unknown` mapping " - "for this chemcomp type,\n" - "(3) use an encoding that contains this token, or\n" - "(4) atomize this token (provided your specified encoding contains atom-level " - "tokens)." - ) - return self.chemcomp_type_to_unknown[chem_type] - else: - raise KeyError( - f"Encountered unknown residue token name: `{token_name}` which is not in the encoding, " - f"and no `chemcomp_type_to_unknown` mapping is defined." - ) - - self._resolve_unknown_token_name = resolve_unknown_token_name - @cached_property def tokens(self) -> np.ndarray: dtypes = {type(token) for token in self.token_atoms} @@ -206,12 +158,13 @@ def idx_to_element(self) -> np.ndarray: elements = np.full((self.n_tokens, self.n_atoms_per_token), "", dtype=" dict[tuple[str | int, str], int]: return token_and_atom_to_idx def resolve_unknown_token_name(self, token_name: str, token_is_atom: bool) -> str: - return self._resolve_unknown_token_name(token_name, token_is_atom) + assert isinstance( + token_name, str | int | np.integer + ), f"Expected `token_name` to be a string or int, but got {type(token_name)}: token_name={token_name}, token_is_atom={token_is_atom}." + + # Case 1: Token is known & valid + if token_name in self.token_atoms: + # ... escape + return token_name + + # Case 2: Token is unknown atom + if token_is_atom: + # ... for unknown atoms + if UNKNOWN_ELEMENT_TOKEN not in self.token_atoms: + # ... ensure that the `UNKNOWN_ELEMENT_TOKEN` is in the encoding + raise KeyError( + f"Encountered unknown atom token `{token_name}` which is not in the encoding, " + f"but the `UNKNOWN_ELEMENT_TOKEN` (`{UNKNOWN_ELEMENT_TOKEN}`) is also not in the encoding." + ) + return UNKNOWN_ELEMENT_TOKEN + + # Case 3: Token is unknown residue + if exists(self.chemcomp_type_to_unknown): + # ... try to resolve which unknown residue token to use based on the chemical component type + chem_type = get_chem_comp_type(token_name) + if chem_type not in self.chemcomp_type_to_unknown: + raise KeyError( + f"Could not resolve unknown residue token name: `{token_name}`, " + f"chemcomp_type: `{chem_type}` not in `encoding.chemcomp_type_to_unknown`." + "You will either have to:\n" + "(1) filter out this token before encoding,\n" + "(2) use an encoding that contains a `chemcomp_type_to_unknown` mapping " + "for this chemcomp type,\n" + "(3) use an encoding that contains this token, or\n" + "(4) atomize this token (provided your specified encoding contains atom-level " + "tokens)." + ) + return self.chemcomp_type_to_unknown[chem_type] + else: + raise KeyError( + f"Encountered unknown residue token name: `{token_name}` which is not in the encoding, " + f"and no `chemcomp_type_to_unknown` mapping is defined." + ) def to_str(self) -> str: """Convenience function for printing the encoding.""" @@ -296,7 +290,7 @@ def __repr__(self): """AF2's atom14 encoding. Reference: - `AlphaFold residue_constants.py `_ + `AlphaFold residue_constants.py `__ """ AF2_ATOM37_ENCODING = TokenEncoding( @@ -334,7 +328,7 @@ def __repr__(self): """AF2's atom37 encoding with atomization support. Reference: - `AlphaFold residue_constants.py `_ + `AlphaFold residue_constants.py `__ """ # fmt: off @@ -411,25 +405,28 @@ def __repr__(self): # RNA nucleotides (classes 22-25): A, C, G, U # 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 - 'A': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', 'N6', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''], - 'C': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', 'N4', '', '', '', '', ''], - 'G': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', '', 'N2', 'O6', '', '', '', '', '', '', '', '', '', '', '', '', ''], - 'U': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', '', 'O4', '', '', '', ''], + 'A': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', 'N6', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'OP3'], + 'C': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', 'N4', '', '', '', '', 'OP3'], + 'G': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', '', 'N2', 'O6', '', '', '', '', '', '', '', '', '', '', '', '', 'OP3'], + 'U': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', '', 'O4', '', '', '', 'OP3'], # Unknown RNA (class 26) 'N': ['P', "C1'", "C2'", "O2'", "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''], # DNA nucleotides (classes 27-30): DA, DC, DG, DT - 'DA': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', 'N6', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''], - 'DC': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', 'N4', '', '', '', '', ''], - 'DG': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', '', 'N2', 'O6', '', '', '', '', '', '', '', '', '', '', '', '', ''], - 'DT': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', '', 'O4', 'C7', '', '', ''], + 'DA': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', 'N6', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'OP3'], + 'DC': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', 'N4', '', '', '', '', 'OP3'], + 'DG': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', 'N9', 'C8', 'N7', 'C5', 'C4', 'N3', 'C2', 'N1', 'C6', '', 'N2', 'O6', '', '', '', '', '', '', '', '', '', '', '', '', 'OP3'], + 'DT': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', 'N1', 'C2', 'O2', 'N3', 'C4', 'C5', 'C6', '', 'O4', 'C7', '', '', 'OP3'], # Unknown DNA (class 31) 'DN': ['P', "C1'", "C2'", '', "C3'", "O3'", "C4'", "O4'", "C5'", "O5'", 'OP1', 'OP2', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''], # Atomised token (class 32) - placeholder for atomised small molecules, always put atom in the second position '': [' ', 'X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], + + # Gap token (class 33) - represents alignment gaps in MSAs + '': [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], }, chemcomp_type_to_unknown=( {chem_type: "UNK" for chem_type in AA_LIKE_CHEM_TYPES} @@ -440,15 +437,17 @@ def __repr__(self): """Unified atom37 encoding for all token types in ConditionalResidueTypeSeqFeat. Provides a comprehensive 37-slot encoding that encompasses: + - Class 0: MASK token (special masking token) - Classes 1-20: Standard amino acids (ALA, ARG, ASN, ASP, CYS, GLN, GLU, GLY, HIS, ILE, - LEU, LYS, MET, PHE, PRO, SER, THR, TRP, TYR, VAL) + LEU, LYS, MET, PHE, PRO, SER, THR, TRP, TYR, VAL) - Class 21: UNK (unknown amino acid) - Classes 22-25: RNA nucleotides (A, C, G, U) - Class 26: N (unknown RNA) - Classes 27-30: DNA nucleotides (DA, DC, DG, DT) - Class 31: DN (unknown DNA) - Class 32: ATOMIZED (atomized small molecule token) +- Class 33: GAP (alignment gap in MSAs) This encoding is compatible with the conditional residue type feature used in protein foundation models, enabling unified handling of proteins, RNA, DNA, and small molecules @@ -456,10 +455,14 @@ def __repr__(self): Usage: UNIFIED_ATOM37_ENCODING serves as the single source of truth for: + - Atom37 layout operations (coordinate processing): + * atom_array_to_encoding() / atom_array_from_encoding() * Converting between AtomArray and atom37 coordinate tensors + - Sequence encoding operations (residue type indices): + * Use UNIFIED_ATOM37_ENCODING.token_to_idx to encode residue names * Use UNIFIED_ATOM37_ENCODING.idx_to_token to decode indices """ @@ -770,14 +773,16 @@ def __repr__(self): ), ) """RF2AA all atom encoding for proteins, nucleic acids and various other elements - - Encodes heavy atoms and hydrogens (max 36 in total) - - Includes 3 unknown tokens: `UNK` for proteins, `DN` for dna, `N` for RNA - - Covers: - - 20 amino acids (+ unknown, + mask), - - 4 DNA bases (+ unknown), - - 4 RNA bases (+ unknown), - - 1 outdated histindine token `HIS_D` - - 45 atom tokens (+ unknown) + +- Encodes heavy atoms and hydrogens (max 36 in total) +- Includes 3 unknown tokens: `UNK` for proteins, `DN` for dna, `N` for RNA +- Covers: + + - 20 amino acids (+ unknown, + mask), + - 4 DNA bases (+ unknown), + - 4 RNA bases (+ unknown), + - 1 outdated histindine token `HIS_D` + - 45 atom tokens (+ unknown) """ # fmt: on diff --git a/src/atomworks/ml/example_id.py b/src/atomworks/ml/example_id.py index 5c01dfc2..8b7a640b 100644 --- a/src/atomworks/ml/example_id.py +++ b/src/atomworks/ml/example_id.py @@ -3,7 +3,21 @@ import re -def generate_example_id(dataset_names: list[str], pdb_id: str, assembly_id: str, query_pn_unit_iids: list) -> str: +def _parse_value(value: str) -> str | list: + """Parse a value, evaluating list literals.""" + value = value.strip() + if value.startswith("["): + return eval(value) + return value + + +def generate_example_id( + dataset_names: list[str], + pdb_id: str, + assembly_id: str, + query_pn_unit_iids: list, + altloc_seed: int | None = None, +) -> str: """Generate a unique example ID from a DataFrame row. This unique ID is helpful for debugging and to track performance on specific examples. @@ -13,40 +27,65 @@ def generate_example_id(dataset_names: list[str], pdb_id: str, assembly_id: str, (2) pdb_id (or any group-level identifier, if using a non-PDB dataset), within the dataset specified by (1) (3) assembly_id (4) query_pn_unit_iids + (5) altloc_seed (optional) — distinguishes different alt-loc conformer samples of the same structure """ - # Format: {[dataset_names]}{pdb_id}{assembly_id}{query_pn_unit_iids} + # Format: {[dataset_names]}{pdb_id}{assembly_id}{query_pn_unit_iids}[{alt_loc_seed}] # Example for pn_unit dataset: {['pdb', 'pn_unit']}{6vyb}{1}{['A_1']} # Example for interface dataset: {['pdb', 'interfaces']}{6vyb}{1}{['A_1', 'B_1']} # Example for a distillation dataset: {['af2_distillation']}{6vyb}{1}{['A_1']} - return f"{{{dataset_names}}}{{{pdb_id}}}{{{assembly_id}}}{{{query_pn_unit_iids}}}" + # Example with alt_loc_seed: {['pdb', 'interfaces']}{6vyb}{1}{['A_1', 'B_1']}{3} + query_pn_unit_iids = [str(x) for x in query_pn_unit_iids] + base = f"{{{dataset_names}}}{{{pdb_id}}}{{{assembly_id}}}{{{query_pn_unit_iids}}}" + if altloc_seed is not None: + base += f"{{{altloc_seed}}}" + return base -def parse_example_id(example_id: str) -> dict: - """ - Parse the example ID into its components: dataset names, pdb_id, assembly_id, and query_pn_unit_iids. +def parse_example_id( + example_id: str, + keys: tuple[str, ...] = ("datasets", "pdb_id", "assembly_id", "query_pn_unit_iids"), + *, + permissive: bool = False, +) -> dict[str, str | list | None]: + """Parse an example ID into its components. Args: - example_id (str): The example ID string generated by the generate_example_id function. + example_id: The example ID string (e.g., ``{mgnify}{123}`` or ``{['pdb']}{6vyb}{1}{['A_1']}``). + keys: Tuple of key names to map to extracted values, in order. + permissive: If ``True``, return ``None`` for missing keys instead of raising. Returns: - dict: A dictionary containing the parsed components. + Dictionary mapping keys to parsed values. List literals are evaluated automatically. + + Raises: + ValueError: If the number of extracted values doesn't match keys (when ``permissive=False``). + + Examples: + >>> parse_example_id("{mgnify}{123}", keys=("datasets", "pdb_id")) + {'datasets': 'mgnify', 'pdb_id': '123'} + + >>> parse_example_id("{['pdb']}{6vyb}{1}{['A_1']}") + {'datasets': ['pdb'], 'pdb_id': '6vyb', 'assembly_id': '1', 'query_pn_unit_iids': ['A_1']} + + >>> parse_example_id("{mgnify}{123}", permissive=True) + {'datasets': 'mgnify', 'pdb_id': '123', 'assembly_id': None, 'query_pn_unit_iids': None} + + >>> parse_example_id( + ... "{['pdb']}{6vyb}{1}{['A_1']}{3}", + ... keys=("datasets", "pdb_id", "assembly_id", "query_pn_unit_iids", "altloc_seed"), + ... ) + {'datasets': ['pdb'], 'pdb_id': '6vyb', 'assembly_id': '1', 'query_pn_unit_iids': ['A_1'], 'alt_loc_seed': '3'} """ - # Use regular expression to find all parts within curly braces matches = re.findall(r"\{(.*?)\}", example_id) - if len(matches) != 4: - raise ValueError(f"Invalid example ID format: {example_id}, with {len(matches)} matches found (expected 4).") - - # Extract the components - datasets = eval(matches[0]) # Convert string representation of list to actual list - pdb_id = matches[1] - assembly_id = matches[2] - # Convert string representation of list to actual list - query_pn_unit_iids = eval(matches[3]) if matches[3] else [] - - return { - "datasets": datasets, - "pdb_id": pdb_id, - "assembly_id": assembly_id, - "query_pn_unit_iids": query_pn_unit_iids, - } + if len(matches) != len(keys) and not permissive: + raise ValueError(f"Expected {len(keys)} values for keys {keys}, got {len(matches)} from: {example_id}") + + result = {} + for i, key in enumerate(keys): + if i < len(matches): + result[key] = _parse_value(matches[i]) + else: + result[key] = None + + return result diff --git a/src/atomworks/ml/executables/dssp.py b/src/atomworks/ml/executables/dssp.py new file mode 100644 index 00000000..0fbb9c7b --- /dev/null +++ b/src/atomworks/ml/executables/dssp.py @@ -0,0 +1,63 @@ +"""DSSP executable wrapper for secondary structure annotation.""" + +import logging +import os +from os import PathLike + +from atomworks.ml.executables import Executable, ExecutableError + +logger = logging.getLogger(__name__) + + +class DSSPExecutable(Executable): + """Executable wrapper for the DSSP program. + + DSSP (Define Secondary Structure of Proteins) is used to annotate secondary + structure elements in protein structures based on hydrogen bonding patterns. + + Examples: + >>> dssp = DSSPExecutable.get_or_initialize() + >>> version = dssp.get_version() + >>> bin_path = dssp.get_bin_path() + """ + + name = "mkdssp" + required_verification_text = ("DSSP", "output-format") + version_cmd = "--version" + verification_cmd = "--help" + + @classmethod + def initialize(cls, bin_path: PathLike | None = None, *args, **kwargs) -> "DSSPExecutable": + """Initialize DSSP executable. + + Args: + bin_path: Path to DSSP executable. If ``None``, attempts to find using ``DSSP_PATH`` env variable. + + Returns: + Initialized DSSPExecutable. + + Raises: + ExecutableError: If executable not found or invalid. + """ + if bin_path is None: + bin_path = cls._infer_bin_path_from_env_var() + return super().initialize(bin_path, *args, **kwargs) + + @staticmethod + def _infer_bin_path_from_env_var() -> PathLike: + """Get the path to the DSSP executable from environment variables.""" + dssp_path = os.environ.get("DSSP_PATH") + if dssp_path is not None and os.path.isfile(dssp_path) and os.access(dssp_path, os.X_OK): + return dssp_path + + raise ExecutableError( + "No `bin_path` provided and `DSSP_PATH` environment variable not set.\n" + "Please set the `DSSP_PATH` environment variable to the path of the DSSP executable " + "or provide a `bin_path` to the `DSSPExecutable` constructor: " + "`DSSPExecutable.initialize(bin_path='/path/to/mkdssp')`." + ) + + @classmethod + def _setup(cls, bin_path: PathLike, *args, **kwargs) -> None: + """Setup method for DSSP (no special setup required).""" + pass diff --git a/src/atomworks/ml/executables/hhblits.py b/src/atomworks/ml/executables/hhblits.py new file mode 100644 index 00000000..caa8635c --- /dev/null +++ b/src/atomworks/ml/executables/hhblits.py @@ -0,0 +1,84 @@ +"""HHblits executable wrapper for MSA generation.""" + +import logging +import os +import subprocess +from os import PathLike + +from atomworks.ml.executables import Executable, ExecutableError + +logger = logging.getLogger(__name__) + + +class HHblits(Executable): + """Executable wrapper for the HHblits program from HH-suite. + + HHblits is used to perform iterative sequence searches against large + databases (UniRef30, BFD) to generate multiple sequence alignments (MSAs) + for protein structure prediction. + + Example: + ```python + hhblits = HHblits.get_or_initialize() + result = hhblits.run_command("-i", "input.fasta", "-oa3m", "output.a3m", "-d", "/path/to/db", "-e", "1e-3") + ``` + """ + + name = "hhblits" + required_verification_text = ("HHblits", "-i", "-d") + version_cmd = "-h" + + @classmethod + def initialize(cls, bin_path: PathLike | None = None, *args, **kwargs) -> "HHblits": + """Initialize HHblits executable. + + Args: + bin_path: Path to hhblits executable. If None, attempts to find using HHBLITS_PATH env variable. + + Returns: + Initialized HHblits executable. + + Raises: + ExecutableError: If executable not found or invalid. + """ + if bin_path is None: + bin_path = cls._infer_bin_path_from_env_var() + return super().initialize(bin_path, *args, **kwargs) + + @staticmethod + def _infer_bin_path_from_env_var() -> PathLike: + """Get the path to the hhblits executable from environment variables.""" + hhblits_path = os.environ.get("HHBLITS_PATH") + if hhblits_path is not None and os.path.isfile(hhblits_path) and os.access(hhblits_path, os.X_OK): + return hhblits_path + + raise ExecutableError( + "No `bin_path` provided and `HHBLITS_PATH` environment variable not set.\n" + "Please set the `HHBLITS_PATH` environment variable to the path of the hhblits executable " + "or provide a `bin_path` to the `HHblits` constructor: " + "`HHblits.initialize(bin_path='/path/to/hhblits')`." + ) + + @classmethod + def run_command(cls, *args: str) -> subprocess.CompletedProcess: + """Run hhblits with the specified arguments. + + Args: + *args: Command line arguments to pass to hhblits. + + Returns: + CompletedProcess instance with command output. + + Raises: + ExecutableError: If HHblits not initialized. + subprocess.CalledProcessError: If the command fails. + """ + if not cls._is_initialized: + raise ExecutableError("HHblits not initialized. Run `HHblits.initialize(...)` first.") + + cmd = [cls._bin_path, *args] + return subprocess.run(cmd, capture_output=True, text=True, check=True) + + @classmethod + def _setup(cls, bin_path: PathLike, *args, **kwargs) -> None: + """No additional setup required for HHblits.""" diff --git a/src/atomworks/ml/executables/mmseqs2.py b/src/atomworks/ml/executables/mmseqs2.py index 27f1515f..de048be7 100644 --- a/src/atomworks/ml/executables/mmseqs2.py +++ b/src/atomworks/ml/executables/mmseqs2.py @@ -12,14 +12,16 @@ # Mapping of MMseqs2 modules to their output parameter positions # Used to detect existing outputs and skip redundant operations MODULE_OUTPUT_POS = { - "createdb": 2, - "search": 3, - "expandaln": 4, "align": 4, - "filterdb": 3, - "result2msa": 3, - "convertalis": 3, - "createseqfiledb": 2, + "convertalis": 4, + "expandaln": 5, + "filterresult": 4, + "lndb": 2, + "mergedbs": 2, + "mvdb": 2, + "pairaln": 4, + "result2msa": 4, + "search": 3, } diff --git a/src/atomworks/ml/pipelines/af3.py b/src/atomworks/ml/pipelines/af3.py index 0a2c4e97..ac6c61da 100644 --- a/src/atomworks/ml/pipelines/af3.py +++ b/src/atomworks/ml/pipelines/af3.py @@ -13,6 +13,7 @@ AddGlobalAtomIdAnnotation, AddGlobalTokenIdAnnotation, AddWithinChainInstanceResIdx, + AddWithinChainSourceResIdx, AddWithinPolyResIdxAnnotation, ComputeAtomToTokenMap, CopyAnnotation, @@ -38,7 +39,7 @@ from atomworks.ml.transforms.center_random_augmentation import CenterRandomAugmentation from atomworks.ml.transforms.chirals import AddAF3ChiralFeatures from atomworks.ml.transforms.covalent_modifications import ( - FlagAndReassignCovalentModifications, + AnnotateCovalentModifications, ) from atomworks.ml.transforms.crop import CropContiguousLikeAF3, CropSpatialLikeAF3 from atomworks.ml.transforms.diffusion.batch_structures import ( @@ -207,7 +208,7 @@ def build_af3_transform_pipeline( False: PadDNA(p_skip=pad_dna_p_skip) if pad_dna_p_skip > 0 else Identity(), }, ), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), FlagNonPolymersForAtomization(), AddGlobalAtomIdAnnotation(allow_overwrite=True), AtomizeByCCDName( @@ -219,6 +220,7 @@ def build_af3_transform_pipeline( RemoveNucleicAcidTerminalOxygen(), AddWithinChainInstanceResIdx(), AddWithinPolyResIdxAnnotation(), + AddWithinChainSourceResIdx(), ] # Crop diff --git a/src/atomworks/ml/pipelines/rf2aa.py b/src/atomworks/ml/pipelines/rf2aa.py index a36d421d..e2f8e0a6 100644 --- a/src/atomworks/ml/pipelines/rf2aa.py +++ b/src/atomworks/ml/pipelines/rf2aa.py @@ -34,7 +34,7 @@ AddTokenBondAdjacency, ) from atomworks.ml.transforms.chirals import AddRF2AAChiralFeatures -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.crop import CropContiguousLikeAF3, CropSpatialLikeAF3 from atomworks.ml.transforms.encoding import EncodeAtomArray, atom_array_from_encoding from atomworks.ml.transforms.feature_aggregation.rf2aa import AggregateFeaturesLikeRF2AA @@ -322,7 +322,7 @@ def build_rf2aa_transform_pipeline( # ...sample residues to atomize (in RF2AA, with some probability, we atomize protein residues randomly) # TODO: SampleResiduesToAtomize # ...handle covalent modifications by atomizing and attaching the bonded residue to the non-polymer - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), # ...flag non-polymers for atomization (in case there are polymer tokens outside of a polymer) FlagNonPolymersForAtomization(), # ...atomize diff --git a/src/atomworks/ml/preprocessing/__init__.py b/src/atomworks/ml/preprocessing/__init__.py deleted file mode 100644 index ccf70102..00000000 --- a/src/atomworks/ml/preprocessing/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -import logging -import os - -logger = logging.getLogger("preprocess") -_log_level = os.environ.get("PREPROCESS_LOG_LEVEL", "WARNING").upper() -logger.setLevel(_log_level) diff --git a/src/atomworks/ml/preprocessing/clustering/cluster.py b/src/atomworks/ml/preprocessing/clustering/cluster.py new file mode 100644 index 00000000..66424426 --- /dev/null +++ b/src/atomworks/ml/preprocessing/clustering/cluster.py @@ -0,0 +1,98 @@ +"""Generic sequence clustering using MMseqs2.""" + +import logging +import tempfile +from pathlib import Path +from typing import Any + +import pandas as pd + +from atomworks.ml.preprocessing.clustering.config import MMseqs2Config +from atomworks.ml.preprocessing.clustering.mmseqs2 import run_mmseqs2_clustering +from atomworks.ml.preprocessing.utils.fasta import create_fasta_file_from_df +from atomworks.ml.utils.misc import hash_sequence + +logger = logging.getLogger(__name__) + + +def _hash_sequence_or_none(s: Any) -> str | None: + """Hash sequence or return None if value is NA.""" + return hash_sequence(s) if pd.notna(s) else None + + +def add_cluster_column( + df: pd.DataFrame, + sequence_column: str, + output_column: str = "cluster", + config: MMseqs2Config | None = None, +) -> pd.DataFrame: + """Add cluster column to DataFrame based on sequence similarity. + + Generic clustering - works with any DataFrame containing sequences. + + Args: + df: DataFrame with sequences to cluster. + sequence_column: Name of column containing sequences. + output_column: Name of output cluster column. + config: MMseqs2 clustering configuration. If None, uses default MMseqs2Config() (40% identity, 80% coverage). + + Returns: + DataFrame with cluster column added. + """ + if config is None: + config = MMseqs2Config() + + df = df.copy() + + # Filter to rows with valid sequences + has_seq = df[sequence_column].notna() + if not has_seq.any(): + df[output_column] = None + return df + + with tempfile.TemporaryDirectory() as tmp: + work_dir = Path(tmp) + fasta_path = work_dir / "sequences.fasta" + + # Create FASTA from sequences + create_fasta_file_from_df(df[has_seq], sequence_column, fasta_path) + + # Run MMseqs2 + cluster_df = run_mmseqs2_clustering(fasta_path, config, work_dir) + + if cluster_df.empty: + df[output_column] = None + return df + + # Build hash -> cluster_rep lookup + df["_seq_hash"] = df[sequence_column].apply(_hash_sequence_or_none) + + # Merge cluster assignments + cluster_lookup = cluster_df.set_index("seq_hash")["cluster_rep_hash"].to_dict() + df[output_column] = df["_seq_hash"].map(cluster_lookup) + df = df.drop(columns=["_seq_hash"]) + + logger.info(f"Added {output_column} column ({config.cluster_identity*100:.0f}% identity)") + return df + + +def add_cluster_column_to_file( + input_path: Path, + sequence_column: str, + output_column: str = "cluster", + config: MMseqs2Config | None = None, + output_path: Path | None = None, +) -> pd.DataFrame: + """Add cluster column to parquet file. + + Convenience wrapper around add_cluster_column() for file I/O. + """ + df = pd.read_parquet(input_path) + logger.info(f"Loaded {len(df)} rows from {input_path}") + + df = add_cluster_column(df, sequence_column, output_column, config) + + out = output_path or input_path + df.to_parquet(out, index=False) + logger.info(f"Saved to {out}") + return df diff --git a/src/atomworks/ml/preprocessing/clustering/config.py b/src/atomworks/ml/preprocessing/clustering/config.py new file mode 100644 index 00000000..77760551 --- /dev/null +++ b/src/atomworks/ml/preprocessing/clustering/config.py @@ -0,0 +1,37 @@ +"""Configuration for MMseqs2 sequence clustering.""" + +from dataclasses import dataclass +from enum import IntEnum + + +class CoverageMode(IntEnum): + """MMseqs2 coverage modes.""" + + BIDIRECTIONAL = 0 + TARGET = 1 + QUERY = 2 + + +class ClusterMode(IntEnum): + """MMseqs2 cluster modes.""" + + GREEDY_SET_COVER = 0 + CONNECTED_COMPONENT = 1 + GREEDY_INCREMENTAL = 2 + + +@dataclass(frozen=True) +class MMseqs2Config: + """Configuration for MMseqs2 easy-cluster.""" + + cluster_identity: float = 0.4 + coverage: float = 0.8 + sensitivity: float = 8.0 + cluster_mode: ClusterMode = ClusterMode.GREEDY_SET_COVER + coverage_mode: CoverageMode = CoverageMode.BIDIRECTIONAL + + def __post_init__(self) -> None: + if not 0.0 <= self.cluster_identity <= 1.0: + raise ValueError(f"cluster_identity must be in [0, 1], got {self.cluster_identity}") + if not 0.0 <= self.coverage <= 1.0: + raise ValueError(f"coverage must be in [0, 1], got {self.coverage}") diff --git a/src/atomworks/ml/preprocessing/clustering/mmseqs2.py b/src/atomworks/ml/preprocessing/clustering/mmseqs2.py new file mode 100644 index 00000000..74e0f0f4 --- /dev/null +++ b/src/atomworks/ml/preprocessing/clustering/mmseqs2.py @@ -0,0 +1,86 @@ +"""MMseqs2 wrapper for sequence clustering.""" + +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pandas as pd + +from atomworks.ml.executables.mmseqs2 import MMseqs2 +from atomworks.ml.preprocessing.clustering.config import MMseqs2Config + +logger = logging.getLogger(__name__) + + +def run_mmseqs2_clustering( + input_fasta: Path, + config: MMseqs2Config | None = None, + working_dir: Path | None = None, +) -> pd.DataFrame: + """Run MMseqs2 easy-cluster on input FASTA file. + + Args: + input_fasta: Path to FASTA file (headers = sequence hashes). + config: MMseqs2 clustering configuration. + working_dir: Directory for MMseqs2 files. Uses temp dir if None. + + Returns: + DataFrame with columns [cluster_rep_hash, seq_hash]. + """ + if config is None: + config = MMseqs2Config() + + use_temp = working_dir is None + work_path = Path(tempfile.mkdtemp()) if use_temp else working_dir + work_path.mkdir(parents=True, exist_ok=True) + + try: + result_prefix = work_path / "result" + tmp_dir = work_path / "tmp" + + logger.info(f"Running MMseqs2 with identity={config.cluster_identity}") + mmseqs_bin = str(MMseqs2.get_or_initialize().get_bin_path()) + subprocess.run( + [ + mmseqs_bin, + "easy-cluster", + str(input_fasta), + str(result_prefix), + str(tmp_dir), + "--min-seq-id", + str(config.cluster_identity), + "-c", + str(config.coverage), + "-s", + str(config.sensitivity), + "--cluster-mode", + str(int(config.cluster_mode)), + "--cov-mode", + str(int(config.coverage_mode)), + ], + check=True, + capture_output=True, + ) + + cluster_tsv = work_path / "result_cluster.tsv" + df = pd.read_csv( + cluster_tsv, + sep="\t", + header=None, + names=["cluster_rep_hash", "seq_hash"], + ) + logger.info(f"Clustered {len(df)} sequences") + return df + + finally: + if use_temp: + shutil.rmtree(work_path, ignore_errors=True) + + +def cleanup_mmseqs2_files(directory: Path) -> None: + """Remove MMseqs2 output files from directory.""" + for pattern in ["result_cluster.tsv", "result_all_seqs.fasta", "result_rep_seq.fasta"]: + for f in directory.glob(pattern): + f.unlink(missing_ok=True) diff --git a/src/atomworks/ml/preprocessing/constants.py b/src/atomworks/ml/preprocessing/constants.py index f9cef713..57c0620f 100644 --- a/src/atomworks/ml/preprocessing/constants.py +++ b/src/atomworks/ml/preprocessing/constants.py @@ -1,36 +1,12 @@ +"""Constants for ML preprocessing.""" + import re from enum import Enum +from typing import Final from atomworks.enums import ChainType -# Cutoff for the number of residues in a peptide -PEPTIDE_MAX_RESIDUES = 20 - -# Define the "NA" values ("missing" values) that should be treated as NaN (for Pandas) -# NOTE: By default, "NA" is considered as a missing value by Pandas, which is obviously a problem -NA_VALUES = [ - "", - " ", - "#N/A", - "#N/A N/A", - "#NA", - "-1.#IND", - "-1.#QNAN", - "-NaN", - "-nan", - "1.#IND", - "1.#QNAN", - "", - "N/A", - "NULL", - "NaN", - "None", - "n/a", - "nan", - "null", -] - -PREPROCESSING_SUPPORTED_CHAIN_TYPES = [ +PREPROCESSING_SUPPORTED_CHAIN_TYPES: Final[list[ChainType]] = [ ChainType.NON_POLYMER, ChainType.POLYPEPTIDE_L, ChainType.POLYPEPTIDE_D, @@ -39,92 +15,118 @@ ChainType.BRANCHED, ChainType.DNA_RNA_HYBRID, ] -PREPROCESSING_SUPPORTED_CHAIN_TYPES_INTS = [type.value for type in PREPROCESSING_SUPPORTED_CHAIN_TYPES] +"""Chain types supported during preprocessing.""" -TRAINING_SUPPORTED_CHAIN_TYPES = [ +PREPROCESSING_SUPPORTED_CHAIN_TYPES_INTS: Final[list[int]] = [t.value for t in PREPROCESSING_SUPPORTED_CHAIN_TYPES] +"""Integer values of chain types supported during preprocessing.""" + +TRAINING_SUPPORTED_CHAIN_TYPES: Final[list[ChainType]] = [ ChainType.NON_POLYMER, ChainType.POLYPEPTIDE_L, ChainType.DNA, ChainType.RNA, ChainType.BRANCHED, ] -TRAINING_SUPPORTED_CHAIN_TYPES_INTS = [type.value for type in TRAINING_SUPPORTED_CHAIN_TYPES] +"""Chain types supported during training.""" + +TRAINING_SUPPORTED_CHAIN_TYPES_INTS: Final[list[int]] = [t.value for t in TRAINING_SUPPORTED_CHAIN_TYPES] +"""Integer values of chain types supported during training.""" -# Regular expression for PDB IDs -PDB_REGEX = re.compile(r"^[0-9A-Za-z]{4}$") +PDB_REGEX: Final[re.Pattern[str]] = re.compile(r"^[0-9A-Za-z]{4}$") +"""Regex pattern for valid PDB IDs (4 alphanumeric characters).""" class ClashSeverity(Enum): - """ - Enum representing the severity of clashes in a PDB file. - """ - - SEVERE = "severe" # More than 50% of polymers are clashing - MODERATE = "moderate" # Any polymers are clashing - MILD = "mild" # Any clashes (polymer or non-polymer) - NO_CLASH = "no-clash" # No clashes - - -# Atomic numbers -OXYGEN_ATOMIC_NUMBER = 8 -FLUORINE_ATOMIC_NUMBER = 9 - -# For building the CellList -CELL_SIZE = 4.5 - -# Entries to exclude from the dataset -ENTRIES_TO_EXCLUDE_FOR_PRE_PROCESSING = [ - # TODO: Deduplicate this list - "7bho", # DNA origami - "7lhd", - "6vyr", - "3zif", - "7nwh", # Ribosome - "6nu3", - "7tbi", # Nuclear pore complex - "7lhd", # Hetero 180-mer - "4l3b", # Hetero 180-mer - "7bsi", # Hetero 2,820-mer - "6nhj", # Hetero 3,300-mer - "5j7v", # Homo 8,280-mer - "6w19", # Hetero 3,060-mer - "6cgr", # Hetero 2,760-mer - "6b43", # Hetero 306-mer - "6cgr", # Hetero 2,760-mer - "7bw6", - "6lgl", - "7as5", - "7fj1", - "7r5k", - "3k1q", - "6ncl", - "5zap", - "6b43", - "1m4x", - "6q1f", - "7qiz", - "4f5x", - "7tbk", - "5jus", - "1uf2", - "6ftg", - "7tbk", - "7v4t", - "7r5j", - "3jbp", - "6ylh", - "7btb", - "6zvk", - "4u3n", - "6woo", - "6ftj", - "4u3n", - "3jan", - "4u3n", - "5tbw", - "6tb3", - "6zvk", - "5dat", - "3jan", - "4v5x", -] + """Severity levels for atomic clashes in a structure.""" + + SEVERE = "severe" + """More than 50% of polymers are clashing.""" + + MODERATE = "moderate" + """Any polymers are clashing.""" + + MILD = "mild" + """Any clashes (polymer or non-polymer).""" + + NO_CLASH = "no-clash" + """No clashes detected.""" + + +class DistanceThresholds: + """Centralized distance thresholds (Angstroms) for preprocessing.""" + + CLASH: Final[float] = 1.0 + """Atoms closer than this are considered clashing.""" + + CONTACT: Final[float] = 5.0 + """Distance threshold for atoms in contact.""" + + INTERFACE: Final[float] = 15.0 + """Cutoff for interface atom identification in large assembly sampling.""" + + +# fmt: off +PDB_IDS_THAT_CRASH_PREPROCESSING: Final[tuple[str, ...]] = ( + "1m4x", "1uf2", "3jan", "3jbp", "3k1q", "3zif", "4f5x", "4l3b", "4u3n", "4v5x", "5dat", "5j7v", "5jus", "5tbw", "5zap", + "6b43", "6cgr", "6ftg", "6ftj", "6lgl", "6ncl", "6nhj", "6nu3", "6q1f", "6tb3", "6vyr", "6w19", "6woo", "6ylh", "6zvk", + "7as5", "7bho", "7bsi", "7btb", "7bw6", "7fj1", "7lhd", "7nwh", "7qiz", "7r5j", "7r5k", "7tbi", "7tbk", "7v4t", +) +# fmt: on +"""PDB IDs that crash or hang the preprocessing pipeline. + +Warning: + These include extremely large assemblies (ribosomes, nuclear pore complexes) + and complex structures (DNA origami) that exceed memory/time limits. +""" + +# fmt: off +PDB_IDS_WITH_UNPHYSICAL_BONDS: Final[tuple[str, ...]] = ( + "185d", "193d", "1a0r", "1ay3", "1b4d", "1b6j", "1by5", "1c0t", "1c0u", "1c1b", "1can", "1cao", "1ci7", "1cpi", "1dtq", "1dtt", "1eva", "1evd", "1fav", "1fwm", + "1gcm", "1jaa", "1jc8", "1jcp", "1jgc", "1l9w", "1niu", "1nma", "1nmb", "1pfe", "1pw8", "1qtn", "1qur", "1u8g", "1v4f", "1v6q", "1v7h", "1vs2", "1vtg", "1xhb", + "1xql", "1xvk", "1xvn", "1xvr", "1zet", "2adw", "2bvr", "2bvs", "2c15", "2c3u", "2ddc", "2et0", "2fni", "2h77", "2j32", "2j58", "2jhg", "2llj", "2n4n", "2n6h", + "2n6i", "2nbl", "2nmp", "2vlc", "2wdy", "2xe4", "2xjh", "2xji", "2xqt", "2yct", "3ah8", "3awo", "3bo7", "3bp2", "3f4z", "3go3", "3k87", "3kch", "3mgn", "3nuv", + "3q9g", "3u4w", "3u51", "4auo", "4b3q", "4bp9", "4bqd", "4bxe", "4cak", "4ce5", "4chx", "4fa5", "4fa9", "4h0t", "4j5t", "4ks6", "4ktx", "4l1s", "4lnm", "4nec", + "4oy5", "4rca", "4v15", "4w9f", "5aa2", "5bmm", "5bqc", "5c55", "5cs2", "5ctv", "5e83", "5eq7", "5eq8", "5f1w", "5g21", "5h1b", "5hkj", "5hwn", "5lgr", "5lvs", + "5m3z", "5ol2", "5yty", "5ytz", "5zur", "6bpu", "6bpw", "6cdh", "6eik", "6euv", "6fbt", "6fcr", "6fcs", "6gmq", "6gmx", "6ibt", "6jze", "6m9z", "6skj", "6te5", + "6tg4", "6th7", "6thq", "6uf4", "6uf7", "6uf8", "6uf9", "6vju", "6wve", "6x8k", "6x8l", "6xte", "6xu2", "6xu3", "6yfy", "6zuk", "7ajz", "7ak4", "7axp", "7axs", + "7azd", "7bgq", "7dq0", "7dq8", "7f0c", "7f0f", "7jr4", "7oov", "7oow", "7opw", "7owm", "7pfz", "7pox", "7q1l", "7q9s", "7qgv", "7qtv", "7r0y", "7rnp", "7s50", + "7ubz", "7v58", "7w3u", "7x6r", "7x97", "7x9f", "7xdj", "7z0l", "7z86", "7z9r", "7zss", "7zwh", "8a99", "8agy", "8ai7", "8an9", "8ao0", "8brn", "8cep", "8cx5", + "8jug", "8sa8", "8sa9", "8sab", "8sng", "8snj", "8su6", "1b23", "1bcs", "1bjg", "1bwc", "1bx5", "1c2q", "1c2w", "1c4c", "1cfm", "1chw", "1d61", "1dd4", "1ddi", + "1e1f", "1e3d", "1e7u", "1efd", "1f8c", "1f8d", "1fp4", "1fq5", "1fq8", "1g20", "1g21", "1g8j", "1ga5", "1gx8", "1h2j", "1hcy", "1hkx", "1hl9", "1i94", "1i95", + "1i96", "1i97", "1j11", "1j12", "1j1a", "1jhi", "1jpc", "1kk4", "1kmk", "1ksj", "1l0l", "1m21", "1mbu", "1mbx", "1md2", "1n1h", "1n7d", "1nte", "1o7t", "1oao", + "1pwf", "1q2c", "1q6k", "1q9x", "1qbv", "1qh1", "1qlb", "1qni", "1qnl", "1qnu", "1r6o", "1r6q", "1rpg", "1rqd", "1sgc", "1siw", "1sp4", "1sqb", "1sqq", "1sqv", + "1sqx", "1t3p", "1th3", "1thc", "1tlf", "1twn", "1uwy", "1vje", "1vl3", "1vqw", "1w3l", "1w4r", "1w9m", "1wck", "1wdr", "1wvm", "1xv6", "1y4z", "1y6f", "1yfg", + "1z7l", "2b7r", "2b7s", "2bjk", "2bvl", "2c3p", "2c5c", "2c6c", "2c6f", "2cch", "2cju", "2da8", "2dc7", "2dc8", "2dca", "2dcb", "2dcd", "2e1a", "2ef5", "2f1o", + "2fwi", "2fyu", "2hhl", "2iwk", "2ja6", "2ja7", "2ja8", "2jji", "2m5b", "2min", "2nqx", "2o7a", "2olb", "2osm", "2p8x", "2rl7", "2uwn", "2uyn", "2uyv", "2uzs", + "2v8f", "2v93", "2vzm", "2w31", "2wpu", "2x8j", "2xcr", "2xrz", "2xsj", "2y6e", "2yaj", "2yiv", "2ylj", "2ylo", "2zur", "3af7", "3bbf", "3bhv", "3c0v", "3c9e", + "3cgb", "3d1l", "3d5x", "3dt0", "3en9", "3enh", "3esc", "3esd", "3f4i", "3h8l", "3hhm", "3hr3", "3i39", "3kku", "3kvs", "3l0o", "3lya", "3ni3", "3np2", "3ott", + "3pa0", "3pot", "3pyk", "3q9h", "3q9i", "3q9j", "3rpd", "3rwr", "3rzs", "3sd3", "3sz0", "3szf", "3t0k", "3t2z", "3t4g", "3t5g", "3tuz", "3tvk", "3up9", "3usl", + "3usm", "3uso", "3vah", "3w6a", "3ze9", "3zp9", "3zx0", "486d", "4a7n", "4a93", "4ag7", "4b09", "4b0n", "4c3a", "4c4m", "4car", "4cft", "4cpq", "4cps", "4cty", + "4ctz", "4cu0", "4cu1", "4cul", "4cum", "4cun", "4cv5", "4cvg", "4cwv", "4cww", "4cwx", "4cwy", "4cwz", "4cx0", "4cx1", "4cx2", "4cyg", "4d34", "4d35", "4d36", + "4d37", "4d38", "4d3a", "4daw", "4dlp", "4e0l", "4e0m", "4e0n", "4e0o", "4e54", "4e5z", "4ef1", "4ef2", "4eqw", "4eu9", "4fy0", "4g5f", "4gjs", "4got", "4i9b", + "4ivh", "4j7v", "4j8w", "4j9u", "4jm0", "4js6", "4jsa", "4jsk", "4jsl", "4jsm", "4jss", "4k0k", "4k3f", "4k5h", "4k5i", "4k5j", "4k5k", "4kcp", "4kcq", "4kcr", + "4kcs", "4kky", "4luw", "4mrr", "4n4k", "4n4l", "4n4m", "4n7k", "4n7l", "4n9r", "4nhp", "4nhq", "4nmm", "4ntp", "4ntr", "4nw8", "4nw9", "4o1w", "4ote", "4oxd", + "4p4v", "4p4w", "4p4x", "4p4y", "4p4z", "4pa5", "4q5t", "4q8d", "4qgn", "4qo5", "4qp1", "4r6c", "4r7u", "4rlo", "4tt5", "4ttt", "4u6w", "4uaz", "4ubb", "4udk", + "4uh7", "4uh8", "4uh9", "4uha", "4upq", "4upr", "4ups", "4upt", "4wc8", "4wna", "4x0s", "4xtd", "4xti", "4z12", "4z13", "4zc0", "4zjx", "4zqw", "5a4m", "5adj", + "5adk", "5adl", "5adm", "5adn", "5brv", "5ci8", "5d51", "5dal", "5ddl", "5e9r", "5ecx", "5eul", "5f1t", "5fez", "5ff0", "5ff3", "5ff4", "5fj2", "5fj3", "5fle", + "5fvy", "5fvz", "5giy", "5hes", "5hpp", "5ieg", "5ik9", "5iwd", "5jiw", "5kfp", "5lh6", "5lms", "5lpy", "5mb1", "5mdj", "5mdk", "5mdl", "5mf2", "5msg", "5mwx", + "5n5r", "5ny6", "5nz6", "5oar", "5ob6", "5ob7", "5ob8", "5oeg", "5sur", "5sus", "5sut", "5suu", "5tpc", "5uhr", "5uod", "5v4g", "5v4h", "5v63", "5v64", "5v65", + "5viv", "5vu9", "5vv7", "5vv8", "5vv9", "5vva", "5vvg", "5vvn", "5w4h", "5w4i", "5w4j", "5wnq", "5wnr", "5wns", "5wp6", "5x7h", "5zzf", "6b1r", "6b41", "6bo1", + "6bo2", "6c6k", "6cdk", "6cg3", "6cg4", "6cg5", "6dr4", "6dr5", "6dr6", "6e1a", "6e5i", "6egw", "6ejw", "6erm", "6evj", "6f2f", "6f2h", "6f2i", "6f2j", "6f2m", + "6frm", "6frn", "6gmi", "6gob", "6h8t", "6hf6", "6hf7", "6hkq", "6hmy", "6hsn", "6hso", "6hy6", "6i24", "6i25", "6ic8", "6jzm", "6kf5", "6ks1", "6lzb", "6m97", + "6m9g", "6ma8", "6mik", "6mu5", "6nys", "6o7m", "6o9t", "6o9v", "6ofy", "6otl", "6pl9", "6pla", "6plb", "6pv2", "6q8j", "6qcv", "6qcw", "6qcx", "6qfu", "6qfv", + "6qfx", "6qjs", "6qqi", "6qqk", "6qsa", "6qsh", "6qss", "6qxc", "6r2r", "6r93", "6rcf", "6rke", "6rqa", "6rr7", "6ruk", "6rve", "6s46", "6s4q", "6s50", "6s6w", + "6s9z", "6swm", "6sym", "6tb2", "6tho", "6tvk", "6tvy", "6twc", "6u7y", "6udr", "6vfb", "6vjr", "6vu1", "6vu4", "6vvj", "6wgo", "6wnh", "6wxm", "6wxz", "6xuv", + "6ycs", "6yh0", "6ykh", "6yo0", "6yxg", "6z4i", "6z7r", "6z8j", "6zre", "7a0d", "7a6q", "7abr", "7b0g", "7b3r", "7b97", "7bay", "7bpa", "7bpf", "7bpg", "7dix", + "7efi", "7fbs", "7g96", "7jqr", "7jqs", "7jqt", "7jqu", "7jrh", "7jxn", "7jxo", "7jyy", "7jz0", "7ksv", "7ktm", "7kvv", "7l6r", "7l6t", "7lok", "7m4t", "7ne0", + "7nh5", "7nwq", "7o1s", "7o25", "7o55", "7o6j", "7odg", "7odh", "7onm", "7onq", "7onv", "7otv", "7ph4", "7ph7", "7pr3", "7pwb", "7qdj", "7qhr", "7qpw", "7qpy", + "7qpz", "7qy3", "7rnb", "7rsr", "7rtz", "7t4h", "7tne", "7tpn", "7tpo", "7tq0", "7tq9", "7tqc", "7tqe", "7tqf", "7tqh", "7tqi", "7tqj", "7tqk", "7tqw", "7u1b", + "7uyn", "7uyo", "7uyp", "7vcn", "7vdt", "7wz9", "7xkj", "7y44", "7yd7", "7yd8", "7yeb", "7yec", "7yee", "7yx0", "7z60", "7z6a", "7z6d", "7zcy", "7zkn", "7zu8", + "7zx5", "7zxl", "7zz3", "8a33", "8a5s", "8a6t", "8ab2", "8ai0", "8aqx", "8aqy", "8b7g", "8bqt", "8by0", "8cbz", "8crz", "8cs0", "8cs2", "8ec9", "8eca", "8es1", + "8f4y", "8fo0", "8fxi", "8hyi", "8jeb", "8omy", "8on3", "8oqf", "8oqg", "8p4e", "8qm3", "8qml", "8w6p", +) +# fmt: on +"""PDB IDs with bond geometries that violate basic plausibility assumptions.""" + +PDB_IDS_TO_EXCLUDE: Final[frozenset[str]] = frozenset(PDB_IDS_THAT_CRASH_PREPROCESSING) +"""PDB IDs to exclude from preprocessing (only those that crash/hang).""" diff --git a/src/atomworks/ml/preprocessing/get_pn_unit_data_from_structure.py b/src/atomworks/ml/preprocessing/get_pn_unit_data_from_structure.py deleted file mode 100644 index 1c63549a..00000000 --- a/src/atomworks/ml/preprocessing/get_pn_unit_data_from_structure.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Pre-process mmCIF files and return a dataframe containing a record for each PN unit in the structure. - -See the `atomworks` README for a term glosssary. -""" - -from __future__ import annotations - -import copy -import json -import logging -from dataclasses import dataclass, field -from os import PathLike -from pathlib import Path -from typing import Any, Literal - -import biotite.structure as struc -import numpy as np -import pandas as pd -from biotite.structure import AtomArray - -import atomworks.ml.preprocessing.utils.structure_utils as dp # to avoid circular imports -from atomworks.common import exists, not_isin -from atomworks.constants import CRYSTALLIZATION_AIDS, METAL_ELEMENTS -from atomworks.enums import ChainType -from atomworks.io import parse -from atomworks.ml.preprocessing.constants import CELL_SIZE, ClashSeverity -from atomworks.ml.utils.misc import hash_sequence - -logger = logging.getLogger("preprocess") - - -@dataclass -class DataPreprocessor: - # (Whether examples are from the PDB) - from_rcsb: bool = True - # (Cutoff distances) - close_distance: float = 30.0 - contact_distance: float = 5 - clash_distance: float = 1.0 - # (Misc) - ignore_residues: list[str] = field(default_factory=list) - # (Efficiency) - polymer_pn_unit_limit: int = 1000 - # (CIFUtils defaults) - add_missing_atoms: bool = True - remove_waters: bool = True - remove_ccds: list = field(default_factory=lambda: CRYSTALLIZATION_AIDS) - fix_ligands_at_symmetry_centers: bool = True - build_assembly: str = "all" - fix_arginines: bool = True - convert_mse_to_met: bool = True - hydrogen_policy: Literal["remove", "infer", "keep"] = "remove" - - def __post_init__(self): - logger.info(f"Initialized DataPreprocessor with the following parameters: {self.__dict__}") - - def _load_structure_with_atomworks(self, path: PathLike) -> dict[str, Any]: - """Load structure file using CIFUtils parser. - - Supported file types: .cif, .cif.gz, .pdb, .pdb.gz - - Args: - path (PathLike): The path to the structure file to load. - """ - self.path = path # for logging - return parse( - filename=path, - build_assembly=self.build_assembly, - add_missing_atoms=self.add_missing_atoms, - remove_waters=self.remove_waters, - remove_ccds=self.remove_ccds, - fix_ligands_at_symmetry_centers=self.fix_ligands_at_symmetry_centers, - fix_arginines=self.fix_arginines, - convert_mse_to_met=self.convert_mse_to_met, - hydrogen_policy=self.hydrogen_policy, - ) - - def _apply_filters(self, atom_array: AtomArray) -> AtomArray: - """Apply filters to the AtomArray to remove non-biological bonds, ignore residues, and filter out atoms with zero occupancy.""" - # Avoid modifying the original AtomArray - filtered_atom_array = copy.deepcopy(atom_array) - - # ----- Filter A: Filter out non-polymers with non-biological bonds to polymers ------ - # ... check for non-biological bonds between the current non-polymer PN unit and any polymer (e.g., oxygen-oxygen, etc.) - inter_pn_unit_bond_mask = dp.get_inter_pn_unit_bond_mask(atom_array) - if np.sum(inter_pn_unit_bond_mask) > 0: - pn_units_with_non_biological_bonds = dp.get_pn_units_with_non_biological_bonds( - atom_array, inter_pn_unit_bond_mask - ) - if len(pn_units_with_non_biological_bonds) > 0: - # ... filter out non-polymer chains with non-biological bonds to polymer chains - non_biological_mask = ( - np.isin(atom_array.pn_unit_iid, pn_units_with_non_biological_bonds) & ~atom_array.is_polymer - ) - filtered_atom_array = atom_array[~non_biological_mask] - logger.warning(f"{self.path}: Non-biological bonds detected between non-polymer and polymer PN units.") - - # ----- Filter B: Apply ignore residue list ------ - processed_ignore_residues = [c.strip() for c in self.ignore_residues] - mask = not_isin( - filtered_atom_array.res_name, processed_ignore_residues - ) # (Applying filter also remove impacted bonds) - filtered_atom_array = filtered_atom_array[mask] - - # ----- Filter C: Filter out atoms with zero occupancy ------ - filtered_atom_array = filtered_atom_array[filtered_atom_array.occupancy > 0.0] - - return filtered_atom_array - - def get_rows( - self, - path_to_structure: PathLike, - ligand_scores: list[str] = [ - "RSCC", - "RSR", - "completeness", - "intermolecular_clashes", - "is_best_instance", - "ranking_model_fit", - "ranking_model_geometry", - ], - ) -> list[dict[str, Any]]: - """Processes a structure file, applies filters, and generates a list of records to be loaded at train-time. - - We create a record for each PN unit (protein, nucleic acid, or non-polymer) in the structure. - Each record contains information about a query PN unit and its partner (contacting) PN units in the structure. - - Args: - path_to_structure (PathLike): The path to the structure file to process. Must be readable by CIFUtils. - - Returns: - list: A list of dictionaries. Each dictionary contains information about a query PN unit and its partner PN units. - """ - path_to_structure = Path(path_to_structure) - if not path_to_structure.exists(): - raise FileNotFoundError(f"File not found: {path_to_structure}") - - result_dict = self._load_structure_with_atomworks(path_to_structure) - id = result_dict["metadata"]["id"] - self.id = id # For logging - - # If applicable, query the RCSB for ligand validity scores - if self.from_rcsb and exists(ligand_scores) and len(ligand_scores) > 0: - # Attempt fetching ligand validity scores - ligand_validity_scores = dp.get_ligand_validity_scores_from_pdb_id(id) - if exists(ligand_validity_scores) and len(ligand_validity_scores) > 0: - # ... if successful, filter and format the data - ligand_validity_scores = pd.DataFrame(ligand_validity_scores) - ligand_validity_scores = ligand_validity_scores.set_index(["asym_id", "res_name"])[ligand_scores] - - # Ensure the index is sorted lexicographically - ligand_validity_scores.sort_index(inplace=True) - else: - # ... if unsuccessful, log and set to None - logger.debug(f"Failed to fetch ligand validity scores for ID {id}") - ligand_validity_scores = None - else: - ligand_validity_scores = None - - # Process each assembly - records = [] - for assembly_id in result_dict["assemblies"]: - result = self._generate_pn_unit_metadata_for_assembly(result_dict, assembly_id, ligand_validity_scores) - if result is not None: - # The path info should be saved as well - for row in result: - row["path"] = path_to_structure - records.extend(result) - return records - - def _generate_pn_unit_metadata_for_assembly( - self, - result_dict: dict, - assembly_id: str, - ligand_validity_scores: pd.DataFrame | None = None, - ) -> list[dict[str, Any]]: - """Processes an atom array that represents a single assembly and generate a list of metadata records for each PN unit. - - Arguments: - result_dict (dict): The dictionary containing the output of CIFUtils parser. - assembly_id (str): The ID of the assembly to process. - ligand_validity_scores (pd.DataFrame | None): A DataFrame containing ligand validity scores, otherwise None. - - Returns: - list[dict[str, Any]]: A list of dictionaries, each containing metadata about a PN unit in the assembly. - """ - id = result_dict["metadata"]["id"] - full_atom_array = result_dict["assemblies"][assembly_id][0] # Choose the first model - - chain_info_dict = result_dict["chain_info"] - - # ---------- Step 1: Upfront pre-processing ---------- # - - # Re-map PN unit IDs to integers, and keep a dictionary that maps back to the verbose PN unit IDs - # (We will use the integer IDs for memory efficiency downstream) - ids_to_remap = ["pn_unit_id", "pn_unit_iid"] - id_map_dict = {} - for id_to_remap in ids_to_remap: - ids = np.unique(full_atom_array.get_annotation(id_to_remap)) - - # ... create the new map - mapped_ids = {old_id: new_id for new_id, old_id in enumerate(ids)} - - # ... apply the new map - new_ids = np.array( - [mapped_ids[old_id] for old_id in full_atom_array.get_annotation(id_to_remap)], dtype=np.int16 - ) - full_atom_array.del_annotation(id_to_remap) # Remove the old annotation (so that we can change the type) - full_atom_array.set_annotation(id_to_remap, new_ids) - - # ... set the reverse map so we can look up the verbose IDs later - id_map_dict[id_to_remap] = {new_id: old_id for old_id, new_id in mapped_ids.items()} - - # ---------- Step 2: Apply filters to the AtomArray ---------- # - filtered_atom_array = self._apply_filters(full_atom_array) - - # ---------- Step 3: Load entry-level criteria ---------- # - loi_ligand_set = set(result_dict["ligand_info"]["ligand_of_interest"]) - num_polymer_pn_units = len(np.unique(filtered_atom_array.pn_unit_iid[filtered_atom_array.is_polymer])) - - if num_polymer_pn_units > self.polymer_pn_unit_limit: - logger.warning( - f"(Example {self.id}): {num_polymer_pn_units} polymer PN units in entry; skipping for performance reasons." - ) - return None - - # ---------- Step 4: Detect and resolve clashes ---------- # - - # Build cell list for rapid distance computations - if len(filtered_atom_array) == 0: - logger.warning(f"(Example {self.id}): No atoms remaining after filtering.") - return [] - cell_list = struc.CellList(filtered_atom_array, cell_size=CELL_SIZE) - - # Get the unique polymer/non-polymer unit IDs from the AtomArray (we will consider process each moving forward) - pn_unit_iids_to_consider = np.unique(filtered_atom_array.pn_unit_iid) - - # Find inter-PN unit clashes - clash_severity = ClashSeverity.NO_CLASH - clashing_pn_units_set, clashing_pn_units_dict = dp.get_clashing_pn_units( - pn_unit_iids_to_consider, filtered_atom_array, cell_list, clash_distance=self.clash_distance - ) - if clashing_pn_units_set: - logger.warning( - f"(Example {self.id}): Clash detected between PN units: {[id_map_dict['pn_unit_iid'][pn_unit] for pn_unit in clashing_pn_units_set]}" - ) - filtered_atom_array, clash_severity = dp.handle_clashing_pn_units( - clashing_pn_units_set, clashing_pn_units_dict, filtered_atom_array, id_map_dict["pn_unit_iid"] - ) - - # Remake the cell list, since we have removed atoms - cell_list = struc.CellList(filtered_atom_array, cell_size=CELL_SIZE) - - # ---------- Step 5: Find contacting/partner PN units and build dataframes ---------- # - # Loop through all considered query PN units, including proteins (single-chain), nucleic acids (single-chain), and ligands (single- or multi-chain) - assembly_records = [] - - # Filter out pn_units that have length 0 atom arrays - pn_unit_iids_to_consider = [ - pn_unit_iid - for pn_unit_iid in pn_unit_iids_to_consider - if len(filtered_atom_array[filtered_atom_array.pn_unit_iid == pn_unit_iid]) > 0 - ] - - for query_pn_unit_iid in pn_unit_iids_to_consider: - query_pn_unit_atom_array = filtered_atom_array[filtered_atom_array.pn_unit_iid == query_pn_unit_iid] - - assert len(query_pn_unit_atom_array) > 0, f"Query PN unit {query_pn_unit_iid} has zero atoms" - - query_pn_unit_type = ChainType( - query_pn_unit_atom_array.chain_type[0] - ) # All chains in a PN unit have the same type - - # Find contacting PN units, which we will use to construct interfaces - contacting_pn_unit_iids = dp.get_contacting_pn_units( - query_pn_unit=query_pn_unit_atom_array, - filtered_atom_array=filtered_atom_array, - cell_list=cell_list, - contact_distance=self.contact_distance, - min_contacts_required=1, - calculate_min_distance=True, - ) - - # Find close PN units, which will be used to determine which PN units to load at train-time - close_pn_unit_iids = dp.get_contacting_pn_units( - query_pn_unit=query_pn_unit_atom_array, - filtered_atom_array=filtered_atom_array, - cell_list=cell_list, - contact_distance=self.close_distance, - min_contacts_required=1, - calculate_min_distance=False, - ) - close_pn_unit_iids = [id_map_dict["pn_unit_iid"][pn_unit["pn_unit_iid"]] for pn_unit in close_pn_unit_iids] - - # Sort contacting PN units by number of contacting atoms and then by minimum distance - contacting_pn_unit_iids = sorted( - contacting_pn_unit_iids, - key=lambda x: (x["num_contacts"], -x["min_distance"]), - reverse=True, - ) - - # Determine the primary polymer chain, which is the first partner polymer PN unit for non-polymers, or the query PN unit itself for polymers - if not query_pn_unit_type.is_non_polymer(): - primary_polymer_partner_pn_unit_iid = query_pn_unit_iid - else: - polymer_pn_unit_iids = np.unique(filtered_atom_array.pn_unit_iid[filtered_atom_array.is_polymer]) - # Find the first polymer PN unit in the contacting_pn_unit_iids sorted list - partner_polymer_pn_units = [ - partner for partner in contacting_pn_unit_iids if partner["pn_unit_iid"] in polymer_pn_unit_iids - ] - primary_polymer_partner_pn_unit_iid = ( - partner_polymer_pn_units[0]["pn_unit_iid"] if len(partner_polymer_pn_units) > 0 else None - ) - - type_specific_criteria = {} - # For non-polymers, calculate additional information - if query_pn_unit_type.is_non_polymer(): - bonded_polymer_pn_units = dp.get_bonded_polymer_pn_units(query_pn_unit_iid, filtered_atom_array) - - # ... check is SOI - residue_names = np.unique(query_pn_unit_atom_array.res_name) - is_loi = len(loi_ligand_set.intersection(residue_names)) > 0 - - # ... check if metal - is_metal = bool( - len(query_pn_unit_atom_array) == 1 and query_pn_unit_atom_array[0].element.upper() in METAL_ELEMENTS - ) - - if exists(ligand_validity_scores): - _query_pn_unit_ligand_ids = sorted( - set(zip(query_pn_unit_atom_array.chain_id, query_pn_unit_atom_array.res_name, strict=False)) - ) - - # ... subset to the ids that have validity scores - _ligands_with_scores = [ - _id for _id in _query_pn_unit_ligand_ids if _id in ligand_validity_scores.index - ] - - ligand_validity = ligand_validity_scores.loc[_ligands_with_scores].to_dict() - else: - ligand_validity = {} - - # Get the sequence of residues in the non-polymer PN unit - non_polymer_res_names = struc.get_residues(query_pn_unit_atom_array)[ - 1 - ] # (`get_residues` returns a tuple of (ids, names)) - - # Other options to consider for criteria: - # -- Get the diameter of the PN unit - # -- Get whether the query PN unit is coordinated - # -- Get polar contacts - # -- Get fraction of atoms in hull - - type_specific_criteria = { - "is_metal": is_metal, - "is_loi": is_loi, - "bonded_polymer_pn_units": bonded_polymer_pn_units, - "ligand_validity": ligand_validity, - "non_polymer_res_names": non_polymer_res_names, - } - elif query_pn_unit_type.is_polymer(): - chain_id = query_pn_unit_atom_array.chain_id[0] # (Polymers have only one chain) - ec_numbers = chain_info_dict[chain_id].get("ec_numbers", None) - type_specific_criteria = { - "ec_numbers": json.dumps(ec_numbers), - "sequence_length": len( - chain_info_dict[chain_id]["processed_entity_canonical_sequence"] - ), # (We need to use the processed sequence) - "processed_entity_canonical_sequence": chain_info_dict[chain_id][ - "processed_entity_canonical_sequence" - ], - "processed_entity_non_canonical_sequence": chain_info_dict[chain_id][ - "processed_entity_non_canonical_sequence" - ], - } - - # Sequence information - q_pn_unit_processed_entity_canonical_sequence = type_specific_criteria.get( - "processed_entity_canonical_sequence", "" - ) - q_pn_unit_processed_entity_non_canonical_sequence = type_specific_criteria.get( - "processed_entity_non_canonical_sequence", "" - ) - - # Sequence hashes - q_pn_unit_processed_entity_canonical_sequence_hash = ( - hash_sequence(q_pn_unit_processed_entity_canonical_sequence) - if q_pn_unit_processed_entity_canonical_sequence - else None - ) - q_pn_unit_processed_entity_non_canonical_sequence_hash = ( - hash_sequence(q_pn_unit_processed_entity_non_canonical_sequence) - if q_pn_unit_processed_entity_non_canonical_sequence - else None - ) - - # Resolved residues (we already removed atoms with zero occupancy) - num_resolved_residues = struc.get_residue_count(query_pn_unit_atom_array) - - # fmt: off - pn_unit_record = { - # ...add entry-level data to the record (e.g., resolution, deposition date, etc.) - "pdb_id": id if self.from_rcsb else None, - "assembly_id": assembly_id, - "clash_severity": clash_severity.value, - "resolution": result_dict["metadata"]["resolution"], - "deposition_date": result_dict["metadata"]["deposition_date"], - "release_date": result_dict["metadata"]["release_date"], - "method": result_dict["metadata"]["method"], - "num_polymer_pn_units": num_polymer_pn_units, - "num_resolved_atoms_in_processed_assembly": len(filtered_atom_array), - "total_num_atoms_in_unprocessed_assembly": len(full_atom_array), - "all_pn_unit_iids_after_processing": json.dumps([id_map_dict["pn_unit_iid"][pn_unit_iid] for pn_unit_iid in np.unique(filtered_atom_array.pn_unit_iid)]), # e.g., what we should load at train-time, after resolving clashes - - # ...add the fundamental PN unit-level data to the record directly from the AtomArray - "q_pn_unit_id": id_map_dict["pn_unit_id"][query_pn_unit_atom_array.pn_unit_id[0]], - "q_pn_unit_iid": id_map_dict["pn_unit_iid"][query_pn_unit_iid], - "q_pn_unit_molecule_id": query_pn_unit_atom_array.molecule_id[0], # All atoms in a PN unit have the same molecule ID - "q_pn_unit_molecule_iid": query_pn_unit_atom_array.molecule_iid[0], # All atoms in a PN unit have the same molecule IID - "q_pn_unit_transformation_id": query_pn_unit_atom_array.transformation_id[ - 0 - ], # All atoms in a PN unit have the same transformation ID - "q_pn_unit_type": query_pn_unit_type.value, - "q_pn_unit_is_polymer": query_pn_unit_type.is_polymer(), - - # ...add derived PN unit-level data to the record - "q_pn_unit_num_resolved_atoms": len(query_pn_unit_atom_array), - "q_pn_unit_is_multichain": len(np.unique(query_pn_unit_atom_array.chain_id)) > 1, - "q_pn_unit_is_multiresidue": len(np.unique(query_pn_unit_atom_array.res_id)) > 1, - "q_pn_unit_num_resolved_residues": num_resolved_residues, - # (Type-specific) Non-polymer criteria - "q_pn_unit_is_metal": type_specific_criteria.get("is_metal", False), - "q_pn_unit_is_loi": type_specific_criteria.get("is_loi", False), - "q_pn_unit_ligand_validity": type_specific_criteria.get("ligand_validity", {}), - "q_pn_unit_bonded_polymer_pn_units": { - id_map_dict["pn_unit_iid"][pn_unit] - for pn_unit in type_specific_criteria.get("bonded_polymer_pn_units", set()) - }, # Covalent modifications - "q_pn_unit_non_polymer_res_names": ",".join(type_specific_criteria.get("non_polymer_res_names", [])), - # (Type-specific) Polymer criteria - "q_pn_unit_ec_numbers": type_specific_criteria.get("ec_numbers", []), - "q_pn_unit_sequence_length": type_specific_criteria.get("sequence_length", None), - - # ...sequences - "q_pn_unit_processed_entity_canonical_sequence": q_pn_unit_processed_entity_canonical_sequence, - "q_pn_unit_processed_entity_non_canonical_sequence": q_pn_unit_processed_entity_non_canonical_sequence, - - # ...sequence hashes - "q_pn_unit_processed_entity_canonical_sequence_hash": q_pn_unit_processed_entity_canonical_sequence_hash, - "q_pn_unit_processed_entity_non_canonical_sequence_hash": q_pn_unit_processed_entity_non_canonical_sequence_hash, - - # ...partners - "q_pn_unit_primary_polymer_partner": id_map_dict["pn_unit_iid"][primary_polymer_partner_pn_unit_iid] - if primary_polymer_partner_pn_unit_iid - else None, - "q_pn_unit_contacting_pn_unit_iids": json.dumps( - [ - { - "pn_unit_iid": id_map_dict["pn_unit_iid"][partner["pn_unit_iid"]], - **{k: v for k, v in partner.items() if k != "pn_unit_iid"}, - } - for partner in contacting_pn_unit_iids - ] - ), - "q_pn_unit_close_pn_unit_iids": json.dumps(close_pn_unit_iids), - } - # fmt: on - - assembly_records.append(pn_unit_record) - return assembly_records diff --git a/src/atomworks/ml/preprocessing/msa/colabfold_server.py b/src/atomworks/ml/preprocessing/msa/colabfold_server.py new file mode 100644 index 00000000..0ad7ee96 --- /dev/null +++ b/src/atomworks/ml/preprocessing/msa/colabfold_server.py @@ -0,0 +1,607 @@ +"""Generate MSAs by querying the public ColabFold MSA server over HTTP. + +Unlike :py:mod:`~atomworks.ml.preprocessing.msa.generating`, which runs MMseqs2/HHblits +locally against on-disk databases, this module submits sequences to a remote MSA +server (by default ``https://api.colabfold.com``) and polls for results. + +Adapted from ColabFold's ``run_mmseqs2`` +(https://github.com/sokrypton/ColabFold/blob/main/colabfold/colabfold.py#L69). + +Can also fetch raw template-alignment hits (``pdb70.m8`` rows) alongside the MSA via +``use_templates``. To download the hit structures, see +:py:mod:`~atomworks.ml.preprocessing.msa.template_structures`. + +References: + * Mirdita, M. et al. (2022). ColabFold: making protein folding accessible to all. *Nature Methods*, 19, 679-682. +""" + +import logging +import os +import random +import tarfile +import tempfile +import time +from os import PathLike +from pathlib import Path +from typing import Literal + +import requests +from tqdm import tqdm + +from atomworks.enums import MSAFileExtension +from atomworks.ml.preprocessing.msa.organizing import ( + MSAOrganizationConfig, + organize_msas, + organize_paired_msas, + organize_template_alignments, +) +from atomworks.ml.utils.misc import get_complex_id + +logger = logging.getLogger(__name__) + + +TQDM_BAR_FORMAT = "{l_bar}{bar}| {n_fmt}/{total_fmt} [elapsed: {elapsed} remaining: {remaining}]" + + +class ColabFoldServerResultError(RuntimeError): + """Raised when a ColabFold MSA server download is missing expected outputs. + + The public ColabFold server can return the wrong cached job for a ticket -- + e.g. an unpaired MSA (no ``pair.a3m``) in response to a paired request -- which + otherwise surfaces as an opaque ``FileNotFoundError`` downstream. + """ + + +def _validate_expected_msa_files(a3m_files: list[str], tar_gz_file: str, *, use_pairing: bool) -> None: + """Verify the download produced every expected a3m file (present and non-empty). + + Guards against the ColabFold server returning an unexpected or incomplete result: + instead of letting a later ``open()`` raise a bare ``FileNotFoundError``, raise a + clear error naming the expected files, the missing ones, and what the downloaded + tarball actually contained. + + Args: + a3m_files: Absolute paths of the a3m files this query is expected to yield. + tar_gz_file: Path of the downloaded ``out.tar.gz`` (read only for diagnostics). + use_pairing: Whether this was a paired query (used only for the message). + + Raises: + ColabFoldServerResultError: If any expected file is missing or empty. + """ + missing: list[str] = [f for f in a3m_files if not os.path.isfile(f) or os.path.getsize(f) == 0] + if not missing: + return + + try: + with tarfile.open(tar_gz_file) as tar_gz: + members: list[str] = sorted(m.name for m in tar_gz.getmembers()) + except (tarfile.TarError, OSError): + members = [""] + + query_kind = "paired" if use_pairing else "unpaired" + raise ColabFoldServerResultError( + f"ColabFold {query_kind} MSA query returned an unexpected or incomplete " + "result.\n" + f" expected (non-empty): {[os.path.basename(f) for f in a3m_files]}\n" + f" missing/empty: {[os.path.basename(f) for f in missing]}\n" + f" tarball contained: {members}\n" + f" download: {tar_gz_file}\n" + "The MSA server likely returned the wrong cached job for this query " + "(e.g. an unpaired result for a paired request). Please inspect the tarball " + "contents and re-run the query if necessary." + ) + + +def query_colabfold_msa_server( + x: list[str], + prefix: Path, + user_agent: str, + use_templates: bool = False, + use_pairing: bool = False, + pairing_strategy: Literal["greedy", "complete"] = "greedy", + use_env: bool = True, + use_filter: bool = True, + filter: bool | None = None, + host_url: str = "https://api.colabfold.com", +) -> list[str] | tuple[list[str], list[str]]: + """Submits a single query to the ColabFold MSA server. + + Adapted from ColabFold's ``run_mmseqs2``: + https://github.com/sokrypton/ColabFold/blob/main/colabfold/colabfold.py#L69 + Upstream variable names (``ID``, ``REDO``, ``TIME``, ...) and the per-request + retry loops are kept as-is to stay easy to diff against upstream; ruff's + N803/N806 naming checks are disabled for this file in ``pyproject.toml``. + + Args: + x (list[str]): + List of amino acid sequences to query the MSA server with. + prefix (Path): + Output directory to save the results to. Must not already exist -- a + pre-existing directory here is always treated as a stale leftover from an + earlier run and rejected, rather than silently reused (see `Raises`). + user_agent (str): + User associated with API call. + use_templates (bool, optional): + Whether to return raw template-alignment hits (``pdb70.m8`` rows) for each + query sequence. Defaults to False. If use_pairing is True, this internally + gets set to False. Only the raw hit table is returned -- to download the + hit structures, see :py:mod:`~atomworks.ml.preprocessing.msa.template_structures`. + use_pairing (bool, optional): + Whether to generate a single paired MSA. Defaults to False. + pairing_strategy (str, optional): + Pairing method, one of ["complete", "greedy"]. For pairing of more than 2 + chains, "complete" requires a taxonomic group to be present in all chains, + where greedy requires it to be in only 2. + use_env (bool, optional): + Whether to align against env db (BFD/cfdb). Defaults to True. + use_filter (bool, optional): + Whether to apply diversity filter. Defaults to True. + filter (bool | None, optional): + Legacy option to enable diversity filter. Defaults to None. + host_url (str, optional): + Host url for MSA server. Defaults to "https://api.colabfold.com". + + Returns: + list[str] | tuple[list[str], list[str | None]]: + List of MSA strings in a3m format, one per query sequence. If use_templates + is True, also returns a list of raw template-alignment hit blocks (tab- + separated ``pdb70.m8`` rows, one per query sequence, in the same order as + `x`); a sequence with no hits gets `None`. + + Raises: + FileExistsError: If `prefix` already exists -- see the `prefix` arg. + """ + submission_endpoint = "ticket/pair" if use_pairing else "ticket/msa" + + # Normalize the host: a trailing slash would produce a doubled slash (e.g. + # ".../com//ticket/msa") in the f-strings below. The server 301-redirects the + # doubled slash, and requests downgrades the POST to GET while dropping the body, + # yielding a misleading "invalid ID". + host_url = str(host_url).rstrip("/") + + headers = {} + if user_agent != "": + headers["User-Agent"] = user_agent + else: + logger.warning( + "No user agent specified. Please set a user agent" + "(e.g., 'toolname/version contact@email') to help" + "us debug in case of problems. This warning will become an error" + "in the future." + ) + + def submit(seqs: list[str], mode: str, N: int = 101) -> None: + n, query = N, "" + for seq in seqs: + query += f">{n}\n{seq}\n" + n += 1 + + error_count = 0 + while True: + try: + # https://requests.readthedocs.io/en/latest/user/advanced/#advanced + # "good practice to set connect timeouts to slightly larger + # than a multiple of 3" + res = requests.post( + f"{host_url}/{submission_endpoint}", + data={"q": query, "mode": mode}, + timeout=6.02, + headers=headers, + ) + except requests.exceptions.Timeout: + logger.warning("Timeout while submitting to MSA server. Retrying...") + continue + except Exception as e: + error_count += 1 + logger.warning(f"Error while fetching result from MSA server.Retrying... ({error_count}/5)") + logger.warning(f"Error: {e}") + time.sleep(5) + if error_count > 5: + raise + continue + break + + try: + out = res.json() + except ValueError: + logger.error(f"Server didn't reply with json: {res.text}") + out = {"status": "ERROR"} + return out + + def status(ID: str) -> dict[str, str]: + error_count = 0 + while True: + try: + res = requests.get(f"{host_url}/ticket/{ID}", timeout=6.02, headers=headers) + except requests.exceptions.Timeout: + logger.warning("Timeout while fetching status from MSA server. Retrying...") + continue + except Exception as e: + error_count += 1 + logger.warning(f"Error while fetching result from MSA server.Retrying... ({error_count}/5)") + logger.warning(f"Error: {e}") + time.sleep(5) + if error_count > 5: + raise + continue + break + try: + out = res.json() + except ValueError: + logger.error(f"Server didn't reply with json: {res.text}") + out = {"status": "ERROR"} + return out + + def download(ID: str, path: str) -> None: + error_count = 0 + while True: + try: + # (connect_timeout, read_timeout): a single float applies to both, and + # 6.02s is too tight for a read timeout on a results tarball that can be + # several MB for a deep MSA -- keep the connect timeout tight but give the + # actual download more room. + res = requests.get( + f"{host_url}/result/download/{ID}", + timeout=(6.02, 60), + headers=headers, + ) + except requests.exceptions.Timeout: + logger.warning("Timeout while fetching result from MSA server. Retrying...") + continue + except Exception as e: + error_count += 1 + logger.warning(f"Error while fetching result from MSA server.Retrying... ({error_count}/5)") + logger.warning(f"Error: {e}") + time.sleep(5) + if error_count > 5: + raise + continue + break + with open(path, "wb") as out: + out.write(res.content) + + seqs = [x] if isinstance(x, str) else x + + # Compatibility to old option + if filter is not None: + use_filter = filter + + # Setup mode + if use_filter: + mode = "env" if use_env else "all" + else: + mode = "env-nofilter" if use_env else "nofilter" + if use_pairing: + use_templates = False + mode = "" + # greedy is default, complete was the previous behavior + if pairing_strategy == "greedy": + mode = "pairgreedy" + elif pairing_strategy == "complete": + mode = "paircomplete" + if use_env: + mode = mode + "-env" + + # Put everything in the same dir. A directory that already exists here is always + # leftover from an earlier (likely failed) run at this exact path -- reusing it + # risks silently parsing a *different* query's stale out.tar.gz, corrupting + # results in a way that's very hard to debug downstream. Fail loudly instead of + # guessing whether it's safe to reuse; the caller can inspect/save it or delete + # it before retrying. See https://github.com/aqlaboratory/openfold-3/issues/39. + path = f"{prefix}" + if os.path.isdir(path): + raise FileExistsError( + f"ColabFold raw output directory already exists: {path}\n" + "This is likely left over from a previous failed run and may contain " + "stale results for a different query. Please remove it (or move it " + "aside first if you want to inspect/save the raw files) before retrying." + ) + os.mkdir(path) + + # Call mmseqs2 api + tar_gz_file = f"{path}/out.tar.gz" + N, REDO = 101, True + + # Deduplicate and keep track of order + seqs_unique = [] + [seqs_unique.append(x) for x in seqs if x not in seqs_unique] + Ms = [N + seqs_unique.index(seq) for seq in seqs] + + # Run query + if not os.path.isfile(tar_gz_file): + TIME_ESTIMATE = 150 * len(seqs_unique) + with tqdm(total=TIME_ESTIMATE, bar_format=TQDM_BAR_FORMAT) as pbar: + while REDO: + pbar.set_description("SUBMIT") + + # Resubmit job until it goes through + out = submit(seqs_unique, mode, N) + while out["status"] in ["UNKNOWN", "RATELIMIT"]: + sleep_time = 5 + random.randint(0, 5) + logger.info(f"Sleeping for {sleep_time}s. Reason: {out['status']}") + time.sleep(sleep_time) + out = submit(seqs_unique, mode, N) + + if out["status"] == "ERROR": + raise Exception( + "MMseqs2 API is giving errors." + "Please confirm your input is a valid protein sequence." + "If error persists, please try again an hour later." + ) + + if out["status"] == "MAINTENANCE": + raise Exception("MMseqs2 API is undergoing maintenance.Please try again in a few minutes.") + + # Wait for job to finish + ID, TIME = out["id"], 0 + pbar.set_description(out["status"]) + while out["status"] in ["UNKNOWN", "RUNNING", "PENDING"]: + t = 5 + random.randint(0, 5) + logger.info(f"Sleeping for {t}s. Reason: {out['status']}") + time.sleep(t) + out = status(ID) + pbar.set_description(out["status"]) + if out["status"] == "RUNNING": + TIME += t + pbar.update(n=t) + + if out["status"] == "COMPLETE": + if TIME < TIME_ESTIMATE: + pbar.update(n=(TIME_ESTIMATE - TIME)) + REDO = False + + if out["status"] == "ERROR": + REDO = False + raise Exception( + "MMseqs2 API is giving errors." + "Please confirm your input is a valid protein sequence." + "If error persists, please try again an hour later." + ) + + # Download results + download(ID, tar_gz_file) + + # Prepare list of a3m files + if use_pairing: + a3m_files = [f"{path}/pair.a3m"] + else: + a3m_files = [f"{path}/uniref.a3m"] + if use_env: + a3m_files.append(f"{path}/bfd.mgnify30.metaeuk30.smag30.a3m") + + # Extract a3m files + if any(not os.path.isfile(a3m_file) for a3m_file in a3m_files): + with tarfile.open(tar_gz_file) as tar_gz: + tar_gz.extractall(path, filter="data") + + # Validate the download produced the expected outputs before any downstream + # code blindly opens them. + _validate_expected_msa_files(a3m_files, tar_gz_file, use_pairing=use_pairing) + + # Parse raw template-alignment hits (pdb70.m8 rows), grouped by ColabFold's + # internal per-sequence index M. Only the raw hit table is returned here; hit + # structures are fetched separately (see template_structures). + if use_templates: + template_hit_lines: dict[int, list[str]] = {} + with open(f"{path}/pdb70.m8") as f: + for line in f: + if not line.strip(): + continue + M = int(line.split("\t", 1)[0]) + template_hit_lines.setdefault(M, []).append(line) + + # Gather a3m lines + a3m_lines = {} + for a3m_file in a3m_files: + update_M, M = True, None + with open(a3m_file) as f: + for line in f: + if len(line) > 0: + if "\x00" in line: + line = line.replace("\x00", "") + update_M = True + if line.startswith(">") and update_M: + M = int(line[1:].rstrip()) + update_M = False + if M not in a3m_lines: + a3m_lines[M] = [] + a3m_lines[M].append(line) + + a3m_lines = ["".join(a3m_lines[n]) for n in Ms] + + if use_templates: + template_alignments = ["".join(template_hit_lines[n]) if n in template_hit_lines else None for n in Ms] + + return (a3m_lines, template_alignments) if use_templates else a3m_lines + + +def make_msas_colabfold_server( + sequences: str | list[str], + output_dir: PathLike, + use_pairing: bool = False, + use_templates: bool = False, + user_agent: str = "", + host_url: str = "https://api.colabfold.com", + use_env: bool = True, + use_filter: bool = True, + pairing_strategy: Literal["greedy", "complete"] = "greedy", + sharding_pattern: str = "/0:2/", + output_extension: str = MSAFileExtension.A3M_GZ.value, +) -> None: + """Generate MSAs from protein sequences via the public ColabFold MSA server. + + Submits exactly ONE query: either an unpaired batch of independent sequences, or + one paired/complex query. To generate both unpaired and paired MSAs for a full + fine-tuning run, use :py:func:`make_msas_colabfold_server_batch`. + + Organizes the raw a3m results into AtomWorks' standard MSA layout via + :py:func:`~atomworks.ml.preprocessing.msa.organizing.organize_msas` (unpaired) or + :py:func:`~atomworks.ml.preprocessing.msa.organizing.organize_paired_msas` + (paired). + + Msa results are discoverable through :py:func:`~atomworks.ml.preprocessing.msa.finding.find_paired_msas` + + Args: + sequences: A single protein sequence string or list of protein sequences. For + a paired query, the full unique-sequence set of one complex. + output_dir: Base output directory + use_pairing: Whether to submit a paired/complex query instead of independent + per-sequence queries. + use_templates: Whether to also fetch and organize raw template-alignment hits + (``pdb70.m8`` rows) for each sequence, under ``/templates``. + Ignored (forced off) when `use_pairing` is True, matching the ColabFold + server's own constraint. To download the hit structures, see + :py:mod:`~atomworks.ml.preprocessing.msa.template_structures`. + user_agent: User agent string sent with the MSA server API calls. + host_url: ColabFold MSA server URL. + use_env: Whether to align against the environmental (metagenomic) database. + use_filter: Whether to apply the ColabFold server's diversity filter. + pairing_strategy: Pairing method, one of "greedy"/"complete" (only used when + `use_pairing` is True). + sharding_pattern: Directory sharding pattern (e.g. "/0:2/"), applied to both + unpaired sequence hashes and, for paired output, complex_ids. + output_extension: Output file extension (.a3m, .a3m.gz) + """ + if isinstance(sequences, str): + sequences = [sequences] + + base_output_path = Path(output_dir) + target_dir = base_output_path / ("paired" if use_pairing else "unpaired") + + effective_use_templates = use_templates and not use_pairing + if use_templates and use_pairing: + logger.warning("use_templates=True is ignored for paired queries (not supported by the ColabFold server).") + + with tempfile.TemporaryDirectory() as scratch: + scratch_path = Path(scratch) + result = query_colabfold_msa_server( + sequences, + prefix=scratch_path / "raw", + user_agent=user_agent, + host_url=host_url, + use_env=use_env, + use_filter=use_filter, + use_pairing=use_pairing, + use_templates=effective_use_templates, + pairing_strategy=pairing_strategy, + ) + a3m_lines, template_alignments = result if effective_use_templates else (result, None) + + # Write one raw a3m file per submitted sequence, mirroring the shape + # make_msas_mmseqs's unpackdb step produces, so organize_msas/organize_paired_msas + # can hash/shard them the same way regardless of which backend generated them. + unpacked_dir = scratch_path / "unpacked" + unpacked_dir.mkdir() + for i, aln in enumerate(a3m_lines): + (unpacked_dir / f"{i}.a3m").write_text(aln) + + org_config = MSAOrganizationConfig( + input_extension=MSAFileExtension.A3M, + output_extension=output_extension, + sharding_pattern=sharding_pattern, + copy_files=False, # Move files instead of copying + ) + logger.info("Organizing MSA files...") + if use_pairing: + organize_paired_msas(unpacked_dir, target_dir, org_config) + else: + organize_msas(unpacked_dir, target_dir, org_config) + + if effective_use_templates: + sequence_to_template_alignment = dict(zip(sequences, template_alignments, strict=True)) + logger.info("Organizing template alignment files...") + organize_template_alignments( + sequence_to_template_alignment, + base_output_path / "templates", + sharding_pattern=sharding_pattern, + ) + + logger.info(f"MSA files saved to: {target_dir.absolute()}") + + +def make_msas_colabfold_server_batch( + sequences: list[str], + output_dir: PathLike, + complexes: list[list[str]] | None = None, + use_templates: bool = False, + user_agent: str = "", + host_url: str = "https://api.colabfold.com", + use_env: bool = True, + use_filter: bool = True, + pairing_strategy: Literal["greedy", "complete"] = "greedy", + sharding_pattern: str = "/0:2/", + output_extension: str = MSAFileExtension.A3M_GZ.value, +) -> None: + """Generate both unpaired and (optionally) paired MSAs for a full fine-tuning run, + all under one `output_dir`. + + Msa results are written to the and has the following structure: + - Unpaired (default): ``/unpaired//``, + discoverable via :py:func:`~atomworks.ml.preprocessing.msa.finding.find_msas` / + :py:func:`~atomworks.ml.transforms.msa._msa_loading_utils.get_msa_path` + (point `dir` at ``/unpaired``). + - Paired (`use_pairing=True`): submits a paired/complex query instead of + independent per-sequence queries, and writes to + ``/paired///`` + where `complex_id` is computed based on the sequence hashes (see `get_complex_id`) + and also sharded in the same way sequence hashes are. + - Templates (`use_templates=True`): raw template-alignment hits for the + unpaired sequences only (paired queries don't support templates), written + to ``/templates//.m8``, discoverable via + :py:func:`~atomworks.ml.preprocessing.msa.finding.find_template_alignments`. + + Args: + sequences: Unique protein sequences to submit as an independent (unpaired) + batch. Every unique sequence appearing in `complexes` is automatically + included as well, even if not passed here directly -- every chain needs + its own unpaired MSA (and, if requested, template alignment) regardless + of whether it also participates in pairing. Pass an empty list (with + `complexes` also empty) to skip unpaired generation entirely. + output_dir: Base output directory, shared by both unpaired and paired output + -- see :py:func:`make_msas_colabfold_server`'s layout. + complexes: Each entry is the full sequence set of one complex, submitted as a + separate paired query. Entries with the same complex_id (same unique + sequences, in any order or with repeats) are submitted once. None or empty + to skip paired generation. + use_templates: Whether to also fetch and organize raw template-alignment + hits for `sequences` (the unpaired batch). See + :py:func:`make_msas_colabfold_server`'s `use_templates` for details and + limitations. + user_agent: User agent string sent with the MSA server API calls. + host_url: ColabFold MSA server URL. + use_env: Whether to align against the environmental (metagenomic) database. + use_filter: Whether to apply the ColabFold server's diversity filter. + pairing_strategy: Pairing method for paired queries, one of "greedy"/"complete". + sharding_pattern: Directory sharding pattern (e.g. "/0:2/"). + output_extension: Output file extension (.a3m, .a3m.gz) + """ + common_kwargs = { + "user_agent": user_agent, + "host_url": host_url, + "use_env": use_env, + "use_filter": use_filter, + "sharding_pattern": sharding_pattern, + "output_extension": output_extension, + } + + # Every chain needs its own unpaired MSA regardless of whether it also + # participates in pairing -- union in complex members that weren't passed in + # `sequences` directly, deduplicating while preserving first-seen order. + unpaired_sequences = list( + dict.fromkeys([*sequences, *(seq for complex_sequences in complexes or [] for seq in complex_sequences)]) + ) + + if unpaired_sequences: + make_msas_colabfold_server(unpaired_sequences, output_dir, use_templates=use_templates, **common_kwargs) + + # One paired query per complex_id -- a duplicate would waste a query and then hit + # organize_paired_msas's existing-directory check. + unique_complexes = {get_complex_id(complex_sequences): complex_sequences for complex_sequences in complexes or []} + for complex_sequences in unique_complexes.values(): + make_msas_colabfold_server( + complex_sequences, + output_dir, + use_pairing=True, + pairing_strategy=pairing_strategy, + **common_kwargs, + ) diff --git a/src/atomworks/ml/preprocessing/msa/finding.py b/src/atomworks/ml/preprocessing/msa/finding.py index 6f4f0d38..ba37d210 100644 --- a/src/atomworks/ml/preprocessing/msa/finding.py +++ b/src/atomworks/ml/preprocessing/msa/finding.py @@ -1,6 +1,7 @@ """Find existing MSA files and their locations.""" import logging +import warnings from os import PathLike from pathlib import Path @@ -8,33 +9,12 @@ from atomworks.constants import _load_env_var from atomworks.enums import MSAFileExtension -from atomworks.ml.utils.misc import hash_sequence +from atomworks.io.utils.io_utils import apply_sharding_pattern, build_sharding_pattern +from atomworks.ml.utils.misc import get_complex_id, hash_sequence logger = logging.getLogger(__name__) -def get_msa_dirs_from_env(raise_if_not_set: bool = True) -> list[Path] | None: - """Parse LOCAL_MSA_DIRS environment variable into Path objects. - - Returns: - List of Path objects for MSA directories from environment variable. - None if LOCAL_MSA_DIRS is not set or empty and raise_if_not_set is False. - - Raises: - ValueError: If LOCAL_MSA_DIRS is not set or empty and raise_if_not_set is True. - """ - local_msa_dirs = _load_env_var("LOCAL_MSA_DIRS") - if not local_msa_dirs: - if raise_if_not_set: - raise ValueError("LOCAL_MSA_DIRS environment variable is not set or empty") - else: - return None - - dirs = [Path(dir_path.strip()) for dir_path in local_msa_dirs.split(",")] - - return dirs - - def get_msa_depth_and_ext_from_folder(folder: Path, max_depth: int = 10) -> tuple[int, MSAFileExtension]: """Automatically detect the shard depth and file extension of an MSA folder. @@ -53,10 +33,10 @@ def get_msa_depth_and_ext_from_folder(folder: Path, max_depth: int = 10) -> tupl ValueError: If no MSA files are found within max_depth levels. Examples: - For structure like `/msa/ab/cd/abcd123.a3m.gz`: + For structure like ``/msa/ab/cd/abcd123.a3m.gz``: - depth, ext = get_msa_depth_and_ext_from_folder(Path("/msa")) - # Returns: (2, MSAFileExtension.A3M_GZ) + >>> depth, ext = get_msa_depth_and_ext_from_folder(Path("/msa")) + >>> # Returns: (2, MSAFileExtension.A3M_GZ) """ if not folder.exists(): raise ValueError(f"Folder does not exist: {folder}") @@ -68,22 +48,28 @@ def get_msa_depth_and_ext_from_folder(folder: Path, max_depth: int = 10) -> tupl depth = 0 while depth <= max_depth: - found_subdir = False - # Check if current directory contains any MSA files + # Separate file detection from subdirectory selection for efficiency: + # First check all files for MSA matches, then find first subdir if needed + files = [] + subdirs = [] for item in current_dir.iterdir(): if item.is_file(): - for ext_str in msa_extensions: - if item.name.endswith(ext_str): - matching_ext = MSAFileExtension(ext_str) - return depth, matching_ext - else: # it's a directory - next_subdir = item - found_subdir = True - - if not found_subdir: + files.append(item) + else: + subdirs.append(item) + + # Check files for MSA matches + for item in files: + for ext_str in msa_extensions: + if item.name.endswith(ext_str): + matching_ext = MSAFileExtension(ext_str) + return depth, matching_ext + + # No MSA files found, descend into first subdirectory (sorted for determinism) + if not subdirs: break - current_dir = next_subdir + current_dir = min(subdirs) # Deterministic: pick lexicographically first depth += 1 raise ValueError( @@ -92,98 +78,176 @@ def get_msa_depth_and_ext_from_folder(folder: Path, max_depth: int = 10) -> tupl ) -def _build_msa_file_paths( - sequence_hash: str, msa_dir: Path, shard_depths: list[int], extensions: list[str] -) -> list[Path]: - """Build all possible MSA file paths for a sequence hash. +def _auto_detect_msa_dir_metadata(msa_dir: Path) -> dict | None: + """Auto-detect MSA directory metadata (depth and extension). + + Args: + msa_dir: Path to the MSA directory. + + Returns: + Dict with 'dir', 'extension', 'directory_depth' keys, or None if + detection fails or directory doesn't exist. + """ + if not msa_dir.exists(): + logger.warning(f"MSA directory does not exist: {msa_dir}") + return None + try: + depth, ext = get_msa_depth_and_ext_from_folder(msa_dir) + return { + "dir": str(msa_dir), + "extension": ext.value, + "directory_depth": depth, + } + except ValueError as e: + logger.warning(f"Could not auto-detect MSA format for {msa_dir}: {e}") + return None + + +def get_msa_dirs( + env_var_name: str = "PROTEIN_MSA_DIRS", + raise_if_not_set: bool = False, +) -> list[dict]: + """Get MSA directories with auto-detected metadata from an environment variable. + + Args: + env_var_name: Environment variable with comma-separated paths. + raise_if_not_set: Raise if env var is unset. Defaults to ``False``. + + Returns: + List of dicts with ``dir``, ``extension``, and ``directory_depth`` keys. + """ + msa_dirs_str = _load_env_var(env_var_name) + if not msa_dirs_str: + if raise_if_not_set: + raise ValueError(f"{env_var_name} environment variable is not set or empty") + return [] + + # Parse comma-separated paths + msa_dirs = [Path(p.strip()) for p in msa_dirs_str.split(",") if p.strip()] + + result = [] + for msa_dir in msa_dirs: + metadata = _auto_detect_msa_dir_metadata(msa_dir) + if metadata is not None: + result.append(metadata) + + return result + + +# Backwards compatibility alias +def get_msa_dirs_from_env(raise_if_not_set: bool = False) -> list[dict]: + """Deprecated: Use :py:func:`get_msa_dirs` instead.""" + warnings.warn( + "get_msa_dirs_from_env is deprecated, use get_msa_dirs('PROTEIN_MSA_DIRS') instead", + DeprecationWarning, + stacklevel=2, + ) + return get_msa_dirs("PROTEIN_MSA_DIRS", raise_if_not_set=raise_if_not_set) + + +def _build_msa_file_path(sequence_hash: str, msa_dir: str, depth: int, extension: str) -> Path: + """Build the MSA file path for a sequence hash. Args: sequence_hash: Hash of the protein sequence. msa_dir: Base MSA directory. - shard_depths: List of sharding depths to check. - extensions: List of file extensions to check. + depth: Shard depth (0 = flat, 2 = "ab/cd/" style). + extension: File extension (e.g., ".a3m.gz"). + + Returns: + Path to the MSA file. + """ + # Build shard path like "ab/cd/" for depth 2 with hash "abcd123..." + shard_path = "".join([f"{sequence_hash[(i*2):(i+1)*2]}/" for i in range(depth)]) + return Path(msa_dir) / shard_path / f"{sequence_hash}{extension}" + + +def _normalize_msa_dirs(msa_dirs: list[dict] | list[PathLike] | None) -> list[dict]: + """Normalize MSA directories to list of dicts with metadata. + + Args: + msa_dirs: MSA directories in one of three formats: + - None: Load from PROTEIN_MSA_DIRS env var + - list[PathLike]: Simple format, auto-detects metadata for each directory + - list[dict]: Pre-computed format with 'dir', 'extension', 'directory_depth' keys Returns: - List of possible file paths for the sequence. + List of dicts with 'dir', 'extension', 'directory_depth' keys. + + Raises: + TypeError: If list contains mixed types (must be all dicts or all PathLike). """ - possible_paths = [] + if msa_dirs is None: + return get_msa_dirs(raise_if_not_set=False) + + if not msa_dirs: + return [] - for shard_depth in shard_depths: - # Build shard path like "ab/cd/" for depth 2 with hash "abcd123..." - shard_path = "".join([f"{sequence_hash[(i * 2) : (i + 1) * 2]}/" for i in range(shard_depth)]) + # Check if already in dict format - validate all elements are dicts + if isinstance(msa_dirs[0], dict): + if not all(isinstance(d, dict) for d in msa_dirs): + raise TypeError("msa_dirs must be homogeneous: all elements must be dicts or all PathLike, not mixed") + return msa_dirs - for extension in extensions: - file_path = msa_dir / shard_path / f"{sequence_hash}{extension}" - possible_paths.append(file_path) + # Simple PathLike format - auto-detect metadata for each directory + result = [] + for msa_dir in msa_dirs: + metadata = _auto_detect_msa_dir_metadata(Path(msa_dir)) + if metadata is not None: + result.append(metadata) - return possible_paths + return result def sequence_has_msa( sequence: str, - msa_dirs: list[PathLike] | None = None, - shard_depths: list[int] | None = None, - extensions: list[MSAFileExtension] | None = None, + msa_dirs: list[dict] | list[PathLike] | None = None, ) -> bool: """Check if a sequence has an existing MSA file in any directory. Args: sequence: Protein sequence to check. - msa_dirs: Directories to search. If None, uses LOCAL_MSA_DIRS env var. - shard_depths: Sharding levels to check. Defaults to [0, 1, 2, 3, 4]. - extensions: File extensions to check. Defaults to [A3M, A3M_GZ, A3M_ZST]. + msa_dirs: Directories to search. Accepts: + - None: Uses PROTEIN_MSA_DIRS env var + - list[PathLike]: Simple format, auto-detects metadata + - list[dict]: Pre-computed format with 'dir', 'extension', 'directory_depth' keys Returns: True if MSA exists, False otherwise. """ - # Set defaults - if msa_dirs is None: - msa_dirs = get_msa_dirs_from_env() - else: - msa_dirs = [Path(d) for d in msa_dirs] - - if shard_depths is None: - shard_depths = [0, 1, 2, 3, 4] - - if extensions is None: - extensions = [MSAFileExtension.A3M, MSAFileExtension.A3M_GZ, MSAFileExtension.A3M_ZST] - - # Convert extensions to string list - extension_strs = [ext.value if isinstance(ext, MSAFileExtension) else str(ext) for ext in extensions] - - # Filter existing directories - existing_dirs = [d for d in msa_dirs if d.exists()] - if not existing_dirs: - logger.warning("No existing MSA directories found") + normalized_dirs = _normalize_msa_dirs(msa_dirs) + if not normalized_dirs: + logger.warning("No MSA directories found") return False sequence_hash = hash_sequence(sequence) - for msa_dir in existing_dirs: - possible_paths = _build_msa_file_paths(sequence_hash, msa_dir, shard_depths, extension_strs) - - # Return True as soon as we find the first existing file - for path in possible_paths: - if path.exists(): - logger.debug(f"Found existing MSA for sequence hash {sequence_hash}: {path}") - return True + for dir_info in normalized_dirs: + path = _build_msa_file_path( + sequence_hash, + dir_info["dir"], + dir_info["directory_depth"], + dir_info["extension"], + ) + if path.exists(): + logger.debug(f"Found existing MSA for sequence hash {sequence_hash}: {path}") + return True return False def find_msas( sequences: list[str], - msa_dirs: list[PathLike] | None = None, - shard_depths: list[int] | None = None, - extensions: list[MSAFileExtension] | None = None, + msa_dirs: list[dict] | list[PathLike] | None = None, ) -> tuple[list[str], dict[str, Path]]: """Find existing MSA files for sequences and return missing sequences with MSA path mapping. Args: sequences: Protein sequences to find MSAs for. - msa_dirs: Directories to search. If None, uses LOCAL_MSA_DIRS env var. - shard_depths: Sharding levels to check. Defaults to [0, 1, 2, 3, 4]. - extensions: File extensions to check. Defaults to [A3M, A3M_GZ, A3M_ZST]. + msa_dirs: Directories to search. Accepts: + - None: Uses PROTEIN_MSA_DIRS env var + - list[PathLike]: Simple format, auto-detects metadata + - list[dict]: Pre-computed format with 'dir', 'extension', 'directory_depth' keys Returns: Tuple of (missing_sequences, sequence_to_msa_path) where: @@ -191,43 +255,25 @@ def find_msas( - sequence_to_msa_path: Dict mapping sequences to their MSA file paths Examples: - Find MSAs with default settings: + Find MSAs with default settings (uses PROTEIN_MSA_DIRS env var): .. code-block:: python sequences = ["MKKKEVE...", "MSYIWRQ..."] missing, found_paths = find_msas(sequences) - Find MSAs in specific directories: + Find MSAs with explicit directories: .. code-block:: python - from pathlib import Path - - dirs = [Path("/projects/msa/chembl"), Path("/projects/msa/mmseqs_gpu")] + dirs = get_msa_dirs("MY_MSA_DIRS") missing, found_paths = find_msas(sequences, msa_dirs=dirs) """ logger.info(f"Finding MSAs for {len(sequences)} sequences") - # Set defaults - if msa_dirs is None: - msa_dirs = get_msa_dirs_from_env() - else: - msa_dirs = [Path(d) for d in msa_dirs] - - if shard_depths is None: - shard_depths = [0, 1, 2, 3, 4] - - if extensions is None: - extensions = [MSAFileExtension.A3M, MSAFileExtension.A3M_GZ, MSAFileExtension.A3M_ZST] - - # Convert extensions to string list - extension_strs = [ext.value if isinstance(ext, MSAFileExtension) else str(ext) for ext in extensions] - - # Filter existing directories - existing_dirs = [d for d in msa_dirs if d.exists()] - if not existing_dirs: - logger.warning("No existing MSA directories found") + normalized_dirs = _normalize_msa_dirs(msa_dirs) + if not normalized_dirs: + logger.warning("No MSA directories found") return sequences.copy(), {} # Find MSAs for each sequence @@ -239,14 +285,16 @@ def find_msas( found_path = None # Search for MSA file in all directories - for msa_dir in existing_dirs: - possible_paths = _build_msa_file_paths(sequence_hash, msa_dir, shard_depths, extension_strs) - for path in possible_paths: - if path.exists(): - found_path = path - logger.debug(f"Found existing MSA for sequence hash {sequence_hash}: {path}") - break - if found_path: + for dir_info in normalized_dirs: + path = _build_msa_file_path( + sequence_hash, + dir_info["dir"], + dir_info["directory_depth"], + dir_info["extension"], + ) + if path.exists(): + found_path = path + logger.debug(f"Found existing MSA for sequence hash {sequence_hash}: {path}") break if found_path: @@ -258,3 +306,223 @@ def find_msas( logger.info(f"Found {found_count} existing MSAs, {len(missing_sequences)} sequences need generation") return missing_sequences, sequence_to_msa_path + + +def _normalize_template_alignment_dirs(template_dirs: list[dict] | list[PathLike] | None) -> list[dict]: + """Normalize template-alignment directories to the same dict shape MSA dirs use. + + Unlike `_normalize_msa_dirs`, PathLike entries aren't auto-detected: template + alignment files (``.m8`` hit tables) aren't a `MSAFileExtension` value, so a + plain PathLike entry is assumed to match + :py:func:`~atomworks.ml.preprocessing.msa.organizing.organize_template_alignments`'s + defaults (``.m8``, sharded 2 chars deep). + + Args: + template_dirs: Template alignment directories in one of three formats: + - list[PathLike]: Simple format, auto-detects metadata for each directory + - list[dict]: Pre-computed format with 'dir', 'extension', 'directory_depth' keys + + Returns: + List of dicts with 'dir', 'extension', 'directory_depth' keys. + """ + if not template_dirs: + return [] + + if isinstance(template_dirs[0], dict): + if not all(isinstance(d, dict) for d in template_dirs): + raise TypeError("template_dirs must be homogeneous: all elements must be dicts or all PathLike, not mixed") + return template_dirs + + return [{"dir": str(d), "extension": ".m8", "directory_depth": 1} for d in template_dirs] + + +def find_template_alignments( + sequences: list[str], + template_dirs: list[dict] | list[PathLike] | None = None, +) -> tuple[list[str], dict[str, Path]]: + """Find existing raw template-alignment (``pdb70.m8``) files for sequences. + + Mirrors :py:func:`find_msas`'s shape and hash/shard convention: template + alignments are looked up by the same sequence hash as MSAs, just under a + different directory/extension. To download the hit structures, see + :py:mod:`~atomworks.ml.preprocessing.msa.template_structures`. + + Args: + sequences: Protein sequences to find template alignments for. + template_dirs: Directories to search. Accepts: + + - None: No directories (all sequences reported missing). + - list[PathLike]: Auto-assumes ``.m8`` / directory_depth=2 (matching + `organize_template_alignments`'s defaults). + - list[dict]: Pre-computed format with 'dir', 'extension', + 'directory_depth' keys. + + Returns: + Tuple of (missing_sequences, sequence_to_template_alignment_path), same + shape as :py:func:`find_msas`. + """ + logger.info(f"Finding template alignments for {len(sequences)} sequences") + + normalized_dirs = _normalize_template_alignment_dirs(template_dirs) + if not normalized_dirs: + logger.warning("No template alignment directories found") + return sequences.copy(), {} + + missing_sequences = [] + sequence_to_template_alignment_path = {} + + for sequence in sequences: + sequence_hash = hash_sequence(sequence) + found_path = None + + for dir_info in normalized_dirs: + path = _build_msa_file_path( + sequence_hash, dir_info["dir"], dir_info["directory_depth"], dir_info["extension"] + ) + if path.exists(): + found_path = path + logger.debug(f"Found existing template alignment for sequence hash {sequence_hash}: {path}") + break + + if found_path: + sequence_to_template_alignment_path[sequence] = found_path + else: + missing_sequences.append(sequence) + + found_count = len(sequence_to_template_alignment_path) + logger.info(f"Found {found_count} existing template alignments, {len(missing_sequences)} sequences need generation") + + return missing_sequences, sequence_to_template_alignment_path + + +def _normalize_paired_msa_dirs(msa_dirs: list[dict] | list[PathLike] | None) -> list[dict]: + """Normalize paired-MSA directories to list of dicts with metadata. + + Paired MSAs sit one level deeper, under a per-``complex_id`` directory, so + :py:func:`_normalize_msa_dirs`'s depth auto-detection over-counts by one. Plain + paths instead assume the writer's default depth of 1 and are searched for every + :py:class:`~atomworks.enums.MSAFileExtension`. Dicts (e.g. with an explicit + ``directory_depth`` for other layouts) pass through unchanged. + """ + if not msa_dirs or isinstance(msa_dirs[0], dict): + return _normalize_msa_dirs(msa_dirs) + + return [{"dir": str(d), "extension": ext.value, "directory_depth": 1} for d in msa_dirs for ext in MSAFileExtension] + + +def _build_paired_msa_file_path( + complex_id: str, sequence_hash: str, msa_dir: str, extension: str, complex_directory_depth: int = 1 +) -> Path: + """Build the paired-MSA file path for a sequence hash within a given complex. + + complex_id is sharded the same way an unpaired sequence hash is (default: one + 2-char directory level) -- a large fine-tuning run can have many complexes, so + this avoids one huge flat directory of complex_ids. Chain files *within* a + complex's own directory stay flat (a complex has few chains). See + :py:func:`~atomworks.ml.preprocessing.msa.colabfold_server.make_msas_colabfold_server` + for the matching writer -- both use the same `build_sharding_pattern`/ + `apply_sharding_pattern` helpers for the complex_id shard, so a + `complex_directory_depth` here always corresponds to the writer's + `sharding_pattern` at the same depth (unlike unpaired lookups' `directory_depth` + vs. `organize_msas`'s `sharding_pattern`, which use different units -- see + test_organizing.py). + + Args: + complex_id: Identifier for the complex, from `get_complex_id`. + sequence_hash: Hash of the chain's protein sequence. + msa_dir: Base directory containing one (possibly sharded) subdirectory per complex_id. + extension: File extension (e.g., ".a3m.gz"). + complex_directory_depth: Shard depth for complex_id (0 = flat, 1 = "ab/" style, + matching `make_msas_colabfold_server`'s default `sharding_pattern="/0:2/"`). + + Returns: + Path to the paired MSA file. + """ + complex_shard_pattern = build_sharding_pattern(depth=complex_directory_depth, chars_per_dir=2) + sharded_complex_path = apply_sharding_pattern(complex_id, complex_shard_pattern) + return Path(msa_dir) / sharded_complex_path / f"{sequence_hash}{extension}" + + +def get_paired_msa_path( + complex_id: str, + sequence: str, + msa_dirs: list[dict] | list[PathLike] | None = None, +) -> Path | None: + """Retrieve the path to a chain's paired MSA file within a given complex. + + Companion to ``get_msa_path`` (in ``atomworks.ml.transforms.msa._msa_loading_utils``) + for paired MSAs, which need two keys -- which complex, and which chain within it -- + rather than a sequence hash alone. + + Args: + complex_id: Identifier for the complex, from `get_complex_id`. + sequence: The chain's protein sequence, within that complex. + msa_dirs: Directories to search, each containing one (possibly sharded) + subdirectory per complex_id. Plain paths assume the writer's default layout + (see :py:func:`_normalize_paired_msa_dirs`); dicts use the same format as + :py:func:`find_msas`, where `directory_depth` shards complex_id (default 1, + matching `make_msas_colabfold_server`'s default), not the chain hash -- + chain files within a complex directory are always flat. + + Returns: + The MSA file path if found, else None. + """ + normalized_dirs = _normalize_paired_msa_dirs(msa_dirs) + sequence_hash = hash_sequence(sequence) + + for dir_info in normalized_dirs: + complex_depth = dir_info.get("directory_depth", 1) + path = _build_paired_msa_file_path( + complex_id, sequence_hash, dir_info["dir"], dir_info["extension"], complex_depth + ) + if path.exists(): + logger.debug(f"Found existing paired MSA for complex {complex_id}, sequence hash {sequence_hash}: {path}") + return path + + return None + + +def find_paired_msas( + sequences: list[str], + msa_dirs: list[dict] | list[PathLike] | None = None, +) -> tuple[list[str], dict[str, Path]]: + """Find the paired MSA files for one complex's full unique sequence set. + + Mirrors :py:func:`find_msas`'s shape, but for a paired/complex query: + `sequences` is treated as the complete set of unique chains in ONE complex -- + the same unit `organize_paired_msas`/`make_msas_colabfold_server` operate on -- + and complex_id is derived from them automatically via `get_complex_id`, not + something the caller computes or passes separately. + + Args: + sequences: The full unique-sequence set of one complex. + msa_dirs: Directories to search, each containing paired MSA output (e.g. + `make_msas_colabfold_server`'s ``/paired``). Accepts the + same formats as :py:func:`find_msas`. + + Returns: + Tuple of (missing_sequences, sequence_to_msa_path), same shape as + :py:func:`find_msas`. + """ + logger.info(f"Finding paired MSAs for {len(sequences)} sequences (one complex)") + + normalized_dirs = _normalize_paired_msa_dirs(msa_dirs) + if not normalized_dirs: + logger.warning("No MSA directories found") + return sequences.copy(), {} + + complex_id = get_complex_id(sequences) + + missing_sequences = [] + sequence_to_msa_path = {} + for sequence in sequences: + found_path = get_paired_msa_path(complex_id, sequence, normalized_dirs) + if found_path: + sequence_to_msa_path[sequence] = found_path + else: + missing_sequences.append(sequence) + + found_count = len(sequence_to_msa_path) + logger.info(f"Found {found_count} existing paired MSAs, {len(missing_sequences)} sequences need generation") + + return missing_sequences, sequence_to_msa_path diff --git a/src/atomworks/ml/preprocessing/msa/generating.py b/src/atomworks/ml/preprocessing/msa/generating.py index de001f43..860165fc 100644 --- a/src/atomworks/ml/preprocessing/msa/generating.py +++ b/src/atomworks/ml/preprocessing/msa/generating.py @@ -1,26 +1,15 @@ -"""Generate ColabFold-style multiple sequence alignments (MSAs) using MMseqs2. +"""Generate MSAs from protein sequences using MMseqs2 or HHblits. -Provides functions to generate MSAs from protein sequences using the MMseqs2 search pipeline adapted from ColabFold. -MSA files are automatically organized with proper hashing, sharding, and compression. - -Examples: - Generate MSAs with custom configuration: - - .. code-block:: python - - from atomworks.ml.preprocessing.msa.generation import make_msas_mmseqs, MSAGenerationConfig - - config = MSAGenerationConfig(gpu=True, threads=16, max_final_sequences=5000) - sequences = ["MSYIWRQLGSPTVAITLSVSTVIYVTVICPIVFIHLFGDHL...", "MKKKEVEKDDLIENASRVASCISIFLIIASTTMYIFIGLKI..."] - make_msas_mmseqs(sequences, "output_msas/", config) - - Generate MSAs from a CSV file with defaults: +Supports both GPU-accelerated MMseqs2 and CPU-based HHblits backends. MSA files are +automatically organized with proper hashing, sharding, and compression. +Example: .. code-block:: python - from atomworks.ml.preprocessing.msa.generation import make_msas_from_csv + from atomworks.ml.preprocessing.msa.generating import make_msas_from_csv, MSAGenerationConfig - make_msas_from_csv("sequences.csv", "sequence_column", "output_msas/") + config = MSAGenerationConfig(backend="hhblits", threads=8, max_final_sequences=5000) + make_msas_from_csv("sequences.csv", "output_msas/", sequence_column="seq", config=config) References: * Mirdita, M. et al. (2022). ColabFold: making protein folding accessible to all. *Nature Methods*, 19, 679-682. @@ -31,20 +20,30 @@ import dataclasses import logging +import math import os import shutil import subprocess import tempfile import time +import uuid from os import PathLike from pathlib import Path import pandas as pd +from tqdm import tqdm from atomworks.constants import _load_env_var from atomworks.enums import MSAFileExtension -from atomworks.ml.executables.mmseqs2 import MMseqs2 -from atomworks.ml.preprocessing.msa.filtering import HHFilterConfig, MSAFilterConfig, filter_msas +from atomworks.ml.executables.hhblits import HHblits +from atomworks.ml.executables.mmseqs2 import MODULE_OUTPUT_POS, MMseqs2 +from atomworks.ml.preprocessing.msa.filtering import ( + HHFilterConfig, + MSAFilterConfig, + count_sequences_in_msa, + filter_msas, + run_hhfilter, +) from atomworks.ml.preprocessing.msa.finding import find_msas from atomworks.ml.preprocessing.msa.organizing import MSAOrganizationConfig, organize_msas from atomworks.ml.utils.misc import hash_sequence @@ -71,6 +70,9 @@ def create_fasta_with_hashed_headers(sequences: list[str], output_file: PathLike) -> None: """Create a FASTA file from sequence strings with SHA-256 hashed headers. + Note: For wrapped sequences with deduplication, see + :py:func:`~atomworks.ml.preprocessing.utils.fasta.create_fasta_file_from_df`. + Args: sequences: List of protein sequence strings. output_file: Path to the output FASTA file. @@ -81,20 +83,6 @@ def create_fasta_with_hashed_headers(sequences: list[str], output_file: PathLike f.write(f">{header}\n{sequence_string}\n") -MODULE_OUTPUT_POS = { - "align": 4, - "convertalis": 4, - "expandaln": 5, - "filterresult": 4, - "lndb": 2, - "mergedbs": 2, - "mvdb": 2, - "pairaln": 4, - "result2msa": 4, - "search": 3, -} - - @dataclasses.dataclass class MMseqs2SearchConfig: """Configuration for MMseqs2 search parameters. @@ -103,6 +91,8 @@ class MMseqs2SearchConfig: Args: filter: Whether to filter the MSA. + num_iterations: Number of MMseqs2 search iterations. + max_seqs: Maximum number of cluster centers in the MSA. search_eval: Search e-value threshold. expand_eval: E-value threshold for expandaln. expand_max_seq_id: Maximum sequence identity for expandaln. @@ -125,44 +115,98 @@ class MMseqs2SearchConfig: """ filter: bool = True + num_iterations: int = 3 + max_seqs: int = 10_000 search_eval: float = 0.1 - expand_eval: float = 1e-3 + expand_eval: float = math.inf expand_max_seq_id: float = 0.95 - align_eval: int = 10 + align_eval: float = 10.0 diff: int = 3000 qsc: float = -20.0 filter_qsc: float = 0.0 filter_max_seq_id: float = 0.95 filter_min_enable: int = 1000 filter_qid: str = "0.0,0.2,0.4,0.6,0.8,1.0" - max_accept: int = 10_000 + max_accept: int = 1_000_000 prefilter_mode: int = 0 s: float = 8.0 # Set to None to use k-score instead db_load_mode: int = 2 +@dataclasses.dataclass +class HHblitsSearchConfig: + """Configuration for HHblits iterative search parameters. + + Default values match the original HHblits MSA generation pipeline. Database paths + are loaded from ``HHBLITS_UNIREF30_DB_PATH`` and ``HHBLITS_BFD_DB_PATH`` env vars if not provided. + + Args: + mact: MAC realignment threshold for HHblits. + maxfilt: Maximum hits allowed through prefilter. + neffmax: Maximum Neff (effective sequence count) value. + use_bfd: Whether to fall back to BFD when UniRef30 yields insufficient sequences. + """ + + uniref30_db_path: str | None = None + bfd_db_path: str | None = None + e_values: list[float] = dataclasses.field(default_factory=lambda: [1e-10, 1e-3]) + bfd_e_value: float = 1e-3 + min_seqs_high_cov: int = 2000 + min_seqs_low_cov: int = 4000 + high_cov: float = 75.0 + low_cov: float = 50.0 + identity: float = 90.0 + max_filter_seqs: int = 100_000 + n_iterations: int = 4 + mem: int = 64 + use_bfd: bool = True + mact: float = 0.35 + maxfilt: int = 10_000_000 + neffmax: float = 20.0 + cov: int = 25 + maxseq: int = 1_000_000 + realign_max: int = 100_000_000 + + def __post_init__(self): + if self.uniref30_db_path is None: + self.uniref30_db_path = _load_env_var("HHBLITS_UNIREF30_DB_PATH") + if self.bfd_db_path is None: + self.bfd_db_path = _load_env_var("HHBLITS_BFD_DB_PATH") + + if self.uniref30_db_path is None: + raise ValueError( + "uniref30_db_path is required for HHblits. " + "Set HHBLITS_UNIREF30_DB_PATH environment variable or provide uniref30_db_path parameter." + ) + if self.use_bfd and self.bfd_db_path is None: + raise ValueError( + "bfd_db_path is required when use_bfd=True. " + "Set HHBLITS_BFD_DB_PATH environment variable, provide bfd_db_path parameter, or set use_bfd=False." + ) + + @dataclasses.dataclass class MSAGenerationConfig: - """Configuration for MSA generation using MMseqs2. + """Configuration for MSA generation. This dataclass encapsulates all user-facing configuration options for MSA generation, - providing a clean interface while maintaining full configurability based on the - working ColabFold script parameters. + supporting both MMseqs2 and HHblits backends. Args: sharding_pattern: Directory sharding pattern for file organization. output_extension: File extension and compression for output files. - use_env: Whether to include environmental (metagenomic) database. - gpu: Whether to use GPU acceleration. - gpu_server: Whether to use GPU server (requires gpu=True). - num_iterations: Number of MMseqs2 search iterations. - max_seqs: Maximum number of cluster centers (NOT total sequences) in the MSA. - threads: Number of CPU threads to use. - use_local_temp_dir: Whether to use local temporary directory for intermediate files. + use_env: Whether to include environmental (metagenomic) database (MMseqs2 only). + gpu: Whether to use GPU acceleration (MMseqs2 only). + gpu_server: Whether to use GPU server (requires gpu=True, MMseqs2 only). + threads: Number of CPU threads for search operations (used by both MMseqs2 and HHblits). + use_local_temp_dir: Whether to use local temporary directory for intermediate files (MMseqs2 only). max_final_sequences: Maximum number of sequences in the final MSA after HHFilter. check_existing: Whether to check for existing MSAs before generation. - existing_msa_dirs: Directories to check for existing MSAs. If None, uses LOCAL_MSA_DIRS env var. - search_config: Advanced MMseqs2 search configuration. + existing_msa_dirs: Directories to check for existing MSAs. If None, uses PROTEIN_MSA_DIRS env var. + search_config: Advanced MMseqs2 search configuration (MMseqs2 only). + backend: MSA generation backend, either ``"mmseqs2"`` or ``"hhblits"``. + hhblits_search_config: HHblits search configuration (HHblits only). If None when using HHblits + backend, a default config is constructed at generation time. References: * Mirdita, M. et al. (2022). ColabFold: making protein folding accessible to all. *Nature Methods*, 19, 679-682. @@ -173,16 +217,19 @@ class MSAGenerationConfig: use_env: bool = True gpu: bool = False gpu_server: bool = False - num_iterations: int = 3 - max_seqs: int = 10000 - threads: int = 32 + threads: int = 4 use_local_temp_dir: bool = True max_final_sequences: int = 10000 check_existing: bool = False existing_msa_dirs: list[PathLike] | None = None search_config: MMseqs2SearchConfig = dataclasses.field(default_factory=lambda: MMseqs2SearchConfig()) + backend: str = "mmseqs2" + hhblits_search_config: HHblitsSearchConfig | None = None def __post_init__(self): + if self.backend not in ("mmseqs2", "hhblits"): + raise ValueError(f"Invalid backend: {self.backend!r}. Must be 'mmseqs2' or 'hhblits'.") + # If we're using GPU, also use the GPU server by default if self.gpu and not self.gpu_server: logger.info("GPU is enabled, setting gpu_server to True") @@ -190,31 +237,73 @@ def __post_init__(self): def _get_database_path(gpu: bool = False) -> Path: - """ - Determine which database path to use, falling back from local to network paths. + """Determine which database path to use, falling back from local to network paths. Args: - gpu: Whether to use GPU databases + gpu: Whether to use GPU databases. Returns: - Path to the database directory + Path to the database directory. + + Raises: + ValueError: If no database paths are configured. """ if gpu: # For GPU, try local GPU path first, then network path - if Path(LOCAL_DB_PATH_GPU).exists(): + if LOCAL_DB_PATH_GPU and Path(LOCAL_DB_PATH_GPU).exists(): logger.info(f"Using local GPU database path: {LOCAL_DB_PATH_GPU}") return Path(LOCAL_DB_PATH_GPU) - else: + elif NET_DB_PATH_GPU: logger.info(f"Local GPU database path not found, using network path: {NET_DB_PATH_GPU}") return Path(NET_DB_PATH_GPU) + else: + raise ValueError( + "No GPU database paths configured. Please set COLABFOLD_LOCAL_DB_PATH_GPU " + "or COLABFOLD_NET_DB_PATH_GPU environment variables." + ) else: # For CPU, try local CPU path first, then network path - if Path(LOCAL_DB_PATH_CPU).exists(): + if LOCAL_DB_PATH_CPU and Path(LOCAL_DB_PATH_CPU).exists(): logger.info(f"Using local CPU database path: {LOCAL_DB_PATH_CPU}") return Path(LOCAL_DB_PATH_CPU) - else: + elif NET_DB_PATH_CPU: logger.info(f"Local CPU database path not found, using network path: {NET_DB_PATH_CPU}") return Path(NET_DB_PATH_CPU) + else: + raise ValueError( + "No CPU database paths configured. Please set COLABFOLD_LOCAL_DB_PATH_CPU " + "or COLABFOLD_NET_DB_PATH_CPU environment variables." + ) + + +def _create_isolated_db_path(original_db_path: Path) -> tuple[Path, Path]: + """Create an isolated database path with symlinks to avoid GPU server socket conflicts. + + When multiple jobs run concurrently on the same node using gpu_server mode, they share + the same /dev/shm socket file because MMseqs2 generates the socket ID from the database + path. By creating symlinks in a unique temp directory, each job gets a unique socket ID. + + Args: + original_db_path: Path to the original database directory. + + Returns: + Tuple of (isolated_db_path, temp_dir) where: + - isolated_db_path: Path to the symlinked database directory + - temp_dir: Path to the temp directory (caller should clean up) + """ + # Create a unique temp directory for this job + unique_id = f"{os.getpid()}_{uuid.uuid4().hex[:8]}" + temp_dir = Path(tempfile.gettempdir()) / f"mmseqs_isolated_{unique_id}" + temp_dir.mkdir(parents=True, exist_ok=True) + + # Create symlinks to all files in the original database directory + for item in original_db_path.iterdir(): + link_path = temp_dir / item.name + if not link_path.exists(): + link_path.symlink_to(item.resolve()) + + logger.info(f"Created isolated database path: {temp_dir} -> {original_db_path}") + return temp_dir, temp_dir def _make_mmseqs_db_from_fasta(fasta_file: PathLike, output_dir: PathLike) -> Path: @@ -229,7 +318,18 @@ def _make_mmseqs_db_from_fasta(fasta_file: PathLike, output_dir: PathLike) -> Pa """ mmseqs2 = MMseqs2.get_or_initialize() output_db = Path(output_dir) / "qdb" - subprocess.check_call([str(mmseqs2.get_bin_path()), "createdb", str(fasta_file), str(output_db)]) + subprocess.check_call( + [ + str(mmseqs2.get_bin_path()), + "createdb", + str(fasta_file), + str(output_db), + "--shuffle", + "0", + "--dbtype", + "1", + ] + ) return output_db @@ -311,10 +411,13 @@ def _run_mmseqs_search_and_filter( align_alt_ali: int = 10, qid: bool = False, filter_diff: int = 0, - filter_max_seq_id: float = 1.0, - filter_min_enable: int = 1000, + inner_filter_max_seq_id: float = 1.0, + inner_filter_min_enable: int = 100, profile_input: str = "qdb", tmp_dir: str = "tmp", + start_gpu_server: bool = False, + gpu_server_max_seqs: int = 10_000, + gpu_server_db_load_mode: int = 2, ) -> None: """Execute core ColabFold MSA generation pipeline. @@ -343,10 +446,13 @@ def _run_mmseqs_search_and_filter( align_alt_ali: Number of alternative alignments to keep. qid: filterresult - Reduce diversity of output MSAs using min.seq. idendity with query sequences. filter_diff: filterresult - Keep at least this many seqs in each MSA block. - filter_max_seq_id: filterresult - Maximum sequence identity for filtering. - filter_min_enable: filterresult - Minimum number of sequences to keep in each MSA block. + inner_filter_max_seq_id: Inner filterresult - Maximum sequence identity for filtering. + inner_filter_min_enable: Inner filterresult - Minimum number of sequences to keep in each MSA block. profile_input: Profile input (usually qdb). tmp_dir: Temporary directory. + start_gpu_server: Whether to start (and stop) an MMseqs2 GPU server around the search. + gpu_server_max_seqs: ``--max-seqs`` for the GPU server (must match the search's max_seqs). + gpu_server_db_load_mode: ``--db-load-mode`` for the GPU server (must match the search's db_load_mode). References: * `ColabFold Paper`_ - MSA generation methodology @@ -354,9 +460,14 @@ def _run_mmseqs_search_and_filter( .. _ColabFold Paper: https://www.nature.com/articles/s41592-022-01488-1 """ - if "--gpu-server" in search_param: + if start_gpu_server: logger.info("Setting up GPU server...") - gpu_server_process = _start_gpu_server(dbbase.joinpath(db_name), search_param[8], search_param[3], "1") + gpu_server_process = _start_gpu_server( + dbbase.joinpath(db_name), + max_seqs=gpu_server_max_seqs, + db_load_mode=gpu_server_db_load_mode, + prefilter_mode=1, # GPU only supports ungapped prefilter + ) logger.info("GPU server setup complete") _run_mmseqs( @@ -372,7 +483,7 @@ def _run_mmseqs_search_and_filter( ], ) - if "--gpu-server" in search_param: + if start_gpu_server: logger.info("Stopping GPU server...") gpu_server_process.terminate() # Send SIGTERM gpu_server_process.wait() @@ -444,9 +555,9 @@ def _run_mmseqs_search_and_filter( "--threads", str(threads), "--max-seq-id", - str(filter_max_seq_id), + str(inner_filter_max_seq_id), "--filter-min-enable", - str(filter_min_enable), + str(inner_filter_min_enable), ], ) @@ -483,16 +594,16 @@ def _mmseqs_search_monomer( use_env: bool = True, filter: bool = True, search_eval: float = 0.1, - expand_eval: float = 1e-3, + expand_eval: float = math.inf, expand_max_seq_id: float = 0.95, - align_eval: int = 10, + align_eval: float = 10.0, diff: int = 3000, qsc: float = -20.0, filter_qsc: float = 0.0, filter_max_seq_id: float = 0.95, filter_min_enable: int = 1000, filter_qid: str = "0.0,0.2,0.4,0.6,0.8,1.0", - max_accept: int = 10_000, # this was 1000000 in the original ColabFold script + max_accept: int = 1_000_000, num_iterations: int = 3, max_seqs: int = 10_000, prefilter_mode: int = 0, @@ -546,9 +657,12 @@ def _mmseqs_search_monomer( .. _ColabFold MMseqs2 Search Script: https://github.com/sokrypton/ColabFold/blob/main/colabfold/mmseqs/search.py """ if filter: - align_eval = 1e-3 + # ColabFold filter-mode overrides: these three values are hardcoded by the upstream + # pipeline when filter=True and intentionally discard user-supplied values. + # See https://github.com/sokrypton/ColabFold/blob/main/colabfold/mmseqs/search.py + align_eval = 10 qsc = 0.8 - max_accept = 10_000 + max_accept = 100_000 # check db types and make sure they exist used_dbs = [uniref_db] @@ -610,7 +724,8 @@ def _mmseqs_search_monomer( "--max-seq-id", str(filter_max_seq_id), ] - expand_param = [ + # UniRef expandaln: full params (matches ColabFold) + uniref_expand_param = [ "--expansion-mode", "0", "-e", @@ -620,6 +735,13 @@ def _mmseqs_search_monomer( "--max-seq-id", str(expand_max_seq_id), ] + # Metagenomic expandaln: reduced params (matches ColabFold, preserves metagenomic diversity) + metagenomic_expand_param = [ + "--expansion-mode", + "0", + "-e", + str(expand_eval), + ] # search and filter uniref if not base.joinpath("uniref.a3m").with_suffix(".a3m.dbtype").exists(): @@ -633,11 +755,14 @@ def _mmseqs_search_monomer( db_load_mode, threads, search_param, - expand_param, + uniref_expand_param, filter_param, align_eval, max_accept, qsc, + start_gpu_server=bool(gpu_server), + gpu_server_max_seqs=max_seqs, + gpu_server_db_load_mode=db_load_mode, ) else: logger.info(f"Skipping {uniref_db} search because uniref.a3m already exists") @@ -654,13 +779,16 @@ def _mmseqs_search_monomer( db_load_mode, threads, search_param, - expand_param, + metagenomic_expand_param, filter_param, align_eval, max_accept, qsc, profile_input="prof_res", tmp_dir="tmp3", + start_gpu_server=bool(gpu_server), + gpu_server_max_seqs=max_seqs, + gpu_server_db_load_mode=db_load_mode, ) elif use_env: logger.info(f"Skipping {metagenomic_db} search because bfd.mgnify30.metaeuk30.smag30.a3m already exists") @@ -704,18 +832,247 @@ def _mmseqs_search_monomer( shutil.rmtree(base.joinpath("tmp3")) +def _run_hhblits_search( + input_file: PathLike, + output_file: PathLike, + db_path: str, + config: HHblitsSearchConfig, + e_value: float, + cpu: int = 4, +) -> None: + """Run a single HHblits search against a database. + + Args: + input_file: Path to the input FASTA or A3M file. + output_file: Path to the output A3M file. + db_path: Path to the HH-suite formatted database. + config: HHblits search configuration. + e_value: E-value threshold for inclusion. + cpu: Number of CPU threads for HHblits (``-cpu``). + """ + hhblits = HHblits.get_or_initialize() + hhblits.run_command( + "-i", + str(input_file), + "-oa3m", + str(output_file), + "-o", + "/dev/null", + "-mact", + str(config.mact), + "-maxfilt", + str(config.maxfilt), + "-neffmax", + str(config.neffmax), + "-cov", + str(config.cov), + "-cpu", + str(cpu), + "-nodiff", + "-realign_max", + str(config.realign_max), + "-maxseq", + str(config.maxseq), + "-maxmem", + str(config.mem), + "-n", + str(config.n_iterations), + "-d", + str(db_path), + "-e", + str(e_value), + "-v", + "0", + ) + + +def _hhblits_iterative_search_single( + sequence: str, + seq_hash: str, + output_dir: Path, + config: HHblitsSearchConfig, + cpu: int = 4, +) -> Path | None: + """Run iterative HHblits search for a single sequence. + + Searches UniRef30 with increasing e-value thresholds, filtering at each step. + Falls back to BFD if insufficient sequences are found. + + Args: + sequence: Protein sequence string. + seq_hash: SHA-256 hash of the sequence. + output_dir: Directory for the final output A3M file. + config: HHblits search configuration. + cpu: Number of CPU threads for HHblits (``-cpu``). + + Returns: + Path to the final A3M file, or None if no results were produced. + """ + out_a3m = output_dir / f"{seq_hash}.a3m" + + if out_a3m.exists(): + logger.debug(f"Skipping {seq_hash}: output already exists at {out_a3m}") + return out_a3m + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Write single-sequence FASTA + fasta_file = tmp_path / f"{seq_hash}.fasta" + fasta_file.write_text(f">{seq_hash}\n{sequence}\n") + + prev_a3m: Path | str = fasta_file + result_a3m: Path | None = None + + # Iterative UniRef30 searches with increasing e-values + for e_value in config.e_values: + uniref_out = tmp_path / f"uniref30.{e_value}.a3m" + _run_hhblits_search(prev_a3m, uniref_out, config.uniref30_db_path, config, e_value, cpu=cpu) + + # Filter at high coverage + high_cov_file = tmp_path / f"uniref30.{e_value}.id{config.identity:.0f}cov{config.high_cov:.0f}.a3m" + run_hhfilter( + uniref_out, high_cov_file, maxseq=config.max_filter_seqs, id=config.identity, cov=config.high_cov + ) + + # Filter at low coverage + low_cov_file = tmp_path / f"uniref30.{e_value}.id{config.identity:.0f}cov{config.low_cov:.0f}.a3m" + run_hhfilter( + uniref_out, low_cov_file, maxseq=config.max_filter_seqs, id=config.identity, cov=config.low_cov + ) + + n_high = count_sequences_in_msa(high_cov_file) + n_low = count_sequences_in_msa(low_cov_file) + logger.debug( + f"[{seq_hash}] UniRef30 e={e_value}: {n_high} seqs at {config.high_cov}% cov, " + f"{n_low} seqs at {config.low_cov}% cov" + ) + + # Use low-coverage file as input for next iteration + prev_a3m = low_cov_file + + if n_high > config.min_seqs_high_cov: + result_a3m = high_cov_file + break + elif n_low > config.min_seqs_low_cov: + result_a3m = low_cov_file + break + + # Fall back to BFD if needed + if result_a3m is None and config.use_bfd and config.bfd_db_path: + e_value = config.bfd_e_value + bfd_out = tmp_path / f"bfd.{e_value}.a3m" + _run_hhblits_search(prev_a3m, bfd_out, config.bfd_db_path, config, e_value, cpu=cpu) + + bfd_high_cov = tmp_path / f"bfd.{e_value}.id{config.identity:.0f}cov{config.high_cov:.0f}.a3m" + run_hhfilter(bfd_out, bfd_high_cov, maxseq=config.max_filter_seqs, id=config.identity, cov=config.high_cov) + + bfd_low_cov = tmp_path / f"bfd.{e_value}.id{config.identity:.0f}cov{config.low_cov:.0f}.a3m" + run_hhfilter(bfd_out, bfd_low_cov, maxseq=config.max_filter_seqs, id=config.identity, cov=config.low_cov) + + n_high = count_sequences_in_msa(bfd_high_cov) + n_low = count_sequences_in_msa(bfd_low_cov) + logger.debug( + f"[{seq_hash}] BFD e={e_value}: {n_high} seqs at {config.high_cov}% cov, " + f"{n_low} seqs at {config.low_cov}% cov" + ) + + prev_a3m = bfd_low_cov + + if n_high > config.min_seqs_high_cov: + result_a3m = bfd_high_cov + elif n_low > config.min_seqs_low_cov: + result_a3m = bfd_low_cov + + # If still no result, use the last processed file (if it's not just the input FASTA) + if result_a3m is None and Path(prev_a3m) != fasta_file and Path(prev_a3m).exists(): + logger.info(f"[{seq_hash}] Insufficient sequences after all searches; using last processed file") + result_a3m = Path(prev_a3m) + + # Copy result to output directory + if result_a3m is not None and result_a3m.exists(): + output_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(result_a3m, out_a3m) + return out_a3m + + return None + + +def make_msas_hhblits( + sequences: str | list[str], + output_dir: PathLike, + max_final_sequences: int = 10_000, + sharding_pattern: str = "/0:2/", + output_extension: str = MSAFileExtension.A3M_GZ.value, + search_config: HHblitsSearchConfig | None = None, + cpu: int = 4, +) -> None: + """Generate MSAs from protein sequences using HHblits (CPU-only, HH-suite). + + Runs iterative HHblits searches per sequence against UniRef30 (and optionally BFD), + then organizes and filters the results following the same post-processing as MMseqs2. + + Args: + sequences: A single protein sequence string or list of protein sequences. + output_dir: Path to the output directory where MSA files will be saved. + max_final_sequences: Maximum number of sequences in final MSAs after filtering. + sharding_pattern: Directory sharding pattern (e.g., "/0:2/"). + output_extension: Output file extension (.a3m, .a3m.gz, .a3m.zst, .afa, .afa.gz, .afa.zst). + search_config: HHblits search configuration. If None, uses defaults. + cpu: Number of CPU threads for HHblits (``-cpu``). + """ + if isinstance(sequences, str): + sequences = [sequences] + + if search_config is None: + search_config = HHblitsSearchConfig() + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Initialize HHblits executable + HHblits.get_or_initialize() + + start_time = time.time() + for sequence in tqdm(sequences, desc="HHblits MSA generation", unit="seq"): + seq_hash = hash_sequence(sequence) + _hhblits_iterative_search_single(sequence, seq_hash, output_path, search_config, cpu=cpu) + + logger.info(f"Completed {len(sequences)} sequences in {time.time() - start_time:.1f}s with HHblits") + + # Organize MSAs using existing organization functionality + org_config = MSAOrganizationConfig( + input_extension=MSAFileExtension.A3M, + output_extension=output_extension, + sharding_pattern=sharding_pattern, + copy_files=False, + ) + + logger.info("Organizing MSA files...") + organize_msas(output_dir, output_dir, org_config) + + # Filter MSA files to reduce sequence count and redundancy + if max_final_sequences is not None: + filter_config = MSAFilterConfig( + input_extension=output_extension, + output_extension=output_extension, + hhfilter=HHFilterConfig(max_sequences=max_final_sequences), + ) + logger.info(f"Filtering MSA files to max {max_final_sequences} sequences...") + filter_msas(output_dir, output_dir, filter_config) + + def make_msas_mmseqs( sequences: str | list[str], output_dir: PathLike, gpu: bool = False, gpu_server: bool = False, - num_iterations: int = 3, - max_seqs: int = 10_000, use_local_temp_dir: bool = True, max_final_sequences: int = 10_000, sharding_pattern: str = "/0:2/", output_extension: str = MSAFileExtension.A3M_GZ.value, search_config: MMseqs2SearchConfig | None = None, + threads: int = 4, ) -> None: """Generate MSAs directly from protein sequences. @@ -724,13 +1081,12 @@ def make_msas_mmseqs( output_dir: Path to the output directory where MSA files will be saved. gpu: Whether to use GPU acceleration. gpu_server: Whether to use GPU server (requires gpu=True). - num_iterations: Number of search iterations. - max_seqs: Maximum number of cluster centers. use_local_temp_dir: Whether to use local temporary directory for intermediate files. max_final_sequences: Maximum number of sequences in final MSAs after filtering. sharding_pattern: Directory sharding pattern (e.g., "/0:2/"). output_extension: Output file extension (.a3m, .a3m.gz, .a3m.zst, .afa, .afa.gz, .afa.zst). - search_config: Advanced MMseqs2 search configuration. + search_config: Advanced MMseqs2 search configuration (includes ``num_iterations`` and ``max_seqs``). + threads: Number of CPU threads for MMseqs2 search. Examples: .. code-block:: python @@ -765,32 +1121,47 @@ def make_msas_mmseqs( if gpu_server and not gpu: raise ValueError("gpu_server is True but gpu is False") + # Get the database path, creating isolated symlinks if using GPU server + # to avoid socket conflicts between concurrent jobs on the same node + original_db_path = _get_database_path(gpu=gpu) + isolated_db_dir = None + if gpu_server: + dbbase, isolated_db_dir = _create_isolated_db_path(original_db_path) + else: + dbbase = original_db_path + start_time = time.time() - _mmseqs_search_monomer( - dbbase=_get_database_path(gpu=gpu), - base=Path(intermediate_dir), - uniref_db=Path(UNIREF30_DB_NAME), - metagenomic_db=Path(COLABFOLD_DB_NAME), - gpu=int(gpu), - gpu_server=int(gpu_server), - num_iterations=num_iterations, - max_seqs=max_seqs, - s=search_config.s, - filter=search_config.filter, - search_eval=search_config.search_eval, - expand_eval=search_config.expand_eval, - expand_max_seq_id=search_config.expand_max_seq_id, - align_eval=search_config.align_eval, - diff=search_config.diff, - qsc=search_config.qsc, - filter_qsc=search_config.filter_qsc, - filter_max_seq_id=search_config.filter_max_seq_id, - filter_min_enable=search_config.filter_min_enable, - filter_qid=search_config.filter_qid, - max_accept=search_config.max_accept, - prefilter_mode=search_config.prefilter_mode, - db_load_mode=search_config.db_load_mode, - ) + try: + _mmseqs_search_monomer( + dbbase=dbbase, + base=Path(intermediate_dir), + uniref_db=Path(UNIREF30_DB_NAME), + metagenomic_db=Path(COLABFOLD_DB_NAME), + gpu=int(gpu), + gpu_server=int(gpu_server), + num_iterations=search_config.num_iterations, + max_seqs=search_config.max_seqs, + s=search_config.s, + filter=search_config.filter, + search_eval=search_config.search_eval, + expand_eval=search_config.expand_eval, + expand_max_seq_id=search_config.expand_max_seq_id, + align_eval=search_config.align_eval, + diff=search_config.diff, + qsc=search_config.qsc, + filter_qsc=search_config.filter_qsc, + filter_max_seq_id=search_config.filter_max_seq_id, + filter_min_enable=search_config.filter_min_enable, + filter_qid=search_config.filter_qid, + max_accept=search_config.max_accept, + prefilter_mode=search_config.prefilter_mode, + db_load_mode=search_config.db_load_mode, + ) + finally: + # Clean up isolated database symlinks + if isolated_db_dir is not None and isolated_db_dir.exists(): + shutil.rmtree(isolated_db_dir) + logger.info(f"Cleaned up isolated database path: {isolated_db_dir}") logger.info( f"Completed {len(sequences)} sequences in {time.time() - start_time} seconds with MMSeqs2 search and alignment" ) @@ -888,8 +1259,6 @@ def make_msas_from_csv( missing_sequences, _ = find_msas( sequences, msa_dirs=config.existing_msa_dirs, - shard_depths=[0, 1, 2, 3, 4], - extensions=[MSAFileExtension.A3M, MSAFileExtension.A3M_GZ], ) sequences = missing_sequences logger.info(f"Found {len(sequences)} sequences needing MSA generation") @@ -898,16 +1267,29 @@ def make_msas_from_csv( logger.info("All sequences already have MSAs, skipping generation") return - make_msas_mmseqs( - sequences=sequences, - output_dir=output_dir, - gpu=config.gpu, - gpu_server=config.gpu_server, - num_iterations=config.num_iterations, - max_seqs=config.max_seqs, - use_local_temp_dir=config.use_local_temp_dir, - max_final_sequences=config.max_final_sequences, - sharding_pattern=config.sharding_pattern, - output_extension=config.output_extension, - search_config=config.search_config, - ) + if config.backend == "hhblits": + hhblits_config = ( + config.hhblits_search_config if config.hhblits_search_config is not None else HHblitsSearchConfig() + ) + make_msas_hhblits( + sequences=sequences, + output_dir=output_dir, + max_final_sequences=config.max_final_sequences, + sharding_pattern=config.sharding_pattern, + output_extension=config.output_extension, + search_config=hhblits_config, + cpu=config.threads, + ) + else: + make_msas_mmseqs( + sequences=sequences, + output_dir=output_dir, + gpu=config.gpu, + gpu_server=config.gpu_server, + use_local_temp_dir=config.use_local_temp_dir, + max_final_sequences=config.max_final_sequences, + sharding_pattern=config.sharding_pattern, + output_extension=config.output_extension, + search_config=config.search_config, + threads=config.threads, + ) diff --git a/src/atomworks/ml/preprocessing/msa/organizing.py b/src/atomworks/ml/preprocessing/msa/organizing.py index 873e96c4..035d15fc 100644 --- a/src/atomworks/ml/preprocessing/msa/organizing.py +++ b/src/atomworks/ml/preprocessing/msa/organizing.py @@ -19,7 +19,7 @@ from atomworks.io.utils.io_utils import apply_sharding_pattern, find_files_by_extension from atomworks.ml.preprocessing.msa.finding import sequence_has_msa from atomworks.ml.utils.io import open_file -from atomworks.ml.utils.misc import hash_sequence +from atomworks.ml.utils.misc import get_complex_id, hash_sequence logger = logging.getLogger(__name__) @@ -200,6 +200,129 @@ def organize_msas(input_dir: PathLike, output_dir: PathLike, config: MSAOrganiza logger.info(f"MSA file organization complete! Saved processed files to: {output_dir}") +def organize_paired_msas(input_dir: PathLike, output_dir: PathLike, config: MSAOrganizationConfig | None = None) -> str: + """Organize raw per-chain a3m files from ONE paired/complex query into AtomWorks' + standard paired-MSA layout. + + Unlike `organize_msas` (which treats every file in `input_dir` as an independent, + unrelated alignment -- one per unique sequence), this treats *all* files found in + `input_dir` as belonging to the SAME complex: their first sequences, collectively, + determine the complex_id (see + :py:func:`~atomworks.ml.utils.misc.get_complex_id`) -- computed from file content, + the same way `organize_msas` derives each file's identity from content, not + passed in or tracked separately by the caller. + + Output layout: ``///`` + -- flat within the complex's own directory (a complex has few chains), but + complex_id itself is sharded the same way a sequence hash would be (a large + fine-tuning run can have many complexes). Discoverable via + :py:func:`~atomworks.ml.preprocessing.msa.finding.find_paired_msas` / + :py:func:`~atomworks.ml.preprocessing.msa.finding.get_paired_msa_path`. + + Args: + input_dir: Directory containing one raw a3m file per chain in the complex + (recursively searched, same as `organize_msas`). + output_dir: Destination directory. + config: MSA processing configuration. `sharding_pattern` shards complex_id + (not the files within it, which are always flat). `check_existing` / + `existing_msa_dirs` / `num_workers` / `execution_model` are not used -- + a complex has too few files to need parallelism, and existing-MSA + deduplication belongs at the sequence-submission level, before this is + ever called. + + Returns: + The complex_id this batch of files was organized under. + + Raises: + FileNotFoundError: If no MSA files are found or `input_dir` doesn't exist. + FileExistsError: If the complex's directory already exists (complete, or partial + from an interrupted run -- delete it to regenerate). + """ + if config is None: + config = MSAOrganizationConfig() + + input_path = Path(input_dir) + if not input_path.exists(): + raise FileNotFoundError(f"Source directory not found: {input_dir}") + + msa_files = find_files_by_extension(input_path, str(config.input_extension)) + if not msa_files: + raise FileNotFoundError(f"No files with extension {config.input_extension} found in {input_dir}") + + chain_hash_by_file: dict[Path, str] = {} + sequences: list[str] = [] + for file_path in msa_files: + sequence = extract_first_sequence_from_msa(file_path) + sequences.append(sequence) + chain_hash_by_file[file_path] = hash_sequence(sequence) + + complex_id = get_complex_id(sequences) + if config.sharding_pattern: + complex_dir = Path(output_dir) / apply_sharding_pattern(complex_id, config.sharding_pattern) + else: + complex_dir = Path(output_dir) / complex_id + # Paired files are row-aligned across chains, so never add to an existing complex + # directory: a partial one left by an interrupted run would otherwise end up mixing + # chains from two separate searches. + complex_dir.mkdir(parents=True, exist_ok=False) + + for file_path, chain_hash in chain_hash_by_file.items(): + dest = complex_dir / f"{chain_hash}{config.output_extension}" + transfer_with_compression(input_file=file_path, output_file=dest, move=not config.copy_files) + + logger.info(f"Paired MSA organization complete! complex_id={complex_id}, saved to: {complex_dir}") + return complex_id + + +def organize_template_alignments( + sequence_to_content: dict[str, str], + output_dir: PathLike, + sharding_pattern: str | None = "/0:2/", + output_extension: str = ".m8", +) -> None: + """Organize raw ColabFold template-alignment hit tables (``pdb70.m8`` rows) into + AtomWorks' standard hash-sharded layout. + + Uses the same sequence-hash/sharding convention as `organize_msas`, so a + sequence's template alignment and its unpaired MSA land at the corresponding + shard. Unlike MSA files, template hit tables don't self-identify their query + sequence (no FASTA-style header), so the sequence must be supplied explicitly + rather than extracted from file content. + + Discoverable via :py:func:`~atomworks.ml.preprocessing.msa.finding.find_template_alignments`. + + Args: + sequence_to_content: Maps each query sequence to its raw ``pdb70.m8`` hit + lines (tab-separated text, one hit per line). Sequences with falsy + content (no hits) are skipped -- no empty file is written. + output_dir: Destination directory for organized files. + sharding_pattern: Directory sharding pattern (e.g. "/0:2/"), matching + `organize_msas`'s convention. + output_extension: Output file extension. + """ + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + for sequence, content in sequence_to_content.items(): + if not content: + continue + + hashed_filename = Path(hash_sequence(sequence)) + if sharding_pattern: + dest = output_path / apply_sharding_pattern(hashed_filename, sharding_pattern).with_suffix(output_extension) + else: + dest = output_path / hashed_filename.with_suffix(output_extension) + + if dest.exists(): + logger.debug(f"Output file already exists, skipping: {dest}") + continue + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content) + + logger.info(f"Template alignment organization complete! Saved to: {output_dir}") + + def _build_output_path(dest_dir: Path, hashed_filename: Path, config: MSAOrganizationConfig) -> Path: """Build the output file path using sharding pattern. diff --git a/src/atomworks/ml/preprocessing/msa/template_structures.py b/src/atomworks/ml/preprocessing/msa/template_structures.py new file mode 100644 index 00000000..6390492f --- /dev/null +++ b/src/atomworks/ml/preprocessing/msa/template_structures.py @@ -0,0 +1,94 @@ +"""Utility library for fetching template structures from RCSB, given m8 alignment files.""" + +import logging +from os import PathLike +from pathlib import Path + +from biotite.database import RequestError +from biotite.database.rcsb import fetch + +from atomworks.constants import PDB_MIRROR_PATH +from atomworks.io.utils.testing import get_pdb_path + +logger = logging.getLogger(__name__) + + +def parse_template_pdb_ids(m8_content: str) -> list[str]: + """Parse the lowercased PDB entry ID of each hit row in a raw ``pdb70.m8`` table. + + Rows are returned by the ColabFold MSA server already sorted best-first (ascending + e-value); order is preserved, and an entry hit by several chains appears once per + hit. + + Raises: + ValueError: If `m8_content` contains no hit rows. A sequence with no PDB70 hits + never gets a ``.m8`` file written, so this suggests an upstream error, e.g. + an empty or wrong file was read. + """ + # Column 2 is "_", e.g. "1qfe_A". + pdb_ids = [line.split("\t")[1].split("_", 1)[0].lower() for line in m8_content.splitlines() if line.strip()] + + if not pdb_ids: + raise ValueError( + "No template hits found in m8_content. A sequence with no PDB70 hits never gets a .m8 file " + "written in the first place, so this indicates a bug -- e.g. an empty or wrong file was read." + ) + + return pdb_ids + + +def fetch_template_structures( + pdb_ids: list[str], + output_dir: PathLike, + max_hits: int = 20, + pdb_mirror_path: PathLike | None = PDB_MIRROR_PATH, +) -> dict[str, Path]: + """Fetch mmCIF files from list of PDB ids. + + Each entry is first looked up in `pdb_mirror_path` via + :py:func:`~atomworks.io.utils.testing.get_pdb_path`; only entries not found there are downloaded + from RCSB into `output_dir`. + + Args: + pdb_ids: List of PDB entry IDs to fetch. + output_dir: Directory to download missing entries into, as ``.cif``. + max_hits: Maximum number of unique PDB entries to resolve. + pdb_mirror_path: Local PDB mirror to check before downloading. + + Returns: + Dict mapping each resolved PDB ID to its local mmCIF path (mirror or download). + Entries that fail to download (e.g. obsolete/invalid IDs) are omitted with a + logged warning. + """ + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + structure_paths: dict[str, Path] = {} + for pdb_id in list(dict.fromkeys(p.lower() for p in pdb_ids))[:max_hits]: + try: + structure_paths[pdb_id] = Path(get_pdb_path(pdb_id, mirror_path=pdb_mirror_path)) + continue + except FileNotFoundError: + pass # Not in the mirror (or no mirror given); download below. + try: + structure_paths[pdb_id] = Path(fetch(pdb_ids=pdb_id, format="cif", target_path=str(output_path))) + except RequestError as e: + logger.warning(f"Could not fetch template structure {pdb_id} from RCSB, skipping: {e}") + + n_mirror = sum(1 for path in structure_paths.values() if not path.is_relative_to(output_path)) + logger.info(f"Resolved {len(structure_paths)} template structures ({n_mirror} from the local PDB mirror)") + return structure_paths + + +def fetch_template_structures_from_m8_file( + m8_path: PathLike, + output_dir: PathLike, + max_hits: int = 20, + pdb_mirror_path: PathLike | None = PDB_MIRROR_PATH, +) -> dict[str, Path]: + """Parse a raw ``pdb70.m8`` file and resolve mmCIF files for its top hits. + + See :py:func:`parse_template_pdb_ids` and :py:func:`fetch_template_structures`. + """ + pdb_ids = parse_template_pdb_ids(Path(m8_path).read_text()) + return fetch_template_structures(pdb_ids, output_dir, max_hits=max_hits, pdb_mirror_path=pdb_mirror_path) diff --git a/src/atomworks/ml/preprocessing/preprocess.py b/src/atomworks/ml/preprocessing/preprocess.py new file mode 100644 index 00000000..c1cf64ad --- /dev/null +++ b/src/atomworks/ml/preprocessing/preprocess.py @@ -0,0 +1,712 @@ +"""Preprocessing pipeline for PDB structures. + +Provides a pipeline for preprocessing PDB structures: +1. parse_entry() - Load structure and fetch entry-level metadata like ligand fit-to-density +2. process_assemblies() - Filter atoms, resolve clashes, create Assembly records +3. process_pn_units() - Extract PN units and contacts +4. process_interfaces() - Create Interface records from contacts +5. _add_closest_chain_info() - Add closest_20/50_pn_unit_iids to assemblies (uses outputs from steps 1-4) +""" + +import copy +import logging +from collections import defaultdict +from dataclasses import dataclass, replace +from os import PathLike +from pathlib import Path +from typing import Any, Literal + +import biotite.structure as struc +import numpy as np +import pandas as pd +from biotite.structure import AtomArray +from scipy.spatial import cKDTree + +import atomworks.ml.preprocessing.utils.structure_utils as structure_utils +from atomworks.common import exists, not_isin +from atomworks.constants import ( + CRYSTALLIZATION_AIDS, + METAL_ELEMENTS, + PEPTIDE_MAX_RESIDUES, + STANDARD_AND_UNKNOWN_POLYMER_RESIDUES, + STANDARD_POLYMER_RESIDUES, +) +from atomworks.enums import ChainType +from atomworks.io import parse +from atomworks.ml.preprocessing.constants import ( + PDB_IDS_WITH_UNPHYSICAL_BONDS, + ClashSeverity, + DistanceThresholds, +) +from atomworks.ml.preprocessing.records import ( + Assembly, + ContactInfo, + Interface, + PNUnit, + serialize_record, +) +from atomworks.ml.preprocessing.utils.large_assembly import sample_large_assembly_interface +from atomworks.ml.preprocessing.utils.token import count_af3_style_tokens +from atomworks.ml.transforms.atom_array import add_global_atom_id_annotation +from atomworks.ml.transforms.atomize import atomize_by_ccd_name +from atomworks.ml.utils.misc import hash_sequence + +logger = logging.getLogger("preprocess") + + +# ============================================================================= +# Configuration +# ============================================================================= + + +@dataclass(frozen=True) +class PreprocessConfig: + """Configuration for preprocessing pipeline. + + All parameters have sensible defaults. Create custom configs by passing + only the parameters you want to change. + """ + + # Distance thresholds (Angstroms) + contact_distance: float = DistanceThresholds.CONTACT + clash_distance: float = DistanceThresholds.CLASH + + # Filtering options + ignore_residues: tuple[str, ...] = () + polymer_pn_unit_limit: int = 1000 + + # Parse options + build_assembly: str = "all" + add_missing_atoms: bool = True # Required for accurate token counts, bonds, etc. + remove_waters: bool = True + remove_ccds: tuple[str, ...] = tuple(CRYSTALLIZATION_AIDS) + fix_ligands_at_symmetry_centers: bool = True + fix_arginines: bool = True + convert_mse_to_met: bool = True + hydrogen_policy: Literal["remove", "infer", "keep"] = "remove" + + # Ligand scores to fetch from RCSB + ligand_scores: tuple[str, ...] = ( + "RSCC", + "RSR", + "completeness", + "intermolecular_clashes", + "is_best_instance", + "ranking_model_fit", + "ranking_model_geometry", + ) + + # Token counting + count_af3_tokens: bool = True + + # Large assembly sampling + sample_large_assembly_interfaces: bool = True + large_assembly_threshold: int = 20 + interface_cutoff_distance: float = DistanceThresholds.INTERFACE + + +# ============================================================================= +# Internal Helper Functions +# ============================================================================= + + +def _load_structure(path: PathLike, config: PreprocessConfig) -> dict[str, Any]: + """Load and parse structure file using PreprocessConfig.""" + return parse( + filename=path, + build_assembly=config.build_assembly, + add_missing_atoms=config.add_missing_atoms, + remove_waters=config.remove_waters, + remove_ccds=list(config.remove_ccds), + fix_ligands_at_symmetry_centers=config.fix_ligands_at_symmetry_centers, + fix_arginines=config.fix_arginines, + convert_mse_to_met=config.convert_mse_to_met, + hydrogen_policy=config.hydrogen_policy, + ) + + +def _apply_filters(atom_array: AtomArray, ignore_residues: tuple[str, ...], log_id: str | None = None) -> AtomArray: + """Apply filters: remove non-biological bonds, ignored residues, zero-occupancy atoms.""" + filtered = copy.deepcopy(atom_array) + + # Remove non-polymers with non-biological bonds to polymers + non_bio_mask = structure_utils.get_non_biological_bond_atom_mask(atom_array) + if np.any(non_bio_mask): + # Get non-polymer PN units involved in non-biological bonds + non_polymer_non_bio = non_bio_mask & ~atom_array.is_polymer + pn_units_to_remove = np.unique(atom_array.pn_unit_iid[non_polymer_non_bio]) + if len(pn_units_to_remove) > 0: + # Subset to atoms not in these PN units + filtered = atom_array[~np.isin(atom_array.pn_unit_iid, pn_units_to_remove)] + if log_id: + logger.warning(f"{log_id}: Non-biological bonds detected between non-polymer and polymer PN units.") + + # Remove ignored residues + if ignore_residues: + filtered = filtered[not_isin(filtered.res_name, [c.strip() for c in ignore_residues])] + + # Remove zero-occupancy atoms + return filtered[filtered.occupancy > 0.0] + + +def _detect_and_resolve_clashes( + atom_array: AtomArray, + pn_unit_iids: np.ndarray, + clash_distance: float, + log_id: str | None = None, +) -> tuple[AtomArray, ClashSeverity]: + """Detect clashes and resolve them by removing clashing PN units.""" + clashing_dict = structure_utils.get_clashing_pn_units(pn_unit_iids, atom_array, clash_distance) + + if not clashing_dict: + return atom_array, ClashSeverity.NO_CLASH + + if log_id: + logger.warning(f"(Example {log_id}): Clash detected between PN units: {list(clashing_dict.keys())}") + + atom_array, severity = structure_utils.handle_clashing_pn_units(clashing_dict, atom_array) + return atom_array, severity + + +def _fetch_ligand_validity_scores(pdb_id: str, ligand_scores: tuple[str, ...]) -> pd.DataFrame | None: + """Fetch ligand validity scores from RCSB.""" + if not ligand_scores: + return None + + scores = structure_utils.get_ligand_validity_scores_from_pdb_id(pdb_id) + if not exists(scores) or len(scores) == 0: + logger.debug(f"Failed to fetch ligand validity scores for ID {pdb_id}") + return None + + df = pd.DataFrame(scores).set_index(["asym_id", "res_name"])[list(ligand_scores)] + df.sort_index(inplace=True) + return df + + +# ============================================================================= +# Stage 1: Parse Entry +# ============================================================================= + + +def parse_entry(path: Path, config: PreprocessConfig) -> dict[str, Any]: + """Load structure and fetch ligand validity scores. + + Args: + path: Path to structure file (.cif, .cif.gz, .pdb, .pdb.gz). + config: Preprocessing configuration. + + Returns: + Parser dict with 'ligand_validity_scores' key added. + """ + result = _load_structure(path, config) + pdb_id = result["metadata"]["id"] + + scores = _fetch_ligand_validity_scores(pdb_id, config.ligand_scores) if config.ligand_scores else None + result["ligand_validity_scores"] = scores + result["_path"] = str(path) + + return result + + +# ============================================================================= +# Stage 2: Process Assemblies +# ============================================================================= + + +def process_assemblies( + parser_dict: dict[str, Any], + config: PreprocessConfig, +) -> tuple[list[Assembly], dict[str, AtomArray]]: + """Filter atoms, resolve clashes, and create Assembly records. + + Args: + parser_dict: Output from parse_entry(). + config: Preprocessing configuration. + + Returns: + Tuple of (Assembly list, dict mapping assembly_id to filtered AtomArray). + """ + assemblies: list[Assembly] = [] + assembly_atoms: dict[str, AtomArray] = {} + + pdb_id = parser_dict["metadata"]["id"] + path = parser_dict.get("_path", "") + metadata = parser_dict["metadata"] + + for assembly_id, assembly_tuple in parser_dict["assemblies"].items(): + raw_array = assembly_tuple[0] + num_raw_atoms = len(raw_array) + + # Calculate % zero-occupancy atoms from the raw assembly + num_zero_occ = int(np.sum(raw_array.occupancy == 0.0)) + fraction_zero_occ = (num_zero_occ / num_raw_atoms) if num_raw_atoms > 0 else 0.0 + + # Token counts (upper bound, before filtering removes unresolved atoms) + n_atomized, n_non_atomized, n_total = 0, 0, 0 + if config.count_af3_tokens: + tc = count_af3_style_tokens(raw_array) + n_atomized, n_non_atomized, n_total = ( + tc.get("n_atomized_tokens", 0), + tc.get("n_non_atomized_tokens", 0), + tc.get("n_tokens_total", 0), + ) + + # Filter atoms + filtered = _apply_filters(raw_array, config.ignore_residues, log_id=path) + + # Check limits + num_polymer_pn_units = len(np.unique(filtered.pn_unit_iid[filtered.is_polymer])) + if num_polymer_pn_units > config.polymer_pn_unit_limit: + logger.warning(f"(Example {pdb_id}): {num_polymer_pn_units} polymer PN units; skipping.") + continue + if len(filtered) == 0: + logger.warning(f"(Example {pdb_id}): No atoms remaining after filtering.") + continue + + # Resolve clashes + filtered, clash_severity = _detect_and_resolve_clashes( + filtered, np.unique(filtered.pn_unit_iid), config.clash_distance, log_id=pdb_id + ) + + # Build Assembly record + cryst = metadata.get("crystallization_details", {}) + all_pn_unit_iids = tuple(sorted(np.unique(filtered.pn_unit_iid))) + + assembly = Assembly( + pdb_id=pdb_id, + assembly_id=assembly_id, + path=path, + resolution=metadata.get("resolution"), + deposition_date=metadata.get("deposition_date"), + release_date=metadata.get("release_date"), + method=metadata.get("method"), + ph=cryst.get("pH") if cryst else None, + clash_severity=clash_severity.value, + num_polymer_pn_units=num_polymer_pn_units, + num_resolved_atoms_in_processed_assembly=len(filtered), + total_num_atoms_in_unprocessed_assembly=num_raw_atoms, + fraction_zero_occupancy_atoms=fraction_zero_occ, + has_unphysical_bonds=pdb_id.lower() in PDB_IDS_WITH_UNPHYSICAL_BONDS if pdb_id else False, + n_atomized_tokens=n_atomized, + n_non_atomized_tokens=n_non_atomized, + n_tokens_total=n_total, + all_pn_unit_iids_after_processing=all_pn_unit_iids, + ) + + assemblies.append(assembly) + assembly_atoms[assembly_id] = filtered + + return assemblies, assembly_atoms + + +# ============================================================================= +# Stage 3: Process PN Units +# ============================================================================= + + +def process_pn_units( + assemblies: list[Assembly], + assembly_atoms: dict[str, AtomArray], + parser_dict: dict[str, Any], + config: PreprocessConfig, +) -> tuple[list[PNUnit], list[ContactInfo]]: + """Extract PN units and compute contacts. + + Args: + assemblies: Assembly records from process_assemblies(). + assembly_atoms: Filtered AtomArrays from process_assemblies(). + parser_dict: Original parser dict (for chain_info, ligand_info, ligand_validity_scores). + config: Preprocessing configuration. + + Returns: + Tuple of (PNUnit list, ContactInfo list). + """ + pn_units: list[PNUnit] = [] + contacts: list[ContactInfo] = [] + + chain_info = parser_dict.get("chain_info", {}) + loi_set = set(parser_dict.get("ligand_info", {}).get("ligand_of_interest", [])) + ligand_scores = parser_dict.get("ligand_validity_scores") + + for assembly in assemblies: + aid = assembly.assembly_id + if aid not in assembly_atoms: + continue + + atoms = copy.deepcopy(assembly_atoms[aid]) + pn_iids = [iid for iid in np.unique(atoms.pn_unit_iid) if len(atoms[atoms.pn_unit_iid == iid]) > 0] + + if not pn_iids: + continue + + # Build KDTree once for all contact queries (major speedup for large assemblies) + kdtree = cKDTree(atoms.coord) + seen_pairs: set[tuple[str, str]] = set() + + for pn_iid in pn_iids: + query_mask = atoms.pn_unit_iid == pn_iid + query = atoms[query_mask] + if len(query) == 0: + continue + + target_mask = ~query_mask + query_type = ChainType(query.chain_type[0]) + pn_unit_id = query.pn_unit_id[0] + + # Get contacts (reuse kdtree for efficiency) + contacting = structure_utils.get_contacting_pn_units( + atoms, + query_mask, + target_mask, + config.contact_distance, + min_contacts_required=1, + calculate_min_distance=True, + tree=kdtree, + ) + + contacting = sorted(contacting, key=lambda x: (x["num_contacts"], -(x["min_distance"] or 0)), reverse=True) + # Detailed contact info with num_atoms, num_contacts, min_distance + contacting_iids = tuple( + { + "pn_unit_iid": c["pn_unit_iid"], + "num_atoms": c.get("num_atoms", 0), + "num_contacts": c.get("num_contacts", 0), + "min_distance": c.get("min_distance"), + } + for c in contacting + ) + + # Extract type-specific data (inline) + bonded: set = set() + is_metal, is_loi, ranking_fit = False, False, None + canonical_seq, non_canonical_seq = "", "" + seq_len, has_non_canonical_residue, ec_nums = None, None, None + non_polymer_names = "" + + if query_type.is_non_polymer(): + bonded = structure_utils.get_bonded_polymer_pn_units(pn_iid, atoms) + res_names = np.unique(query.res_name) + is_loi = bool(loi_set & set(res_names)) + is_metal = len(query) == 1 and query[0].element.upper() in METAL_ELEMENTS + non_polymer_names = ",".join(struc.get_residues(query)[1]) + + if ligand_scores is not None: + ligand_ids = sorted(set(zip(query.chain_id, query.res_name, strict=False))) + matching = [lid for lid in ligand_ids if lid in ligand_scores.index] + if matching: + ranking_fit = ligand_scores.loc[matching].to_dict().get("ranking_model_fit") + else: # polymer + cinfo = chain_info.get(query.chain_id[0], {}) + canonical_seq = cinfo.get("processed_entity_canonical_sequence", "") + non_canonical_seq = cinfo.get("processed_entity_non_canonical_sequence", "") + seq_len = len(canonical_seq) if canonical_seq else 0 + ec_nums = cinfo.get("ec_numbers") + has_non_canonical_residue = bool( + set(np.unique(query.res_name)) - set(STANDARD_AND_UNKNOWN_POLYMER_RESIDUES) + ) + + # Primary polymer partner + if not query_type.is_non_polymer(): + primary_partner = pn_iid + else: + polymer_iids = np.unique(atoms.pn_unit_iid[atoms.is_polymer]) + partners = [c for c in contacting if c["pn_unit_iid"] in polymer_iids] + primary_partner = partners[0]["pn_unit_iid"] if partners else None + + # Sampling weights + n_prot = 1 if query_type.is_protein() and (seq_len or 0) > PEPTIDE_MAX_RESIDUES else 0 + n_peptide = 1 if query_type.is_protein() and (seq_len or 0) <= PEPTIDE_MAX_RESIDUES else 0 + n_nuc = 1 if query_type.is_nucleic_acid() else 0 + n_ligand = 1 if query_type.is_non_polymer() else 0 + + bonded_iids = tuple(bonded) + + mol_id = query.molecule_id[0] + mol_iid = query.molecule_iid[0] + trans_id = query.transformation_id[0] + + pn_units.append( + PNUnit( + pdb_id=assembly.pdb_id, + assembly_id=aid, + pn_unit_id=pn_unit_id, + pn_unit_iid=pn_iid, + molecule_id=mol_id, + molecule_iid=mol_iid, + transformation_id=trans_id, + pn_unit_type=query_type.value, + is_polymer=query_type.is_polymer(), + is_metal=is_metal, + is_loi=is_loi, + num_resolved_atoms=len(query), + num_resolved_residues=struc.get_residue_count(query), + is_multichain=len(np.unique(query.chain_id)) > 1, + is_multiresidue=len(np.unique(query.res_id)) > 1, + sequence_length=seq_len, + has_non_canonical_residue=has_non_canonical_residue, + processed_entity_canonical_sequence=canonical_seq or None, + processed_entity_non_canonical_sequence=non_canonical_seq or None, + processed_entity_canonical_sequence_hash=hash_sequence(canonical_seq) if canonical_seq else None, + processed_entity_non_canonical_sequence_hash=hash_sequence(non_canonical_seq) + if non_canonical_seq + else None, + ec_numbers=tuple(ec_nums) if ec_nums else None, + non_polymer_res_names=non_polymer_names or None, + bonded_polymer_pn_units=bonded_iids or None, + ranking_model_fit=ranking_fit, + primary_polymer_partner_iid=primary_partner, + contacting_pn_unit_iids=contacting_iids, + n_prot=n_prot, + n_peptide=n_peptide, + n_nuc=n_nuc, + n_ligand=n_ligand, + ) + ) + + # Create ContactInfo for new pairs + for c in contacting: + partner_iid = c["pn_unit_iid"] + pair = tuple(sorted([pn_iid, partner_iid])) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + contacts.append( + ContactInfo( + pn_unit_1_iid=pair[0], + pn_unit_2_iid=pair[1], + num_contacts=c.get("num_contacts", 0), + min_distance=c.get("min_distance", 0.0) or 0.0, + involves_covalent_modification=partner_iid in bonded, + ) + ) + + return pn_units, contacts + + +# ============================================================================= +# Stage 4: Process Interfaces +# ============================================================================= + + +def process_interfaces(pn_units: list[PNUnit], contact_info: list[ContactInfo]) -> list[Interface]: + """Create Interface records from contacts. + + Args: + pn_units: PNUnit records from process_pn_units(). + contact_info: ContactInfo records from process_pn_units(). + + Returns: + List of Interface records. + """ + lookup = {p.pn_unit_iid: p for p in pn_units} + interfaces = [] + + for c in contact_info: + p1, p2 = lookup.get(c.pn_unit_1_iid), lookup.get(c.pn_unit_2_iid) + if not (p1 and p2) or p1.assembly_id != p2.assembly_id: + continue + + # Check covalent modification in both directions + bonded_1 = p1.bonded_polymer_pn_units or () + bonded_2 = p2.bonded_polymer_pn_units or () + involves_covalent = c.pn_unit_2_iid in bonded_1 or c.pn_unit_1_iid in bonded_2 + + interfaces.append( + Interface( + pdb_id=p1.pdb_id, + assembly_id=p1.assembly_id, + pn_unit_1_iid=c.pn_unit_1_iid, + pn_unit_2_iid=c.pn_unit_2_iid, + num_contacts=c.num_contacts, + min_distance=c.min_distance, + involves_covalent_modification=involves_covalent, + is_inter_molecule=p1.molecule_id != p2.molecule_id, + involves_loi=p1.is_loi or p2.is_loi, + involves_metal=p1.is_metal or p2.is_metal, + ) + ) + + return interfaces + + +# ============================================================================= +# Stage 5: Add Closest Chain Info (Final Step) +# ============================================================================= + + +def _add_closest_chain_info( + assemblies: list[Assembly], + assembly_atoms: dict[str, AtomArray], + pn_units: list[PNUnit], + interfaces: list[Interface], + config: PreprocessConfig, +) -> tuple[list[Assembly], list[PNUnit], list[Interface]]: + """Add closest_20/50_pn_unit_iids to assemblies. + + Uses existing Interface objects to identify polymer-polymer contacts. + For small assemblies (≤threshold), all chains are considered "closest". + For large assemblies, samples an interface and finds closest chains. + """ + # Build lookup: pn_unit_iid -> is_polymer + is_polymer = {p.pn_unit_iid: p.is_polymer for p in pn_units} + + # Group polymer-polymer interfaces by assembly + assembly_interfaces: dict[str, list[Interface]] = defaultdict(list) + for iface in interfaces: + if is_polymer.get(iface.pn_unit_1_iid) and is_polymer.get(iface.pn_unit_2_iid): + assembly_interfaces[iface.assembly_id].append(iface) + + updated = [] + for assembly in assemblies: + aid = assembly.assembly_id + atoms = assembly_atoms.get(aid) + all_iids = set(assembly.all_pn_unit_iids_after_processing) + + # Default: all chains are closest (for small assemblies) + closest_20: set[str] = all_iids + closest_50: set[str] = all_iids + + # Sample for large assemblies + if ( + config.sample_large_assembly_interfaces + and assembly.num_polymer_pn_units > config.large_assembly_threshold + and atoms is not None + ): + polymer_ifaces = assembly_interfaces.get(aid, []) + + if polymer_ifaces: + # We need the atomize annotation to label tokens when sampling chains near interfaces + atoms_with_id = add_global_atom_id_annotation(atoms) + atomized_atoms = atomize_by_ccd_name( + atoms_with_id, + atomize_by_default=True, + res_names_to_ignore=[*STANDARD_POLYMER_RESIDUES], + ) + + # Get polymer-polymer contact pairs + pairs = [(i.pn_unit_1_iid, i.pn_unit_2_iid) for i in polymer_ifaces] + result = sample_large_assembly_interface(atomized_atoms, pairs, config.interface_cutoff_distance) + + if result: + closest_20 = result.get(20, set()) + closest_50 = result.get(50, set()) + + updated.append( + replace( + assembly, + closest_20_pn_unit_iids=tuple(sorted(closest_20 & all_iids)), + closest_50_pn_unit_iids=tuple(sorted(closest_50 & all_iids)), + ) + ) + + # Build assembly_id -> (closest_20, closest_50) lookup + assembly_closest = { + a.assembly_id: (set(a.closest_20_pn_unit_iids), set(a.closest_50_pn_unit_iids)) for a in updated + } + + # Update pn_units with closest chain flags + updated_pn_units = [] + for p in pn_units: + c20, c50 = assembly_closest.get(p.assembly_id, (set(), set())) + updated_pn_units.append( + replace( + p, + within_20_closest_chains=(p.pn_unit_iid in c20) if c20 else True, + within_50_closest_chains=(p.pn_unit_iid in c50) if c50 else True, + ) + ) + + # Update interfaces with closest chain flags + updated_interfaces = [] + for i in interfaces: + c20, c50 = assembly_closest.get(i.assembly_id, (set(), set())) + updated_interfaces.append( + replace( + i, + within_20_closest_chains=(i.pn_unit_1_iid in c20 and i.pn_unit_2_iid in c20) if c20 else True, + within_50_closest_chains=(i.pn_unit_1_iid in c50 and i.pn_unit_2_iid in c50) if c50 else True, + ) + ) + + return updated, updated_pn_units, updated_interfaces + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + + +def preprocess( + path: Path, + config: PreprocessConfig | None = None, +) -> tuple[list[Assembly], list[PNUnit], list[Interface]]: + """Preprocess a structure file into normalized records. + + This is the main entry point that runs all 5 stages: + 1. parse_entry() - Load and prepare structure + 2. process_assemblies() - Filter and create Assembly records + 3. process_pn_units() - Extract PN units and contacts + 4. process_interfaces() - Create Interface records + 5. _add_closest_chain_info() - Add closest chain info for large assemblies + + Args: + path: Path to structure file (.cif, .cif.gz, .pdb, .pdb.gz). + config: Preprocessing configuration. Uses defaults if None. + + Returns: + Tuple of (assemblies, pn_units, interfaces). + """ + config = config or PreprocessConfig() + path = Path(path) + + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + + parser_dict = parse_entry(path, config) + assemblies, assembly_atoms = process_assemblies(parser_dict, config) + pn_units, contact_info = process_pn_units(assemblies, assembly_atoms, parser_dict, config) + interfaces = process_interfaces(pn_units, contact_info) + + # Update assemblies, pn_units, interfaces with closest chain info + assemblies, pn_units, interfaces = _add_closest_chain_info(assemblies, assembly_atoms, pn_units, interfaces, config) + + return assemblies, pn_units, interfaces + + +# ============================================================================= +# Save to Parquet +# ============================================================================= + + +def save_records( + out_dir: Path, + pdb_id: str, + assemblies: list[Assembly], + pn_units: list[PNUnit], + interfaces: list[Interface], + exclude: set[str] | None = None, +) -> None: + """Save preprocessing result to parquet files. + + Creates: + out_dir/assemblies/{pdb_id}.parquet + out_dir/pn_units/{pdb_id}.parquet + out_dir/interfaces/{pdb_id}.parquet + + Args: + out_dir: Base output directory. + pdb_id: PDB identifier for file naming. + assemblies: List of Assembly records. + pn_units: List of PNUnit records. + interfaces: List of Interface records. + exclude: Optional set of field names to exclude from output. + """ + exclude = exclude or set() + + for name, records in [("assemblies", assemblies), ("pn_units", pn_units), ("interfaces", interfaces)]: + if not records: + continue + subdir = out_dir / name + subdir.mkdir(parents=True, exist_ok=True) + df = pd.DataFrame([serialize_record(r.to_dict(exclude)) for r in records]) + df.to_parquet(subdir / f"{pdb_id}.parquet", index=False) diff --git a/src/atomworks/ml/preprocessing/records.py b/src/atomworks/ml/preprocessing/records.py new file mode 100644 index 00000000..bec33983 --- /dev/null +++ b/src/atomworks/ml/preprocessing/records.py @@ -0,0 +1,259 @@ +"""Record definitions for preprocessing pipeline. + +This module defines the core dataclasses for the normalized three-table design: +- Assembly: Entry-level metadata and statistics +- PNUnit: Polymer/non-polymer unit information +- Interface: Contact information between PN units +- ContactInfo: Intermediate contact data during processing +""" + +import json +from dataclasses import dataclass, fields +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any + +import numpy as np + +# ============================================================================= +# Serialization Utilities +# ============================================================================= + + +def to_python_native(obj: Any) -> Any: + """Convert numpy/Path/datetime/Enum to Python native types recursively.""" + if isinstance(obj, dict): + return {str(k): to_python_native(v) for k, v in obj.items()} + if isinstance(obj, list | tuple): + return [to_python_native(item) for item in obj] + if isinstance(obj, set): + return [to_python_native(item) for item in sorted(obj)] + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, Path): + return str(obj) + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, Enum): + return obj.value + return obj + + +def serialize_value(value: Any) -> Any: + """Serialize a value for parquet storage (JSON for complex types).""" + native = to_python_native(value) + if isinstance(native, dict | list): + return json.dumps(native) + return native + + +def serialize_record(record: dict[str, Any]) -> dict[str, Any]: + """Serialize a record for parquet storage. + + Handles conversion of: + - dicts/lists/sets -> JSON strings + - Path -> str + - Enum -> value + - datetime -> ISO format string + - numpy types -> native Python types + """ + return {key: serialize_value(value) for key, value in record.items()} + + +def deserialize_field(value: Any) -> Any: + """Deserialize a field from parquet storage. + + Attempts to parse JSON strings back to dicts/lists. + """ + if isinstance(value, str): + try: + return json.loads(value) + except (json.JSONDecodeError, ValueError): + return value + return value + + +def to_dict_with_exclude(obj: Any, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize dataclass, converting tuples to lists, with optional exclusion.""" + exclude = exclude or set() + result = {} + for f in fields(obj): + if f.name in exclude: + continue + val = getattr(obj, f.name) + if isinstance(val, tuple): + val = list(val) + result[f.name] = val + return result + + +# ============================================================================= +# Core Dataclasses +# ============================================================================= + + +@dataclass(frozen=True, slots=True) +class Assembly: + """Assembly-level record with metadata and statistics. + + This represents the entry-level data for a biological assembly, + including experimental metadata, quality metrics, and token counts. + """ + + # Identifiers + pdb_id: str + assembly_id: str + path: str + + # Experimental metadata + resolution: float | None + deposition_date: str | None + release_date: str | None + method: str | None + ph: float | None + + # Assembly statistics + clash_severity: str = "no-clash" + num_polymer_pn_units: int = 0 + num_resolved_atoms_in_processed_assembly: int = 0 + total_num_atoms_in_unprocessed_assembly: int = 0 + fraction_zero_occupancy_atoms: float = 0.0 + all_pn_unit_iids_after_processing: tuple[str, ...] = () + + # Bond flags + has_unphysical_bonds: bool = False + has_non_biological_bonds: bool = False + + # Token counts + n_atomized_tokens: int = 0 + n_non_atomized_tokens: int = 0 + n_tokens_total: int = 0 + + # Closest chains (for large assembly sampling) + closest_20_pn_unit_iids: tuple[str, ...] = () + closest_50_pn_unit_iids: tuple[str, ...] = () + + def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize to dictionary with optional field exclusion.""" + return to_dict_with_exclude(self, exclude) + + +@dataclass(frozen=True, slots=True) +class PNUnit: + """Polymer or non-polymer unit record. + + This represents a single PN unit within an assembly, containing + type information, structural properties, and sequence data for polymers. + """ + + # Foreign keys + pdb_id: str + assembly_id: str + + # Identifiers + pn_unit_id: str + pn_unit_iid: str + molecule_id: str + molecule_iid: str + transformation_id: str + + # Type information + pn_unit_type: str + is_polymer: bool + is_metal: bool = False + is_loi: bool = False + + # Structural properties + num_resolved_atoms: int = 0 + num_resolved_residues: int | None = None + is_multichain: bool = False + is_multiresidue: bool = False + + # Polymer-specific fields + sequence_length: int | None = None + has_non_canonical_residue: bool | None = None + processed_entity_canonical_sequence: str | None = None + processed_entity_non_canonical_sequence: str | None = None + processed_entity_canonical_sequence_hash: str | None = None + processed_entity_non_canonical_sequence_hash: str | None = None + ec_numbers: tuple[str, ...] | None = None + + # Non-polymer-specific fields + non_polymer_res_names: str | None = None + bonded_polymer_pn_units: tuple[str, ...] | None = None + ranking_model_fit: float | None = None + + # Contact information + primary_polymer_partner_iid: str | None = None + # Each contact dict has: pn_unit_iid, num_atoms, num_contacts, min_distance + contacting_pn_unit_iids: tuple[dict[str, Any], ...] = () + + # Sampling weights (for training data balancing) + n_prot: int = 0 + n_peptide: int = 0 + n_nuc: int = 0 + n_ligand: int = 0 + + # Closest chain flags (computed in _add_closest_chain_info) + within_20_closest_chains: bool = True + within_50_closest_chains: bool = True + + def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize to dictionary with optional field exclusion.""" + return to_dict_with_exclude(self, exclude) + + +@dataclass(frozen=True, slots=True) +class Interface: + """Interface record between two PN units. + + This represents contact information between a pair of PN units, + including distance metrics and interaction properties. + """ + + # Foreign keys + pdb_id: str + assembly_id: str + pn_unit_1_iid: str + pn_unit_2_iid: str + + # Contact properties + num_contacts: int + min_distance: float + involves_covalent_modification: bool = False + is_inter_molecule: bool = False + involves_loi: bool = False + involves_metal: bool = False + + # Closest chain flags (computed in _add_closest_chain_info) + within_20_closest_chains: bool = True + within_50_closest_chains: bool = True + + def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize to dictionary with optional field exclusion.""" + return to_dict_with_exclude(self, exclude) + + +# ============================================================================= +# Helper Dataclasses +# ============================================================================= + + +@dataclass +class ContactInfo: + """Intermediate contact data during processing. + + This is a mutable container used during interface computation, + before conversion to immutable Interface records. + """ + + pn_unit_1_iid: str + pn_unit_2_iid: str + num_contacts: int + min_distance: float + involves_covalent_modification: bool = False diff --git a/src/atomworks/ml/preprocessing/utils/fasta.py b/src/atomworks/ml/preprocessing/utils/fasta.py index 43ee0620..a30101b8 100644 --- a/src/atomworks/ml/preprocessing/utils/fasta.py +++ b/src/atomworks/ml/preprocessing/utils/fasta.py @@ -24,6 +24,10 @@ def create_fasta_file_from_df( ) -> None: """Create a FASTA file from sequences stored as a dataframe in a Parquet file. + Wraps sequences to 80 characters per line, filters null/all-X sequences, and deduplicates. + For simple unwrapped FASTA from a list of sequences, see + :py:func:`~atomworks.ml.preprocessing.msa.generating.create_fasta_with_hashed_headers`. + Args: pn_units_df (pd.DataFrame | PathLike | str): Dataframe, as a path Parquet or object directly, containing a column with the sequences to be clustered. sequence_col_name (str): The name of the column containing the canonical sequences to be clustered. diff --git a/src/atomworks/ml/preprocessing/utils/large_assembly.py b/src/atomworks/ml/preprocessing/utils/large_assembly.py new file mode 100644 index 00000000..09fa1b6f --- /dev/null +++ b/src/atomworks/ml/preprocessing/utils/large_assembly.py @@ -0,0 +1,138 @@ +"""Large assembly interface sampling utilities. + +Implements the AF3 supplement sampling strategy for large assemblies: +"For bioassemblies with greater than 20 chains, we select a random interface token +(with a centre atom <15 Å to the centre atom of a token in another chain) and select +the closest 20 chains to this token based on minimum distance between any tokens centre atom." +""" + +import logging + +import numpy as np +from biotite.structure import AtomArray +from scipy.spatial import KDTree + +from atomworks.ml.utils.token import get_af3_token_center_masks + +logger = logging.getLogger(__name__) + + +def get_interface_atoms( + atom_array: AtomArray, + pn_unit_1_iid: str, + pn_unit_2_iid: str, + cutoff_distance: float = 15.0, +) -> np.ndarray: + """Identify atoms at the interface between two PN units. + + Args: + atom_array: The atom array containing the full structure. + pn_unit_1_iid: First PN unit instance ID. + pn_unit_2_iid: Second PN unit instance ID. + cutoff_distance: Distance threshold in Angstroms for interface definition. + + Returns: + Boolean mask of atoms within cutoff_distance of the other PN unit. + """ + mask_1 = (atom_array.pn_unit_iid == pn_unit_1_iid) & (atom_array.occupancy > 0) + mask_2 = (atom_array.pn_unit_iid == pn_unit_2_iid) & (atom_array.occupancy > 0) + + if not np.any(mask_1) or not np.any(mask_2): + return np.zeros(len(atom_array), dtype=bool) + + tree_1 = KDTree(atom_array.coord[mask_1]) + tree_2 = KDTree(atom_array.coord[mask_2]) + + dists = tree_1.sparse_distance_matrix(tree_2, max_distance=cutoff_distance, output_type="coo_matrix") + + is_at_interface = np.zeros(len(atom_array), dtype=bool) + indices_1 = np.where(mask_1)[0] + indices_2 = np.where(mask_2)[0] + is_at_interface[indices_1[np.unique(dists.row)]] = True + is_at_interface[indices_2[np.unique(dists.col)]] = True + + return is_at_interface + + +def find_closest_pn_units( + atom_array: AtomArray, + reference_coord: np.ndarray, + n_closest: int = 20, +) -> list[str]: + """Find the closest PN units based on minimum token center distance. + + Args: + atom_array: The atom array containing the full structure. + reference_coord: 3D coordinates of the reference point (shape: (3,)). + n_closest: Number of closest PN units to return. + + Returns: + List of PN unit IIDs sorted by distance (closest first). + """ + token_center_mask = get_af3_token_center_masks(atom_array, enforce_one_per_token=False) + token_coords = atom_array.coord[token_center_mask] + token_iids = atom_array.pn_unit_iid[token_center_mask] + + valid_mask = np.isfinite(token_coords).all(axis=1) + if not np.any(valid_mask): + return [] + + valid_coords = token_coords[valid_mask] + valid_iids = token_iids[valid_mask] + + distances = np.linalg.norm(valid_coords - reference_coord, axis=1) + + unique_iids, inverse_indices = np.unique(valid_iids, return_inverse=True) + + sort_idx = np.argsort(inverse_indices) + sorted_distances = distances[sort_idx] + sorted_inverse = inverse_indices[sort_idx] + + group_starts = np.concatenate([[0], np.where(np.diff(sorted_inverse) != 0)[0] + 1]) + min_distances = np.minimum.reduceat(sorted_distances, group_starts) + + sorted_idx = np.argsort(min_distances) + return [unique_iids[i] for i in sorted_idx[:n_closest]] + + +def sample_large_assembly_interface( + atom_array: AtomArray, + contacting_pairs: list[tuple[str, str]], + interface_cutoff_distance: float = 15.0, + n_closest: tuple[int, ...] = (20, 50), + seed: int | None = None, +) -> dict[int, set[str]] | None: + """Sample an interface and find closest PN units for large assemblies. + + Args: + atom_array: The filtered atom array for the assembly. + contacting_pairs: List of polymer-polymer contacting pairs (pn_unit_iids). + interface_cutoff_distance: Cutoff for interface atom identification. + n_closest: Tuple of n values for finding closest PN units. + seed: Random seed for reproducibility. + + Returns: + Dict mapping n -> set of closest pn_unit_iids, or None if sampling failed. + """ + rng = np.random.RandomState(seed) + + if not contacting_pairs: + logger.warning("No polymer-polymer interfaces found for large assembly") + return None + + idx = rng.randint(len(contacting_pairs)) + pn_unit_1_iid, pn_unit_2_iid = contacting_pairs[idx] + + is_interface = get_interface_atoms( + atom_array, pn_unit_1_iid, pn_unit_2_iid, cutoff_distance=interface_cutoff_distance + ) + + interface_indices = np.where(is_interface)[0] + if len(interface_indices) == 0: + logger.warning("No interface atoms found; falling back to None.") + return None + + sampled_idx = interface_indices[rng.randint(len(interface_indices))] + reference_coord = atom_array.coord[sampled_idx] + + return {n: set(find_closest_pn_units(atom_array, reference_coord, n_closest=n)) for n in n_closest} diff --git a/src/atomworks/ml/preprocessing/utils/structure_utils.py b/src/atomworks/ml/preprocessing/utils/structure_utils.py index c833c370..ee60bb32 100644 --- a/src/atomworks/ml/preprocessing/utils/structure_utils.py +++ b/src/atomworks/ml/preprocessing/utils/structure_utils.py @@ -1,17 +1,15 @@ -"""Utilities to create the dataset to be loaded by RF2AA. See the main script for term Glossary.""" - -from __future__ import annotations +"""Utilities for preprocessing PDB structures.""" import logging import math +import warnings from collections import defaultdict -from typing import Any, Final +from typing import Any, Final, Literal -import networkx as nx import numpy as np import requests from biotite.structure import AtomArray, CellList -from scipy.spatial.distance import cdist +from scipy.spatial import cKDTree from atomworks.common import default, not_isin from atomworks.constants import ELEMENT_NAME_TO_ATOMIC_NUMBER, METAL_ELEMENTS @@ -20,152 +18,152 @@ logger = logging.getLogger("preprocess") -def get_pn_units_with_non_biological_bonds(atom_array: AtomArray, bond_mask: np.ndarray) -> np.ndarray: - """ - Checks for non-biological bonds between PN units within an assembly. - Note that "inter-PN-unit bonds" in this instance do not include bonds between non-polymers, - which are treated as the same PN unit (by definition). +def get_non_biological_bond_atom_mask(atom_array: AtomArray) -> np.ndarray: + """Return mask of atoms involved in non-biological inter-PN-unit bonds.""" + inter_bond_mask = get_inter_pn_unit_bond_mask(atom_array) - Specifically, this function looks for inter-molecular: - - Oxygen-oxygen bonds - - Fluorine-fluorine bonds - - Bonds involving free oxygen or hydroxyl groups (e.g. HOH, OH, O) + if not np.any(inter_bond_mask): + return np.zeros(len(atom_array), dtype=bool) - Args: - atom_array: AtomArray containing the relevant structure - bond_mask: Mask of inter-molecular bonds + bonds = atom_array.bonds.as_array()[inter_bond_mask] - Returns: - numpy.ndarray: Array of polymer/non-polymer unit instance IDs that contain non-biological bonds - """ - # Get atoms and residues involved in the inter-molecular bonds - bonds_to_check = atom_array.bonds.as_array()[bond_mask] - atom_a_elements = atom_array.atomic_number[bonds_to_check[:, 0]] - atom_b_elements = atom_array.atomic_number[bonds_to_check[:, 1]] - atom_a_res_names = atom_array.res_name[bonds_to_check[:, 0]] - atom_b_res_names = atom_array.res_name[bonds_to_check[:, 1]] - - # Check for non-biological bonds - non_biological_bonds = ( - (atom_a_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["O"]) - & (atom_b_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["O"]) # Oxygen-oxygen bonds - | (atom_a_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["F"]) - & (atom_b_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["F"]) # Fluorine-fluorine bonds - | np.isin(atom_a_res_names, ["HOH", "OH", "O"]) # Bonds involving free oxygen or hydroxyl groups - | np.isin(atom_b_res_names, ["HOH", "OH", "O"]) - ) + atom_a_elements = atom_array.atomic_number[bonds[:, 0]] + atom_b_elements = atom_array.atomic_number[bonds[:, 1]] + atom_a_res_names = atom_array.res_name[bonds[:, 0]] + atom_b_res_names = atom_array.res_name[bonds[:, 1]] - # Get unique polymer/non-polymer unit IDs with non-biological bonds - return np.unique( - np.concatenate( - [ - atom_array.pn_unit_iid[bonds_to_check[:, 0]][non_biological_bonds], - atom_array.pn_unit_iid[bonds_to_check[:, 1]][non_biological_bonds], - ] + # Identify non-biological bonds + non_bio = ( + ( + (atom_a_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["O"]) + & (atom_b_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["O"]) ) + | ( + (atom_a_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["F"]) + & (atom_b_elements == ELEMENT_NAME_TO_ATOMIC_NUMBER["F"]) + ) + | np.isin(atom_a_res_names, ["HOH", "OH", "O"]) + | np.isin(atom_b_res_names, ["HOH", "OH", "O"]) ) + # Build atom mask - mark both atoms in each non-biological bond + result = np.zeros(len(atom_array), dtype=bool) + result[bonds[non_bio, 0]] = True + result[bonds[non_bio, 1]] = True + return result + def get_clashing_pn_units( - pn_unit_iids_to_consider: np.Array, atom_array: AtomArray, cell_list: CellList, clash_distance: float -) -> tuple[set[str], dict[str, set[str]]]: - """Wrapper function to find clashing PN units within an atom array.""" - # Process PN unit-by-PN unit to avoid memory issues - clashing_pn_units_dict = defaultdict(set) - clashing_pn_units_set = set() - for query_pn_unit_iid in pn_unit_iids_to_consider: - query_pn_unit_atom_array = atom_array[atom_array.pn_unit_iid == query_pn_unit_iid] - clashing_pn_units = get_pn_units_clashing_with_pn_unit( - query_pn_unit_atom_array, atom_array, cell_list, clash_distance - ) - if clashing_pn_units: - clashing_pn_units_set.update(clashing_pn_units | {query_pn_unit_iid}) - clashing_pn_units_dict[query_pn_unit_iid] = set(clashing_pn_units) - return clashing_pn_units_set, clashing_pn_units_dict + pn_unit_iids_to_consider: np.ndarray, + atom_array: AtomArray, + clash_distance: float, +) -> dict[str, set[str]]: + """Find clashing PN units within an atom array. + + Uses cKDTree.query_pairs for efficient O(n log n) pair detection. + """ + tree = cKDTree(atom_array.coord) + pairs = tree.query_pairs(r=clash_distance, output_type="ndarray") + + if len(pairs) == 0: + return {} + + # Filter to inter-PN-unit pairs + pn_a = atom_array.pn_unit_iid[pairs[:, 0]] + pn_b = atom_array.pn_unit_iid[pairs[:, 1]] + clashing_pairs = pairs[pn_a != pn_b] + + if len(clashing_pairs) == 0: + return {} + + # Build result dict for requested PN units only + iids_set = set(pn_unit_iids_to_consider) + clashing_dict: dict[str, set[str]] = {} + + for i, j in clashing_pairs: + iid_i = atom_array.pn_unit_iid[i] + iid_j = atom_array.pn_unit_iid[j] + if iid_i in iids_set: + clashing_dict.setdefault(iid_i, set()).add(iid_j) + if iid_j in iids_set: + clashing_dict.setdefault(iid_j, set()).add(iid_i) + + return clashing_dict def get_atom_mask_from_cell_list( - coord: np.array, cell_list: CellList, cell_list_size: int, cutoff: float, chunk_size: int = int(2e9) + coord: np.ndarray, + cell_list: CellList, + cell_list_size: int, + cutoff: float, + *, + output_mode: Literal["pairwise", "any_target"] = "pairwise", + chunk_size: int = int(2e9), ) -> np.ndarray: - """ - Builds a mask indicating which atoms clash with the query PN unit. If the number of comparisons is too large, - the computation is split into manageable chunks along the rows of `coord`. + """Return distance-based mask between query coords and cell_list atoms. - TODO: Update documentation since this is not specific to PN units or clashes. + .. deprecated:: + Use :py:class:`scipy.spatial.cKDTree` instead. See warning message for details. + + Large queries are split into chunks for memory efficiency. Args: - coord (ndarray): The coordinates of the query PN unit. Shape is (n, 3). - cell_list (CellList): A CellList object that allows efficient vicinity searches. - cell_list_size (int): The number of atoms in the cell list. - clash_distance (float): The distance threshold below which atoms are considered to be clashing. - chunk_size (int): The maximum number of comparisons allowed in a single chunk. + coord: Query coordinates, shape (n, 3). + cell_list: CellList for efficient vicinity searches. + cell_list_size: Number of atoms in cell_list. + cutoff: Distance threshold. + output_mode: Output format: + - "pairwise": Full 2D mask (n_query, cell_list_size) + - "any_target": 1D mask (cell_list_size,) collapsed via np.any(axis=0) + chunk_size: Max comparisons per chunk for memory efficiency. Returns: - ndarray: Mask indicating which atoms in `cell_list` clash with the atoms in `coord`. Shape is (n, cell_list_size), dtype is bool. + Boolean mask. Shape is (n_query, cell_list_size) for "pairwise" mode, + or (cell_list_size,) for "any_target" mode. """ + warnings.warn( + "get_atom_mask_from_cell_list is deprecated. " + "Use scipy.spatial.cKDTree instead:\n" + " tree = cKDTree(target_coords)\n" + " nearby = tree.query_ball_point(query_coords, r=cutoff)\n" + "For pair detection, use tree.query_pairs(r=cutoff).", + DeprecationWarning, + stacklevel=2, + ) num_coords = coord.shape[0] - clashing_atom_mask = np.zeros((num_coords, cell_list_size), dtype=bool) + max_rows_per_chunk = max(1, chunk_size // cell_list_size) + + if output_mode == "any_target": + # Memory-efficient: collapse during chunking + result = np.zeros(cell_list_size, dtype=bool) + for i in range(0, num_coords, max_rows_per_chunk): + end = min(i + max_rows_per_chunk, num_coords) + chunk_mask = cell_list.get_atoms(coord[i:end], cutoff, as_mask=True) + result |= np.any(chunk_mask, axis=0) + return result + + # output_mode == "pairwise": full 2D mask + pairwise_mask = np.zeros((num_coords, cell_list_size), dtype=bool) - max_rows_per_chunk = max(1, chunk_size // cell_list_size) # Ensure at least 1 row per chunk if num_coords * cell_list_size > chunk_size: logger.info( - f"{num_coords * cell_list_size:,} comparisons needed; distance computation split into {math.ceil(num_coords / max_rows_per_chunk)} chunks." + f"{num_coords * cell_list_size:,} comparisons needed; " + f"distance computation split into {math.ceil(num_coords / max_rows_per_chunk)} chunks." ) for i in range(0, num_coords, max_rows_per_chunk): end = min(i + max_rows_per_chunk, num_coords) - clashing_atom_mask[i:end, :] = cell_list.get_atoms(coord[i:end], cutoff, as_mask=True) + pairwise_mask[i:end, :] = cell_list.get_atoms(coord[i:end], cutoff, as_mask=True) else: - clashing_atom_mask = cell_list.get_atoms(coord, cutoff, as_mask=True) + pairwise_mask = cell_list.get_atoms(coord, cutoff, as_mask=True) - return clashing_atom_mask - - -def get_pn_units_clashing_with_pn_unit( - query_pn_unit: AtomArray, filtered_atom_array: AtomArray, cell_list: CellList, clash_distance: float -) -> set[str]: - """ - Finds clashes between a query PN unit and the rest of the structure. - A clash is defined as any pair of atoms from the query PN unit and the rest of the structure that are closer than `clash_distance`. - - Args: - query_pn_unit: AtomArray containing the query PN unit that we want to check for clashes - filtered_atom_array: AtomArray containing atoms with non-zero occupancy - cell_list: CellList of structure for rapid distance computations - clash_distance: Distance threshold for clashing atoms - - Returns: - Set of polymer/non-polymer unit instance IDs that are clashing with the query PN unit - """ - clashing_atom_mask = get_atom_mask_from_cell_list( - query_pn_unit.coord, cell_list, len(filtered_atom_array), clash_distance - ) - collapsed_mask = np.any(clashing_atom_mask, axis=0) - clashing_atoms = filtered_atom_array[collapsed_mask] - - # Filter out query PN unit atoms - query_pn_unit_iids = np.unique(query_pn_unit.pn_unit_iid) - clashing_atoms = clashing_atoms[not_isin(clashing_atoms.pn_unit_iid, query_pn_unit_iids)] - return set(np.unique(clashing_atoms.pn_unit_iid)) + return pairwise_mask def handle_clashing_pn_units( - clashing_pn_units_set: set, clashing_pn_units_dict: dict, atom_array: AtomArray, pn_unit_iid_map: dict -) -> AtomArray: - """ - Resolves clashing PN units according to the following process: - 1. Sort clashing PN units by the number of atoms within the PN unit - 2. Iterate through the sorted list keeping (1) the larger PN unit (2) the lower transformation number until all clashes are resolved - - Args: - clashing_pn_units_set: Set of polymer/non-polymer unit instance IDs that contain clashing atoms - clashing_pn_units_dict: Dictionary mapping polymer/non-polymer unit instance IDs to a list of clashing polymer/non-polymer unit instance IDs - atom_array: AtomArray containing atoms with non-zero occupancy - pn_unit_iid_map: Dictionary mapping integer representations of polymer/non-polymer unit instance IDs to the polymer/non-polymer unit instance IDs themselves - - Returns: - AtomArray: AtomArray with clashing atoms removed - ClashSeverity: Enum representing the severity of the clash - """ + clashing_pn_units_dict: dict[str, set[str]], atom_array: AtomArray +) -> tuple[AtomArray, ClashSeverity]: + """Resolve clashing PN units by keeping larger units.""" + clashing_pn_units_set = set(clashing_pn_units_dict.keys()) pn_units_to_remove = set() pn_units_to_keep = set() @@ -179,23 +177,19 @@ def handle_clashing_pn_units( "is_polymer": pn_unit_atom_array[0].is_polymer, } - # Sort clashing PN units by the number of atoms in the reference PN unit (high to low) and then the transformation ID (low to high) - # The last character in the polymer/non-polymer unit instance ID is the transformation ID, even in cases where we have covalently bound chains (e.g., A_1,B_1) def sort_key(x: tuple[str, dict[str, Any]]) -> tuple[int, int]: try: - return (x[1]["num_atoms"], -int(pn_unit_iid_map[x[0]][-1])) + return (x[1]["num_atoms"], -int(x[0][-1])) except ValueError: - return (x[1]["num_atoms"], -ord(pn_unit_iid_map[x[0]][-1])) + return (x[1]["num_atoms"], -ord(x[0][-1])) sorted_clashing_pn_units = [x[0] for x in sorted(clashing_pn_unit_details.items(), key=sort_key, reverse=True)] # Define the ClashSeverity - num_polymers = len( - np.unique(atom_array.pn_unit_iid[atom_array.is_polymer]) - ) # Number of polymer PN units in the structure + num_polymers = len(np.unique(atom_array.pn_unit_iid[atom_array.is_polymer])) num_clashing_polymers = len( np.unique([pn_unit for pn_unit in clashing_pn_units_set if clashing_pn_unit_details[pn_unit]["is_polymer"]]) - ) # Number of clashing polymer PN units + ) if num_clashing_polymers / num_polymers > 0.5: clash_severity = ClashSeverity.SEVERE @@ -209,9 +203,7 @@ def sort_key(x: tuple[str, dict[str, Any]]) -> tuple[int, int]: if pn_unit not in pn_units_to_remove: pn_units_to_keep.add(pn_unit) pn_units_to_remove.update(clashing_pn_units_dict[pn_unit] - pn_units_to_keep) - logger.warning( - f"Removing clashing PN units: {[pn_unit_iid_map[pn_unit] for pn_unit in pn_units_to_remove]} from the structure." - ) + logger.warning(f"Removing clashing PN units: {list(pn_units_to_remove)} from the structure.") # Remove clashing PN units atom_array = atom_array[not_isin(atom_array.pn_unit_iid, list(pn_units_to_remove))] @@ -219,188 +211,68 @@ def sort_key(x: tuple[str, dict[str, Any]]) -> tuple[int, int]: def get_contacting_pn_units( - query_pn_unit: AtomArray, - filtered_atom_array: AtomArray, - cell_list: CellList, - contact_distance: float = 4.5, - min_contacts_required: float = 1, - mask: np.ndarray = None, + atom_array: AtomArray, + query_mask: np.ndarray, + target_mask: np.ndarray, + contact_distance: float, + min_contacts_required: int = 1, calculate_min_distance: bool = False, -) -> list[dict[str, str]]: - """ - Finds PN units (proteins, nucleic acids, or small molecules) with a minimum number of atoms within a given distance of the query PN unit. + *, + tree: cKDTree, +) -> list[dict[str, str | int | float | None]]: + """Find PN units with atoms within contact distance of query atoms. - Args: - query_pn_unit: AtomArray containing the query PN unit that we want to check for contacts (could be a single chain or multiple covalently bonded chains) - filtered_atom_array: AtomArray containing the set of atoms to consider when looking for contacts (must correspond to the `cell_list`) - cell_list: CellList of `atom_array` for rapid distance computations; must correspond to the `filtered_atom_array` - contact_distance: Distance threshold for contacting atoms - min_contacts_required: Minimum number of atoms within the cutoff distance to consider a PN unit as a potential partner - mask: Mask of PN units to consider as potential partners within the atom_array. If None, all PN units (except the query) are considered. - calculate_min_distance: Whether to calculate the minimum distance between the query PN unit and each PN unit within the cutoff distance - - Returns: - list: A list of dictionaries, each containing: - - 'pn_unit_iid' (str): A string representing a unique partner PN unit in contact with the query PN unit. - - 'num_atoms' (int): Number of non-zero occupancy atoms in the partner PN unit. - - 'num_contacts' (int): Number of atoms in the partner PN unit within the contact_distance of the query PN unit. - - 'min_distance' (float): Minimum distance between the query PN unit and the partner PN unit. + Uses cKDTree.query_ball_point for efficient sparse neighbor lookup. """ - contacting_pn_unit_summary = [] - - # ---------- Step 1: Find and count all atoms within contact_distance ---------- # - full_contacting_atom_mask = get_atom_mask_from_cell_list( - query_pn_unit.coord, cell_list, len(filtered_atom_array), contact_distance - ) # (n_query, n_cell_list) - collapsed_contacting_atom_mask = np.any(full_contacting_atom_mask, axis=0) # (n_cell_list,) - - # Filter out unwanted atoms (either those in the query PN unit, or those not in the potential partner mask) - contacting_atoms_mask = ( - mask & collapsed_contacting_atom_mask if mask is not None else collapsed_contacting_atom_mask - ) - # Create a mask for atoms that are not part of the query PN unit - non_query_atoms_mask = not_isin(filtered_atom_array.pn_unit_iid, np.unique(query_pn_unit.pn_unit_iid)) - contacting_atoms_mask = contacting_atoms_mask & non_query_atoms_mask - # Using the final mask to get contacting atoms - contacting_atoms = filtered_atom_array[contacting_atoms_mask] - - # ---------- Step 2: Calculate the minimum distance to each contacting PN unit ---------- # - min_distances = {} - if calculate_min_distance and len(contacting_atoms) > 0: - # Get a list of PN units within `contact_distance` - close_atom_pn_unit_ids = np.unique(contacting_atoms.pn_unit_iid) - atoms_to_check = contacting_atoms - - # Perform distance calculation and store the minimum distance for each PN unit - distances = cdist(query_pn_unit.coord, atoms_to_check.coord) - for pn_unit_iid in close_atom_pn_unit_ids: - mask = atoms_to_check.pn_unit_iid == pn_unit_iid - min_distances[pn_unit_iid] = np.min(distances[:, mask]) if np.sum(mask) > 0 else None - - # ---------- Step 3: Apply criteria and add to list ---------- # - for pn_unit_iid in np.unique(contacting_atoms.pn_unit_iid): - contacting_pn_unit_atoms = contacting_atoms_mask & (filtered_atom_array.pn_unit_iid == pn_unit_iid) - # Count number of pairwise contacts (which is lower bounded by the number of contacting atoms) - pairwise_contacts = int(np.sum(full_contacting_atom_mask[:, contacting_pn_unit_atoms])) - - if pairwise_contacts >= min_contacts_required: - contacting_pn_unit_summary.append( - { - "pn_unit_iid": pn_unit_iid, - "num_atoms": len(filtered_atom_array[filtered_atom_array.pn_unit_iid == pn_unit_iid]), - "num_contacts": pairwise_contacts, - "min_distance": min_distances[pn_unit_iid] if min_distances else None, - } - ) - - return contacting_pn_unit_summary - - -def get_intra_pn_unit_bonds(pn_unit_iid: str, full_atom_array: AtomArray) -> np.ndarray: - # NOTE: Not currently used; kept for potential future use - """ - Retrieve all bonds within the PN unit. - Includes inter-chain bonds if the PN unit is composed of multiple chains. - Does NOT include bonds between the PN unit and any other PN units (e.g., protein-ligand bonds). - - Args: - pn_unit_iid (str): The polymer/non-polymer unit instance ID (e.g., 'A1', 'C2,B3', etc.) - full_atom_array (AtomArray): The full structure AtomArray. Must include all atoms, including those with zero occupancy. - - Returns: - numpy.ndarray: Array of Biotite bond objects (atom_a_index, atom_b_index, bond_type) within the specified PN unit. - """ - all_bonds = full_atom_array.bonds.as_array() - atom_a_indices, atom_b_indices = all_bonds[:, 0], all_bonds[:, 1] - mask = (full_atom_array.pn_unit_iid[atom_a_indices] == pn_unit_iid) & ( - full_atom_array.pn_unit_iid[atom_b_indices] == pn_unit_iid - ) - return all_bonds[mask] - - -def calculate_molecule_diameter(full_molecule_atoms: AtomArray) -> float: - # NOTE: Not currently used; kept for potential future use - """ - Calculates the molecular diameter, defined as the maximum number of bonds between any two vertices - in the molecule, using a maximum spanning tree. - - Args: - full_molecule_atoms (AtomArray): The array of atoms representing the molecule. Must include all atoms, including those with zero occupancy. - - Returns: - float: The molecular diameter. If the diameter cannot be computed, returns 0.0. - """ - if len(full_molecule_atoms.bonds.as_array() > 0): - try: - nx_graph = full_molecule_atoms.bonds.as_graph() # as a NetworkX graph - tree = nx.maximum_spanning_tree(nx_graph) - return nx.diameter(tree) - except nx.exception.NetworkXError: - logger.warning("Could not compute diameter.") - return 0.0 - else: - return 0.0 - - -def get_soi_ligands_from_pdb_id(pdb_id: str) -> set[str]: - """ - This function takes a PDB ID and returns a set of ligand names that are annotated as subject of - investigation (SOI) in the PDB. Such ligands are often considered - biologically meaningful. - - NOTE: This function is kept from the old pipeline for convenience and testing, - but not used in the current processing pipeline, where the LOI is extracted directly - from the cif file instead of via an FTP query. - - Args: - pdb_id (str): The PDB ID to query. - - Returns: - Set[str]: A set of ligand names that are annotated as SOI in the PDB. - """ - try: - pdb_id = pdb_id.lower() - core_response = requests.get(f"https://data.rcsb.org/rest/v1/core/entry/{pdb_id}") - core_json_dict = core_response.json() - nonpolymer_ids = core_json_dict["rcsb_entry_container_identifiers"].get("non_polymer_entity_ids", []) - soi_ligand_names = [] - - for nonpolymer_id in nonpolymer_ids: - ligand_response = requests.get( - f"https://data.rcsb.org/rest/v1/core/nonpolymer_entity/{pdb_id}/{nonpolymer_id}" - ) - ligand_json_dict = ligand_response.json() - ligand_name = ligand_json_dict["pdbx_entity_nonpoly"]["comp_id"] - - if "rcsb_nonpolymer_entity_annotation" in ligand_json_dict: - for annotation_dict in ligand_json_dict["rcsb_nonpolymer_entity_annotation"]: - if annotation_dict["type"] == "SUBJECT_OF_INVESTIGATION" and annotation_dict["name"] == ligand_name: - soi_ligand_names.append(ligand_name) - break - except Exception: - return set() - return set(soi_ligand_names) + query_coords = atom_array.coord[query_mask] + if len(query_coords) == 0 or not np.any(target_mask): + return [] + + # Query returns global indices into atom_array + nearby_lists_raw = tree.query_ball_point(query_coords, r=contact_distance) + + # Filter to target indices using vectorized boolean lookup (O(n) vs O(n*m) set lookup) + nearby_lists = [[idx for idx in nearby if target_mask[idx]] for nearby in nearby_lists_raw] + + # Track unique contact pairs per target PN unit and min distances + contact_pairs: dict[str, set[tuple[int, int]]] = defaultdict(set) + min_dists: dict[str, float] = {} + + for q_idx, nearby in enumerate(nearby_lists): + if not nearby: + continue + q_coord = query_coords[q_idx] + for idx in nearby: + iid = atom_array.pn_unit_iid[idx] + contact_pairs[iid].add((q_idx, idx)) + if calculate_min_distance: + t_coord = atom_array.coord[idx] + dist = float(np.linalg.norm(q_coord - t_coord)) + if iid not in min_dists or dist < min_dists[iid]: + min_dists[iid] = dist + + if not contact_pairs: + return [] + + # Precompute PN unit sizes for contacting units only + pn_unit_sizes = {iid: int(np.sum(atom_array.pn_unit_iid == iid)) for iid in contact_pairs} + + return [ + { + "pn_unit_iid": iid, + "num_atoms": pn_unit_sizes[iid], + "num_contacts": len(pairs), + "min_distance": min_dists.get(iid) if calculate_min_distance else None, + } + for iid, pairs in contact_pairs.items() + if len(pairs) >= min_contacts_required + ] def get_ligand_validity_scores_from_pdb_id(pdb_id: str) -> list[dict[str, str | int | float | None]]: - """ - Query the RCSB PDB for ligand validity scores for a given PDB ID. - - Args: - pdb_id (str): The PDB ID to query. - - Returns: - records: (list[dict[str, str | int | float | None]]): A list of dictionaries, each containing - the ligand validity scores for a ligand (e.g. RSCC, RSR) as well identifiers such as the - residue name, chain ID, and entity ID. Can easily be converted to a pandas DataFrame for - easier handling via `pd.DataFrame(records)`. - - Reference: - `RCSB Ligand Structure Quality Guide `_ - """ + """Query the RCSB PDB for ligand validity scores for a given PDB ID.""" pdb_graphql_url: Final[str] = "https://data.rcsb.org/graphql" - # Query string in graphql language to get ligand validity scores from a PDB entry ligand_validity_query: Final[str] = """ query ($id: String!) { entry(entry_id:$id){ @@ -447,10 +319,8 @@ def get_ligand_validity_scores_from_pdb_id(pdb_id: str) -> list[dict[str, str | } """ - # Perform the actual query for the target PDB ID response = requests.post(pdb_graphql_url, json={"query": ligand_validity_query, "variables": {"id": pdb_id}}) - # Extract the records from the response records = [] if response.status_code == 200: data = response.json() @@ -476,47 +346,21 @@ def get_ligand_validity_scores_from_pdb_id(pdb_id: str) -> list[dict[str, str | def get_inter_pn_unit_bond_mask(atom_array: AtomArray) -> np.ndarray: - """ - Returns a mask indicating which bonds in `atom_array.bonds` are between two distinct PN units. - Because we are operating at the PN unit-level, such bonds cannot be bonds between non-polymers. - - WARNING: Must be applied before reassigning PN unit IIDs (e.g., as is done for covalent modifications). - - Args: - atom_array (AtomArray): The full atom array. Must have PN unit-level annotations. - - Returns: - numpy.ndarray: A boolean mask indicating which bonds are between two PN units. - """ + """Returns a mask indicating which bonds are between two distinct PN units.""" bond_pn_unit_a = atom_array.pn_unit_iid[atom_array.bonds.as_array()[:, 0]] bond_pn_unit_b = atom_array.pn_unit_iid[atom_array.bonds.as_array()[:, 1]] return bond_pn_unit_a != bond_pn_unit_b def get_bonded_polymer_pn_units(query_pn_unit_iid: str, filtered_atom_array: AtomArray) -> set[str]: - """ - Returns a set of polymer PN units that are covalently bonded to a given PN unit. - For example, useful to detect oligosaccharides that are covalently bonded to a protein. - - Args: - query_pn_unit_iid (str): The full ID of the non-polymer PN unit to check for bonds. - filtered_atom_array (AtomArray): AtomArray with non-zero occupancy - - Returns: - set[str]: A set of full IDs of polymer PN units that are covalently bonded to the query PN unit. - """ - # Check if the non polymer is covalently bonded to a polymer + """Returns a set of polymer PN units that are covalently bonded to a given PN unit.""" inter_pn_unit_bonds = filtered_atom_array.bonds.as_array()[get_inter_pn_unit_bond_mask(filtered_atom_array)] bond_atom_a_pn_unit_iids = filtered_atom_array.pn_unit_iid[inter_pn_unit_bonds[:, 0]] bond_atom_b_pn_unit_iids = filtered_atom_array.pn_unit_iid[inter_pn_unit_bonds[:, 1]] - # Find bonded PN units bonded_pn_units = set(bond_atom_b_pn_unit_iids[np.where(bond_atom_a_pn_unit_iids == query_pn_unit_iid)[0]]) | set( bond_atom_a_pn_unit_iids[np.where(bond_atom_b_pn_unit_iids == query_pn_unit_iid)[0]] ) - # Get the set of polymers polymer_pn_unit_iids = set(filtered_atom_array.pn_unit_iid[filtered_atom_array.is_polymer]) - - # Set intersection between bonded PN units and polymer PN units return bonded_pn_units & polymer_pn_unit_iids diff --git a/src/atomworks/ml/preprocessing/utils/token.py b/src/atomworks/ml/preprocessing/utils/token.py new file mode 100644 index 00000000..a8868230 --- /dev/null +++ b/src/atomworks/ml/preprocessing/utils/token.py @@ -0,0 +1,56 @@ +"""AF3-style token counting utilities. + +NOTE: Due to changing token definitions and/or stochasticity like random atomization of sidechains, +the actual number of sampled tokens may differ and this number should be viewed as approximate. +""" + +from biotite.structure import AtomArray + +from atomworks.constants import STANDARD_AA, STANDARD_DNA, STANDARD_RNA +from atomworks.ml.transforms.atomize import AtomizeByCCDName, FlagNonPolymersForAtomization +from atomworks.ml.transforms.base import Compose +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications +from atomworks.ml.transforms.filters import ( + RemoveHydrogens, +) +from atomworks.ml.utils.token import get_token_starts + + +def count_af3_style_tokens(atom_array: AtomArray) -> dict[str, int]: + """Count AF3-style tokens in an AtomArray. + + Tokens are defined as: + - Polymer tokens (residue-level): Standard amino acids and nucleotides + - Non-polymer tokens (atom-level): Ligands and non-canonical amino acids + + Args: + atom_array: The AtomArray to process and count tokens in. + + Returns: + Dictionary with n_atomized_tokens, n_non_atomized_tokens, n_tokens_total. + """ + # Build preprocessing pipeline to match AF3 tokenization (as an upper-bound estimate) + transforms = [ + RemoveHydrogens(), + AnnotateCovalentModifications(), + FlagNonPolymersForAtomization(), + AtomizeByCCDName( + atomize_by_default=True, + res_names_to_ignore=list(STANDARD_AA) + list(STANDARD_RNA) + list(STANDARD_DNA), + ), + ] + + pipeline = Compose(transforms) + atom_array = pipeline({"atom_array": atom_array})["atom_array"] + + token_starts = get_token_starts(atom_array) + token_level_array = atom_array[token_starts] + + n_atomized = len(token_level_array[token_level_array.atomize]) + n_non_atomized = len(token_level_array[~token_level_array.atomize]) + + return { + "n_atomized_tokens": n_atomized, + "n_non_atomized_tokens": n_non_atomized, + "n_tokens_total": n_atomized + n_non_atomized, + } diff --git a/src/atomworks/ml/samplers.py b/src/atomworks/ml/samplers.py index e47ba0ed..60e6a410 100644 --- a/src/atomworks/ml/samplers.py +++ b/src/atomworks/ml/samplers.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd +import pyarrow as pa import torch from toolz import accumulate from torch.utils.data import Dataset, DistributedSampler, Sampler, WeightedRandomSampler @@ -13,7 +14,7 @@ logger = logging.getLogger(__name__) -def calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], beta: float) -> pd.Series: +def _calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], beta: float) -> pd.Series: """Determines the weight of each example in the DataFrame using a methodology inspired by AF-3. In AF-3, the weight of a given example is a function of: @@ -33,7 +34,7 @@ def calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], be We make the following modifications to the original AF-3 formula: - We introduce n_peptide and a_peptide to better control the sampling over peptides (which were being over-sampled). We define peptides - as proteins with fewer than PEPTIDE_MAX_RESIDUES residues (see `atomworks.ml.preprocessing.constants`). + as proteins with fewer than PEPTIDE_MAX_RESIDUES residues (see `atomworks.constants`). - We introduce an incremental a_loi weight to control the sampling of ligands of interests (LOI), also described as Subject of Investigation. Thus, our full formula is: @@ -47,12 +48,6 @@ def calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], be Returns: pd.Series: A Series containing the calculated weights for each row in the DataFrame """ - required_columns = ["n_prot", "n_nuc", "n_ligand", "cluster_size"] - assert all(col in df.columns for col in required_columns), ( - "Missing required columns in the (loaded) DataFrame. " - f"Please ensure the DataFrame contains the following columns: {required_columns}" - "Also ensure that the columns to include are specified in the Hydra configuration file." - ) # Extract relevant columns with default handling n_prot = df["n_prot"] n_nuc = df["n_nuc"] @@ -60,14 +55,6 @@ def calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], be n_peptide = df["n_peptide"] cluster_size = df["cluster_size"] - # For interfaces, the column "involves_loi" indicates whether the interface involves a ligand of interest - # For pn_units, the column "q_pn_unit_is_loi" indicates whether the query PN Unit is a ligand of interest - # (1 = True, 0 = False) - assert "involves_loi" in df.columns or "q_pn_unit_is_loi" in df.columns, ( - "Missing column for 'involves_loi' or 'q_pn_unit_is_loi'. " - "Please check the columns in the DataFrame: {df.columns}, " - "and the columns to include specified in the Hydra configuration file." - ) is_loi = (df["involves_loi"] if "involves_loi" in df.columns else df["q_pn_unit_is_loi"]).astype(int) # Assert that all cluster sizes are greater than 0 @@ -105,75 +92,152 @@ def calculate_af3_example_weights(df: pd.DataFrame, alphas: dict[str, float], be return weights -def get_cluster_sizes(df: pd.DataFrame, cluster_column: str = "cluster") -> dict[str, int]: - """Generate a mapping between cluster alphanumeric IDs and the number of PN units/interfaces in each cluster. +def _col_to_series(table: pa.Table | pd.DataFrame, column: str) -> pd.Series: + """Extract a single column from a pandas DataFrame or PyArrow Table as a pandas Series. + + Only the requested column is materialised, keeping memory usage low when the + backing table is a PyArrow Table held in columnar format. + """ + col = table[column] + return col.to_pandas() if hasattr(col, "to_pandas") else col + + +def _get_effective_cluster_sizes( + dataset_df: pa.Table | pd.DataFrame, + cluster_column: str, + altloc_weights: pd.Series | None = None, +) -> pd.Series: + """Return a per-row Series of effective cluster sizes. + + Without altloc weights, effective size is simply the row count per cluster. + With altloc weights, effective size is the sum of altloc weights per cluster, + which equals the number of unique examples (e.g. pdb_ids / assembly id / pn_unit) + since each unique example's altloc weights sum to exactly 1.0. Args: - df (pd.DataFrame): DataFrame containing the PN unit or interface data - cluster_column (str): Name of the column containing the cluster alphanumeric IDs + dataset_df: DataFrame or PyArrow Table containing the data. + cluster_column: Column identifying sequence clusters. + altloc_weights: If provided, summed per cluster to get effective size. + Defaults to ``None`` (use raw row counts). Returns: - dict: A dictionary where the keys are unique cluster IDs and the values are the counts of occurrences. + Per-row Series of effective cluster sizes. """ - # Use the value_counts method to count occurrences of each unique value in the cluster column - cluster_counts = df[cluster_column].value_counts() + cluster_col = _col_to_series(dataset_df, cluster_column) + if altloc_weights is None: + cluster_to_size = cluster_col.value_counts().to_dict() + else: + tmp = pd.DataFrame({"cluster": cluster_col, "altloc_weight": altloc_weights}) + cluster_to_size = tmp.groupby("cluster")["altloc_weight"].sum().to_dict() + return cluster_col.map(cluster_to_size) + + +def _get_altloc_weights( + dataset_df: pa.Table | pd.DataFrame, + altloc_seed_column: str, + pdb_id_column: str, +) -> pd.Series: + """Return a per-row Series of ``1 / num_altlocs`` for altloc-aware sampling. - # Convert the Series to a dictionary and return - return cluster_counts.to_dict() + ``num_altlocs`` is the number of unique values in ``altloc_seed_column`` per + ``pdb_id``. ``NaN`` counts as one unique value (structures with no altlocs + have a single row with ``altloc_seed=None``, so ``num_altlocs=1``). + """ + pdb_id_col = _col_to_series(dataset_df, pdb_id_column) + altloc_col = _col_to_series(dataset_df, altloc_seed_column) + tmp = pd.DataFrame({pdb_id_column: pdb_id_col, altloc_seed_column: altloc_col}) + pdb_id_to_num_altlocs = tmp.groupby(pdb_id_column)[altloc_seed_column].nunique(dropna=False).to_dict() + return 1.0 / pdb_id_col.map(pdb_id_to_num_altlocs) def calculate_weights_for_pdb_dataset_df( - dataset_df: pd.DataFrame, alphas: dict[str, float], beta: float, cluster_column: str = "cluster" + dataset_df: pa.Table | pd.DataFrame, + alphas: dict[str, float], + beta: float, + cluster_column: str = "cluster", + altloc_seed_column: str | None = None, + pdb_id_column: str = "pdb_id", ) -> torch.Tensor: - """Calculate weights for each row in the DataFrame based on the cluster size and the AF-3 weighting methodology. + """Calculate weights based on the AF-3 methodology, optionally adjusted for altlocs. + + Base weight per row: ``(beta / cluster_size) * (a_prot * n_prot + ...)``. + If ``altloc_seed_column`` is provided, weights are additionally multiplied by + ``1 / num_altlocs`` so that each altloc variant of a structure is sampled uniformly. Args: - dataset_df (pd.DataFrame): DataFrame containing the PN unit or interface data - alphas (dict[str, float]): Dictionary containing alpha values for the weighting calculation (common across interfaces and chains/pn_units) - beta (float): Beta value for the weighting calculation (distinct for interfaces and chains/pn_units) + dataset_df: DataFrame or PyArrow Table containing the PN unit or interface data. + alphas: Alpha hyperparameters for the AF-3 weighting formula. + beta: Beta hyperparameter (distinct for interfaces vs. chains). + cluster_column: Column identifying sequence clusters. Defaults to ``"cluster"``. + altloc_seed_column: If provided, weights are divided by the number of unique + altloc seeds per ``pdb_id``. Defaults to ``None`` (no altloc adjustment). + pdb_id_column: Column identifying structures, used only when + ``altloc_seed_column`` is set. Defaults to ``"pdb_id"``. Returns: - torch.Tensor: A tensor containing the calculated weights for each row in the DataFrame + Tensor of per-row weights with shape ``(len(dataset_df),)``. """ - # Generate the cluster sizes... - cluster_id_to_size_map = get_cluster_sizes(dataset_df, cluster_column=cluster_column) + col_names = dataset_df.schema.names if isinstance(dataset_df, pa.Table) else list(dataset_df.columns) - # ...map the cluster sizes to the DataFrame - dataset_df["cluster_size"] = dataset_df[cluster_column].map(cluster_id_to_size_map) + required_columns = [cluster_column, "n_prot", "n_nuc", "n_ligand", "n_peptide"] + assert all(col in col_names for col in required_columns), ( + "Missing required columns in the (loaded) table. " + f"Please ensure the table contains the following columns: {required_columns}. " + "Also ensure that the columns to include are specified in the Hydra configuration file." + ) + assert "involves_loi" in col_names or "q_pn_unit_is_loi" in col_names, ( + "Missing column for 'involves_loi' or 'q_pn_unit_is_loi'. " + f"Please check the columns in the table: {col_names}, " + "and the columns to include specified in the Hydra configuration file." + ) - # ... assert no NaN cluster sizes - assert not dataset_df["cluster_size"].isnull().any(), "Cluster sizes must not be NaN" + loi_col = "involves_loi" if "involves_loi" in col_names else "q_pn_unit_is_loi" + needed_cols = [cluster_column, "n_prot", "n_nuc", "n_ligand", "n_peptide", loi_col] + df = pd.DataFrame({col: _col_to_series(dataset_df, col) for col in needed_cols}) - # ...calculate weights using vectorized operations - weights = calculate_af3_example_weights(dataset_df, alphas, beta).values + altloc_weights = ( + _get_altloc_weights(dataset_df, altloc_seed_column, pdb_id_column) if altloc_seed_column is not None else None + ) + df["cluster_size"] = _get_effective_cluster_sizes(dataset_df, cluster_column, altloc_weights) + assert not df["cluster_size"].isnull().any(), "Cluster sizes must not be NaN" - # ...and return the weights as a tensor + weights = _calculate_af3_example_weights(df, alphas, beta).values + if altloc_weights is not None: + weights = weights * altloc_weights.values return torch.tensor(weights) def calculate_weights_by_inverse_cluster_size( - dataset_df: pd.DataFrame, cluster_column: str = "cluster" + dataset_df: pa.Table | pd.DataFrame, + cluster_column: str = "cluster", + altloc_seed_column: str | None = None, + pdb_id_column: str = "pdb_id", ) -> torch.Tensor: - """Calculate weights for each row in the DataFrame as the inverse of its cluster size. + """Calculate weights as the inverse of cluster size, optionally adjusted for altlocs. + + Base weight per row: ``1 / cluster_size``. + If ``altloc_seed_column`` is provided, weights are additionally multiplied by + ``1 / num_altlocs`` for uniform three-level sampling: cluster → example → altloc. Args: - dataset_df (pd.DataFrame): DataFrame containing the PN unit or interface data - cluster_column (str): Column name in `dataset_df` corresponding to the cluster info. Default is "cluster". + dataset_df: DataFrame or PyArrow Table containing the PN unit or interface data. + cluster_column: Column identifying sequence clusters. Defaults to ``"cluster"``. + altloc_seed_column: If provided, weights are divided by the number of unique + altloc seeds per ``pdb_id``. Defaults to ``None`` (no altloc adjustment). + pdb_id_column: Column identifying structures, used only when + ``altloc_seed_column`` is set. Defaults to ``"pdb_id"``. Returns: - torch.Tensor: A tensor containing the calculated weights for each row in the DataFrame + Tensor of per-row weights with shape ``(len(dataset_df),)``. """ - # Generate the cluster sizes... - cluster_id_to_size_map = get_cluster_sizes(dataset_df, cluster_column=cluster_column) - - # ... map the cluster sizes to the DataFrame - dataset_df["cluster_size"] = dataset_df[cluster_column].map(cluster_id_to_size_map) - - # ... calculate weights as the inverse of the cluster size - weights = 1 / dataset_df["cluster_size"].values - - # ... and return the weights as a tensor - return torch.tensor(weights) + altloc_weights = ( + _get_altloc_weights(dataset_df, altloc_seed_column, pdb_id_column) if altloc_seed_column is not None else None + ) + effective_cluster_sizes = _get_effective_cluster_sizes(dataset_df, cluster_column, altloc_weights) + weights = 1.0 / effective_cluster_sizes + if altloc_weights is not None: + weights = weights * altloc_weights + return torch.tensor(weights.values, dtype=torch.float64) def set_sampler_epoch(sampler: Sampler, epoch: int, add_random_offset: bool = False) -> None: @@ -196,13 +260,13 @@ def set_sampler_epoch(sampler: Sampler, epoch: int, add_random_offset: bool = Fa class DistributedMixedSampler(Sampler): - """Custom DistributedSampler implementation that samples from an arbitrary list of samplers with specified probabilities. + r"""Custom DistributedSampler implementation that samples from an arbitrary list of samplers with specified probabilities. Child samplers can be any type of non-distributed sampler, including a MixedSampler. After gathering all indices, shards the samples across nodes, ensuring each node receives a unique slice of the dataset. Example: - Imagine we have the following sampling tree: + Imagine we have the following sampling tree:: DistributedMixedSampler | diff --git a/src/atomworks/ml/transforms/_checks.py b/src/atomworks/ml/transforms/_checks.py index 47f7b206..c69f5efe 100644 --- a/src/atomworks/ml/transforms/_checks.py +++ b/src/atomworks/ml/transforms/_checks.py @@ -4,8 +4,6 @@ All checks take a `data` dictionary as input and raise an error if the check fails. """ -from __future__ import annotations - from typing import Any from atomworks.io.utils.selection import get_annotation_categories diff --git a/src/atomworks/ml/transforms/atom_array.py b/src/atomworks/ml/transforms/atom_array.py index f317bb89..acc5bb4c 100644 --- a/src/atomworks/ml/transforms/atom_array.py +++ b/src/atomworks/ml/transforms/atom_array.py @@ -1,7 +1,5 @@ """Transforms on atom arrays.""" -from __future__ import annotations - import copy import logging from collections.abc import Callable, Iterator @@ -13,7 +11,8 @@ from biotite.structure import AtomArray, get_residue_count, spread_residue_wise from atomworks.enums import ChainType -from atomworks.io.utils.testing import has_annotation +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.atom_array import has_annotation from atomworks.ml.transforms._checks import ( check_atom_array_annotation, check_contains_keys, @@ -44,19 +43,42 @@ def get_chain_instance_starts(array: AtomArray, add_exclusive_stop: bool = False Returns: - np.ndarray: An array of indices indicating the beginning of each chain instance. """ - # This mask is 'true' at indices where the chain_iid changes - chain_iid_changes = array.chain_iid[1:] != array.chain_iid[:-1] + return get_group_instance_starts(array, group_by="chain_iid", add_exclusive_stop=add_exclusive_stop) + + +def get_group_instance_starts(array: AtomArray, group_by: str, add_exclusive_stop: bool = False) -> np.ndarray: + """Get indices for an atom array, each indicating the beginning of a new group instance (e.g. chain_iid, molecule_iid, res_id, etc). + + This is a more general version of `get_chain_instance_starts`, where the grouping can be defined by any annotation. + + NOTE: Assumes that groups are contiguous within the AtomArray + + Args: + - array (AtomArray): The atom array to get the group instance starts from. + - group_by (str): The annotation name to group by (e.g. "chain_iid", "molecule_iid", "res_id", etc). + - add_exclusive_stop (bool, optional): If True, add an exclusive stop to the group instance starts for the last group instance. Defaults to False. + + Returns: + - np.ndarray: An array of indices indicating the beginning of each group instance. + """ + assert ( + group_by in array.get_annotation_categories() + ), f"Annotation '{group_by}' not found in atom array when trying to get group starts" + group_annotation = array.get_annotation(group_by) + + # This is true at indices where the group annotation changes + group_changes = group_annotation[1:] != group_annotation[:-1] # Convert mask to indices # Add 1, to shift the indices from the end of a residue # to the start of a new chain instance - chain_iid_starts = np.where(chain_iid_changes)[0] + 1 + group_starts = np.where(group_changes)[0] + 1 # The first chain instance is not included yet -> Insert '[0]' if add_exclusive_stop: - return np.concatenate(([0], chain_iid_starts, [array.array_length()])) + return np.concatenate(([0], group_starts, [array.array_length()])) else: - return np.concatenate(([0], chain_iid_starts)) + return np.concatenate(([0], group_starts)) def chain_instance_iter(array: AtomArray) -> Iterator[AtomArray]: @@ -270,6 +292,33 @@ def get_within_group_res_idx(atom_array: AtomArray, group_by: str) -> np.ndarray return within_group_res_idx +def get_within_group_source_res_idx(atom_array: AtomArray, group_by: str) -> np.ndarray: + """Like get_within_group_res_idx but relies on the res_id field to preserve gaps that may exist in the original sequence. + + NOTE: groups do not need to be contiguous + + Args: + atom_array (AtomArray): The atom array to process. + group_by (str): The annotation name to group residues by (e.g., "chain_iid"). + + Returns: + np.ndarray: An array of within-group source residue indices for each atom in the atom array. + """ + + group_annotation = atom_array.get_annotation(group_by) + + # Initialize as res_id + within_group_source_res_idx = atom_array.res_id.copy() + + # Zero-index + for group in np.unique(group_annotation): + group_mask = group_annotation == group + min_res_id = np.min(within_group_source_res_idx[group_mask]) + within_group_source_res_idx[group_mask] -= min_res_id + + return within_group_source_res_idx + + def get_within_group_atom_idx(atom_array: AtomArray, group_by: str) -> np.ndarray: """Get the within-group atom index for the atom array. @@ -496,8 +545,6 @@ class AddGlobalAtomIdAnnotation(Transform): Useful for keeping track of atoms after cropping, slicing or shuffling operations. """ - incompatible_previous_transforms: ClassVar[list[str | Transform]] = ["AddGlobalAtomIdAnnotation"] - def __init__(self, allow_overwrite: bool = False): """ Args: @@ -540,8 +587,6 @@ class AddGlobalTokenIdAnnotation(Transform): Useful for keeping track of tokens after cropping, slicing or shuffling operations. """ - incompatible_previous_transforms: ClassVar[list[str | Transform]] = ["AddGlobalTokenIdAnnotation"] - def __init__(self, allow_overwrite: bool = False): self.allow_overwrite = allow_overwrite @@ -596,6 +641,25 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: return data +class AddWithinChainSourceResIdx(Transform): + """Within-chain residue index derived from ``res_id`` (0-indexed, gap-preserving). + + Like :class:`AddWithinChainInstanceResIdx` but keeps internal gaps from ``res_id``. + Read by :class:`EncodeAF3TokenLevelFeatures` when ``use_source_residue_index=True``. + """ + + def check_input(self, data: dict[str, Any]) -> None: + check_atom_array_annotation(data, ["chain_iid", "res_id"]) + + def forward(self, data: dict[str, Any]) -> dict[str, Any]: + atom_array = data["atom_array"] + + ensure_annotations(atom_array, "within_chain_source_res_idx") + + data["atom_array"] = atom_array + return data + + def sort_poly_then_non_poly(atom_array: AtomArray, treat_atomized_as_non_poly: bool = True) -> AtomArray: """Sort the atom array such that polymer chains are first, followed by non-polymer chains. diff --git a/src/atomworks/ml/transforms/atomize.py b/src/atomworks/ml/transforms/atomize.py index c9937140..5b21bae1 100644 --- a/src/atomworks/ml/transforms/atomize.py +++ b/src/atomworks/ml/transforms/atomize.py @@ -1,9 +1,9 @@ from typing import Any, ClassVar -import biotite.structure as struc import numpy as np from biotite.structure import AtomArray +from atomworks.io.transforms.atomize import atomize_by_ccd_name from atomworks.ml.transforms._checks import ( check_atom_array_annotation, check_contains_keys, @@ -43,79 +43,6 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: return data -def _validate_atomize(atom_array: AtomArray, atomize: np.ndarray) -> None: - """ - Validate that each residue is either atomized or not. Raises a ValueError if this is not the case. - """ - _all = struc.apply_residue_wise(atom_array, atomize, np.all) - _any = struc.apply_residue_wise(atom_array, atomize, np.any) - if np.any(_all != _any): - raise ValueError("For each residue, all atoms must be atomized or none must be atomized.") - - -def atomize_by_ccd_name( - atom_array: AtomArray, - atomize_by_default: bool = True, - res_names_to_atomize: list[str] = [], - res_names_to_ignore: list[str] = [], - move_atomized_part_to_end: bool = False, - validate_atomize: bool = False, -) -> AtomArray: - """ - Atomize residues by breaking down the res_name field into the actual element names. - - Args: - atom_array (AtomArray): The atom array to atomize. - atomize_by_default (bool): Whether to atomize residues by default. - res_names_to_atomize (list[str]): List of residue names to atomize. Defaults to []. - res_names_to_ignore (list[str]): List of residue names to ignore. These residues - will only be atomized, if their `atomize` flag is already explicitly set to `True`, e.g. from a - previous transform to sample random residues for atomization for data augmentation. Defaults to []. - move_atomized_part_to_end (bool, optional): Whether to move atomized parts to the end of the array. Defaults to False. - This is relevant for RF2AA, which follows the convention that atomized parts are grouped together at the end of the - input. - validate_atomize (bool, optional): Whether to validate that a residue is either atomized or not. Defaults to False. - - Returns: - AtomArray: The atomized atom array. The `atomize` flag is set for each atom in the array. - NOTE: The returned array may be reordered if `move_atomized_part_to_end` is True. - """ - atomize = np.full(len(atom_array), atomize_by_default, dtype=bool) - - # Exclude residues to ignore - if len(res_names_to_ignore) > 0: - atomize[np.isin(atom_array.res_name, res_names_to_ignore)] = False - - # Include residues to atomize - if len(res_names_to_atomize) > 0: - atomize[np.isin(atom_array.res_name, res_names_to_atomize)] = True - - # Include everything with the `atomize` flag from possible previous transforms - # this is used to manually define residues to atomize, e.g. as a data augmentation - if "atomize" in atom_array.get_annotation_categories(): - atomize |= atom_array.atomize - - if validate_atomize: - # ... validate that a residue is either atomized or not - _validate_atomize(atom_array, atomize) - - # Perform atomization - atom_array.set_annotation("atomize", atomize) - - if move_atomized_part_to_end: - # as per RF2AA convention, the atomized parts are grouped together at the end - # of the input. This flag enables that. - # NOTE: This needs to be done via `reshuffling` in order to preserve the correct - # bonding information. - _idxs_pre_shuffling = np.arange(len(atom_array)) - reordered_idxs = np.concatenate( - [_idxs_pre_shuffling[~atom_array.atomize], _idxs_pre_shuffling[atom_array.atomize]] - ) - atom_array = atom_array[reordered_idxs] - - return atom_array - - class AtomizeByCCDName(Transform): """Atomize residues by breaking down the CCD res_name field into the actual element names. diff --git a/src/atomworks/ml/transforms/bonds.py b/src/atomworks/ml/transforms/bonds.py index 5aef7bff..9f481305 100644 --- a/src/atomworks/ml/transforms/bonds.py +++ b/src/atomworks/ml/transforms/bonds.py @@ -243,49 +243,27 @@ def forward(self, data: dict) -> dict: return data -def get_bond_distances(atom_array: AtomArray) -> np.ndarray: - """Returns the bond distance (adjacency) list as a 1D array.""" - coords = atom_array.coord - atom1_idxs, atom2_idxs, _ = atom_array.bonds.as_array().T - return np.linalg.norm(coords[atom1_idxs] - coords[atom2_idxs], axis=1) - - -def get_bond_distance_matrix(atom_array: AtomArray) -> np.ndarray: - """Returns the bond adjacency matrix with bond distances as values.""" - atom1_idxs, atom2_idxs, _ = atom_array.bonds.as_array().T - bond_distances = get_bond_distances(atom_array) - bond_distance_matrix = np.full((atom_array.array_length(), atom_array.array_length()), np.inf) - bond_distance_matrix[atom1_idxs, atom2_idxs] = bond_distances - bond_distance_matrix[atom2_idxs, atom1_idxs] = bond_distances - return bond_distance_matrix - - -def get_af3_token_bond_features(atom_array: AtomArray, distance_cutoff: float = 2.4) -> np.ndarray: +def get_af3_token_bond_features(atom_array: AtomArray) -> np.ndarray: """ Generates AF3-style token bond features for an AtomArray. - For bonds between multi-atom tokens (i.e., residues), we define the "bond distance" as the minimum distance between an atom of one token and any atom of the other token. From AF3: Returns a 2D matrix indicating if there is a bond between any atom in token i and token j, restricted to just polymer-ligand and ligand-ligand - bonds and bonds less than 2.4 Å during training. + bonds. + + Note: + Distance-based bond filtering is handled during parsing via `filter_bonds_by_distance`. Args: - atom_array (AtomArray): The input AtomArray containing atomic coordinates and bond information. - - distance_cutoff (float, optional): The maximum distance (in Angstroms) for considering a bond. Defaults to 2.4. Returns: - np.ndarray: A boolean matrix where True indicates a bond between tokens that meets the specified criteria. """ token_start_end_idxs = get_token_starts(atom_array, add_exclusive_stop=True) token_starts = token_start_end_idxs[:-1] - token_bonds = apply_segment_wise_2d(get_bond_distance_matrix(atom_array), token_start_end_idxs, np.min) - - # remove bonds above distance cutoff - token_bonds = token_bonds < distance_cutoff - - # remove token self-bonds - np.fill_diagonal(token_bonds, False) + token_bonds = _atom_adjacency_to_token_adjacency(atom_array.bonds.adjacency_matrix(), token_start_end_idxs) # remove poly-poly bonds is_poly_poly_bond = np.outer(~atom_array.atomize[token_starts], ~atom_array.atomize[token_starts]) @@ -298,33 +276,31 @@ class AddAF3TokenBondFeatures(Transform): Transform that generates AF3-style token bond features for an AtomArray. This transform creates a 2D matrix indicating if there is a bond between any atom in - token i and token j, restricted to just polymer-ligand and ligand-ligand bonds and - bonds less than a specified distance cutoff. + token i and token j, restricted to just polymer-ligand and ligand-ligand bonds. - Args: - - distance_cutoff (float, optional): The maximum distance (in Angstroms) for considering a bond. - Defaults to 2.4. - - Returns: - - dict: A dictionary containing the input data and the new 'af3_token_bond_features' key with - the computed boolean matrix. + Note: + Distance-based bond filtering is handled during parsing via `filter_bonds_by_distance`. + The `distance_cutoff` argument has been removed - if you were using it, the filtering + now happens automatically in parse. """ requires_previous_transforms: ClassVar[list[str | Transform]] = ["AtomizeByCCDName"] - def __init__(self, distance_cutoff: float = 2.4): - self.distance_cutoff = distance_cutoff + def __init__(self, **kwargs): + if "distance_cutoff" in kwargs: + raise DeprecationWarning( + "The `distance_cutoff` argument has been removed from AddAF3TokenBondFeatures. " + "Distance-based bond filtering is now handled during parsing via `filter_bonds_by_distance`. " + "Please remove this argument from your code." + ) def check_input(self, data: dict) -> None: - check_contains_keys(data, ["atom_array"]) - check_is_instance(data, "atom_array", AtomArray) - check_nonzero_length(data, "atom_array") check_atom_array_has_bonds(data) check_atom_array_annotation(data, ["is_polymer", "atomize"]) def forward(self, data: dict) -> dict: atom_array = data["atom_array"] - af3_token_bond_features = get_af3_token_bond_features(atom_array, self.distance_cutoff) + af3_token_bond_features = get_af3_token_bond_features(atom_array) if "feats" not in data: data["feats"] = {} diff --git a/src/atomworks/ml/transforms/chirals.py b/src/atomworks/ml/transforms/chirals.py index 0dccada0..cbca757a 100644 --- a/src/atomworks/ml/transforms/chirals.py +++ b/src/atomworks/ml/transforms/chirals.py @@ -158,8 +158,10 @@ def get_rf2aa_chiral_features( """Extracts chiral centers and featurize them for RF2AA. NOTE: Each row of output features contains the indices of the plane pairs and the signed ideal - dihedral angle for each chiral center. For example, the entry: + dihedral angle for each chiral center. For example, the entry:: + [c, i, j, k, angle] + means that the atom at index c is a chiral center with atoms at indices (i, j, k) bonded to it. The signed dihedral angle angle is the signed angle between the planes (cij) and (ijk). The sign of the angle determines the chirality of the chiral center. @@ -201,6 +203,10 @@ def get_rf2aa_chiral_features( chiral_center: int = chiral_center_info["chiral_center_idx"] bonded_atoms: list[int] = chiral_center_info["bonded_explicit_atom_idxs"] + # skip non-tetrahedral (>4 neighbor) centers + if len(bonded_atoms) not in (3, 4): + continue + # get the keys for uniquely identifying all pairs of planes that can be formed between # a side of the tetrahedron and a plane that contains two atoms of the tetrahedral side and the chiral center # (only planes with ) @@ -361,15 +367,20 @@ def add_af3_chiral_features( for res_start, res_end in zip(_res_starts, _res_ends, strict=False): res_name = atom_array.res_name[res_start] - chirals = chiral_centers[res_name] + chirals = chiral_centers.get(res_name, []) if len(chirals) == 0: continue # get rdkit->atomarray mapping + rdmol = rdkit_mols.get(res_name) + if rdmol is None: + logger.warning(f"No RDKit molecule found for residue {res_name}, skipping chiral features") + continue + conformer = atom_array_from_rdkit( - rdkit_mols[res_name], + rdmol, conformer_id=0, - remove_hydrogens=True, + remove_hydrogens=False, ) _ref_to_conf_map = _get_reference_conformer_to_residue_mapping( atom_names=atom_array.atom_name[res_start:res_end], conformer=conformer diff --git a/src/atomworks/ml/transforms/covalent_modifications.py b/src/atomworks/ml/transforms/covalent_modifications.py index 99a09187..2cf08338 100644 --- a/src/atomworks/ml/transforms/covalent_modifications.py +++ b/src/atomworks/ml/transforms/covalent_modifications.py @@ -1,13 +1,10 @@ """Transforms to handle covalent modifications""" -from __future__ import annotations - from typing import ClassVar -import numpy as np from biotite.structure import AtomArray -from atomworks.ml.preprocessing.utils.structure_utils import get_inter_pn_unit_bond_mask +from atomworks.io.transforms.atomize import flag_and_reassign_covalent_modifications from atomworks.ml.transforms._checks import ( check_atom_array_annotation, check_contains_keys, @@ -17,77 +14,7 @@ from atomworks.ml.transforms.base import Transform -def flag_and_reassign_covalent_modifications(atom_array: AtomArray) -> AtomArray: - """ - Mark covalent modifications for atomization and reassign the corresponding - PN unit annotations. - - Args: - atom_array (AtomArray): Current `AtomArray` within the Transform pipeline - - Returns: - AtomArray: The modified `AtomArray` with updated annotations for covalent - modifications. The `pn_unit_id` and `pn_unit_iid` of polymer atoms are - reassigned to those of the non-polymer unit they are bound to, and the - `atomize` annotation is set to `True` for these atoms. Additionally, the - entire pn_unit is marked with `is_covalent_modification = True`. - - NOTE: If `atomize` annotation is not present in the `AtomArray`, it will be added. - NOTE: If `is_covalent_modification` annotation is not present in the `AtomArray`, it will be added. - NOTE: We do not modify the `is_polymer` annotation, which will still refer to the protein chain - for the atomized polymer atoms. - """ - # Get all inter-PN unit bonds in the entry (i.e. between a polymer and a non-polymer PN unit) - inter_pn_unit_bond_mask = get_inter_pn_unit_bond_mask(atom_array) - bonds_to_check = atom_array.bonds.as_array()[inter_pn_unit_bond_mask] - - # Filter out bonds that are not between a polymer and a non-polymer PN unit - bonds_to_check = bonds_to_check[ - # One atom is a polymer, the other is not => must be polymer/non-polymer bond - atom_array.is_polymer[bonds_to_check[:, 0]] != atom_array.is_polymer[bonds_to_check[:, 1]] - ] - - # Add the atomize annotation to the AtomArray, if not already present - if "atomize" not in atom_array.get_annotation_categories(): - atom_array.set_annotation("atomize", np.array([False] * len(atom_array))) - - # Add the is_covalent_modification annotation to the AtomArray, if not already present - if "is_covalent_modification" not in atom_array.get_annotation_categories(): - atom_array.set_annotation("is_covalent_modification", np.array([False] * len(atom_array))) - - # Loop through inter-molecular bonds - # NOTE: There aren't likely to be many inter-molecular bonds in the entry, so vectorization is not necessary and would be less readable - for bond in bonds_to_check: - # Get the atoms involved in the inter-molecular bonds - atom_a = atom_array[bond[0]] - atom_b = atom_array[bond[1]] - - # Note which atom is in the polymer and which is in the non-polymer - polymer_atom, non_polymer_atom = (atom_a, atom_b) if atom_a.is_polymer else (atom_b, atom_a) - - # Create a mask of the atoms in the residue that is covalently bound to the non-polymer PN unit - # We can uniquely identify a residue by its res_id, pn_unit_iid, and chain_id (or chain_iid, either works) - polymer_residue_mask = ( - (atom_array.res_id == polymer_atom.res_id) - & (atom_array.chain_id == polymer_atom.chain_id) - & (atom_array.pn_unit_iid == polymer_atom.pn_unit_iid) - ) - - # For all atoms in the target polymer residue, set the pn_unit_iid and the pn_unit_id to that of the non-polymer PN unit - num_residues = np.sum(polymer_residue_mask) - atom_array.pn_unit_id[polymer_residue_mask] = np.array([non_polymer_atom.pn_unit_id] * num_residues) - atom_array.pn_unit_iid[polymer_residue_mask] = np.array([non_polymer_atom.pn_unit_iid] * num_residues) - - # Mark the non-polymer residue for atomization (now includes all atoms in the bonded polymer residue) - atom_array.atomize[(atom_array.pn_unit_iid == non_polymer_atom.pn_unit_iid)] = True - - # Mark the entire pn_unit as a covalent modification - atom_array.is_covalent_modification[(atom_array.pn_unit_iid == non_polymer_atom.pn_unit_iid)] = True - - return atom_array - - -class FlagAndReassignCovalentModifications(Transform): +class AnnotateCovalentModifications(Transform): """Handles covalent modifications within the AtomArray. Covalent modifications, e.g., glycosylation, are handled by the following algorithm: @@ -98,17 +25,42 @@ class FlagAndReassignCovalentModifications(Transform): set atomize = true (thus, this transform must be run before the Atomize transform) set is_covalent_modification = true (for the entire pn_unit) - TODO: Break into two Transforms - one that flags, one that reassigns. Atomizing covalent modifications is a design choice - that may not be desired in all pipelines. Annotating covalent modifications, however, is broadly useful. + Side-chain-modified residues are atomized with probability ``p_reassign_modified_residue``, + sampled independently per residue. Backbone modifications (cyclizations, crosslinks, caps) are atomized when + ``atomize_backbone_modifications``. The non-polymer is flagged and atomized either way, so + ``is_covalent_modification`` is always annotated. """ incompatible_previous_transforms: ClassVar[list[str | Transform]] = [AtomizeByCCDName, "AddGlobalTokenIdAnnotation"] + def __init__(self, p_reassign_modified_residue: float = 1.0, atomize_backbone_modifications: bool = True): + """Initialize the transform. + + Args: + p_reassign_modified_residue: Per-residue probability of atomizing side-chain-modified + residues, between 0 and 1 inclusive. Defaults to ``1.0``. + atomize_backbone_modifications: Atomize the residue when the modifier attaches at its + polymerization atom (a cyclization, crosslink, or cap). Defaults to ``True``. + """ + super().__init__() + if not 0.0 <= p_reassign_modified_residue <= 1.0: + raise ValueError("p_reassign_modified_residue must be between 0 and 1 inclusive.") + self.p_reassign_modified_residue = p_reassign_modified_residue + self.atomize_backbone_modifications = atomize_backbone_modifications + def check_input(self, data: dict) -> None: check_contains_keys(data, ["atom_array"]) check_is_instance(data, "atom_array", AtomArray) check_atom_array_annotation(data, ["pn_unit_id", "pn_unit_iid"]) def forward(self, data: dict) -> dict: - data["atom_array"] = flag_and_reassign_covalent_modifications(data["atom_array"]) + data["atom_array"] = flag_and_reassign_covalent_modifications( + data["atom_array"], + p_reassign_modified_residue=self.p_reassign_modified_residue, + atomize_backbone_modifications=self.atomize_backbone_modifications, + ) return data + + +# Backwards-compatible alias for the pre-rename class name. +FlagAndReassignCovalentModifications = AnnotateCovalentModifications diff --git a/src/atomworks/ml/transforms/crop.py b/src/atomworks/ml/transforms/crop.py index 2db9ef77..5493826d 100644 --- a/src/atomworks/ml/transforms/crop.py +++ b/src/atomworks/ml/transforms/crop.py @@ -1,5 +1,6 @@ import itertools import logging +import warnings from typing import ClassVar import numpy as np @@ -27,34 +28,147 @@ class CropTransformBase(Transform): - """ - Base class for crop-type transforms. - """ + """Base for crops that record retained source indices in ``crop_info["crop_atom_idxs"]``.""" - def __init__(self, annotate_crop_boundary: bool = False, crop_boundary_radius: float = 6.0, **kwargs): + def __init__( + self, + crop_size: int | None = None, + crop_size_range: tuple[int, int] | tuple[float, float] | None = None, + max_crop_size: int | None = None, + p_no_subcrop: float = 0.0, + annotate_crop_boundary: bool = False, + crop_boundary_radius: float = 6.0, + **kwargs, + ): + self.crop_size = crop_size + self.crop_size_range = crop_size_range + self.max_crop_size = max_crop_size + self.p_no_subcrop = p_no_subcrop self.annotate_crop_boundary = annotate_crop_boundary self.crop_boundary_radius = crop_boundary_radius def _validate(self) -> None: - assert self.crop_size > 0, "Crop size must be greater than 0" + _validate_crop_size_spec(self.crop_size, self.crop_size_range, self.max_crop_size) + + def _resolve_crop_size(self, reference_length: int | None = None) -> int: + """Resolve the crop size to use for this call (see `sample_dynamic_crop_size`).""" + return sample_dynamic_crop_size( + self.crop_size, + self.crop_size_range, + reference_length=reference_length, + max_crop_size=self.max_crop_size, + p_no_subcrop=self.p_no_subcrop, + ) def __call__(self, data: dict) -> dict: if self.annotate_crop_boundary: - atom_array = data["atom_array"] - atom_array.set_annotation("precrop_hash", compute_local_hash(atom_array, self.crop_boundary_radius)) + uncropped = data["atom_array"] data = super().__call__(data) - if self.annotate_crop_boundary: + # Crops return a sliced array; validation skips preserve the input object. + if self.annotate_crop_boundary and data["atom_array"] is not uncropped: atom_array = data["atom_array"] - postcrop_hash = compute_local_hash(atom_array, self.crop_boundary_radius) - atom_array.set_annotation("at_crop_boundary", atom_array.precrop_hash != postcrop_hash) - # ... delete the precrop hash - atom_array.del_annotation("precrop_hash") + removed = ~is_any_coord_nan(uncropped) + removed[data["crop_info"]["crop_atom_idxs"]] = False + at_boundary = np.zeros(len(atom_array), dtype=bool) + if removed.any(): + # A retained atom is at the boundary exactly when it has a removed neighbor within the radius. + valid = ~is_any_coord_nan(atom_array) + tree = KDTree(uncropped.coord[removed]) + at_boundary[valid] = ( + tree.query_ball_point(atom_array.coord[valid], r=self.crop_boundary_radius, return_length=True) > 0 + ) + atom_array.set_annotation("at_crop_boundary", at_boundary) return data +def _validate_crop_size_spec( + crop_size: int | None, + crop_size_range: tuple[int, int] | tuple[float, float] | None, + max_crop_size: int | None, +) -> None: + """Validate the combination of `crop_size` and `crop_size_range` (see `sample_dynamic_crop_size`).""" + if not exists(crop_size_range): + assert crop_size is not None, "Either crop_size or crop_size_range must be set" + assert crop_size > 0, "Crop size must be greater than 0" + return + + assert crop_size is None, "crop_size must be None when crop_size_range is set, to avoid ambiguity" + assert len(crop_size_range) == 2, f"crop_size_range must be a (min, max) pair, got {crop_size_range}" + lo, hi = crop_size_range + assert type(lo) is type(hi) and isinstance( + lo, int | float + ), f"crop_size_range must be a pair of the same type (both int or both float), got {crop_size_range}" + assert 0 < lo <= hi, f"crop_size_range must be (min, max) with 0 < min <= max, got {crop_size_range}" + + if exists(max_crop_size): + assert max_crop_size > 0, f"max_crop_size must be greater than 0, got {max_crop_size}" + + if isinstance(lo, float): + assert hi <= 1.0, f"crop_size_range fractions must be in (0, 1], got {crop_size_range}" + assert exists( + max_crop_size + ), "max_crop_size must be set when crop_size_range is fraction-based, to bound the sampled crop size" + elif exists(max_crop_size): + assert max_crop_size >= lo, ( + f"max_crop_size ({max_crop_size}) must be >= crop_size_range's lower bound ({lo}), otherwise the " + "sampled crop size would always be clamped below the configured range" + ) + + +def sample_dynamic_crop_size( + crop_size: int | None, + crop_size_range: tuple[int, int] | tuple[float, float] | None, + reference_length: int | None = None, + max_crop_size: int | None = None, + p_no_subcrop: float = 0.0, +) -> int: + """Resolve the crop size to use, optionally sampling uniformly from `crop_size_range`. + + Args: + crop_size: The fixed crop size to use when `crop_size_range` is not given. Must be `None` + when `crop_size_range` is given. + crop_size_range: An inclusive ``(min, max)`` range to sample the crop size from uniformly. + Either a pair of absolute token counts (``int``), or a pair of fractions (``float``, + in ``(0, 1]``) of `reference_length`. Fraction-based ranges require both + `reference_length` and `max_crop_size` to be set, so the sampled crop size cannot grow + unboundedly with the size of the structure. + reference_length: The length (e.g. total number of tokens) used to resolve a + fraction-based `crop_size_range`. + max_crop_size: Upper bound applied to the sampled crop size. Required when + `crop_size_range` is fraction-based. + p_no_subcrop: Probability of returning the full budget (`max_crop_size`) instead of + sampling `crop_size_range` -- no sub-crop this call. Only used with `crop_size_range`. + + Returns: + The concrete crop size to use for this call. + """ + if not exists(crop_size_range): + return crop_size + + # Skip the sub-crop this call: return the full budget (a no-op when the structure fits). + if p_no_subcrop > 0.0 and np.random.random() < p_no_subcrop: + return max_crop_size if exists(max_crop_size) else int(crop_size_range[1]) + + lo, hi = crop_size_range + + if isinstance(lo, float): + assert exists(reference_length), "reference_length must be provided when crop_size_range is fraction-based" + lo, hi = lo * reference_length, hi * reference_length + + # ... sample uniformly among the integers in the inclusive [lo, hi] range + lo, hi = int(np.ceil(lo)), int(np.floor(hi)) + hi = max(hi, lo) # guard against an empty integer range after rounding a narrow fraction-based span + sampled = np.random.randint(lo, hi + 1) + + if exists(max_crop_size): + sampled = min(sampled, max_crop_size) + + return max(sampled, 1) + + def compute_local_hash(atom_array: AtomArray, radius: float = 6.0) -> np.ndarray: """ Compute a local hash for each atom in the atom array. @@ -73,12 +187,9 @@ def compute_local_hash(atom_array: AtomArray, radius: float = 6.0) -> np.ndarray is_valid = ~is_any_coord_nan(atom_array) kdtree = KDTree(atom_array.coord[is_valid]) - # ... query local neighbourhoods - neighbour_idxs: list[list[int]] = kdtree.query_ball_tree(kdtree, r=radius) - # ... use number of neighbours as hash (# TODO: elaborate this if needed) num_neighbours = np.zeros(n_atoms, dtype=int) - num_neighbours[is_valid] = list(map(len, neighbour_idxs)) + num_neighbours[is_valid] = kdtree.query_ball_point(atom_array.coord[is_valid], r=radius, return_length=True) return num_neighbours @@ -179,7 +290,8 @@ def get_spatial_crop_center( atom_array: AtomArray, query_pn_unit_iids: list[str], cutoff_distance: float = 15.0, - raise_if_missing_query: bool = True, + raise_if_missing_crop_center: bool = True, + raise_if_missing_query: bool | None = None, ) -> np.ndarray: """ Sample a crop center from a spatial region of the atom array. @@ -199,13 +311,22 @@ def get_spatial_crop_center( atom_array (AtomArray): The array containing atom information. query_pn_unit_iids (list[str]): List of PN unit instance IDs to query. cutoff_distance (float, optional): The distance cutoff to consider for spatial proximity. Defaults to 15.0. - raise_if_missing_query (bool): Whether to raise an Exception if no crop centers are found, e.g. if the + raise_if_missing_crop_center (bool): Whether to raise an Exception if no crop centers are found, e.g. if the query pn_unit(s) are not present due to a previous filtering step. Defaults to `True`. If `False`, a random pn_unit will be selected for the crop center. + raise_if_missing_query (bool | None): Deprecated. Use ``raise_if_missing_crop_center`` instead. Returns: np.ndarray: A boolean mask indicating the crop center. """ + if raise_if_missing_query is not None: + warnings.warn( + "raise_if_missing_query is deprecated, use raise_if_missing_crop_center instead.", + DeprecationWarning, + stacklevel=2, + ) + raise_if_missing_crop_center = raise_if_missing_query + # ... get mask for query polymer/non-polymer unit is_query_pn_unit = np.isin(atom_array.pn_unit_iid, query_pn_unit_iids) @@ -213,7 +334,7 @@ def get_spatial_crop_center( is_occupied = atom_array.occupancy > 0 # ... optionally provide a fallback when not all query pn_units are present - if not raise_if_missing_query: + if not raise_if_missing_crop_center: available_query_pn_unit_iids = np.unique(atom_array.pn_unit_iid[is_query_pn_unit]) # If only one of the query pn_units is present, we will just use that @@ -236,23 +357,30 @@ def get_spatial_crop_center( # If there's only one query unit, we don't need to check for spatial proximity, # so we can just return the mask for the query unit. can_be_crop_center = is_query_pn_unit & is_occupied - assert np.any( - can_be_crop_center - ), f"No crop center found! It appears `query_pn_unit_iid` {query_pn_unit_iids} is not in the atom array or unresolved." + if not np.any(can_be_crop_center): + if raise_if_missing_crop_center: + raise AssertionError( + f"No crop center found! It appears `query_pn_unit_iid` {query_pn_unit_iids} " + "is not in the atom array or unresolved." + ) + else: + logger.warning( + f"No crop center found for query pn_unit {query_pn_unit_iids}. " + "All atoms may be unresolved (occupancy == 0). Falling back to full unit mask." + ) + can_be_crop_center = is_query_pn_unit return can_be_crop_center is_at_interface = np.zeros_like(is_query_pn_unit, dtype=bool) - for pn_unit_1_iid, pn_unit_2_iid in itertools.combinations(query_pn_unit_iids, 2): - # ... get mask, indices, and kdtree for pn_unit_1 - pn_unit_1_mask = (atom_array.pn_unit_iid == pn_unit_1_iid) & is_occupied - pn_unit_1_indices = np.where(pn_unit_1_mask)[0] - _tree1 = KDTree(atom_array.coord[pn_unit_1_mask]) + query_units = {} + for unit_id in query_pn_unit_iids: + mask = (atom_array.pn_unit_iid == unit_id) & is_occupied + query_units[unit_id] = (np.where(mask)[0], KDTree(atom_array.coord[mask])) - # ... get mask, indices, and kdtree for pn_unit_2 - pn_unit_2_mask = (atom_array.pn_unit_iid == pn_unit_2_iid) & is_occupied - pn_unit_2_indices = np.where(pn_unit_2_mask)[0] - _tree2 = KDTree(atom_array.coord[pn_unit_2_mask]) + for pn_unit_1_iid, pn_unit_2_iid in itertools.combinations(query_pn_unit_iids, 2): + pn_unit_1_indices, _tree1 = query_units[pn_unit_1_iid] + pn_unit_2_indices, _tree2 = query_units[pn_unit_2_iid] dists = _tree1.sparse_distance_matrix(_tree2, max_distance=cutoff_distance, output_type="coo_matrix") @@ -263,7 +391,16 @@ def get_spatial_crop_center( # ... assemble final crop mask can_be_crop_center = is_query_pn_unit & is_at_interface & is_occupied - assert np.any(can_be_crop_center), "No crop center found!" + if not np.any(can_be_crop_center): + if raise_if_missing_crop_center: + raise AssertionError("No crop center found!") + + # Fall back to all occupied query atoms when no interface atoms exist + # (e.g., disconnected fragments beyond cutoff_distance) + logger.warning("No interface atoms found between query pn_units. Falling back to all occupied query atoms.") + can_be_crop_center = is_query_pn_unit & is_occupied + assert np.any(can_be_crop_center), "No crop center found even after fallback!" + return can_be_crop_center @@ -345,7 +482,15 @@ class CropContiguousLikeAF3(CropTransformBase): - AF2 Multimer https://www.biorxiv.org/content/10.1101/2021.10.04.463034v2.full.pdf Attributes: - crop_size (int): The maximum number of tokens to crop. + crop_size (int | None): The maximum number of tokens to crop. Either this or + `crop_size_range` must be set (not both). + crop_size_range (tuple[int, int] | tuple[float, float] | None): If set, the crop size is + resampled uniformly from this inclusive ``(min, max)`` range on every call, instead of + using a fixed `crop_size` (which must then be `None`). Either a pair of absolute token + counts, or a pair of fractions of the total number of tokens (requires `max_crop_size`). + Defaults to `None`. + max_crop_size (int | None): Upper bound on the sampled crop size. Required when + `crop_size_range` is fraction-based. Defaults to `None`. keep_uncropped_atom_array (bool): Whether to keep the uncropped atom array in the data. If `True`, the uncropped atom array will be stored in the `crop_info` dictionary under the key `"atom_array"`. Defaults to `False`. @@ -361,10 +506,15 @@ class CropContiguousLikeAF3(CropTransformBase): ] def __init__( - self, crop_size: int, keep_uncropped_atom_array: bool = False, max_atoms_in_crop: int | None = None, **kwargs + self, + crop_size: int | None = None, + crop_size_range: tuple[int, int] | tuple[float, float] | None = None, + max_crop_size: int | None = None, + keep_uncropped_atom_array: bool = False, + max_atoms_in_crop: int | None = None, + **kwargs, ): - super().__init__(**kwargs) - self.crop_size = crop_size + super().__init__(crop_size=crop_size, crop_size_range=crop_size_range, max_crop_size=max_crop_size, **kwargs) self.keep_uncropped_atom_array = keep_uncropped_atom_array self.max_atoms_in_crop = max_atoms_in_crop self._validate() @@ -377,7 +527,10 @@ def check_input(self, data: dict) -> None: def forward(self, data: dict) -> dict: atom_array = data["atom_array"] - requires_crop = get_token_count(atom_array) > self.crop_size + n_tokens = get_token_count(atom_array) + crop_size = self._resolve_crop_size(reference_length=n_tokens) + + requires_crop = n_tokens > crop_size if requires_crop: # Extract chain data chain_iids = np.unique(atom_array.chain_iid) @@ -386,7 +539,7 @@ def forward(self, data: dict) -> dict: ] # Sample crop as in AF2 multimer - keep_token_dict = crop_contiguous_af2_multimer(chain_iids, chain_n_tokens, self.crop_size) + keep_token_dict = crop_contiguous_af2_multimer(chain_iids, chain_n_tokens, crop_size) # Turn crop information into atom-level mask is_token_start_idxs = get_token_starts(atom_array) @@ -401,7 +554,6 @@ def forward(self, data: dict) -> dict: is_token_in_crop = np.ones(get_token_count(atom_array), dtype=bool) crop_info = { - "type": self.__class__.__name__, "requires_crop": requires_crop, "crop_token_idxs": np.where(is_token_in_crop)[0], "crop_atom_idxs": np.where(is_atom_in_crop)[0], @@ -413,10 +565,16 @@ def forward(self, data: dict) -> dict: max_atoms=self.max_atoms_in_crop, ) - # Update data - data["crop_info"] = crop_info + data["crop_info"] = { + "type": self.__class__.__name__, + "crop_size": crop_size, + "crop_size_range": self.crop_size_range, + "max_atoms_in_crop": self.max_atoms_in_crop, + } | crop_info + if self.keep_uncropped_atom_array: data["crop_info"]["atom_array"] = atom_array + data["atom_array"] = atom_array[crop_info["crop_atom_idxs"]] return data @@ -429,7 +587,8 @@ def crop_spatial_like_af3( jitter_scale: float = 1e-3, crop_center_cutoff_distance: float = 15.0, force_crop: bool = False, - raise_if_missing_query: bool = True, + raise_if_missing_crop_center: bool = True, + raise_if_missing_query: bool | None = None, ) -> dict: """Crop spatial tokens around a given `crop_center` by keeping the `crop_size` nearest neighbors (with jitter). @@ -443,9 +602,10 @@ def crop_spatial_like_af3( consider for crop center. Defaults to 15.0 Angstroms. - force_crop (bool, optional): Whether to force crop even if the atom array is already small enough. Defaults to False. - - raise_if_missing_query (bool): Whether to raise an Exception if no crop centers are found, e.g. if the + - raise_if_missing_crop_center (bool): Whether to raise an Exception if no crop centers are found, e.g. if the query pn_unit(s) are not present due to a previous filtering step. Defaults to `True`. If `False`, a random pn_unit will be selected for the crop center. + - raise_if_missing_query (bool | None): Deprecated. Use ``raise_if_missing_crop_center`` instead. Returns: dict: A dictionary containing crop information, including: @@ -463,22 +623,33 @@ def crop_spatial_like_af3( - AF3 https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-024-07487-w/MediaObjects/41586_2024_7487_MOESM1_ESM.pdf - AF2 Multimer https://www.biorxiv.org/content/10.1101/2021.10.04.463034v2.full.pdf """ + if raise_if_missing_query is not None: + warnings.warn( + "raise_if_missing_query is deprecated, use raise_if_missing_crop_center instead.", + DeprecationWarning, + stacklevel=2, + ) + raise_if_missing_crop_center = raise_if_missing_query + token_segments = get_token_starts(atom_array, add_exclusive_stop=True) n_tokens = len(token_segments) - 1 requires_crop = n_tokens > crop_size - # ... get possible crop centers - can_be_crop_center = get_spatial_crop_center( - atom_array, query_pn_unit_iids, crop_center_cutoff_distance, raise_if_missing_query=raise_if_missing_query - ) - - # ... sample crop center atom - crop_center_atom_id = np.random.choice(atom_array[can_be_crop_center].atom_id) - crop_center_atom_idx = atom_id_to_atom_idx(atom_array, crop_center_atom_id) - crop_center_token_idx = atom_id_to_token_idx(atom_array, crop_center_atom_id) - # ... sample crop if force_crop or requires_crop: + # ... get possible crop centers + can_be_crop_center = get_spatial_crop_center( + atom_array, + query_pn_unit_iids, + crop_center_cutoff_distance, + raise_if_missing_crop_center=raise_if_missing_crop_center, + ) + + # ... sample crop center atom + crop_center_atom_id = np.random.choice(atom_array[can_be_crop_center].atom_id) + crop_center_atom_idx = atom_id_to_atom_idx(atom_array, crop_center_atom_id) + crop_center_token_idx = atom_id_to_token_idx(atom_array, crop_center_atom_id) + token_coords = get_af3_token_center_coords(atom_array) is_token_in_crop = get_spatial_crop_mask( token_coords, crop_center_token_idx, crop_size=crop_size, jitter_scale=jitter_scale @@ -487,6 +658,9 @@ def crop_spatial_like_af3( is_atom_in_crop = spread_token_wise(atom_array, is_token_in_crop, token_starts=token_segments) else: # ... no need to crop since the atom array is already small enough + crop_center_atom_id = np.nan + crop_center_atom_idx = np.nan + crop_center_token_idx = np.nan is_atom_in_crop = np.ones(len(atom_array), dtype=bool) is_token_in_crop = np.ones(n_tokens, dtype=bool) @@ -536,8 +710,9 @@ def resize_crop_info_if_too_many_atoms( # Calculate distances to center token # ... get token center coordinates token_coords = get_af3_token_center_coords(crop_atom_array) # [n_token, 3] - if "crop_center_atom_idx" in crop_info: - crop_center_coords = atom_array.coord[crop_info["crop_center_atom_idx"]] + crop_center_atom_idx = crop_info.get("crop_center_atom_idx") + if crop_center_atom_idx is not None and np.isfinite(crop_center_atom_idx): + crop_center_coords = atom_array.coord[int(crop_center_atom_idx)] else: # ... use center of mass of tokens in crop as center coordinate crop_center_coords = np.mean(token_coords, axis=0) @@ -589,7 +764,15 @@ class CropSpatialLikeAF3(CropTransformBase): - AF2 Multimer https://www.biorxiv.org/content/10.1101/2021.10.04.463034v2.full.pdf Attributes: - crop_size (int): The maximum number of tokens to crop. Must be greater than 0. + crop_size (int | None): The maximum number of tokens to crop. Either this or + `crop_size_range` must be set (not both). + crop_size_range (tuple[int, int] | tuple[float, float] | None): If set, the crop size is + resampled uniformly from this inclusive ``(min, max)`` range on every call, instead of + using a fixed `crop_size` (which must then be `None`). Either a pair of absolute token + counts, or a pair of fractions of the total number of tokens (requires `max_crop_size`). + Defaults to `None`. + max_crop_size (int | None): Upper bound on the sampled crop size. Required when + `crop_size_range` is fraction-based. Defaults to `None`. jitter_scale (float): The scale of the jitter to apply to the crop center. This is to break ties between atoms with the same spatial distance. Defaults to 1e-3. crop_center_cutoff_distance (float): The cutoff distance to consider for selecting crop @@ -609,19 +792,30 @@ class CropSpatialLikeAF3(CropTransformBase): def __init__( self, - crop_size: int, + crop_size: int | None = None, + crop_size_range: tuple[int, int] | tuple[float, float] | None = None, + max_crop_size: int | None = None, jitter_scale: float = 1e-3, crop_center_cutoff_distance: float = 15.0, keep_uncropped_atom_array: bool = False, force_crop: bool = False, max_atoms_in_crop: int | None = None, - raise_if_missing_query: bool = True, + raise_if_missing_crop_center: bool = True, + raise_if_missing_query: bool | None = None, **kwargs, ): """Initialize the CropSpatialLikeAF3 transform. Args: - crop_size: The maximum number of tokens to crop. Must be greater than 0. + crop_size: The maximum number of tokens to crop. Either this or `crop_size_range` must + be set (not both). + crop_size_range: If set, the crop size is resampled uniformly from this inclusive + ``(min, max)`` range on every call, instead of using a fixed `crop_size` (which must + then be `None`). Either a pair of absolute token counts, or a pair of fractions of + the total number of tokens (requires `max_crop_size`, to bound the crop size on + large structures). Defaults to `None`. + max_crop_size: Upper bound on the sampled crop size. Required when `crop_size_range` is + fraction-based. Defaults to `None`. jitter_scale: The scale of the jitter to apply to the crop center. This is to break ties between atoms with the same spatial distance. Defaults to 1e-3. crop_center_cutoff_distance: The cutoff distance to consider for @@ -633,18 +827,25 @@ def __init__( Defaults to `False`. max_atoms_in_crop (int, optional): Maximum number of atoms allowed in a crop. If None, no resizing is performed. Defaults to None. - raise_if_missing_query (bool): Whether to raise an Exception if no crop centers are found, e.g. if the + raise_if_missing_crop_center (bool): Whether to raise an Exception if no crop centers are found, e.g. if the query pn_unit(s) are not present due to a previous filtering step. Defaults to `True`. If `False`, a random pn_unit will be selected for the crop center. + raise_if_missing_query (bool | None): Deprecated. Use ``raise_if_missing_crop_center`` instead. """ - super().__init__(**kwargs) - self.crop_size = crop_size + if raise_if_missing_query is not None: + warnings.warn( + "raise_if_missing_query is deprecated, use raise_if_missing_crop_center instead.", + DeprecationWarning, + stacklevel=2, + ) + raise_if_missing_crop_center = raise_if_missing_query + super().__init__(crop_size=crop_size, crop_size_range=crop_size_range, max_crop_size=max_crop_size, **kwargs) self.jitter_scale = jitter_scale self.crop_center_cutoff_distance = crop_center_cutoff_distance self.keep_uncropped_atom_array = keep_uncropped_atom_array self.force_crop = force_crop self.max_atoms_in_crop = max_atoms_in_crop - self.raise_if_missing_query = raise_if_missing_query + self.raise_if_missing_crop_center = raise_if_missing_crop_center self._validate() def check_input(self, data: dict) -> None: @@ -661,14 +862,17 @@ def forward(self, data: dict) -> dict: query_pn_units = np.unique(atom_array.pn_unit_iid) logger.info(f"No query PN unit(s) provided for spatial crop. Randomly selecting from {query_pn_units}.") + reference_length = get_token_count(atom_array) if exists(self.crop_size_range) else None + crop_size = self._resolve_crop_size(reference_length=reference_length) + crop_info = crop_spatial_like_af3( atom_array=atom_array, query_pn_unit_iids=query_pn_units, - crop_size=self.crop_size, + crop_size=crop_size, jitter_scale=self.jitter_scale, crop_center_cutoff_distance=self.crop_center_cutoff_distance, force_crop=self.force_crop, - raise_if_missing_query=self.raise_if_missing_query, + raise_if_missing_crop_center=self.raise_if_missing_crop_center, ) crop_info = resize_crop_info_if_too_many_atoms( crop_info=crop_info, @@ -676,7 +880,13 @@ def forward(self, data: dict) -> dict: max_atoms=self.max_atoms_in_crop, ) - data["crop_info"] = {"type": self.__class__.__name__} | crop_info + data["crop_info"] = { + "type": self.__class__.__name__, + "crop_size": crop_size, + "crop_size_range": self.crop_size_range, + "crop_center_cutoff_distance": self.crop_center_cutoff_distance, + "max_atoms_in_crop": self.max_atoms_in_crop, + } | crop_info if self.keep_uncropped_atom_array: data["crop_info"]["atom_array"] = atom_array diff --git a/src/atomworks/ml/transforms/design_task.py b/src/atomworks/ml/transforms/design_task.py index 3200544e..242b7682 100644 --- a/src/atomworks/ml/transforms/design_task.py +++ b/src/atomworks/ml/transforms/design_task.py @@ -85,7 +85,7 @@ def __init__( if not design_tasks_to_use: logger.warning( - "No design tasks with non-zero frequency found. SampleDesignTask will act as an identity transform." + "No design tasks with non-zero frequency found. " "SampleDesignTask will act as an identity transform." ) self.design_tasks = design_tasks_to_use diff --git a/src/atomworks/ml/transforms/dna/pad_dna.py b/src/atomworks/ml/transforms/dna/pad_dna.py index ce7f643d..d9c8f8ef 100644 --- a/src/atomworks/ml/transforms/dna/pad_dna.py +++ b/src/atomworks/ml/transforms/dna/pad_dna.py @@ -1,7 +1,5 @@ """Transforms for augmentation of nucleic acids""" -from __future__ import annotations - import logging import os import pathlib @@ -548,7 +546,7 @@ def _pad_dna_structure( # check for clash between newly generated DNA coords and the original non-DNA coords all_new_array = component_first + component_third + component_fourth + component_sixth - if is_clash(all_new_array, atom_array_non_dna): + if is_clash(all_new_array, remove_nan_coords(atom_array_non_dna)): logger.warning( "PadDNA failed. PadDNA found a clash between newly generated DNA coords and the original non-DNA coords." ) @@ -592,7 +590,12 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: # ... compute all base-pairs based on the structure dna_array_no_nan = remove_nan_coords(dna_array) - _base_pair_idxs = base_pairs(dna_array_no_nan, no_hbond_dist_cut=self.no_hbond_dist_cut) + try: + _base_pair_idxs = base_pairs(dna_array_no_nan, no_hbond_dist_cut=self.no_hbond_dist_cut) + except Exception as e: + # Base pair detection threw exception, do not pad + logger.warning(f"PadDNA base-pair detection failed ({e!r}); skipping padding.") + return data if not len(_base_pair_idxs): # ... early stop if no base-pairs are found @@ -632,6 +635,17 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: ) continue + # If DNA has _any_ occ != 1 bases, do not try to extend + # the resulting pose will almost certainly be wrong + chain1_occ = dna_array.occupancy[dna_array.chain_iid == chain1_iid] + chain2_occ = dna_array.occupancy[dna_array.chain_iid == chain2_iid] + if (chain1_occ != 1).any() or (chain2_occ != 1).any(): + logger.warning( + f"DNA duplex {chain1_iid}:{chain2_iid} has non-fully-resolved atoms (occupancy != 1). " + "Skipping in PadDNA." + ) + continue + ### TODO: Move into function # ... skip if desired duplex is already of the desired length if chain1["is_base_paired"].sum() > self.max_pad_tot: diff --git a/src/atomworks/ml/transforms/dssp.py b/src/atomworks/ml/transforms/dssp.py new file mode 100644 index 00000000..bf1ef054 --- /dev/null +++ b/src/atomworks/ml/transforms/dssp.py @@ -0,0 +1,252 @@ +"""Secondary structure annotation using DSSP.""" + +import logging +from enum import IntEnum, StrEnum + +import biotite.application.dssp as dssp +import numpy as np +from biotite.structure import AtomArray + +from atomworks.enums import ChainType +from atomworks.ml.executables.dssp import DSSPExecutable +from atomworks.ml.transforms._checks import ( + check_atom_array_annotation, +) +from atomworks.ml.transforms.base import Transform +from atomworks.ml.utils.token import get_token_starts, spread_token_wise + +logger = logging.getLogger("atomworks.ml") + + +class SSEnum(IntEnum): + """Secondary structure enum for protein residues. + + Groups DSSP codes into five categories for efficient storage and manipulation. + Used for both DSSP output annotations and secondary structure conditioning. + + Values: + NONE: Not set/not conditioned (value: 0) + HELIX: Alpha helix (H, G, I in DSSP) (value: 1) + SHEET: Beta sheet (E, B in DSSP) (value: 2) + LOOP: Coil/loop/turn (T, S, C, P in DSSP) (value: 3) + NON_PROTEIN: Non-protein chain or DSSP failed (value: 4) + """ + + NONE = 0 + HELIX = 1 + SHEET = 2 + LOOP = 3 + NON_PROTEIN = 4 + + @classmethod + def names(cls) -> list[str]: + """Return human-readable names for each group.""" + return ["none", "helix", "sheet", "loop", "non_protein"] + + @classmethod + def to_string(cls, value: int) -> str: + """Convert integer value to human-readable string.""" + # Handle NONE specially since it's 0 + if value == 0: + return "none" + return cls.names()[value] + + +class DSSPCode(StrEnum): + """DSSP secondary structure codes as defined by the DSSP program.""" + + ALPHA_HELIX = "H" # alpha-helix + ISOLATED_BETA_BRIDGE = "B" # residue in isolated beta-bridge + EXTENDED_STRAND = "E" # extended strand, participates in beta ladder + THREE_TEN_HELIX = "G" # 3-10 helix + PI_HELIX = "I" # pi-helix + POLYPROLINE_HELIX = "P" # kappa-helix (poly-proline II helix) + HYDROGEN_BONDED_TURN = "T" # hydrogen-bonded turn + BEND = "S" # bend + OTHER = "C" # loop, coil, or irregular + NON_PROTEIN = "!" # non-protein + + @classmethod + def valid_codes(cls) -> set[str]: + """Return set of valid DSSP codes.""" + return {e.value for e in cls} + + @classmethod + def to_group_index(cls, code: str) -> int: + """Map DSSP code to SSEnum index.""" + if code in {cls.ALPHA_HELIX.value, cls.THREE_TEN_HELIX.value, cls.PI_HELIX.value}: + return SSEnum.HELIX + if code in {cls.EXTENDED_STRAND.value, cls.ISOLATED_BETA_BRIDGE.value}: + return SSEnum.SHEET + if code == cls.NON_PROTEIN.value: + return SSEnum.NON_PROTEIN + return SSEnum.LOOP + + +def _get_chain_sse_and_valid(chain_atom_array: AtomArray, bin_path: str) -> tuple[np.ndarray, bool]: + """Run DSSP on a chain's protein atoms, return group indices and whether DSSP ran successfully. + + Args: + chain_atom_array: AtomArray containing atoms from a single chain. + bin_path: Path to DSSP executable. + + Returns: + Tuple of (group_indices, is_valid) where group_indices are integers from + SSEnum and is_valid indicates if DSSP ran successfully. + """ + try: + dssp_codes = dssp.DsspApp.annotate_sse(chain_atom_array, bin_path=bin_path) + # Convert DSSP codes to group indices + group_indices = np.array([DSSPCode.to_group_index(code) for code in dssp_codes], dtype=np.int8) + return group_indices, True + except Exception as e: + chain_id = getattr(chain_atom_array, "chain_id", ["?"])[0] + logger.error( + f"Error running DSSP for entity {chain_id}: {e}; " + f"using NON_PROTEIN code for this entity's residues, and setting is_valid annotation to False" + ) + return ( + np.full(len(chain_atom_array), SSEnum.NON_PROTEIN, dtype=np.int8), + False, + ) + + +def annotate_secondary_structure( + atom_array: AtomArray, + bin_path: str | None = None, + annotation_name: str = "dssp_sse", + is_valid_annotation_name: str | None = None, +) -> AtomArray: + """Annotate secondary structure for each residue using DSSP. + + Only protein tokens are assigned secondary structure groups; all others are + set to NON_PROTEIN. + + Also adds a boolean annotation indicating whether the SSE is valid (not default + NON_PROTEIN due to error). + + Args: + atom_array: AtomArray to annotate. + bin_path: Path to DSSP executable. If ``None``, uses executable from ``DSSPExecutable``. + annotation_name: Name for the SSE annotation. Defaults to ``"dssp_sse"``. + is_valid_annotation_name: Name for the validity annotation. If ``None``, + uses ``"{annotation_name}_is_valid"``. Defaults to ``None``. + + Returns: + AtomArray with secondary structure annotations added. + """ + # Get bin_path from executable manager if not provided + if bin_path is None: + dssp_exec = DSSPExecutable.get_or_initialize() + bin_path = dssp_exec.get_bin_path() + + # Atom-level masks + is_protein_atom_lvl = np.isin(atom_array.chain_type, ChainType.get_proteins()) + is_atomized_atom_lvl = ( + atom_array.atomize + if "atomize" in atom_array.get_annotation_categories() + else np.zeros(atom_array.array_length(), dtype=bool) + ) + + # Token-level masks + token_starts = get_token_starts(atom_array) + atom_array_token_lvl = atom_array[token_starts] + + # Default all tokens to NON_PROTEIN and all is_valid to False + sse = np.full(len(atom_array_token_lvl), SSEnum.NON_PROTEIN, dtype=np.int8) + is_valid = np.zeros(len(atom_array_token_lvl), dtype=bool) + + if np.any(is_protein_atom_lvl): + # Loop over chain instances + for chain_iid in np.unique(atom_array.chain_iid): + chain_iid_mask = atom_array.chain_iid == chain_iid + chain_iid_protein_mask = chain_iid_mask & is_protein_atom_lvl & ~is_atomized_atom_lvl + + if not np.any(chain_iid_protein_mask): + # Early exit if this chain has no protein atoms + continue + + # Get chain atoms and compute SSE + chain_atom_array = atom_array[chain_iid_protein_mask] + sse_chain, is_valid_chain = _get_chain_sse_and_valid(chain_atom_array, bin_path) + + # Assign to all tokens in this chain instance + token_mask = chain_iid_protein_mask[token_starts] + if len(sse_chain) == token_mask.sum(): + sse[token_mask] = sse_chain + is_valid[token_mask] = is_valid_chain + else: + # Catch-all for situations that arise (usually due to cropping) + logger.warning( + f"Mismatch in SSE length for chain {chain_iid}: {len(sse_chain)} != {token_mask.sum()}. " + f"We will use NON_PROTEIN for this chain, and set is_valid to False." + ) + + # Spread token-wise to all atoms and set annotations + sse_spread = spread_token_wise(atom_array, sse) + is_valid_spread = spread_token_wise(atom_array, is_valid) + + # Use provided is_valid annotation name or default + if is_valid_annotation_name is None: + is_valid_annotation_name = f"{annotation_name}_is_valid" + + atom_array.set_annotation(annotation_name, sse_spread) + atom_array.set_annotation(is_valid_annotation_name, is_valid_spread) + + return atom_array + + +class AnnotateSecondaryStructure(Transform): + """Annotate secondary structure for each residue using DSSP. + + Adds integer annotations from :py:class:`SSEnum` indicating the secondary structure type. + + Args: + bin_path: Path to DSSP executable. If ``None``, uses ``DSSP`` environment variable. Defaults to ``None``. + annotation_name: Name for the SSE annotation. Defaults to ``"dssp_sse"``. + is_valid_annotation_name: Name for the validity annotation. If ``None``, + uses ``"{annotation_name}_is_valid"``. Defaults to ``None``. + max_n_tokens: Maximum number of tokens to run DSSP on. If structure exceeds this, + DSSP is skipped and no annotations are added. Defaults to 800, which encompasses most proteins. + """ + + def __init__( + self, + bin_path: str | None = None, + annotation_name: str = "dssp_sse", + is_valid_annotation_name: str | None = None, + max_n_tokens: int | None = 800, + ): + # Initialize executable if not already done + if bin_path is None: + DSSPExecutable.get_or_initialize() + else: + DSSPExecutable.get_or_initialize(bin_path) + self.annotation_name = annotation_name + self.is_valid_annotation_name = is_valid_annotation_name + self.max_n_tokens = max_n_tokens + + def check_input(self, data: dict) -> None: + check_atom_array_annotation(data, ["chain_type", "chain_iid"]) + + def forward(self, data: dict) -> dict: + atom_array: AtomArray = data["atom_array"] + + # Check if structure exceeds max_n_tokens + if self.max_n_tokens is not None: + token_starts = get_token_starts(atom_array) + n_tokens = len(token_starts) + + if n_tokens > self.max_n_tokens: + # Skip DSSP and return data without annotations + logger.info(f"Skipping DSSP: structure has {n_tokens} tokens, exceeds max_n_tokens={self.max_n_tokens}") + return data + + # Proceed with normal DSSP annotation + data["atom_array"] = annotate_secondary_structure( + atom_array, + bin_path=None, # Use executable manager + annotation_name=self.annotation_name, + is_valid_annotation_name=self.is_valid_annotation_name, + ) + return data diff --git a/src/atomworks/ml/transforms/encoding.py b/src/atomworks/ml/transforms/encoding.py index 6a0cf55a..45ccabd8 100644 --- a/src/atomworks/ml/transforms/encoding.py +++ b/src/atomworks/ml/transforms/encoding.py @@ -24,30 +24,50 @@ check_is_instance, check_nonzero_length, ) -from atomworks.ml.transforms.atom_array import get_within_entity_idx +from atomworks.ml.transforms.atom_array import ( + add_global_token_id_annotation, + get_within_entity_idx, +) from atomworks.ml.transforms.base import Transform from atomworks.ml.utils.token import get_token_count, get_token_starts, token_iter logger = getLogger(__name__) +def _resolve_atom_token_name(atom_array: AtomArray, atomize_token: str | None, atom_index: int = 0) -> str | int: + """Resolve the token name for the atom at ``atom_index``. + + If *atomize_token* is provided, uses that literal string. Otherwise + falls back to the atom's ``atomic_number`` annotation or an + element-name lookup via :data:`ELEMENT_NAME_TO_ATOMIC_NUMBER`. + """ + if atomize_token is not None: + return atomize_token + if "atomic_number" in atom_array.get_annotation_categories(): + return atom_array.atomic_number[atom_index] + return ELEMENT_NAME_TO_ATOMIC_NUMBER[atom_array.element[atom_index].upper()] + + def atom_array_to_encoded_resnames( atom_array: AtomArray, encoding: TokenEncoding, - atomize_token: str = "", + atomize_token: str | None = None, + res_names: np.ndarray | None = None, ) -> np.ndarray: """Encode residue types from an AtomArray. Encodes at token level, then spreads to atom level for efficiency. Handles proteins, DNA, RNA, atomized residues (ligands, ions), and unknown tokens. - For atomized residues, uses the atomize_token. + For atomized residues, uses the atomize_token if provided. If None, uses atomic number tokens. For masked/to-be-generated residues, use the `` (mask) token. Args: atom_array: AtomArray with token_id annotation. encoding: TokenEncoding defining the mapping (e.g., UNIFIED_ATOM37_ENCODING). - atomize_token: Token to use for atomized residues. Defaults to ``. + atomize_token: Token to use for atomized residues. If None, uses atomic number tokens. + res_names: Optional [n_atoms] residue-name source overriding ``atom_array.res_name`` (e.g. a + conditioning annotation). Atomized tokens are unaffected. Returns: Array of token type indices with shape [n_atoms], where each index @@ -61,24 +81,26 @@ def atom_array_to_encoded_resnames( n_tokens = get_token_count(atom_array) token_encoded_seq = np.empty(n_tokens, dtype=int) - # Check if atom array has atomize annotation - has_atomize = "atomize" in atom_array.get_annotation_categories() - + # ensure that atomize annotation exists + assert "atomize" in atom_array.get_annotation_categories(), "Atomize annotation is required for encoding" + source = atom_array.res_name if res_names is None else res_names + token_starts = get_token_starts(atom_array) + token_res_name = source[token_starts] # Iterate over tokens and encode token names (token-level encoding) - for i, token in enumerate(token_iter(atom_array)): - # Case 1: atomized tokens (ligands, ions) or single-atom tokens - # Use atomize_token - if (has_atomize and token.atomize[0]) or len(token) == 1: - token_name = atomize_token + for i, start in enumerate(token_starts): + # Case 1: atomized tokens (ligands, ions) + if atom_array.atomize[start]: + token_is_atom = True + token_name = _resolve_atom_token_name(atom_array, atomize_token, start) # Case 2: residue tokens (proteins, DNA, RNA) # Use res_name as token identifier else: - token_name = token.res_name[0] + token_name = token_res_name[i] + token_is_atom = False # Resolve unknown tokens (e.g., UNK for unknown AA, N for unknown RNA, DN for unknown DNA) if token_name not in encoding.token_to_idx: - token_is_atom = (has_atomize and token.atomize[0]) or len(token) == 1 token_name = encoding.resolve_unknown_token_name(token_name, token_is_atom) assert token_name in encoding.token_to_idx, f"Unknown token name: {token_name}" @@ -102,6 +124,8 @@ def atom_array_to_encoding( "transformation_id", ], coord_annotation: str = "coord", + atomize_token: str | None = None, + atomize_atom_name: str | None = None, ) -> dict: """ Encode an atom array using a specified `TokenEncoding`. @@ -117,7 +141,6 @@ def atom_array_to_encoding( the atom array has the `atomize` annotation, in which case the number of tokens may exceed the number of residues. - TODO: Refactor so that `atom_array_to_encoding` uses `atom_array_to_encoded_resnames` internally. TODO: Vectorize Args: @@ -136,6 +159,8 @@ def atom_array_to_encoding( ["chain_id", "chain_entity", "molecule_iid", "chain_iid", "transformation_id"]. - coord_annotation (str, optional): The annotation of the AtomArray containing the coordinates to encode. Defaults to "coord". + - atomize_token (str | None, optional): Token to use for atomized residues. If None, uses atomic numbers. + - atomize_atom_name (str | None, optional): Atom name to use for atomized residues when atomize_token is set. Returns: - dict: A dictionary containing the following keys: @@ -166,6 +191,15 @@ def atom_array_to_encoding( encoded_seq = np.empty(n_token, dtype=int) # [n_token] (int) encoded_token_is_atom = np.empty(n_token, dtype=bool) # [n_token] (bool) + if atomize_token is not None: + if "atomize" not in atom_array.get_annotation_categories(): + atom_array.set_annotation("atomize", np.zeros(atom_array.array_length(), dtype=bool)) + if "token_id" not in atom_array.get_annotation_categories(): + atom_array = add_global_token_id_annotation(atom_array) + token_starts = get_token_starts(atom_array, add_exclusive_stop=False) + atom_level_encoded_seq = atom_array_to_encoded_resnames(atom_array, encoding, atomize_token=atomize_token) + encoded_seq = atom_level_encoded_seq[token_starts] + # init additional annotation extra_annot_counters = {} extra_annot_encoded = {} @@ -181,12 +215,8 @@ def atom_array_to_encoding( # ... extract token name # ... case 1: atom tokens (e.g. 6 - for carbon) if (has_atomize and token.atomize[0]) or len(token) == 1: - token_name = ( - token.atomic_number[0] - if "atomic_number" in token.get_annotation_categories() - else ELEMENT_NAME_TO_ATOMIC_NUMBER[token.element[0].upper()] - ) token_is_atom = True + token_name = _resolve_atom_token_name(token, atomize_token) # ... case 2: residue tokens (e.g. "ALA") else: token_name = token.res_name[0] @@ -197,14 +227,18 @@ def atom_array_to_encoding( assert token_name in encoding.token_to_idx, f"Unknown token name: {token_name}" # Encode sequence - encoded_seq[i] = encoding.token_to_idx[token_name] + if atomize_token is None: + encoded_seq[i] = encoding.token_to_idx[token_name] # Encode if token is an `atom-level` token or a `residue-level` token encoded_token_is_atom[i] = token_is_atom # Encode coords for atom in token: - atom_name = str(token_name) if token_is_atom else atom.atom_name + if token_is_atom and atomize_token is not None: + atom_name = atomize_atom_name if atomize_atom_name is not None else "X" + else: + atom_name = str(token_name) if token_is_atom else atom.atom_name # (token_name, atom_name) is e.g. # ... ('ALA', 'CA') if token_is_atom=False # ... ('UNK', whatever) if token_is_atom=False but we had to resolve an unknown token @@ -214,7 +248,7 @@ def atom_array_to_encoding( if (token_name, atom_name) in encoding.atom_to_idx: to_idx = encoding.atom_to_idx[(token_name, atom_name)] encoded_coord[i, to_idx, :] = getattr(atom, coord_annotation) - encoded_mask[i, to_idx] = atom.occupancy > occupancy_threshold + encoded_mask[i, to_idx] = getattr(atom, "occupancy", 1.0) > occupancy_threshold # ... case 2: atom name does not exist for token, but token is an `unknown` token, # so it's `ok` to not match @@ -395,6 +429,8 @@ def __init__( "transformation_id", ], coord_annotation: str = "coord", + atomize_token: str | None = None, + atomize_atom_name: str | None = None, ): """ Convert an atom array to an encoding. @@ -409,7 +445,10 @@ def __init__( of a given `id` will be encoded as `0`, and each subsequent occurrence will be encoded as `1`, `2`, etc. Defaults to ["chain_id", "chain_entity", "molecule_iid", "chain_iid", "transformation_id"]. - `coord_annotation` (str, optional): The annotation of the AtomArray containing the coordinates to encode. - Defaults to "coord," but in same cases we may want to use a different annotation (e.g., if we imputed coordinates) + Defaults to "coord," but in same cases we may want to use a different annotation (e.g., if we imputed coordinates). + - `atomize_token` (str | None, optional): Token to use for atomized residues. If None, uses atomic number tokens. + - `atomize_atom_name` (str | None, optional): Atom name to use for atomized residues when `atomize_token` is set. + Typically set alongside `atomize_token`. If `atomize_token` is None, this argument is ignored. """ if not isinstance(encoding, TokenEncoding): raise ValueError(f"Encoding must be a `TokenEncoding`, but got: {type(encoding)}.") @@ -418,6 +457,8 @@ def __init__( self.occupancy_threshold = occupancy_threshold self.extra_annotations = extra_annotations self.coord_annotation = coord_annotation + self.atomize_token = atomize_token + self.atomize_atom_name = atomize_atom_name def check_input(self, data: dict[str, Any]) -> None: required = ["occupancy", *([self.coord_annotation] if self.coord_annotation not in (None, "coord") else [])] @@ -433,6 +474,8 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: occupancy_threshold=self.occupancy_threshold, extra_annotations=self.extra_annotations, coord_annotation=self.coord_annotation, + atomize_token=self.atomize_token, + atomize_atom_name=self.atomize_atom_name, ) data["encoded"] = encoded @@ -527,24 +570,32 @@ class EncodeAF3TokenLevelFeatures(Transform): Reference: `Section 2.8 of the AF3 supplementary (Table 5) `_ + + Note: + When ``use_source_residue_index=True``, + :class:`~atomworks.ml.transforms.atom_array.AddWithinChainSourceResIdx` must run + earlier in the pipeline so the ``within_chain_source_res_idx`` annotation is present. """ - def __init__(self, sequence_encoding: AF3SequenceEncoding): + def __init__(self, sequence_encoding: AF3SequenceEncoding, use_source_residue_index: bool = False): self.sequence_encoding = sequence_encoding + # If False (default): use within_chain_res_idx (np.arange per chain, gap-blind). + # If True: derive residue_index from res_id to preserve gap spacing. + self.use_source_residue_index = use_source_residue_index def check_input(self, data: dict[str, Any]) -> None: check_contains_keys(data, ["atom_array"]) check_is_instance(data, "atom_array", AtomArray) - check_atom_array_annotation( - data, - [ - "atomize", - "pn_unit_iid", - "chain_entity", - "res_name", - "within_chain_res_idx", - ], - ) + required_annotations = [ + "atomize", + "pn_unit_iid", + "chain_entity", + "res_name", + "within_chain_res_idx", + ] + if self.use_source_residue_index: + required_annotations.append("within_chain_source_res_idx") + check_atom_array_annotation(data, required_annotations) def forward(self, data: dict[str, Any]) -> dict[str, Any]: atom_array = data["atom_array"] @@ -554,7 +605,11 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: # ... identifier tokens # ... (residue) - residue_index = token_level_array.within_chain_res_idx + if self.use_source_residue_index: + # gap-aware: relpos diffs > 1 across internal res_id gaps + residue_index = token_level_array.within_chain_source_res_idx + else: + residue_index = token_level_array.within_chain_res_idx # ... (token) token_index = np.arange(len(token_starts)) # ... (chain instance) diff --git a/src/atomworks/ml/transforms/featurize_unresolved_residues.py b/src/atomworks/ml/transforms/featurize_unresolved_residues.py index d87f99a5..d792191c 100644 --- a/src/atomworks/ml/transforms/featurize_unresolved_residues.py +++ b/src/atomworks/ml/transforms/featurize_unresolved_residues.py @@ -44,6 +44,9 @@ def mask_residues_with_specific_unresolved_atoms( Returns: AtomArray: The modified atom array. """ + # Early return for empty arrays + if len(atom_array) == 0: + return atom_array # Use default AF-3 frame atoms if not specified if chain_type_to_atom_names is None: diff --git a/src/atomworks/ml/transforms/filters.py b/src/atomworks/ml/transforms/filters.py index b0451652..2b50003a 100644 --- a/src/atomworks/ml/transforms/filters.py +++ b/src/atomworks/ml/transforms/filters.py @@ -1,7 +1,5 @@ """Transforms that filter an AtomArray, removing chains, residues, or atoms based on some criteria""" -from __future__ import annotations - from collections.abc import Sequence from functools import lru_cache from typing import Any, ClassVar @@ -13,6 +11,7 @@ from atomworks.common import exists, not_isin from atomworks.constants import HYDROGEN_LIKE_SYMBOLS from atomworks.enums import ChainType, ChainTypeInfo +from atomworks.io.utils.link_chemistry import get_chem_comp_leaving_atom_groups from atomworks.io.utils.query import QueryExpression from atomworks.io.utils.selection import get_annotation from atomworks.io.utils.sequence import get_1_from_3_letter_code, get_3_from_1_letter_code @@ -243,7 +242,8 @@ def random_remove_pn_units_by_annotation_query( rng: Random number generator for probabilistic deletion """ if rng is None: - rng = np.random.default_rng() + # Seed from the global stream; an unseeded default_rng() ignores the seed. + rng = np.random.default_rng(np.random.randint(0, 2**32)) # Apply query to get mask of atoms matching the criteria query_expr = QueryExpression(query) @@ -280,10 +280,10 @@ class RandomlyRemovePNUnitsByAnnotationQuery(Transform): Args: query: Query string in atomworks.io query syntax to identify pn_units to potentially delete delete_probability: Probability of deleting matched pn_units (0.0 = never delete, 1.0 = always delete) - rng_seed: Random seed for reproducibility (default: 42) + rng_seed: Fixed seed. ``None`` (default) seeds per call from the global numpy stream. """ - def __init__(self, query: str, delete_probability: float = 1.0, rng_seed: int = 42): + def __init__(self, query: str, delete_probability: float = 1.0, rng_seed: int | None = None): self.query = query self.delete_probability = delete_probability self.rng_seed = rng_seed @@ -292,7 +292,12 @@ def check_input(self, data: dict) -> None: check_atom_array_annotation(data, ["pn_unit_iid"]) def forward(self, data: dict) -> dict: - rng = data.get("rng", np.random.default_rng(self.rng_seed)) + rng = data.get("rng") + if rng is None: + # A fresh default_rng(self.rng_seed) per call gave every example the same draw. + # Seed from the global stream instead, which PyTorch seeds per dataloader worker. + seed = self.rng_seed if self.rng_seed is not None else np.random.randint(0, 2**32) + rng = np.random.default_rng(seed) data["atom_array"] = random_remove_pn_units_by_annotation_query( data["atom_array"], self.query, self.delete_probability, rng @@ -306,7 +311,7 @@ class RandomlyRemoveLigands(RandomlyRemovePNUnitsByAnnotationQuery): def check_input(self, data: dict) -> None: check_atom_array_annotation(data, ["is_polymer", "is_covalent_modification"]) - def __init__(self, delete_probability: float = 1.0, rng_seed: int = 42): + def __init__(self, delete_probability: float = 1.0, rng_seed: int | None = None): query = "~is_polymer & ~is_covalent_modification" super().__init__(query=query, delete_probability=delete_probability, rng_seed=rng_seed) @@ -400,6 +405,19 @@ def __init__(self, min_occupancy: float = 0.5): super().__init__(func=lambda arr: remove_unresolved_atoms(arr, min_occupancy)) +def _leaving_atom_names(res_name: str) -> frozenset[str]: + """Atom names the CCD declares as displaced when `res_name` forms an inter-residue bond. + + `OXT`/`HXT` for amino acids, `OP3`/`HOP3` for nucleotides. A residue inside a polymer has + already lost these during parsing (see `atomworks.io.template`), so they must not be + required when matching an observed residue against a free-monomer CCD template. + """ + names: set[str] = set() + for groups in get_chem_comp_leaving_atom_groups(res_name).values(): + names |= {str(name) for group in groups for name in group} + return frozenset(names) + + class HandleUndesiredResTokens(Transform): """ Remove, or otherwise handle, undesired residue tokens from the AtomArray. @@ -408,12 +426,13 @@ class HandleUndesiredResTokens(Transform): - For undesired residues in non-polymer residues: - Remove the entire non-polymer (pn_unit_iid) - For undesired residues in polymer residues: + - If in undesired_tokens_never_map do nothing - Map to the closest canonical residue name (if possible) - Else, map to an unknown residue name (if possible, i.e if backbone atoms are present) - Else, atomize """ - def __init__(self, undesired_res_tokens: list | tuple): + def __init__(self, undesired_res_tokens: list | tuple, undesired_tokens_never_map: list | tuple | None = None): """ HandleUndesiredResTokens is a Transform that removes undesired residue tokens from an AtomArray. @@ -425,11 +444,14 @@ def __init__(self, undesired_res_tokens: list | tuple): Args: - undesired_res_tokens (list | tuple): A list or tuple of undesired residue names to be removed or mapped. + - undesired_tokens_never_map (list | tuple): A list or tuple of undesired residue names that are + NOT mapped when polymeric (but are removed as ligands). Example: >>> transform = HandleUndesiredResTokens(undesired_res_tokens=["PTR", "SO4"]) """ self.undesired_res_tokens = undesired_res_tokens + self.undesired_tokens_never_map = undesired_tokens_never_map or () def check_input(self, data: dict) -> None: check_contains_keys(data, ["atom_array"]) @@ -458,9 +480,14 @@ def _map_to_closest_canonical_residue( if not has_hydrogens: canonical_res = canonical_res[not_isin(canonical_res.element, HYDROGEN_LIKE_SYMBOLS)] - # If canonical residue is a strict subset of the original residue, + # Polymerization can displace CCD leaving atoms; require only the other template atoms. + required_atom_names = canonical_res.atom_name[ + not_isin(canonical_res.atom_name, list(_leaving_atom_names(canonical_res_name))) + ] + + # If the canonical residue is a subset of the original residue, # keep all matching atom names and delete the rest - if np.all(np.isin(canonical_res.atom_name, atom_name)): + if np.all(np.isin(required_atom_names, atom_name)): to_keep = np.isin(atom_name, canonical_res.atom_name) # ... if we match without `force_unknown` break loop early return to_keep, canonical_res_name @@ -477,6 +504,9 @@ def forward(self, data: dict) -> dict: # Mark undesired residues to_remove = np.isin(atom_array.res_name, self.undesired_res_tokens) + # polymeric residues in 'undesired_tokens_never_map' are not removed + to_remove &= ~(atom_array.is_polymer & np.isin(atom_array.res_name, self.undesired_tokens_never_map)) + # Case 1: Undesired residue is part of non-polymer: # - Remove the entire non-polymer (pn_unit_iid) is_undesired_non_poly = to_remove & (~atom_array.is_polymer) diff --git a/src/atomworks/ml/transforms/interactions.py b/src/atomworks/ml/transforms/interactions.py new file mode 100644 index 00000000..5c27bc8d --- /dev/null +++ b/src/atomworks/ml/transforms/interactions.py @@ -0,0 +1,1473 @@ +"""Non-covalent interaction annotation for biomolecular structures (PLIP-aligned). + +Detects H-bonds, hydrophobic contacts, pi-stacking, pi-cation, salt bridges, +halogen bonds, and metal coordination between chains. The +:class:`AnnotateInteractions` transform adds pH-aware hydrogens +(:mod:`atomworks.experimental.protonation`) if missing, writes 1D/2D annotations, then strips temporary H. +Two H-bond geometry models are available, selected via ``hbond_model``: +``"rosetta"`` (O'Meara et al. 2015) and ``"plip"`` (Hubbard & Haider 2001). +""" + +import contextlib +import logging +from collections import defaultdict +from dataclasses import dataclass +from enum import IntEnum +from typing import Any, Literal + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray +from scipy.spatial import cKDTree + +from atomworks.constants import METAL_ELEMENTS, STANDARD_DNA, STANDARD_POLYMER_RESIDUES, STANDARD_RNA +from atomworks.experimental.protonation import add_hydrogens +from atomworks.io.tools.rdkit import atom_array_to_rdkit, suppress_rdkit_warnings +from atomworks.io.utils.atom_array import chain_identifier +from atomworks.io.utils.atom_array_plus import AnnotationList2D, as_atom_array_plus +from atomworks.io.utils.scatter import safe_scatter +from atomworks.io.utils.selection import get_annotation_categories +from atomworks.ml.transforms._checks import check_atom_array_has_bonds, check_contains_keys, check_is_instance +from atomworks.ml.transforms.base import Transform +from atomworks.ml.utils.geometry import ( + angle_between_vectors, + dihedral_angle, + in_plane_offset, + is_planar, + plane_normal, +) + +logger = logging.getLogger("atomworks.ml") + + +def _atom_keys(aa: AtomArray) -> list[tuple[str, int, str]]: + """Per-atom identity tuple ``(chain_identifier, res_id, atom_name)``.""" + return list(zip(chain_identifier(aa), aa.res_id, aa.atom_name, strict=False)) + + +def build_atom_index_mapping(src: AtomArray, dst: AtomArray) -> np.ndarray: + """Map each atom in *src* to its counterpart in *dst* by ``(chain_identifier, res_id, atom_name)``. + + Returns: + Int array of length ``len(src)``. ``result[i] = j`` means + ``src[i]`` corresponds to ``dst[j]``. ``-1`` for unmatched atoms. + If a key appears multiple times in *dst*, the first occurrence wins. + """ + dst_lookup: dict[tuple, int] = {} + for i, k in enumerate(_atom_keys(dst)): + dst_lookup.setdefault(k, i) + + mapping = np.full(len(src), -1, dtype=np.intp) + for i, k in enumerate(_atom_keys(src)): + if k in dst_lookup: + mapping[i] = dst_lookup[k] + + n_matched = int((mapping >= 0).sum()) + if n_matched < len(src) or len(src) != len(dst): + logger.warning( + "Annotation transfer: matched %d/%d src atoms to %d dst atoms.", + n_matched, + len(src), + len(dst), + ) + return mapping + + +_EMPTY_PAIRS = np.empty((0, 2), dtype=np.int32) +_EMPTY_VALUES = np.empty(0, dtype=np.int32) + +_INTERACTION_1D = ( + "interaction_hbond_role", + "interaction_hydrophobic", + "interaction_aromatic", + "interaction_pistacking", + "interaction_pication_role", + "interaction_charged_role", + "interaction_metal_role", + "interaction_halogen", +) +_ANNOTATION_NAMES_2D: dict[str, str] = { + "hbond": "interaction_hbond", + "hydrophobic": "interaction_hydrophobic", + "pistacking": "interaction_pistacking", + "pication": "interaction_pication", + "saltbridge": "interaction_saltbridge", + "halogen": "interaction_halogen", + "metal": "interaction_metal", +} + + +def _transfer_interaction_annotations(src: AtomArray, dst: AtomArray) -> None: + """Transfer all interaction annotations from *src* to *dst*, matching atoms by identity. + + Handles 1D (per-atom role) and 2D (pairwise interaction) annotations. + Unmatched atoms get zero-filled defaults. + """ + idx = build_atom_index_mapping(src, dst) + + for name in _INTERACTION_1D: + if name not in src.get_annotation_categories(): + continue + src_vals = src.get_annotation(name) + dst.set_annotation(name, safe_scatter(src_vals, idx, np.zeros(len(dst), dtype=src_vals.dtype))) + + for name in _ANNOTATION_NAMES_2D.values(): + if name not in get_annotation_categories(src, n_body=2): + continue + ann2d = src.get_annotation(name, n_body=2) + if len(ann2d.pairs) == 0: + dst.set_annotation(name, AnnotationList2D(len(dst), _EMPTY_PAIRS, _EMPTY_VALUES), n_body=2) + continue + a_mapped = idx[ann2d.pairs[:, 0]] + b_mapped = idx[ann2d.pairs[:, 1]] + keep = (a_mapped >= 0) & (b_mapped >= 0) + if keep.any(): + dst.set_annotation( + name, + AnnotationList2D( + len(dst), np.column_stack([a_mapped[keep], b_mapped[keep]]).astype(np.int32), ann2d.values[keep] + ), + n_body=2, + ) + else: + dst.set_annotation(name, AnnotationList2D(len(dst), _EMPTY_PAIRS, _EMPTY_VALUES), n_body=2) + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class InteractionType(IntEnum): + """Integer codes stored as ``AnnotationList2D`` values.""" + + HBOND = 1 + HYDROPHOBIC = 2 + PISTACKING_P = 3 + PISTACKING_T = 4 + PICATION = 5 + SALTBRIDGE = 6 + HALOGEN = 7 + METAL_COORD = 8 + + +class HBondRole(IntEnum): + """Per-atom H-bond role. Supports bitwise OR for dual-role atoms.""" + + NONE = 0 + DONOR = 1 + ACCEPTOR = 2 + BOTH = 3 + + +class ChargedRole(IntEnum): + """Per-atom charge role in salt bridges.""" + + NONE = 0 + POSITIVE = 1 + NEGATIVE = 2 + + +class MetalRole(IntEnum): + """Per-atom role in metal coordination.""" + + NONE = 0 + METAL = 1 + COORDINATING = 2 + + +class PiCationRole(IntEnum): + """Per-atom role in pi-cation interactions. Supports bitwise OR for dual-role atoms.""" + + NONE = 0 + AROMATIC = 1 + CATION = 2 + BOTH = 3 + + +# --------------------------------------------------------------------------- +# Atom-typing lookup tables +# --------------------------------------------------------------------------- + +_DONOR_ATOMS_AA: frozenset[tuple[str, str]] = frozenset( + { + ("*", "N"), + ("SER", "OG"), + ("THR", "OG1"), + ("TYR", "OH"), + ("ASN", "ND2"), + ("GLN", "NE2"), + ("LYS", "NZ"), + ("ARG", "NH1"), + ("ARG", "NH2"), + ("ARG", "NE"), + ("HIS", "ND1"), + ("HIS", "NE2"), + ("TRP", "NE1"), + ("CYS", "SG"), + } +) + +_ACCEPTOR_TABLE_AA: frozenset[tuple[str, str]] = frozenset( + { + ("*", "O"), + ("SER", "OG"), + ("THR", "OG1"), + ("TYR", "OH"), + ("ASN", "OD1"), + ("GLN", "OE1"), + ("ASP", "OD1"), + ("ASP", "OD2"), + ("GLU", "OE1"), + ("GLU", "OE2"), + ("HIS", "ND1"), + ("HIS", "NE2"), + ("MET", "SD"), + } +) + +_AROMATIC_RINGS: dict[str, list[list[str]]] = { + "PHE": [["CG", "CD1", "CD2", "CE1", "CE2", "CZ"]], + "TYR": [["CG", "CD1", "CD2", "CE1", "CE2", "CZ"]], + "TRP": [["CG", "CD1", "NE1", "CE2", "CD2"], ["CD2", "CE2", "CE3", "CZ2", "CZ3", "CH2"]], + "HIS": [["CG", "ND1", "CD2", "CE1", "NE2"]], + "DA": [["N9", "C8", "N7", "C5", "C4"], ["C4", "C5", "C6", "N1", "C2", "N3"]], + "DG": [["N9", "C8", "N7", "C5", "C4"], ["C4", "C5", "C6", "N1", "C2", "N3"]], + "DC": [["N1", "C2", "N3", "C4", "C5", "C6"]], + "DT": [["N1", "C2", "N3", "C4", "C5", "C6"]], + "A": [["N9", "C8", "N7", "C5", "C4"], ["C4", "C5", "C6", "N1", "C2", "N3"]], + "G": [["N9", "C8", "N7", "C5", "C4"], ["C4", "C5", "C6", "N1", "C2", "N3"]], + "C": [["N1", "C2", "N3", "C4", "C5", "C6"]], + "U": [["N1", "C2", "N3", "C4", "C5", "C6"]], +} + +_POSITIVE_CHARGED_AA: frozenset[tuple[str, str]] = frozenset( + { + ("ARG", "CZ"), + ("ARG", "NH1"), + ("ARG", "NH2"), + ("ARG", "NE"), + ("LYS", "NZ"), + ("HIS", "CE1"), + ("HIS", "ND1"), + ("HIS", "NE2"), + } +) + +_NEGATIVE_CHARGED_AA: frozenset[tuple[str, str]] = frozenset( + { + ("ASP", "CG"), + ("ASP", "OD1"), + ("ASP", "OD2"), + ("GLU", "CD"), + ("GLU", "OE1"), + ("GLU", "OE2"), + } +) + + +_SP2_ACCEPTORS: frozenset[tuple[str, str]] = frozenset( + { + ("*", "O"), + ("ASN", "OD1"), + ("GLN", "OE1"), + ("ASP", "OD1"), + ("ASP", "OD2"), + ("GLU", "OE1"), + ("GLU", "OE2"), + } +) + + +def _build_na_tables() -> tuple[frozenset[tuple[str, str]], frozenset[tuple[str, str]], frozenset[tuple[str, str]]]: + """Build donor/acceptor/negative-charge sets extended with DNA/RNA entries.""" + donor: set[tuple[str, str]] = set(_DONOR_ATOMS_AA) + acceptor: set[tuple[str, str]] = set(_ACCEPTOR_TABLE_AA) + negative: set[tuple[str, str]] = set(_NEGATIVE_CHARGED_AA) + base_acceptors = { + "A": ("N1", "N3", "N7"), + "G": ("N3", "N7", "O6"), + "C": ("N3", "O2"), + "T": ("O2", "O4"), + "U": ("O2", "O4"), + } + for na_res in (*STANDARD_DNA, *STANDARD_RNA): + acceptor.update((na_res, atom) for atom in base_acceptors[na_res.removeprefix("D")]) + for oa in ("OP1", "OP2", "O3'", "O5'", "O4'", "O2'"): + acceptor.add((na_res, oa)) + negative.add((na_res, "OP1")) + negative.add((na_res, "OP2")) + if na_res in ("DA", "A"): + donor.add((na_res, "N6")) + elif na_res in ("DG", "G"): + donor.add((na_res, "N1")) + donor.add((na_res, "N2")) + elif na_res in ("DC", "C"): + donor.add((na_res, "N4")) + elif na_res in ("U", "DT"): + donor.add((na_res, "N3")) + return frozenset(donor), frozenset(acceptor), frozenset(negative) + + +_DONOR_ATOMS: frozenset[tuple[str, str]] +_ACCEPTOR_TABLE: frozenset[tuple[str, str]] +_NEGATIVE_CHARGED: frozenset[tuple[str, str]] +_DONOR_ATOMS, _ACCEPTOR_TABLE, _NEGATIVE_CHARGED = _build_na_tables() + +_ALL_STANDARD_RESIDUES = frozenset(STANDARD_POLYMER_RESIDUES) + + +# --------------------------------------------------------------------------- +# AtomTyping dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class AtomTyping: + """Per-atom boolean masks and ring indices for each chemical role. + + All boolean arrays have shape ``(n_atoms,)`` matching the parent + ``AtomArray``. + + Attributes: + hba: H-bond acceptor mask. + hbd_heavy: H-bond donor heavy-atom mask. + hbd_h: H-bond donor hydrogen mask. + hydrophobic: Hydrophobic carbon mask (C with only C/H neighbors). + cation: Positively charged atom mask. + anion: Negatively charged atom mask. + elements: Element symbol array. + res_idx: Per-atom integer residue index (from ``get_residue_starts``). + ``res_idx[a] == res_idx[b]`` iff atoms a, b are in the same residue. + chain_idx: Per-atom integer chain index. ``chain_idx[a] == chain_idx[b]`` + iff atoms a, b are in the same chain (uses ``chain_iid`` when available). + aromatic_rings: List of index arrays, one per aromatic ring. + ring_res_idx: Integer residue index for each aromatic ring. + xb_donor: Halogen bond donor mask (Cl/Br/I bonded to C). + xb_acceptor: Halogen bond acceptor mask (O/N/S with C/P/N/S neighbor). + metal: Metal ion mask. + sp2_acceptor: sp2-hybridised acceptor mask (for rosetta torsion check). + adj: Bond adjacency dict ``{atom_idx: [neighbor_idxs]}``. + donor_h_map: ``{donor_heavy_idx: [h_idxs]}``. + acceptor_base: ``{acceptor_idx: base_heavy_neighbor_idx}``. + xb_acceptor_base: ``{acceptor_idx: Y_neighbor_idx}`` for halogen + acceptor angle check (Y-A...X). + """ + + hba: np.ndarray + hbd_heavy: np.ndarray + hbd_h: np.ndarray + hydrophobic: np.ndarray + cation: np.ndarray + anion: np.ndarray + elements: np.ndarray + res_idx: np.ndarray + chain_idx: np.ndarray + aromatic_rings: list[np.ndarray] + ring_res_idx: list[int] + xb_donor: np.ndarray + xb_acceptor: np.ndarray + metal: np.ndarray + sp2_acceptor: np.ndarray + adj: dict[int, list[int]] + donor_h_map: dict[int, list[int]] + acceptor_base: dict[int, int] + xb_acceptor_base: dict[int, int] + + +# --------------------------------------------------------------------------- +# Atom-typing implementation +# --------------------------------------------------------------------------- + + +def _build_adjacency(bond_arr: np.ndarray) -> dict[int, list[int]]: + """Build adjacency dict from a bond array for O(1) neighbor lookups. + + Args: + bond_arr: ``(n_bonds, 3)`` array with columns ``[idx_a, idx_b, type]``. + + Returns: + Dict mapping each atom index to a list of bonded neighbor indices. + """ + adj: dict[int, list[int]] = defaultdict(list) + for row in bond_arr: + a, b = int(row[0]), int(row[1]) + adj[a].append(b) + adj[b].append(a) + return adj + + +def _detect_ligand_rings( + atom_array: AtomArray, + elements: np.ndarray, + res_starts: np.ndarray, + aromatic_rings: list[np.ndarray], + ring_res_idx: list[int], +) -> None: + """Detect aromatic rings in non-standard residues via RDKit SSSR. + + For each non-standard residue, converts to an RDKit Mol (preserving + CCD bond orders) and uses RDKit's ring perception + aromaticity + detection. Falls back to a planarity check for rings that RDKit + does not flag as aromatic. Appends detected rings in-place. + + Args: + atom_array: Full structure. + elements: Element symbols. + res_starts: Residue start indices (from ``get_residue_starts``). + aromatic_rings: List to append ring index arrays to. + ring_res_idx: List to append residue indices to. + """ + for r in range(len(res_starts) - 1): + s, e = res_starts[r], res_starts[r + 1] + rn = atom_array.res_name[s] + if rn in _ALL_STANDARD_RESIDUES: + continue + + local_el = elements[s:e] + valid = ~np.isin(local_el, [*METAL_ELEMENTS, "H"]) + keep = np.arange(s, e)[valid] + if len(keep) < 5: + continue + + sub_array = atom_array[keep] + if sub_array.bonds is None: + raise ValueError( + f"No bonds on sub-array for residue {rn} (atoms {s}:{e}). " + "Bonds must be assigned before interaction detection (e.g. via connect_via_residue_names)." + ) + + mol = atom_array_to_rdkit( + sub_array, + set_coord=True, + hydrogen_policy="keep", + sanitize=True, + attempt_fixing_corrupted_molecules=False, + ) + if mol is None: + continue + + ri = mol.GetRingInfo() + atom_rings = list(ri.AtomRings()) + + for ring_atom_local in atom_rings: + if not (4 < len(ring_atom_local) <= 6): + continue + + is_aromatic = all(mol.GetAtomWithIdx(a).GetIsAromatic() for a in ring_atom_local) + if not is_aromatic: + ring_coords = sub_array.coord[list(ring_atom_local)] + is_aromatic = is_planar(ring_coords) + + if is_aromatic: + global_indices = np.array([keep[j] for j in ring_atom_local]) + aromatic_rings.append(global_indices) + ring_res_idx.append(r) + + +def _type_atoms_lookup(atom_array: AtomArray) -> AtomTyping: + """Assign chemical roles using residue lookup tables and formal charges. + + Standard residues (amino acids, DNA, RNA) use ``_DONOR_ATOMS`` / + ``_ACCEPTOR_TABLE`` to identify donor and acceptor heavy atoms. + Donor hydrogen atoms are always found via the bond graph (bonded H + neighbors). This works regardless of H naming convention. + + Non-standard residues (ligands) fall back to element-based heuristics + matching PLIP: + + - **Hydrophobic**: C with only C/H neighbors. + - **H-bond acceptor**: N, O, S. + - **H-bond donor**: N or O bonded to at least one H. + - **Halogen bond acceptor**: O/N/S with a C/P/N/S neighbor. + + Nonzero values of the optional ``charge`` annotation are respected if given. + When the charge is zero on a standard residue atom that is known to be charged + (e.g. LYS CZ), the known charge will applied. + + Args: + atom_array: Structure with optional ``bonds`` attribute. + + Returns: + Populated ``AtomTyping`` dataclass. + """ + n = len(atom_array) + res_names = atom_array.res_name + atom_names = np.char.strip(atom_array.atom_name.astype(str)) + elements = np.char.upper(atom_array.element) + + hba = np.zeros(n, dtype=bool) + hbd_heavy = np.zeros(n, dtype=bool) + hbd_h = np.zeros(n, dtype=bool) + hydrophobic = np.zeros(n, dtype=bool) + cation = np.zeros(n, dtype=bool) + anion = np.zeros(n, dtype=bool) + sp2_acceptor = np.zeros(n, dtype=bool) + metal = np.zeros(n, dtype=bool) + xb_donor = np.zeros(n, dtype=bool) + xb_acceptor = np.zeros(n, dtype=bool) + + donor_h_map: dict[int, list[int]] = {} + acceptor_base: dict[int, int] = {} + xb_acceptor_base: dict[int, int] = {} + aromatic_rings: list[np.ndarray] = [] + + if atom_array.bonds is None: + raise ValueError("Bonds are required for interaction type detection. ") + + bond_arr = atom_array.bonds.as_array() + adj = _build_adjacency(bond_arr) + + res_starts = struc.get_residue_starts(atom_array, add_exclusive_stop=True) + res_lengths = np.diff(res_starts) + res_idx = np.repeat(np.arange(len(res_lengths), dtype=np.int32), res_lengths) + + _chain_arr = chain_identifier(atom_array) + _, chain_idx = np.unique(_chain_arr, return_inverse=True) + + # --- Main typing loop (standard residues) --- + for i in range(n): + rn, an, el = res_names[i], atom_names[i], elements[i] + + if el in METAL_ELEMENTS: + metal[i] = True + continue + + if el in ("CL", "BR", "I"): + xb_donor[i] = True + + # Halogen bond acceptor: O/N/S with a C/P/N/S neighbor (PLIP definition) + if el in ("O", "N", "S"): + for partner in adj.get(i, []): + if elements[partner] in ("C", "P", "N", "S"): + xb_acceptor[i] = True + xb_acceptor_base[i] = partner + break + + # Hydrophobic: C with only C/H neighbors (PLIP: same rule for all residues) + if ( + el == "C" + and (rn, an) not in _POSITIVE_CHARGED_AA + and (rn, an) not in _NEGATIVE_CHARGED_AA + and all(elements[p] in ("C", "H") for p in adj.get(i, [])) + ): + hydrophobic[i] = True + + if (rn, an) in _POSITIVE_CHARGED_AA: + cation[i] = True + if (rn, an) in _NEGATIVE_CHARGED_AA: + anion[i] = True + + is_acc = (rn, an) in _ACCEPTOR_TABLE or ("*", an) in _ACCEPTOR_TABLE + if is_acc: + hba[i] = True + if (rn, an) in _SP2_ACCEPTORS or ("*", an) in _SP2_ACCEPTORS: + sp2_acceptor[i] = True + + # Donor heavy atoms from lookup table, bonded H found via adjacency + is_donor = (rn, an) in _DONOR_ATOMS or ("*", an) in _DONOR_ATOMS + if is_donor: + hbd_heavy[i] = True + h_indices = [p for p in adj.get(i, []) if elements[p] == "H"] if adj else [] + for h_idx in h_indices: + hbd_h[h_idx] = True + if h_indices: + donor_h_map[i] = h_indices + + # --- Acceptor base atoms (heavy neighbor for BAH angle) --- + for i in np.where(hba)[0]: + for partner in adj.get(int(i), []): + if elements[partner] != "H": + acceptor_base[int(i)] = partner + break + + # --- Aromatic rings from lookup tables (standard residues) --- + ring_res_idx: list[int] = [] + for r in range(len(res_starts) - 1): + s, e = res_starts[r], res_starts[r + 1] + rn = res_names[s] + if rn not in _AROMATIC_RINGS: + continue + local_names = atom_names[s:e] + for ring_atom_names in _AROMATIC_RINGS[rn]: + indices = [] + for ra in ring_atom_names: + matches = np.where(local_names == ra)[0] + if len(matches) > 0: + indices.append(s + int(matches[0])) + if len(indices) >= 3: + aromatic_rings.append(np.array(indices)) + ring_res_idx.append(r) + + # --- Aromatic rings for non-standard residues (RDKit SSSR) --- + _detect_ligand_rings( + atom_array, + elements, + res_starts, + aromatic_rings, + ring_res_idx, + ) + + # --- Ligand heuristics (non-standard residues) --- + for i in range(n): + rn, el = res_names[i], elements[i] + if rn in _ALL_STANDARD_RESIDUES or metal[i]: + continue + if el in ("N", "O", "S") and not hba[i]: + hba[i] = True + if el in ("N", "O") and not hbd_heavy[i] and adj: + for partner in adj.get(i, []): + if elements[partner] == "H": + hbd_heavy[i] = True + hbd_h[partner] = True + donor_h_map.setdefault(i, []).append(partner) + if el == "C" and not hydrophobic[i] and adj and all(elements[p] in ("C", "H") for p in adj.get(i, [])): + hydrophobic[i] = True + + if "charge" in atom_array.get_annotation_categories(): + charge = atom_array.charge + charged = (charge > 0) | (charge < 0) + cation[charged] = charge[charged] > 0 + anion[charged] = charge[charged] < 0 + hydrophobic[charged] = False + + # --- Exclude atoms with NaN coordinates (cKDTree crashes on NaN) --- + has_nan = np.isnan(atom_array.coord).any(axis=-1) + if has_nan.any(): + valid = ~has_nan + hba &= valid + hbd_heavy &= valid + hbd_h &= valid + hydrophobic &= valid + cation &= valid + anion &= valid + xb_donor &= valid + xb_acceptor &= valid + metal &= valid + sp2_acceptor &= valid + donor_h_map = {k: [h for h in v if valid[h]] for k, v in donor_h_map.items() if valid[k]} + donor_h_map = {k: v for k, v in donor_h_map.items() if v} + acceptor_base = {k: v for k, v in acceptor_base.items() if valid[k] and valid[v]} + xb_acceptor_base = {k: v for k, v in xb_acceptor_base.items() if valid[k] and valid[v]} + valid_ring_mask = [valid[r].all() for r in aromatic_rings] + aromatic_rings = [r for r, v in zip(aromatic_rings, valid_ring_mask, strict=False) if v] + ring_res_idx = [ri for ri, v in zip(ring_res_idx, valid_ring_mask, strict=False) if v] + + return AtomTyping( + hba=hba, + hbd_heavy=hbd_heavy, + hbd_h=hbd_h, + hydrophobic=hydrophobic, + cation=cation, + anion=anion, + elements=elements, + res_idx=res_idx, + chain_idx=chain_idx, + aromatic_rings=aromatic_rings, + ring_res_idx=ring_res_idx, + xb_donor=xb_donor, + xb_acceptor=xb_acceptor, + metal=metal, + sp2_acceptor=sp2_acceptor, + adj=adj, + donor_h_map=donor_h_map, + acceptor_base=acceptor_base, + xb_acceptor_base=xb_acceptor_base, + ) + + +# --------------------------------------------------------------------------- +# H-bond geometry kernels +# --------------------------------------------------------------------------- + + +def _find_hbonds_rosetta( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + dist_ha_max: float = 3.0, + ahd_angle_min: float = 120.0, + bah_angle_min: float = 90.0, + sp2_torsion_max: float = 60.0, +) -> list[tuple[int, int]]: + """Rosetta-style H-bond detection (O'Meara et al. 2015). + + Checks H...A distance, A-H-D angle, B-A-H angle, and for sp2 acceptors + the B-A-H-chi torsion ensuring approach in the lone-pair plane. Requires + explicit H atoms. + + Args: + coords: Coordinate array ``(n_atoms, 3)``. + typing: Pre-computed atom typing (includes ``chain_idx``). + inter_chain_only: Only detect inter-chain interactions. + dist_ha_max: Max H...A distance (A). + ahd_angle_min: Min A-H-D angle (deg). + bah_angle_min: Min B-A-H angle (deg). + sp2_torsion_max: Max chi torsion for sp2 acceptors (deg). + + Returns: + List of ``(donor_heavy_idx, acceptor_idx)`` pairs. + """ + donor_heavy_idx = np.where(typing.hbd_heavy)[0] + acceptor_idx = np.where(typing.hba)[0] + if len(donor_heavy_idx) == 0 or len(acceptor_idx) == 0: + return [] + + results: list[tuple[int, int]] = [] + acc_tree = cKDTree(coords[acceptor_idx]) + + for d_heavy in donor_heavy_idx: + for h_idx in typing.donor_h_map.get(d_heavy, []): + h_pos = coords[h_idx] + for j in acc_tree.query_ball_point(h_pos, dist_ha_max): + a_idx = acceptor_idx[j] + if a_idx in (d_heavy, h_idx): + continue + if inter_chain_only and typing.chain_idx[d_heavy] == typing.chain_idx[a_idx]: + continue + + a_pos, d_pos = coords[a_idx], coords[d_heavy] + if angle_between_vectors(a_pos - h_pos, d_pos - h_pos) < ahd_angle_min: + continue + + base_idx = typing.acceptor_base.get(a_idx) + if base_idx is not None: + b_pos = coords[base_idx] + if angle_between_vectors(b_pos - a_pos, h_pos - a_pos) < bah_angle_min: + continue + if typing.sp2_acceptor[a_idx]: + torsion = dihedral_angle(b_pos, a_pos, h_pos, d_pos) + if min(torsion, abs(180 - torsion)) > sp2_torsion_max: + continue + + results.append((d_heavy, a_idx)) + + return results + + +def _find_hbonds_plip( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + dist_da_max: float = 4.1, + dist_min: float = 0.5, + dha_angle_min: float = 100.0, +) -> list[tuple[int, int]]: + """PLIP-style H-bond detection (Hubbard & Haider 2001). + + Checks D...A distance and D-H-A angle. Thresholds match PLIP v3.0.0 + defaults: ``HBOND_DIST_MAX = 4.1``, ``HBOND_DON_ANGLE_MIN = 100``, + ``MIN_DIST = 0.5``. Requires explicit H atoms. + + Args: + coords: Coordinate array ``(n_atoms, 3)``. + typing: Pre-computed atom typing (includes ``chain_idx``). + inter_chain_only: Only detect inter-chain interactions. + dist_da_max: Max D-A distance (A). PLIP default: 4.1. + dist_min: Min D-A distance (A). PLIP default: 0.5. + dha_angle_min: Min D-H-A angle (deg). PLIP default: 100. + + Returns: + List of ``(donor_heavy_idx, acceptor_idx)`` pairs. + """ + donor_heavy_idx = np.where(typing.hbd_heavy)[0] + acceptor_idx = np.where(typing.hba)[0] + if len(donor_heavy_idx) == 0 or len(acceptor_idx) == 0: + return [] + + results: list[tuple[int, int]] = [] + acc_tree = cKDTree(coords[acceptor_idx]) + + for d_heavy in donor_heavy_idx: + h_list = typing.donor_h_map.get(d_heavy, []) + for j in acc_tree.query_ball_point(coords[d_heavy], dist_da_max): + a_idx = acceptor_idx[j] + if a_idx == d_heavy: + continue + da_dist = float(np.linalg.norm(coords[d_heavy] - coords[a_idx])) + if da_dist < dist_min: + continue + if inter_chain_only and typing.chain_idx[d_heavy] == typing.chain_idx[a_idx]: + continue + if h_list and any( + angle_between_vectors(coords[d_heavy] - coords[h], coords[a_idx] - coords[h]) >= dha_angle_min + for h in h_list + ): + results.append((d_heavy, a_idx)) + + return results + + +# --------------------------------------------------------------------------- +# H-bond refinement (PLIP post-processing) +# --------------------------------------------------------------------------- + + +def _refine_hbonds( + hbonds: list[tuple[int, int]], + saltbridges: list[tuple[int, int]], + coords: np.ndarray, + typing: AtomTyping, +) -> list[tuple[int, int]]: + """Apply PLIP-style H-bond refinement. + + 1. Remove H-bonds between atoms already connected by a salt bridge. + 2. One-H-one-hbond rule: per donor H atom, keep only the H-bond with the + DHA angle closest to 180 deg. Donor heavy atoms that carry multiple H + (e.g. ASN ND2 with HD21 + HD22) may therefore contribute more than one + H-bond — one per H atom — matching PLIP's per-hydrogen semantics. + + Args: + hbonds: Raw H-bond pairs ``(donor_heavy, acceptor)``. + saltbridges: Salt bridge pairs ``(cation, anion)``. + coords: Coordinate array. + typing: Atom typing (for donor H positions). + + Returns: + Refined list of ``(donor_heavy, acceptor)`` pairs. + """ + if not hbonds: + return hbonds + + # Step 1: remove H-bonds overlapping with salt bridges + sb_atoms = set() + for cat, ani in saltbridges: + sb_atoms.add(cat) + sb_atoms.add(ani) + + filtered = [(d, a) for d, a in hbonds if not (d in sb_atoms and a in sb_atoms)] + + # Step 2: one H atom -> one H-bond (keep best DHA angle per H). + # Key is (donor_heavy, h_idx) so bidentate NH2 donors (ASN ND2, GLN NE2, + # nucleic acid NH2 groups) can each H donate independently. + best_per_h: dict[tuple[int, int], tuple[float, int, int]] = {} + for d, a in filtered: + h_list = typing.donor_h_map.get(d, []) + if h_list: + for h in h_list: + angle = angle_between_vectors(coords[d] - coords[h], coords[a] - coords[h]) + key = (d, h) + if key not in best_per_h or angle > best_per_h[key][0]: + best_per_h[key] = (angle, d, a) + else: + # No explicit H recorded; fall back to keying on donor heavy atom + key = (d, -1) + if key not in best_per_h or best_per_h[key][0] < 180.0: + best_per_h[key] = (180.0, d, a) + + return [(d, a) for _, d, a in best_per_h.values()] + + +# --------------------------------------------------------------------------- +# Other interaction geometry kernels +# --------------------------------------------------------------------------- + + +def _find_hydrophobic( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + cutoff: float = 4.0, +) -> list[tuple[int, int]]: + """Find hydrophobic contacts (PLIP: ``HYDROPH_DIST_MAX = 4.0``). + + Hydrophobic atoms are carbons with only carbon or hydrogen neighbors. + Same-chain same-residue pairs are always excluded. + + Args: + coords: Coordinate array. + typing: Pre-computed atom typing. + inter_chain_only: Only detect inter-chain interactions. + cutoff: Distance cutoff (A). PLIP default: 4.0. + + Returns: + List of ``(atom_i, atom_j)`` pairs. + """ + hp_idx = np.where(typing.hydrophobic)[0] + if len(hp_idx) < 2: + return [] + tree = cKDTree(coords[hp_idx]) + results: list[tuple[int, int]] = [] + for i, j in tree.query_pairs(cutoff): + ai, aj = hp_idx[i], hp_idx[j] + if inter_chain_only: + if typing.chain_idx[ai] == typing.chain_idx[aj]: + continue + elif typing.res_idx[ai] == typing.res_idx[aj]: + continue + results.append((ai, aj)) + return results + + +def _refine_hydrophobic( + hydrophobic: list[tuple[int, int]], + pistacking_atoms: set[int], + typing: AtomTyping, + coords: np.ndarray, +) -> list[tuple[int, int]]: + """Apply PLIP-style hydrophobic contact reduction. + + 1. Remove pairs where both atoms participate in pi-stacking. + 2. Per-residue dedup: keep only the closest contact per residue pair. + + Args: + hydrophobic: Raw hydrophobic pairs. + pistacking_atoms: Set of atom indices involved in pi-stacking. + typing: Pre-computed atom typing (for ``res_idx``). + coords: Coordinate array. + + Returns: + Reduced list of hydrophobic pairs. + """ + if not hydrophobic: + return hydrophobic + + # Step 1: exclude pairs overlapping with pi-stacking rings + filtered = [(a, b) for a, b in hydrophobic if not (a in pistacking_atoms and b in pistacking_atoms)] + + # Step 2: per-residue-pair, keep only closest + best: dict[tuple[int, int], tuple[float, int, int]] = {} + for a, b in filtered: + ra, rb = int(typing.res_idx[a]), int(typing.res_idx[b]) + key = (min(ra, rb), max(ra, rb)) + dist = float(np.linalg.norm(coords[a] - coords[b])) + if key not in best or dist < best[key][0]: + best[key] = (dist, a, b) + + return [(a, b) for _, a, b in best.values()] + + +def _find_pistacking( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + dist_max: float = 5.5, + ang_dev: float = 30.0, + offset_max: float = 2.0, +) -> list[tuple[int, int, int]]: + """Find pi-stacking interactions (PLIP-aligned). + + PLIP thresholds: ``PISTACK_DIST_MAX = 5.5``, ``PISTACK_ANG_DEV = 30``, + ``PISTACK_OFFSET_MAX = 2.0``. P-stacking (parallel): inter-normal angle + 0-30 deg. T-stacking (perpendicular): 60-120 deg. The ring-center + offset check projects each center onto the opposite ring plane. + + Args: + coords: Coordinate array. + typing: Pre-computed atom typing. + inter_chain_only: Only detect inter-chain interactions. + dist_max: Max center-center distance. PLIP default: 5.5. + ang_dev: Max angular deviation from ideal (0 or 90 deg). + offset_max: Max ring-center offset. PLIP default: 2.0. + + Returns: + List of ``(atom_a, atom_b, InteractionType)`` tuples, one per pair of + atoms across the two interacting rings (full cross-product). + """ + rings = typing.aromatic_rings + if len(rings) < 2: + return [] + + centroids = np.array([coords[r].mean(axis=0) for r in rings]) + normals = [plane_normal(coords[r[:3]]) for r in rings] + tree = cKDTree(centroids) + results: list[tuple[int, int, int]] = [] + + for i, j in tree.query_pairs(dist_max): + ri_a, ri_b = typing.ring_res_idx[i], typing.ring_res_idx[j] + if ri_a == ri_b: + continue + if inter_chain_only and typing.chain_idx[rings[i][0]] == typing.chain_idx[rings[j][0]]: + continue + + normal_angle = angle_between_vectors(normals[i], normals[j]) + normal_angle = min(normal_angle, 180.0 - normal_angle) + + # Offset check (PLIP: project each center onto the other ring plane) + offset = min( + in_plane_offset(normals[j], centroids[j], centroids[i]), + in_plane_offset(normals[i], centroids[i], centroids[j]), + ) + if offset > offset_max: + continue + + if normal_angle <= ang_dev: + itype: InteractionType | None = InteractionType.PISTACKING_P + elif (90.0 - ang_dev) <= normal_angle <= (90.0 + ang_dev): + itype = InteractionType.PISTACKING_T + else: + itype = None + + if itype is not None: + for ai in rings[i]: + for aj in rings[j]: + results.append((int(ai), int(aj), itype)) + + return results + + +def _find_pication( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + dist_cutoff: float = 6.0, + offset_max: float = 2.0, +) -> list[tuple[int, int]]: + """Find pi-cation interactions (PLIP-aligned). + + PLIP thresholds: ``PICATION_DIST_MAX = 6.0``, + ``PISTACK_OFFSET_MAX = 2.0``. + + Args: + coords: Coordinate array. + typing: Pre-computed atom typing. + inter_chain_only: Only detect inter-chain interactions. + dist_cutoff: Max cation-ring-center distance. PLIP default: 6.0. + offset_max: Max offset of cation projection onto ring plane. + + Returns: + List of ``(ring_atom, cation_atom)`` pairs, one per ring atom. + """ + cat_idx = np.where(typing.cation)[0] + rings = typing.aromatic_rings + if len(cat_idx) == 0 or len(rings) == 0: + return [] + + results: list[tuple[int, int]] = [] + centroids = [coords[r].mean(axis=0) for r in rings] + normals = [plane_normal(coords[r[:3]]) for r in rings] + + for ci in cat_idx: + for ri, ring in enumerate(rings): + if ci in ring: + continue + if inter_chain_only and typing.chain_idx[ci] == typing.chain_idx[ring[0]]: + continue + dist = float(np.linalg.norm(coords[ci] - centroids[ri])) + if dist > dist_cutoff: + continue + offset = in_plane_offset(normals[ri], centroids[ri], coords[ci]) + if offset > offset_max: + continue + for ra in ring: + results.append((int(ra), int(ci))) + + return results + + +def _find_saltbridges( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + cutoff: float = 5.5, +) -> list[tuple[int, int]]: + """Find salt bridges (PLIP: ``SALTBRIDGE_DIST_MAX = 5.5``). + + Args: + coords: Coordinate array. + typing: Pre-computed atom typing. + inter_chain_only: Only detect inter-chain interactions. + cutoff: Max distance between opposite charges. PLIP default: 5.5. + + Returns: + List of ``(cation_atom, anion_atom)`` pairs. + """ + cat_idx = np.where(typing.cation)[0] + ani_idx = np.where(typing.anion)[0] + if len(cat_idx) == 0 or len(ani_idx) == 0: + return [] + + results: list[tuple[int, int]] = [] + tree = cKDTree(coords[ani_idx]) + for ci in cat_idx: + for j in tree.query_ball_point(coords[ci], cutoff): + ai = ani_idx[j] + if inter_chain_only: + if typing.chain_idx[ci] == typing.chain_idx[ai]: + continue + elif typing.res_idx[ci] == typing.res_idx[ai]: + continue + results.append((ci, ai)) + return results + + +def _find_halogen( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + dist_max: float = 4.0, + don_angle_center: float = 165.0, + don_angle_dev: float = 30.0, + acc_angle_center: float = 120.0, + acc_angle_dev: float = 30.0, +) -> list[tuple[int, int]]: + """Find halogen bonds (PLIP-aligned). + + PLIP checks both donor angle (C-X...A at 165 +/- 30 deg) and acceptor + angle (Y-A...X at 120 +/- 30 deg). + + Args: + coords: Coordinate array. + typing: Pre-computed atom typing. + inter_chain_only: Only detect inter-chain interactions. + dist_max: Max X...A distance. PLIP default: 4.0. + don_angle_center: Ideal C-X...A angle. PLIP: 165. + don_angle_dev: Allowed deviation from ideal donor angle. PLIP: 30. + acc_angle_center: Ideal Y-A...X angle. PLIP: 120. + acc_angle_dev: Allowed deviation from ideal acceptor angle. PLIP: 30. + + Returns: + List of ``(halogen_idx, acceptor_idx)`` pairs. + """ + xb_d = np.where(typing.xb_donor)[0] + xb_a = np.where(typing.xb_acceptor)[0] + if len(xb_d) == 0 or len(xb_a) == 0: + return [] + + x_to_c: dict[int, int] = {} + for xi in xb_d: + for partner in typing.adj.get(int(xi), []): + if typing.elements[partner] == "C": + x_to_c[int(xi)] = partner + break + + results: list[tuple[int, int]] = [] + tree = cKDTree(coords[xb_a]) + for xi in xb_d: + c_idx = x_to_c.get(int(xi)) + if c_idx is None: + continue # free halide ion + for j in tree.query_ball_point(coords[xi], dist_max): + ai = xb_a[j] + if ai == xi: + continue + if inter_chain_only and typing.chain_idx[xi] == typing.chain_idx[ai]: + continue + # Donor angle: C-X...A + don_angle = angle_between_vectors(coords[c_idx] - coords[xi], coords[ai] - coords[xi]) + if abs(don_angle - don_angle_center) > don_angle_dev: + continue + # Acceptor angle: Y-A...X (Y is the heavy neighbor of A) + y_idx = typing.xb_acceptor_base.get(int(ai)) + if y_idx is not None: + acc_angle = angle_between_vectors(coords[y_idx] - coords[ai], coords[xi] - coords[ai]) + if abs(acc_angle - acc_angle_center) > acc_angle_dev: + continue + results.append((int(xi), int(ai))) + + return results + + +def _find_metal_coordination( + coords: np.ndarray, + typing: AtomTyping, + inter_chain_only: bool = True, + cutoff: float = 3.0, +) -> list[tuple[int, int]]: + """Find metal coordination (PLIP: ``METAL_DIST_MAX = 3.0``). + + Coordinating atoms are N, O, S per PLIP 3.0 and Harding (2001). + Phosphorus is excluded: phosphate groups coordinate metals through + their oxygen atoms (OP1/OP2), not through P directly. + + Args: + coords: Coordinate array. + typing: Pre-computed atom typing. + inter_chain_only: Only detect inter-chain interactions. + cutoff: Max metal-target distance. PLIP default: 3.0. + + Returns: + List of ``(metal_idx, coordinating_idx)`` pairs. + """ + metal_idx = np.where(typing.metal)[0] + if len(metal_idx) == 0: + return [] + + has_valid_coords = ~np.isnan(coords).any(axis=-1) + coord_mask = np.isin(typing.elements, ["N", "O", "S"]) & ~typing.metal & has_valid_coords + coord_idx = np.where(coord_mask)[0] + if len(coord_idx) == 0: + return [] + + results: list[tuple[int, int]] = [] + tree = cKDTree(coords[coord_idx]) + for mi in metal_idx: + for j in tree.query_ball_point(coords[mi], cutoff): + ci = coord_idx[j] + if inter_chain_only and typing.chain_idx[mi] == typing.chain_idx[ci]: + continue + results.append((int(mi), int(ci))) + + return results + + +# --------------------------------------------------------------------------- +# 1D role derivation from 2D pairs +# --------------------------------------------------------------------------- + + +def _derive_1d_roles( + atom_array: AtomArray, + results: dict[str, list[tuple[int, int]]], +) -> dict[str, np.ndarray]: + """Derive per-atom 1D role annotations from detected 2D pairs. + + Args: + atom_array: Structure (used only for length). + results: Interaction results dict from ``annotate_interactions``. + + Returns: + Dict mapping annotation names to arrays of length ``len(atom_array)``. + """ + n = len(atom_array) + + hbond_role = np.full(n, HBondRole.NONE, dtype=np.int32) + for d, a in results.get("hbond", []): + hbond_role[d] |= HBondRole.DONOR + hbond_role[a] |= HBondRole.ACCEPTOR + + is_hydrophobic = np.zeros(n, dtype=bool) + for ai, aj in results.get("hydrophobic", []): + is_hydrophobic[ai] = True + is_hydrophobic[aj] = True + + is_aromatic = np.zeros(n, dtype=bool) + for ai, aj in results.get("pistacking", []): + is_aromatic[ai] = True + is_aromatic[aj] = True + for ai, _aj in results.get("pication", []): + is_aromatic[ai] = True + + is_pistacking = np.zeros(n, dtype=bool) + for ai, aj in results.get("pistacking", []): + is_pistacking[ai] = True + is_pistacking[aj] = True + + pication_role = np.full(n, PiCationRole.NONE, dtype=np.int32) + for ai, ci in results.get("pication", []): + pication_role[ai] |= PiCationRole.AROMATIC + pication_role[ci] |= PiCationRole.CATION + + charged_role = np.full(n, ChargedRole.NONE, dtype=np.int32) + for ci, ai in results.get("saltbridge", []): + charged_role[ci] = ChargedRole.POSITIVE + charged_role[ai] = ChargedRole.NEGATIVE + + metal_role = np.full(n, MetalRole.NONE, dtype=np.int32) + for mi, ci in results.get("metal", []): + metal_role[mi] = MetalRole.METAL + metal_role[ci] = MetalRole.COORDINATING + + is_halogen = np.zeros(n, dtype=bool) + for xi, ai in results.get("halogen", []): + is_halogen[xi] = True + is_halogen[ai] = True + + return { + "interaction_hbond_role": hbond_role, + "interaction_hydrophobic": is_hydrophobic, + "interaction_aromatic": is_aromatic, + "interaction_pistacking": is_pistacking, + "interaction_pication_role": pication_role, + "interaction_charged_role": charged_role, + "interaction_metal_role": metal_role, + "interaction_halogen": is_halogen, + } + + +# --------------------------------------------------------------------------- +# Top-level annotation function +# --------------------------------------------------------------------------- + + +def annotate_interactions( + atom_array: AtomArray, + interaction_types: tuple[str, ...] = ( + "hbond", + "hydrophobic", + "pistacking", + "pication", + "saltbridge", + "halogen", + "metal", + ), + hbond_model: Literal["rosetta", "plip"] = "rosetta", + inter_chain_only: bool = True, +) -> dict[str, Any]: + """Detect non-covalent interactions in a structure. + + For accurate H-bond detection the input should contain explicit + hydrogen atoms. Add them with :func:`~atomworks.experimental.protonation.assign_hydrogens` + and :func:`~atomworks.experimental.protonation.add_hydrogens`, or call through :class:`AnnotateInteractions` + which handles the H lifecycle automatically. + + All default thresholds match PLIP v3.0.0. + + Salt bridges use amino-acid charge-group lookup supplemented by the optional + formal ``charge`` annotation. Nonzero charges take precedence over lookup. + + Args: + atom_array: Structure with bonds (via ``connect_via_residue_names``). + Should contain explicit H for accurate H-bond detection. + Chain disambiguation uses ``chain_iid`` when present, falling + back to ``chain_id``. + interaction_types: Which interaction types to detect. + hbond_model: ``"rosetta"`` (O'Meara 2015) or ``"plip"`` (PLIP defaults). + inter_chain_only: If ``True``, only detect inter-chain interactions. + + Returns: + Dict mapping interaction name to list of ``(idx_a, idx_b)`` pairs. + Indices refer to *atom_array*. A special ``"_pistacking_types"`` + key holds the ``InteractionType`` code for each pi-stack pair. + """ + typing = _type_atoms_lookup(atom_array) + coords = atom_array.coord + results: dict[str, list[tuple[int, int]]] = {} + results["_aromatic_rings"] = typing.aromatic_rings + + # Salt bridges first (needed for H-bond refinement) + if "saltbridge" in interaction_types or "hbond" in interaction_types: + saltbridges = _find_saltbridges(coords, typing, inter_chain_only) + if "saltbridge" in interaction_types: + results["saltbridge"] = saltbridges + else: + saltbridges = [] + + if "hbond" in interaction_types: + fn = _find_hbonds_rosetta if hbond_model == "rosetta" else _find_hbonds_plip + raw_hbonds = fn(coords, typing, inter_chain_only) + results["hbond"] = _refine_hbonds(raw_hbonds, saltbridges, coords, typing) + + if "pistacking" in interaction_types: + raw = _find_pistacking(coords, typing, inter_chain_only) + results["pistacking"] = [(a, b) for a, b, _ in raw] + results["_pistacking_types"] = [t for _, _, t in raw] + + if "hydrophobic" in interaction_types: + raw_hp = _find_hydrophobic(coords, typing, inter_chain_only) + pistack_atoms: set[int] = set() + for a, b in results.get("pistacking", []): + pistack_atoms.add(a) + pistack_atoms.add(b) + results["hydrophobic"] = _refine_hydrophobic(raw_hp, pistack_atoms, typing, coords) + + if "pication" in interaction_types: + results["pication"] = _find_pication(coords, typing, inter_chain_only) + + if "halogen" in interaction_types: + results["halogen"] = _find_halogen(coords, typing, inter_chain_only) + + if "metal" in interaction_types: + results["metal"] = _find_metal_coordination(coords, typing, inter_chain_only) + + return results + + +# --------------------------------------------------------------------------- +# Transform class +# --------------------------------------------------------------------------- + + +class AnnotateInteractions(Transform): + """Annotate non-covalent interactions on ``AtomArrayPlus``. + + Adds hydrogens via RDKit with pH-aware protonation (Dimorphite-DL) if + missing, detects interactions on the H-augmented array, writes 2D + 1D + annotations, then strips temporary H. ``AtomArrayPlus`` boolean-mask + slicing auto-remaps 2D pair indices. + + 2D annotations (``AnnotationList2D``): + ``interaction_hbond``, ``interaction_hydrophobic``, + ``interaction_pistacking``, ``interaction_pication``, + ``interaction_saltbridge``, ``interaction_halogen``, + ``interaction_metal``. + + 1D annotations (per-atom arrays): + ``interaction_hbond_role``, ``interaction_hydrophobic``, + ``interaction_aromatic``, ``interaction_pistacking``, + ``interaction_pication_role``, ``interaction_charged_role``, + ``interaction_metal_role``, ``interaction_halogen``. + + Args: + interaction_types: Which interaction types to detect. + hbond_model: ``"rosetta"`` or ``"plip"``. + inter_chain_only: Only detect inter-chain interactions. + """ + + def __init__( + self, + interaction_types: tuple[str, ...] = ( + "hbond", + "hydrophobic", + "pistacking", + "pication", + "saltbridge", + "halogen", + "metal", + ), + hbond_model: Literal["rosetta", "plip"] = "rosetta", + inter_chain_only: bool = True, + silence_rdkit_warnings: bool = False, + ) -> None: + self.interaction_types = interaction_types + self.hbond_model = hbond_model + self.inter_chain_only = inter_chain_only + self.silence_rdkit_warnings = silence_rdkit_warnings + + def check_input(self, data: dict[str, Any]) -> None: + check_contains_keys(data, ["atom_array"]) + check_is_instance(data, "atom_array", AtomArray) + check_atom_array_has_bonds(data) + + def forward(self, data: dict[str, Any]) -> dict[str, Any]: + aap = as_atom_array_plus(data["atom_array"]) + + ctx = suppress_rdkit_warnings() if self.silence_rdkit_warnings else contextlib.nullcontext() + with ctx: + # Work on a copy: add H, detect interactions, then transfer annotations + # back to the original array. This ensures no original annotations are + # lost during hydrogen addition / concatenation. + aap_h = as_atom_array_plus(add_hydrogens(aap)) + added_h = len(aap_h) > len(aap) + + results = annotate_interactions( + aap_h, + interaction_types=self.interaction_types, + hbond_model=self.hbond_model, + inter_chain_only=self.inter_chain_only, + ) + + pistack_types = results.pop("_pistacking_types", []) + # Drop the aromatic-ring index lists so the 2D-annotation loop below only + # iterates real interaction types; the values themselves are not used here. + results.pop("_aromatic_rings", None) + + # Write 2D + 1D interaction annotations onto the H-added copy + for int_type, pairs_list in results.items(): + annot_name = _ANNOTATION_NAMES_2D[int_type] + if pairs_list: + pairs = np.array(pairs_list, dtype=np.int32) + if int_type == "pistacking" and pistack_types: + values = np.array(pistack_types, dtype=np.int32) + else: + values = np.ones(len(pairs_list), dtype=np.int32) + aap_h.set_annotation(annot_name, AnnotationList2D(len(aap_h), pairs, values), n_body=2) + else: + aap_h.set_annotation( + annot_name, AnnotationList2D(len(aap_h), _EMPTY_PAIRS, _EMPTY_VALUES), n_body=2 + ) + + for annot_name, annot_values in _derive_1d_roles(aap_h, results).items(): + aap_h.set_annotation(annot_name, annot_values) + + # Strip H -- AtomArrayPlus boolean slicing auto-remaps 2D indices + if added_h: + aap_h = aap_h[aap_h.element != "H"] + + # Transfer interaction annotations from the copy back to the original. + # Match by (chain_id, res_id, atom_name) to handle cases where + # protonation changed heavy atom count (e.g. input had partial H). + _transfer_interaction_annotations(src=aap_h, dst=aap) + + data["atom_array"] = aap + return data diff --git a/src/atomworks/ml/transforms/mask_generator.py b/src/atomworks/ml/transforms/mask_generator.py index 89f91163..d4c98d8a 100644 --- a/src/atomworks/ml/transforms/mask_generator.py +++ b/src/atomworks/ml/transforms/mask_generator.py @@ -20,9 +20,9 @@ from atomworks.common import exists from atomworks.constants import STANDARD_AA_TIP_ATOM_NAMES from atomworks.enums import ChainType -from atomworks.io.utils.atom_array import apply_and_spread from atomworks.io.utils.bonds import _atom_array_to_networkx_graph from atomworks.io.utils.query import QueryExpression +from atomworks.io.utils.scatter import apply_and_spread_segment_wise from atomworks.io.utils.selection import annot_start_stop_idxs from atomworks.ml.utils.token import get_token_starts @@ -62,7 +62,7 @@ def _get_is_eligible(self, atom_array: AtomArray) -> np.ndarray: def _get_already_sampled(self, atom_array: AtomArray, total_mask: np.ndarray) -> np.ndarray: if exists(self.avoid_same): segments = annot_start_stop_idxs(atom_array, annots=self.avoid_same, add_exclusive_stop=True) - already_sampled = apply_and_spread(segments, total_mask, np.any) + already_sampled = apply_and_spread_segment_wise(segments, total_mask, np.any) else: already_sampled = total_mask return already_sampled @@ -286,7 +286,7 @@ def __call__( ) atom_mask = np.zeros(atom_array.array_length(), dtype=bool) atom_mask[seed_idx] = True - atom_mask = apply_and_spread(segment_start_stop_idxs, atom_mask, np.any) + atom_mask = apply_and_spread_segment_wise(segment_start_stop_idxs, atom_mask, np.any) return atom_mask @@ -687,7 +687,7 @@ def __call__(self, atom_array: AtomArray, total_mask: np.ndarray, all_masks: lis n_res = len(res_starts) - 1 assert self.n_min_residues <= n_res, f"Can never satisfy budget with {self.n_min_residues=}<= {n_res=}" - res_mask_atom_lvl = apply_and_spread(res_starts, total_mask, self.reduce) + res_mask_atom_lvl = apply_and_spread_segment_wise(res_starts, total_mask, self.reduce) res_mask_res_lvl = res_mask_atom_lvl[res_starts[:-1]] n_selected_res = res_mask_res_lvl.sum() if n_selected_res < self.n_min_residues: @@ -713,7 +713,7 @@ def __call__(self, atom_array: AtomArray, total_mask: np.ndarray, all_masks: lis n_tokens = len(token_starts) assert self.n_min_tokens <= n_tokens, f"Can never satisfy budget with {self.n_min_tokens=}<= {n_tokens=}" - token_mask_atom_lvl = apply_and_spread(token_starts, total_mask, np.any) + token_mask_atom_lvl = apply_and_spread_segment_wise(token_starts, total_mask, np.any) token_mask_token_lvl = token_mask_atom_lvl[token_starts[:-1]] n_selected_tokens = token_mask_token_lvl.sum() if n_selected_tokens < self.n_min_tokens: diff --git a/src/atomworks/ml/transforms/mirror_transform.py b/src/atomworks/ml/transforms/mirror_transform.py index 7989d81e..b906b039 100644 --- a/src/atomworks/ml/transforms/mirror_transform.py +++ b/src/atomworks/ml/transforms/mirror_transform.py @@ -32,8 +32,10 @@ class RandomlyMirrorInputs(Transform): """ This component reflects inputs with a user-provided probability. - Only protein and ligand comonents are reflected, nucleic acids are not. Ligand name mapping - is properly handled by giving mirrored ligands a unique identifier. + Only complexes with *only* proteins and ligands components are reflected; + nucleic acids are not. + + Ligand name mapping is properly handled by giving mirrored ligands a unique identifier. Inputs: mirror_prob: the fraction of the time non-NA containing inputs are mirrored. diff --git a/src/atomworks/ml/transforms/msa/_msa_loading_utils.py b/src/atomworks/ml/transforms/msa/_msa_loading_utils.py index 47f3259f..9835d572 100644 --- a/src/atomworks/ml/transforms/msa/_msa_loading_utils.py +++ b/src/atomworks/ml/transforms/msa/_msa_loading_utils.py @@ -1,9 +1,9 @@ -import re -import string +import io from collections.abc import Iterable from os import PathLike from pathlib import Path +import numba import numpy as np from atomworks.enums import ChainType, MSAFileExtension @@ -12,18 +12,30 @@ AMINO_ACID_ONE_LETTER_ASCII_TO_INT_LOOKUP_TABLE, RNA_NUCLEOTIDE_ONE_LETTER_ASCII_TO_INT_LOOKUP_TABLE, ) -from atomworks.ml.utils.io import open_file +from atomworks.ml.utils.io import opened_file from atomworks.ml.utils.misc import hash_sequence +# Marker for TaxID extraction +_TAXID_MARKER = "TaxID=" +_TAXID_MARKER_LEN = len(_TAXID_MARKER) + def extract_tax_id(line: str, unknown_tax_id: str = "") -> str: - """Extract taxonomy ID from the header line""" - # ...extract the TaxID from the header line - # (Example line: ">UniRef100_A0A183IZU9 Kinesin-like protein n=1 Tax=Soboliphyme baturini TaxID=241478 RepID=A0A183IZU9_9BILA") - match = re.search(r"TaxID=(\d+)", line) - if match: - return match.group(1) - return unknown_tax_id # (unknown tax ID, which must be handled correctly when pairing downstream) + """Extract taxonomy ID from the header line using string operations (faster than regex).""" + # Example: ">UniRef100_A0A183IZU9 Kinesin-like protein n=1 Tax=Soboliphyme baturini TaxID=241478 RepID=A0A183IZU9_9BILA" + idx = line.find(_TAXID_MARKER) + if idx == -1: + return unknown_tax_id + + # Extract substring after "TaxID=" and find where digits end + start = idx + _TAXID_MARKER_LEN + # Find next space or end of line - TaxID is always followed by space or EOL + end = line.find(" ", start) + if end == -1: + end = len(line) + + tax_id = line[start:end].rstrip() + return tax_id if tax_id else unknown_tax_id def get_msa_format_from_extension(filename: PathLike) -> str: @@ -56,18 +68,23 @@ def get_msa_format_from_extension(filename: PathLike) -> str: def parse_msa( - filename: PathLike, maxseq: int = 10000, query_tax_id: str = "query" + source: PathLike | str | None = None, + maxseq: int = 10000, + query_tax_id: str = "query", + msa_format: str | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Routes to the appropriate MSA parser based on the file extension. + """Parse an MSA ``source`` into raw ``(msa, ins, tax_ids)`` arrays, routing by format. - Supports .a3m and .afa (fasta) formats with optional .gz or .zst compression. + ``source`` may be a path or an open text stream; pass ``msa_format`` (``"a3m"``/``"fasta"``) for a stream, + else it is inferred from the extension. Supports .a3m/.afa with optional .gz/.zst. """ - msa_format = get_msa_format_from_extension(filename) + if msa_format is None: + msa_format = get_msa_format_from_extension(source) if msa_format == "a3m": - return parse_a3m(filename, maxseq, query_tax_id) + return parse_a3m(source, maxseq, query_tax_id) elif msa_format == "fasta": - return parse_fasta(filename, maxseq, query_tax_id) + return parse_fasta(source, maxseq, query_tax_id) else: raise ValueError(f"Unsupported MSA format: {msa_format}") @@ -85,7 +102,9 @@ def remove_header_from_msa_file(fstream: Iterable[str]) -> Iterable[str]: yield line -def parse_fasta(filename: PathLike, maxseq: int = 10000, query_tax_id: str = "query") -> tuple[np.ndarray, np.ndarray]: +def parse_fasta( + filename: PathLike, maxseq: int = 10000, query_tax_id: str = "query" +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Reads a FASTA (.afa or .fasta) file and returns sequences as a numpy array, along with insertion positions and taxonomy IDs. @@ -115,40 +134,41 @@ def parse_fasta(filename: PathLike, maxseq: int = 10000, query_tax_id: str = "qu ins = [] tax_ids = [] - fstream = remove_header_from_msa_file(open_file(filename)) + with opened_file(filename) as raw_stream: + fstream = remove_header_from_msa_file(raw_stream) - for index, line in enumerate(fstream): - # Extract taxonomy ID from the header line, but don't process like the rest of the MSA - if line[0] == ">": - if index == 0: - # ...force the query sequence to have the query tax ID - tax_ids.append(query_tax_id) # query sequence - else: - # ...extract the TaxID from the header line - tax_ids.append(extract_tax_id(line)) + for index, line in enumerate(fstream): + # Extract taxonomy ID from the header line, but don't process like the rest of the MSA + if line[0] == ">": + if index == 0: + # ...force the query sequence to have the query tax ID + tax_ids.append(query_tax_id) # query sequence + else: + # ...extract the TaxID from the header line + tax_ids.append(extract_tax_id(line)) - # ...don't process the header line any further - continue + # ...don't process the header line any further + continue - # ...remove right whitespaces - line = line.rstrip() + # ...remove right whitespaces + line = line.rstrip() - if len(line) == 0: - continue + if len(line) == 0: + continue - # ...append to MSA (no lowercase letters in FASTA files that we need to remove) - msa.append(line) + # ...append to MSA (no lowercase letters in FASTA files that we need to remove) + msa.append(line) - # ...get the sequence length - L = len(msa[-1]) + # ...get the sequence length + L = len(msa[-1]) - # HACK: There are never insertions in RNA MSAs, so we set the insertion array to all zeros - i = np.zeros(L) - ins.append(i) + # HACK: There are never insertions in RNA MSAs, so we set the insertion array to all zeros + i = np.zeros(L) + ins.append(i) - # ...break if we've reached the maximum number of sequences - if len(msa) >= maxseq: - break + # ...break if we've reached the maximum number of sequences + if len(msa) >= maxseq: + break # ...convert lists to numpy arrays for return msa_array = np.array([list(seq) for seq in msa], dtype="S") @@ -158,11 +178,32 @@ def parse_fasta(filename: PathLike, maxseq: int = 10000, query_tax_id: str = "qu return msa_array, ins_array, tax_ids_array +@numba.njit(cache=True) +def _count_insertions_and_extract_aligned(line_bytes: np.ndarray, L: int) -> tuple[np.ndarray, np.ndarray]: + """Count insertions per aligned position and extract aligned sequence bytes. + + Insertions are lowercase letters (ASCII 97-122). Aligned characters are uppercase (65-90) or dash (45). + Returns insertion counts per aligned position and the aligned sequence bytes. + """ + ins = np.zeros(L, dtype=np.float64) + aligned = np.empty(L, dtype=np.uint8) + aligned_idx = 0 + + for b in line_bytes: + if 97 <= b <= 122: # lowercase a-z = insertion + ins[aligned_idx] += 1 + else: + aligned[aligned_idx] = b + aligned_idx += 1 + + return ins, aligned + + def parse_a3m( filename: PathLike, maxseq: int = 10000, query_tax_id: str = "query" ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """ - Reads an A3M file and returns sequences as a numpy array, along with insertion positions and taxonomy IDs. + """Reads an A3M file and returns sequences as a numpy array, along with insertion positions and taxonomy IDs. + A3M files differ from A2M files in that dots (".") are discarded for compactness; thus, lines may be different lengths (but we still have the same number of aligned columns). While parsing: @@ -171,7 +212,7 @@ def parse_a3m( NOTE: Files must contain only ASCII characters; we do not handle Unicode characters. - Parameters: + Args: filename (str or Path): The path to the A3M file, can be gzipped. maxseq (int): The maximum number of sequences to read from the file (for processing speed). Passed from the associated Transform. query_tax_id (str): The taxonomy ID for the query sequence. @@ -190,75 +231,62 @@ def parse_a3m( Reference: `A3M Format Documentation `_ """ - msa = [] - ins = [] tax_ids = [] - - # ...create a translation table to remove lowercase letters - table = str.maketrans(dict.fromkeys(string.ascii_lowercase)) - - # ...open the file - fstream = remove_header_from_msa_file(open_file(filename)) - - for index, line in enumerate(fstream): - line = line.replace("\x00", "") # Files from the mmseq server may have stray null characters - if len(line) == 0: - continue - # Extract taxonomy ID from the header line, but don't process like the rest of the MSA - if line[0] == ">": - if index == 0: - # ...force the query sequence to have the query tax ID - tax_ids.append(query_tax_id) # query sequence - else: - # ...extract the TaxID from the header line + sequences_data = [] # List of (ins_array, aligned_bytes) tuples + L = None # Sequence length, determined from first sequence + + with opened_file(filename) as fstream: + # Skip any lines before the first header (e.g., comments), then process the first header. + # The first sequence is always the query, so we assign it `query_tax_id` directly instead + # of extracting a TaxID from the header. This ensures proper MSA pairing downstream. + for line in fstream: + if line.startswith(">"): + tax_ids.append(query_tax_id) + break + + # Process remaining lines (headers get TaxID extracted, sequence lines get parsed) + for line in fstream: + # Skip empty lines and handle null bytes only if present + if not line or line == "\n": + continue + if "\x00" in line: + line = line.replace("\x00", "") + if not line: + continue + + # Extract taxonomy ID from header lines + if line[0] == ">": tax_ids.append(extract_tax_id(line)) + continue - # ...don't process the header line any further - continue - - # ...remove right whitespaces - line = line.rstrip() - - if len(line) == 0: - continue - - # ...remove lowercase letters and append to MSA - # (lowercase letters represent insertion positions between alignment columns) - msa.append(line.translate(table)) + # Strip whitespace for sequence lines + line = line.rstrip() + if not line: + continue - # ...get the sequence length - # (since we removed lowercase letters, and we're using a3m without dot representations, all sequences should be the same length) - L = len(msa[-1]) + # Convert to bytes for numba processing + line_bytes = np.frombuffer(line.encode("ascii"), dtype=np.uint8) - # (0 - match or gap; 1 - insertion) - a = np.array([0 if c.isupper() or c == "-" else 1 for c in line]) - i = np.zeros(L) + # Determine L from first sequence (count non-lowercase characters) + if L is None: + L = np.sum((line_bytes < 97) | (line_bytes > 122)) - if np.sum(a) > 0: - # ...get the positions of insertions - pos = np.where(a == 1)[0] + # Process with numba-jitted function + ins_row, aligned_row = _count_insertions_and_extract_aligned(line_bytes, L) + sequences_data.append((ins_row, aligned_row)) - # ...shift by occurrence - a = pos - np.arange(pos.shape[0]) + if len(sequences_data) >= maxseq: + break - # ...get position of insertions in cleaned sequence and their length - pos, num = np.unique(a, return_counts=True) + # Build final arrays + n_seqs = len(sequences_data) + msa_array = np.empty((n_seqs, L), dtype="S1") + ins_array = np.empty((n_seqs, L), dtype=np.float64) - # ...append to the matrix of insertions - # (num represents the number of insertions to the LEFT of the index specified by pos) - i[pos] = num + for i, (ins_row, aligned_row) in enumerate(sequences_data): + msa_array[i] = aligned_row.view("S1") + ins_array[i] = ins_row - ins.append(i) - - # ...break if we've reached the maximum number of sequences - if len(msa) >= maxseq: - break - - fstream.close() - - # ...convert lists to numpy arrays for return - msa_array = np.array([list(seq) for seq in msa], dtype="S") - ins_array = np.array(ins) tax_ids_array = np.array(tax_ids) return msa_array, ins_array, tax_ids_array @@ -287,22 +315,34 @@ def get_msa_path(seq: str, msa_dirs: list[dict[str, str]]) -> Path | None: return None -def load_msa_data_from_path( - msa_file_path: PathLike, chain_type: ChainType, max_msa_sequences: int = 10_000, query_tax_id: str = "query" +def load_msa( + source: PathLike | str | bytes, # also accepts an open text stream + chain_type: ChainType, + max_msa_sequences: int = 10_000, + query_tax_id: str = "query", + *, + suffix: str = MSAFileExtension.A3M, ) -> dict[str, np.array]: - """Given an MSA file path and the corresponding chain type, load the MSA data. + """Load model-ready MSA integer features from ``source`` — a file path, raw ``bytes``, or a text stream. - We must consider the ChainType to determine how to convert the MSA to our intermediate integer representation - (since the single-letter representations of amino acids and nucleotides overlap) + Wraps :func:`parse_msa` (the low-level path/stream -> raw ``(msa, ins, tax_ids)`` parser) and converts to our + integer representation via a ChainType-specific ASCII lookup (protein vs RNA one-letter codes overlap), plus a + per-row query-similarity. Args: - msa_file_path (PathLike): The path to the MSA file (can be gzipped). - chain_type (ChainType): The type of the chain (e.g., Protein or RNA). - max_msa_sequences (int): The maximum number of sequences to read from the file (for processing speed). - query_tax_id (str): The taxonomy ID for the query sequence. Defaults to "query"; ensures the query sequence is paired with itself. + source: An MSA file path (may be gzipped/zstd), raw ``bytes``, or an open text stream. + chain_type: The type of the chain (e.g., Protein or RNA). + max_msa_sequences: The maximum number of sequences to read (for processing speed). + query_tax_id: The taxonomy ID for the query sequence. Defaults to "query"; ensures the query + sequence is paired with itself. + suffix: Selects the a3m/fasta format for in-memory sources (bytes/stream). Paths infer it from + the extension. """ - # ... parse the MSA file (handles both A3M or FASTA formats) - msa, ins, tax_ids = parse_msa(msa_file_path, maxseq=max_msa_sequences, query_tax_id=query_tax_id) + if isinstance(source, bytes | bytearray): + source = io.StringIO(bytes(source).decode()) + # in-memory sources (bytes/stream) take the format from `suffix`; paths infer it from the extension + msa_format = get_msa_format_from_extension(suffix) if hasattr(source, "read") else None + msa, ins, tax_ids = parse_msa(source, maxseq=max_msa_sequences, query_tax_id=query_tax_id, msa_format=msa_format) # ... convert to integers, using the protein one-letter ASCII lookup table if chain_type.is_protein(): diff --git a/src/atomworks/ml/transforms/msa/_msa_pairing_utils.py b/src/atomworks/ml/transforms/msa/_msa_pairing_utils.py index 0f04df21..bcce4804 100644 --- a/src/atomworks/ml/transforms/msa/_msa_pairing_utils.py +++ b/src/atomworks/ml/transforms/msa/_msa_pairing_utils.py @@ -1,7 +1,5 @@ """Utility functions for MSA pairing.""" -from __future__ import annotations - import logging from functools import reduce diff --git a/src/atomworks/ml/transforms/msa/msa.py b/src/atomworks/ml/transforms/msa/msa.py index 5f64e065..a04579d5 100644 --- a/src/atomworks/ml/transforms/msa/msa.py +++ b/src/atomworks/ml/transforms/msa/msa.py @@ -1,8 +1,8 @@ """Transforms on MSAs""" -from __future__ import annotations - +import functools import logging +import warnings from copy import deepcopy from os import PathLike from pathlib import Path @@ -14,7 +14,7 @@ from biotite.structure import AtomArray from atomworks.common import exists -from atomworks.enums import ChainType +from atomworks.enums import ChainType, MSAFileExtension from atomworks.ml.encoding_definitions import RF2AA_ATOM36_ENCODING, AF3SequenceEncoding, TokenEncoding from atomworks.ml.transforms._checks import ( check_atom_array_annotation, @@ -40,9 +40,13 @@ transform_ins_counts, uniformly_select_rows, ) -from atomworks.ml.transforms.msa._msa_loading_utils import get_msa_path, load_msa_data_from_path +from atomworks.ml.transforms.msa._msa_loading_utils import ( + get_msa_path, + load_msa, +) from atomworks.ml.transforms.msa._msa_pairing_utils import join_multiple_msas_by_tax_id -from atomworks.ml.utils.io import cache_to_disk_as_pickle +from atomworks.ml.transforms.msa.msa_store import PackedMsaStore +from atomworks.ml.utils import nested_dict from atomworks.ml.utils.misc import grouped_count from atomworks.ml.utils.token import apply_token_wise, get_token_count, get_token_starts @@ -102,7 +106,7 @@ def forward(self, data: dict) -> dict: atom_array = data["atom_array"] # Create a map from unique entity IDs to constituent chain IDs - # We directly generate the mapping from the AtomArray since the `rcsb_entity` may be inaccurate post-processing + # We directly generate the mapping from the AtomArray since the `entity` may be inaccurate post-processing # We need entity-level information to pair chains that belong to separate entities; otherwise, we simply concatenate the MSAs chain_with_msa_entity_to_ids = {} chain_with_msa_id_to_entity = {} @@ -191,6 +195,37 @@ def forward(self, data: dict) -> dict: return data +@functools.cache +def _get_msa_store(store_url: str, endpoint_url: str | None = None) -> PackedMsaStore: + """Process-wide cached packed MSA store.""" + return PackedMsaStore(store_url, endpoint_url=endpoint_url) + + +def _resolve_and_load_msa( + seq: str, + msa_dirs: list[dict], + chain_type: ChainType, + max_msa_sequences: int, + query_tax_id: str, +) -> dict | None: + """Load MSA data for ``seq`` from the first matching source. + + Each source is either a packed store (``{"store": }``, content-addressed by sequence hash) or a + hashed-file directory (``{"dir", "extension", "directory_depth"}``, the existing layout). + """ + suffix = MSAFileExtension.A3M if chain_type.is_protein() else MSAFileExtension.AFA + for src in msa_dirs: + if "store" in src: + raw = _get_msa_store(src["store"], src.get("endpoint_url")).get_bytes_for_seq(seq) + if raw is not None: + return load_msa(raw, chain_type, max_msa_sequences, query_tax_id, suffix=suffix) + else: + path = get_msa_path(seq, [src]) + if path is not None: + return load_msa(path, chain_type, max_msa_sequences=max_msa_sequences, query_tax_id=query_tax_id) + return None + + def load_polymer_msas( atom_array: AtomArray, chain_info: dict, @@ -202,79 +237,88 @@ def load_polymer_msas( raise_if_missing_msa_for_protein_of_length_n: int | None = None, unk_symbol: str = "X", ) -> dict[str, np.array]: - """ - Load MSAs for all polymer chains in the AtomArray and store them in a dictionary. See the LoadPolymerMSAs transform for more information + """Load MSAs for all polymer chains in the AtomArray and store them in a dictionary. See the LoadPolymerMSAs transform for more information + Args: atom_array (AtomArray): The AtomArray for the full structure chain_info (dict): A dictionary containing chain information, including: - - processed_entity_non_canonical_sequence: The non-canonical sequence for the chain - - processed_entity_canonical_sequence: The canonical sequence for the chain - chain_type: The type of the chain (e.g., protein, RNA) - msa_path (optional): The path to the MSA file for the chain, if available + - processed_entity_non_canonical_sequence (required if msa_path not provided): The non-canonical sequence for the chain + - processed_entity_canonical_sequence (required if msa_path not provided): The canonical sequence for the chain protein_msa_dirs (list[dict[str, str]]): The directories containing the protein MSAs and their associated file types. rna_msa_dirs (list[dict[str, str]]): The directories containing the RNA MSAs and their associated file types. max_msa_sequences (int): The maximum number of sequences to load from the MSA files. Defaults to 10_000. - msa_cache_dir (PathLike | None): The directory to cache the parsed MSA data (since loading from text files is slow). If None, caching is turned off. + msa_cache_dir (PathLike | None): **DEPRECATED**. MSA parsing has been optimized with Numba and caching is no longer necessary. This parameter is ignored. use_paths_in_chain_info (bool): Whether to use the MSA paths provided in the chain_info dictionary. If True, we will first check the chain_info dictionary for MSA paths. raise_if_missing_msa_for_protein_of_length_n (int | None): If provided, raises an error if a protein of length >= n is missing an MSA file. + Returns: dict[str, np.array]: A dictionary mapping chain IDs to their corresponding MSA data """ msas_by_chain_id = {} - # NOTE: If `msa_cache_dir` is `None`, the cache decorator will be a no-op - cached_load_msa_data_from_path = cache_to_disk_as_pickle(msa_cache_dir)(load_msa_data_from_path) + # msa_cache_dir is deprecated - ignore it + if msa_cache_dir is not None: + warnings.warn( + "msa_cache_dir is deprecated and will be removed in a future version. " + "MSA parsing has been optimized with Numba and caching is no longer necessary.", + DeprecationWarning, + stacklevel=2, + ) for chain_id in np.unique(atom_array.chain_id[np.isin(atom_array.chain_type, ChainType.get_polymers())]): - non_canonical_sequence = chain_info[chain_id]["processed_entity_non_canonical_sequence"] - canonical_sequence = chain_info[chain_id]["processed_entity_canonical_sequence"] chain_type = chain_info[chain_id]["chain_type"] # Set the query chain tax_id to "query" to avoid pairing issues downstream (we force all query sequences to be paired with themselves) # Subsequent occurrences of the query sequence will not have the "query" tax ID, and will be paired appropriately query_chain_msa_tax_id = "query" - # ... find the path - msa_file_path = None + # Inference-time override: a path in chain_info loads directly. Otherwise resolve by sequence below. if ( use_paths_in_chain_info and "msa_path" in chain_info[chain_id] and chain_info[chain_id]["msa_path"] is not None ): - # Use provided path - msa_file_path = Path(chain_info[chain_id]["msa_path"]) + msa_path = Path(chain_info[chain_id]["msa_path"]) + assert msa_path.exists(), f"MSA file not found at given path: {msa_path}" + msa_data = load_msa( + msa_path, chain_type, max_msa_sequences=max_msa_sequences, query_tax_id=query_chain_msa_tax_id + ) else: - # Check both canonical and non-canonical sequences + non_canonical_sequence = chain_info[chain_id]["processed_entity_non_canonical_sequence"] + canonical_sequence = chain_info[chain_id]["processed_entity_canonical_sequence"] + + # Resolve from packed stores or hashed-file dirs (first hit wins) + # Try non-canonical then canonical, each with X->unk (protein) / U->T (RNA) substitution (RNA MSA pipelines use varying conventions; we accept both) + msa_dirs = ( + protein_msa_dirs if chain_type.is_protein() else (rna_msa_dirs if chain_type == ChainType.RNA else []) + ) + msa_data = None for sequence in [non_canonical_sequence, canonical_sequence]: - if chain_type.is_protein() and protein_msa_dirs: - msa_file_path = get_msa_path(sequence, protein_msa_dirs) - if msa_file_path is None and unk_symbol != "X": - sequence = sequence.replace("X", unk_symbol) - msa_file_path = get_msa_path(sequence, protein_msa_dirs) - elif chain_type == ChainType.RNA and rna_msa_dirs: - msa_file_path = get_msa_path(sequence, rna_msa_dirs) - if not msa_file_path: - # Older MSAs replace U->T. If no matches try replacing - msa_file_path = get_msa_path(sequence.replace("U", "T"), rna_msa_dirs) - if msa_file_path: + candidates = [sequence] + if chain_type.is_protein() and unk_symbol != "X": + candidates.append(sequence.replace("X", unk_symbol)) + elif chain_type == ChainType.RNA: + candidates.append(sequence.replace("U", "T")) + for candidate in candidates: + msa_data = _resolve_and_load_msa( + candidate, msa_dirs, chain_type, max_msa_sequences, query_chain_msa_tax_id + ) + if msa_data is not None: + break + if msa_data is not None: break - if msa_file_path is None: - # If no MSA file path is found, we skip this chain - if raise_if_missing_msa_for_protein_of_length_n is not None: # noqa: SIM102 - if chain_type.is_protein() and len(canonical_sequence) >= raise_if_missing_msa_for_protein_of_length_n: - raise ValueError(f"MSA file not found for protein of length {len(canonical_sequence)}") - continue - - assert msa_file_path.exists(), f"MSA file not found at given path: {msa_file_path}" - - # ... load the MSA data from the specified path - msa_data = cached_load_msa_data_from_path( - msa_file_path=msa_file_path, - chain_type=chain_type, - max_msa_sequences=max_msa_sequences, - query_tax_id=query_chain_msa_tax_id, - ) + if msa_data is None: + # No MSA found; skip this chain (optionally raising for long proteins). + if raise_if_missing_msa_for_protein_of_length_n is not None: # noqa: SIM102 + if ( + chain_type.is_protein() + and len(canonical_sequence) >= raise_if_missing_msa_for_protein_of_length_n + ): + raise ValueError(f"MSA file not found for protein of length {len(canonical_sequence)}") + continue if msa_data["msa"] is not None: msas_by_chain_id[chain_id] = { @@ -299,10 +343,10 @@ class LoadPolymerMSAs(Transform): We check both the canonical and non-canonical sequences for MSAs, preferring the canonical sequence if both are present. Args: - protein_msa_dirs (list[dict]): The directories containing the protein MSAs and - their associated file types, as a list of dictionaries. If multiple - directories are provided, all of them will be searched. Keys in the dictionary - are: + protein_msa_dirs (list[dict] | None): The directories containing the protein MSAs and + their associated file types, as a list of dictionaries. + If multiple directories are given, all of them will be searched in the order provided. + Keys in the dictionary are: - dir (str): The directory where the MSA files are stored. - extension (str): The file extension of the MSA files (e.g., ".a3m.gz" or ".fasta"). - directory_depth (int, optional): The directory nesting depth, i.e., the MSA file @@ -312,17 +356,16 @@ class LoadPolymerMSAs(Transform): (a) The files must be named using the SHA-256 hash of the sequence (see `hash_sequence` in `utils/misc`). (b) Order matters - directories will be searched in the order provided, and the first match will be returned. - rna_msa_dirs (list[dict]): The directories containing the RNA MSAs and their - associated file types, as a list of dictionaries. See `protein_msa_dirs` - for directory structure details. + rna_msa_dirs (list[dict] | None): The directories containing the RNA MSAs and their + associated file types, as a list of dictionaries. use_paths_in_chain_info (bool): Whether to use the MSA paths provided in the chain_info dictionary. E.g., for inference mode. If True, we will first check the chain_info dictionary for MSA paths. max_msa_sequences (int, optional): The maximum number of sequences to load from the MSA files. Defaults to 10000. Only applies when loading; further sub-sampling of the MSA occurs downstream (e.g., for the standard or extra MSA stack). AF-3 used a large value (~16K), but our MSAs on disk are already pre-filtered to 10K. - msa_cache_dir (PathLike, optional): The directory to cache the parsed MSA data - (since loading from text files is slow). If None, caching is turned off. + msa_cache_dir (PathLike, optional): **DEPRECATED**. MSA parsing has been optimized + with Numba and caching is no longer necessary. This parameter is ignored. raise_if_missing_msa_for_protein_of_length_n (int | None): If provided, raises an error if a protein of length >= n is missing an MSA file. unk_symbol (string): The character to use for unknown residues. Defaults to 'X'. @@ -341,26 +384,38 @@ class LoadPolymerMSAs(Transform): filling the full MSA from the encoded MSA. """ - max_msa_sequences: int - protein_msa_dirs: list[dict] - rna_msa_dirs: list[dict] - def __init__( self, - protein_msa_dirs: list[ - dict - ] = [], # Example: [{"dir": "/path/to/protein/msas", "extension": ".a3m.gz", "directory_depth": 2}] - rna_msa_dirs: list[dict] = [], + protein_msa_dirs: list[dict] | None = None, + rna_msa_dirs: list[dict] | None = None, max_msa_sequences: int = 10000, msa_cache_dir: PathLike | None = None, use_paths_in_chain_info: bool = True, raise_if_missing_msa_for_protein_of_length_n: int | None = None, unk_symbol: str = "X", ): + if msa_cache_dir is not None: + warnings.warn( + "msa_cache_dir is deprecated and will be removed in a future version. " + "MSA parsing has been optimized with Numba and caching is no longer necessary.", + DeprecationWarning, + stacklevel=2, + ) + + if protein_msa_dirs is None: + protein_msa_dirs = [] + else: + logger.info(f"Using protein MSA directories: {protein_msa_dirs}") + + if rna_msa_dirs is None: + rna_msa_dirs = [] + else: + logger.info(f"Using RNA MSA directories: {rna_msa_dirs}") + self.max_msa_sequences = max_msa_sequences self.protein_msa_dirs = protein_msa_dirs self.rna_msa_dirs = rna_msa_dirs - self.msa_cache_dir = msa_cache_dir + self.msa_cache_dir = None # Ignore msa_cache_dir, caching is deprecated self.use_paths_in_chain_info = use_paths_in_chain_info self.raise_if_missing_msa_for_protein_of_length_n = raise_if_missing_msa_for_protein_of_length_n self.unk_symbol = unk_symbol @@ -388,8 +443,7 @@ def forward(self, data: dict) -> dict: class EncodeMSA(Transform): - """ - Encode a MSA from MSA-general integer representations to model-specific token indices using the Enoding. + """Encode a MSA from MSA-general integer representations to model-specific token indices using the Enoding. Args: token_encoding (TokenEncoding): The TokenEncoding object to use for encoding the MSA. @@ -410,14 +464,14 @@ class EncodeMSA(Transform): requires_previous_transforms: ClassVar[list[str]] = ["LoadPolymerMSAs"] def __init__(self, encoding: TokenEncoding | AF3SequenceEncoding, token_to_use_for_gap: int | None = None): - # ... create a lookup table to map from MSA integers to token indices + # Lookup table to map from MSA integers to token indices lookup_for_encoding = np.zeros(len(MSA_INTEGER_TO_THREE_LETTER), dtype=int) for tmp_int, three_letter in MSA_INTEGER_TO_THREE_LETTER.items(): if three_letter == GAP_THREE_LETTER and token_to_use_for_gap is not None: - # ... if we defined a substitute token for gaps, use it + # If we defined a substitute token for gaps, use it lookup_for_encoding[tmp_int] = token_to_use_for_gap else: - # ... otherwise, we assume that the gap token is present in the encoding + # Otherwise, we assume that the gap token is present in the encoding lookup_for_encoding[tmp_int] = encoding.token_to_idx[three_letter] self.lookup_for_encoding = lookup_for_encoding @@ -428,23 +482,22 @@ def check_input(self, data: dict) -> None: def forward(self, data: dict) -> dict: atom_array = data["atom_array"] - # ...loop through all of the polymer chain IDs still present in the atom array (may be a subset of `polymer_msas_by_chain_id`) + # Loop through all of the polymer chain IDs still present in the atom array (may be a subset of `polymer_msas_by_chain_id`) polymer_chain_ids = np.unique(atom_array.chain_id[atom_array.is_polymer]) for chain_id in polymer_chain_ids: - # ...check if we have an MSA for this chain + # Check if we have an MSA for this chain if chain_id in data["polymer_msas_by_chain_id"]: msa = data["polymer_msas_by_chain_id"][chain_id]["msa"] - # ...encode the MSA (to tokens integers), based on the lookup table + # Encode the MSA (to tokens integers), based on the lookup table encoded_msa = self.lookup_for_encoding[msa] # [n_rows, n_res_in_chain] (int) - # ...set the encoded MSA in the output in-place data["polymer_msas_by_chain_id"][chain_id]["encoded_msa"] = encoded_msa return data class FillFullMSAFromEncoded(Transform): - """ - Fills in the full MSA from the encoded MSA, using the atom array to determine the order of the tokens. + """Fills in the full MSA from the encoded MSA, using the atom array to determine the order of the tokens. + Starts by creating full np.arrays with default values (padding tokens), and then fills in the encoded MSA by looping over chain instances. This function requires that all MSAs have the same number of rows, but does not require them to necessarily be paired. @@ -457,6 +510,8 @@ class FillFullMSAFromEncoded(Transform): pad_token (str): The token used for padding in the MSA. The pad token should match the padding token used when padding unpaired MSA sequences. add_residue_is_paired_feature (bool): Whether to add a binary feature indicating whether a residue is part of a paired MSA. Must match the value used in PairAndMergePolymerMSAs. + encoded_seq_path (tuple[str, ...]): Path to the encoded query sequence in the data dict. + Uses :py:func:`~atomworks.ml.utils.nested_dict.getitem` for access. Defaults to ``("encoded", "seq")``. Returns: The full MSA, with padding, as a 2D np.array of integers, stored in `data["encoded"]["msa"]`. @@ -501,20 +556,31 @@ class FillFullMSAFromEncoded(Transform): requires_previous_transforms: ClassVar[list[str]] = ["EncodeMSA", AtomizeByCCDName, AddWithinPolyResIdxAnnotation] - def __init__(self, pad_token: str, add_residue_is_paired_feature: bool = False): + def __init__( + self, + pad_token: str, + add_residue_is_paired_feature: bool = False, + encoded_seq_path: tuple[str, ...] = ("encoded", "seq"), + ): self.PAD_TOKEN = pad_token self.add_residue_is_paired_feature = add_residue_is_paired_feature + self.encoded_seq_path = encoded_seq_path def check_input(self, data: dict) -> None: - check_contains_keys(data, ["polymer_msas_by_chain_id", "encoded"]) + check_contains_keys(data, ["polymer_msas_by_chain_id", "atom_array"]) def forward(self, data: dict) -> dict: atom_array = data["atom_array"] + encoded_seq = nested_dict.getitem(data, self.encoded_seq_path) + + # Ensure "encoded" dict exists for storing MSA output + if "encoded" not in data: + data["encoded"] = {} # If we have no polymer MSAs (either all polymer sequences have no MSAs, or we have no polymer sequences)... if len(data["polymer_msas_by_chain_id"]) == 0: # ... we set `full_encoded_msa` to be the query sequence (expanded to 2D) - full_encoded_msa = np.expand_dims(data["encoded"]["seq"], axis=0) # [1, n_tokens_across_chains] (int) + full_encoded_msa = np.expand_dims(encoded_seq, axis=0) # [1, n_tokens_across_chains] (int) num_tokens_in_example = full_encoded_msa.shape[1] data["full_msa_details"] = { # ... we set `token_idx_has_msa` to all zeros @@ -525,7 +591,7 @@ def forward(self, data: dict) -> dict: "msa_raw_ins": np.zeros((1, num_tokens_in_example), dtype=int), } if self.add_residue_is_paired_feature: - # ... we set `msa_residue_is_paired` to all zeros + # Set `msa_residue_is_paired` to all zeros data["full_msa_details"]["msa_residue_is_paired"] = np.zeros((1, num_tokens_in_example), dtype=bool) data["encoded"]["msa"] = full_encoded_msa # ...and we early return! @@ -588,7 +654,7 @@ def forward(self, data: dict) -> dict: # Check if we have an MSA for this chain if chain_id in data["polymer_msas_by_chain_id"]: - # ... if so, get the encoded MSA and the mask + # If so, get the encoded MSA and the mask chain_encoded_msa = data["polymer_msas_by_chain_id"][chain_id][ "encoded_msa" ] # [n_rows, n_res_in_chain] (int) @@ -638,9 +704,9 @@ def forward(self, data: dict) -> dict: full_msa_residue_is_paired[:, mask] = subselected_residue_pairing token_idx_has_msa[mask] = True # [n_tokens_across_chains] (bool) - # ... for the first row, set the tokens directly from the output of the `Atomize` transform (i.e., the atomized tokens, and anything without an MSA) - # (Note that this also handles setting the MSA for polymers without MSAs, and non-polymers) - full_encoded_msa[0] = data["encoded"]["seq"] # [n_tokens_across_chains] (int) + # For the first row, set the tokens directly from the output of the `Atomize` transform (i.e., the atomized tokens, and anything without an MSA) + # NOTE: This operation also handles setting the MSA for polymers without MSAs, and non-polymers + full_encoded_msa[0] = encoded_seq # [n_tokens_across_chains] (int) full_msa_is_padded_mask[0] = False # [n_tokens_across_chains] (bool) if self.add_residue_is_paired_feature: full_msa_residue_is_paired[0] = True # [n_tokens_across_chains] (bool) @@ -731,11 +797,11 @@ def forward(self, data: dict) -> dict: msa_is_padded_mask = data["full_msa_details"]["msa_is_padded_mask"] # [n_rows, n_tokens_across_chains] (bool) msa_raw_ins = data["full_msa_details"]["msa_raw_ins"] # [n_rows, n_tokens_across_chains] (int) - # ...select either the first `n_msa_cluster_representatives` rows or all rows, whichever is smaller + # Select either the first `n_msa_cluster_representatives` rows or all rows, whichever is smaller n_rows, n_seq = encoded_msa.shape n_msa_cluster_representatives = min(self.n_msa_cluster_representatives, n_rows) - # ...compute the raw MSA profile, which is required for the BERT-style masking of the MSA, where with a 10% probability, we replace + # Compute the raw MSA profile, which is required for the BERT-style masking of the MSA, where with a 10% probability, we replace # an amino acid with an amino sampled from the MSA profile at a given position full_msa_profile = grouped_count( encoded_msa, @@ -746,12 +812,12 @@ def forward(self, data: dict) -> dict: ], n_tokens=self.encoding.n_tokens, # ... return a float tensor dtype=torch.float, # ... return a float tensor - ).squeeze() # [n_tokens_across_chains, n_tokens] (float) + ).squeeze(0) # [n_tokens_across_chains, n_tokens] (float) full_msa_profile /= ( full_msa_profile.sum(dim=-1, keepdim=True) + self.eps ) # [n_tokens_across_chains, n_tokens] (float) - # ...generate a unique MSA (both cluster representative MSA and extra MSA) for each recycle (up to `n_recycles`) + # ... generate a unique MSA (both cluster representative MSA and extra MSA) for each recycle (up to `n_recycles`) msa_features_per_recycle_dict = { "first_row_of_msa": [], # [n_tokens_across_chains] (int) "cluster_representatives_msa_ground_truth": [], # [n_msa_cluster_representatives, n_tokens_across_chains] (int) @@ -1068,11 +1134,9 @@ def check_input(self, data: dict) -> None: check_contains_keys(data, ["encoded", "full_msa_details"]) def forward(self, data: dict) -> dict: - # ...unpack the MSA data encoded_msa = data["encoded"]["msa"] # [n_rows, n_tokens_across_chains] (int) msa_is_padded_mask = data["full_msa_details"]["msa_is_padded_mask"] # [n_rows, n_tokens_across_chains] (bool) - # ...get the MSA features msa_features = featurize_msa_like_af3( encoded_msa=encoded_msa, n_recycles=self.n_recycles, @@ -1084,7 +1148,6 @@ def forward(self, data: dict) -> dict: residue_is_paired=data["full_msa_details"].get("msa_residue_is_paired", None), ) - # ...add them to the data dictionary data["msa_features"] = msa_features return data @@ -1100,7 +1163,7 @@ def featurize_msa_like_af3( residue_is_paired: torch.Tensor | None = None, ) -> dict[str, list[torch.Tensor]]: """Functional version of FeaturizeMSALikeAF3. See FeaturizeMSALikeAF3 for more details.""" - # ...select either the first `n_msa` rows or all rows, whichever is smaller + # Select either the first `n_msa` rows or all rows, whichever is smaller n_rows, _ = encoded_msa.shape n_msa = min(n_msa, n_rows) @@ -1112,7 +1175,7 @@ def featurize_msa_like_af3( eps=eps, ) - # ...generate features for each recycle + # Generate features for each recycle... msa_features_per_recycle_dict = { "msa": [], # [n_msa, n_tokens_across_chains, n_tokens] (float) "has_insertion": [], # [n_msa, n_tokens_across_chains] (bool) @@ -1123,10 +1186,10 @@ def featurize_msa_like_af3( msa_features_per_recycle_dict["residue_is_paired"] = [] for _ in range(n_recycles): - # ...uniformly select n_msa sequences from the n_rows sequences in the (paired) MSA + # Uniformly select n_msa sequences from the n_rows sequences in the (paired) MSA selected_indices, _ = uniformly_select_rows(n_rows, n_msa, preserve_first_index=True) - # ...fill in the MSA features + # Fill in the MSA features msa_features_per_recycle_dict["msa"].append( F.one_hot(encoded_msa[selected_indices], num_classes=encoding.n_tokens) ) @@ -1135,7 +1198,8 @@ def featurize_msa_like_af3( if exists(residue_is_paired): msa_features_per_recycle_dict["residue_is_paired"].append(residue_is_paired[selected_indices]) - # ...and the features that do not differ across recycles + # ... and the features that do not differ across recycles + ins_mean = transform_ins_counts(ins_mean) msa_static_features_dict = { "profile": full_msa_profile, # [n_tokens_across_chains, n_tokens] (float) "insertion_mean": ins_mean, # [n_tokens_across_chains] (float) @@ -1155,10 +1219,10 @@ def get_full_msa_profile_and_insertion_mean( eps: float = 1e-6, ) -> tuple[torch.Tensor, torch.Tensor]: """Computes the full MSA profile and insertion mean on the untruncated MSA""" - # ...select either the first `n_msa` rows or all rows, whichever is smaller + # Select either the first `n_msa` rows or all rows, whichever is smaller n_rows, n_seq = encoded_msa.shape - # ...compute the FULL MSA token profile (e.g., before truncation to `n_msa` sequences) + # Compute the FULL MSA token profile (e.g., before truncation to `n_msa` sequences) full_msa_profile = grouped_count( encoded_msa, mask=~msa_is_padded_mask, # ... ignore padding when computing the profile @@ -1168,13 +1232,15 @@ def get_full_msa_profile_and_insertion_mean( ], n_tokens=encoding.n_tokens, # ... return a float tensor dtype=torch.float, # ... return a float tensor - ).squeeze() # [n_tokens_across_chains, n_tokens] (float) - # ...normalize + ).squeeze(0) # [n_tokens_across_chains, n_tokens] (float) + + # (Normalize) full_msa_profile /= full_msa_profile.sum(dim=-1, keepdim=True) + eps # [n_tokens_across_chains, n_tokens] (float) - # ...compute the FULL MSA deletion (insertion) profile (e.g., before truncation to `n_msa` sequences) + # ... compute the FULL MSA deletion (insertion) profile (e.g., before truncation to `n_msa` sequences) ins_mean = (msa_raw_ins * ~msa_is_padded_mask).sum(dim=0).float() # [n_tokens] (float) - # ... normalize + + # (Normalize) ins_mean /= (~msa_is_padded_mask).sum(dim=0) + eps # [n_tokens] (float) return full_msa_profile, ins_mean diff --git a/src/atomworks/ml/transforms/msa/msa_store.py b/src/atomworks/ml/transforms/msa/msa_store.py new file mode 100644 index 00000000..e8ce0c30 --- /dev/null +++ b/src/atomworks/ml/transforms/msa/msa_store.py @@ -0,0 +1,61 @@ +"""Content-addressed packed MSA store: ``hash_sequence(seq, MSA_KEY_LEN)`` -> ``(shard, offset, length)`` -> bytes. + +Layout (local path or ``s3://`` URL): ``/index.parquet`` (columns hash, shard, offset, length) + +``/data/*.bin`` (per-record-zstd a3m). Keyed via :func:`~atomworks.ml.utils.misc.hash_sequence`. Reads go through :class:`~atomworks.ml.utils.blob_store.BlobStore`. +""" + +from collections.abc import Sequence + +from atomworks.ml.utils.blob_store import BlobStore +from atomworks.ml.utils.io import read_parquet_with_metadata +from atomworks.ml.utils.misc import hash_sequence + +# Canonical MSA content-address key: 72-bit sha256 prefix (~1e-6 collision odds at 100M). +MSA_KEY_LEN = 18 +# Legacy 11-char key; probed as a fallback so stores built before MSA_KEY_LEN still resolve. +LEGACY_MSA_KEY_LEN = 11 + + +class PackedMsaStore: + """Look up an MSA's bytes by its ``hash_sequence`` digest. + + Args: + store_url: Store root (local path or ``s3://`` URL) holding ``index.parquet`` + ``data/*.bin``. + endpoint_url: Endpoint URL for ``s3://`` stores (defaults to the ambient AWS config). + key_lens: Digest lengths to probe, in order — canonical ``MSA_KEY_LEN`` first, then legacy fallbacks — + so a store built with an older key length still resolves. + """ + + def __init__( + self, + store_url: str, + *, + endpoint_url: str | None = None, + key_lens: Sequence[int] = (MSA_KEY_LEN, LEGACY_MSA_KEY_LEN), + ): + store_url = str(store_url).rstrip("/") + self._index_url = f"{store_url}/index.parquet" + self._blob = BlobStore(f"{store_url}/data", endpoint_url=endpoint_url) + self._key_lens = tuple(key_lens) + self._index: dict[str, tuple[str, int, int]] | None = None # lazy per-worker + + def _get_index(self) -> dict[str, tuple[str, int, int]]: + if self._index is None: + df = read_parquet_with_metadata(self._index_url) + self._index = { + h: (s, int(o), int(length)) + for h, s, o, length in zip(df["hash"], df["shard"], df["offset"], df["length"], strict=True) + } + return self._index + + def get_bytes_for_seq(self, seq: str) -> bytes | None: + """Return the (decompressed) MSA bytes for ``seq``, or ``None`` if it is not in the store. + + Probes each length in ``key_lens`` (canonical first, legacy fallback next) so older stores still resolve. + """ + index = self._get_index() + for length in self._key_lens: + loc = index.get(hash_sequence(seq, length=length)) + if loc is not None: + return self._blob.get_bytes(*loc) + return None diff --git a/src/atomworks/ml/transforms/openbabel_utils.py b/src/atomworks/ml/transforms/openbabel_utils.py index 9fa54758..93957285 100644 --- a/src/atomworks/ml/transforms/openbabel_utils.py +++ b/src/atomworks/ml/transforms/openbabel_utils.py @@ -10,6 +10,8 @@ `Biotite documentation `_ """ +from __future__ import annotations + import logging from collections import Counter from typing import Any, ClassVar @@ -17,7 +19,6 @@ import biotite.structure as struc import numpy as np from biotite.structure import AtomArray -from openbabel import openbabel, pybel from atomworks.constants import ATOMIC_NUMBER_TO_ELEMENT, ELEMENT_NAME_TO_ATOMIC_NUMBER, UNKNOWN_LIGAND from atomworks.ml.transforms._checks import ( @@ -29,6 +30,25 @@ ) from atomworks.ml.transforms.base import Transform +try: + from openbabel import openbabel, pybel + + _HAS_OPENBABEL = True +except ImportError: + openbabel = None + pybel = None + _HAS_OPENBABEL = False + + +def _require_openbabel() -> None: + """Raise ImportError with install instructions if openbabel is missing.""" + if not _HAS_OPENBABEL: + raise ImportError( + "OpenBabel is required for this function but is not installed. " + "Install it with: pip install atomworks[openbabel]" + ) + + logger = logging.getLogger(__name__) _BIOTITE_BOND_TYPE_TO_OPENBABEL = { @@ -41,6 +61,8 @@ struc.bonds.BondType.AROMATIC_SINGLE: (1, True), struc.bonds.BondType.AROMATIC_DOUBLE: (2, True), struc.bonds.BondType.AROMATIC_TRIPLE: (3, True), + # OpenBabel has no dative/coordination bond type; treat as a single bond (cf. RDKit's DATIVE). + struc.bonds.BondType.COORDINATION: (1, False), } """ Mapping from biotite bond type to openbabel bond order and aromaticity. @@ -137,7 +159,7 @@ def atom_array_to_openbabel( print(f"Number of atoms: {obmol.NumAtoms()}") # Number of atoms: 5 # Print atom information - print("\nAtom information:") + print("\\nAtom information:") for atom in openbabel.OBMolAtomIter(obmol): print( f"Atomic number: {atom.GetAtomicNum()}, Coordinates: ({atom.GetX():.1f}, {atom.GetY():.1f}, {atom.GetZ():.1f})" @@ -150,6 +172,7 @@ def atom_array_to_openbabel( # Atomic number: 7, Coordinates: (0.0, 1.5, 0.0) # Atomic number: 1, Coordinates: (0.0, 0.0, 1.5) """ + _require_openbabel() # Initialize empty OpenBabel molecule obmol = openbabel.OBMol() @@ -235,6 +258,7 @@ def atom_array_from_openbabel(obmol: openbabel.OBMol) -> AtomArray: >>> print(atom_array) AtomArray([Atom(element='6', coord=array([0.0, 0.0, 0.0]), ...)]) """ + _require_openbabel() # Set atoms atoms = [] element_counter = Counter() @@ -254,8 +278,7 @@ def atom_array_from_openbabel(obmol: openbabel.OBMol) -> AtomArray: charge=obatom.GetFormalCharge(), # formal charge hyb=obatom.GetHyb(), # hybridization state is_metal=obatom.IsMetal(), # whether the atom is a metal - nhyd=obatom.GetTotalDegree() - - obatom.GetHvyDegree(), # number of bonded hydrogens (implicit or explicit) + nhyd=obatom.GetImplicitHCount(), # implicit hydrogens only (explicit H are in the bond list) total_deg=obatom.GetTotalDegree(), # total bond count including multiplicities hvydeg=obatom.GetHvyDegree(), # bond count of heavy atoms only, including multiplicities n_implicit_hyd=obatom.GetImplicitHCount(), # number of implicit hydrogens @@ -266,7 +289,6 @@ def atom_array_from_openbabel(obmol: openbabel.OBMol) -> AtomArray: # Set bonds bonds = [] - _explicit_hydrogen_counts = np.zeros(len(atoms), dtype=np.int8) for obbond in openbabel.OBMolBondIter(obmol): obatom_begin = obbond.GetBeginAtom() obatom_end = obbond.GetEndAtom() @@ -275,19 +297,10 @@ def atom_array_from_openbabel(obmol: openbabel.OBMol) -> AtomArray: order = obbond.GetBondOrder() is_aromatic = obbond.IsAromatic() - # ... count explicit hydrogens - if obatom_begin.GetAtomicNum() == 1: - _explicit_hydrogen_counts[end_atom_idx] += order - if obatom_end.GetAtomicNum() == 1: - _explicit_hydrogen_counts[start_atom_idx] += order - bonds.append((start_atom_idx, end_atom_idx, _OPENBABEL_BOND_TYPE_TO_BIOTITE[(order, is_aromatic)])) # ... transform bonds into a biotite BondList atom_array.bonds = struc.BondList(len(atoms), np.array(bonds)) - # Set the `nhyd` annotation for the case of explicit hydrogens - atom_array.set_annotation("n_explicit_hyd", _explicit_hydrogen_counts) - # Set extra annotations annotations = obmol._annotations if hasattr(obmol, "_annotations") else {} if len(annotations) > 0: @@ -345,6 +358,7 @@ def get_chiral_centers(obmol: openbabel.OBMol) -> list[int]: - "bonded_explicit_atom_idxs" (list[int]): A list of indices of the atoms bonded to the chiral center, excluding implicit hydrogens. """ + _require_openbabel() stereo_facade = openbabel.OBStereoFacade(obmol) # iterate over all tetrahedral stereo centers and record the plane pairs that define the tetrahedral side @@ -383,6 +397,7 @@ def smiles_to_openbabel(smiles: str) -> openbabel.OBMol: Note: This function uses the Pybel module to read the SMILES string and convert it to an OBMol object. """ + _require_openbabel() mol = pybel.readstring("smi", smiles) return mol.OBMol @@ -439,6 +454,7 @@ def find_automorphisms(obmol: openbabel.OBMol, max_automorphs: int = 1000, max_m [6 1] [7 2]]] """ + _require_openbabel() n_atoms = obmol.NumAtoms() assert ( n_atoms == obmol.NumHvyAtoms() @@ -520,6 +536,7 @@ def check_input(self, data: dict[str, Any]) -> None: check_atom_array_annotation(data, ["atomize", "atom_id"]) def forward(self, data: dict[str, Any]) -> dict[str, Any]: + _require_openbabel() atom_array: AtomArray = data["atom_array"] # Subset to atomized molecules @@ -609,6 +626,7 @@ def check_input(self, data: dict[str, Any]) -> None: check_atom_array_annotation(data, ["atomize", "atom_id"]) def forward(self, data: dict[str, Any]) -> dict[str, Any]: + _require_openbabel() # Iterate over the molecules (covalently bonded components in the atom_array) data["chiral_centers"] = [] for obmol in data["openbabel"].values(): diff --git a/src/atomworks/ml/transforms/rdkit_utils.py b/src/atomworks/ml/transforms/rdkit_utils.py index fe9c55b8..b5e359bb 100644 --- a/src/atomworks/ml/transforms/rdkit_utils.py +++ b/src/atomworks/ml/transforms/rdkit_utils.py @@ -8,6 +8,8 @@ from rdkit.Chem import AllChem, Mol, rdDistGeom from atomworks.common import default +from atomworks.constants import METAL_ELEMENTS +from atomworks.enums import ChiralType from atomworks.io.tools.rdkit import ( add_hydrogens, atom_array_from_rdkit, @@ -80,6 +82,7 @@ def generate_conformers( optimize: Whether to optimize the generated conformers using UFF. Default is True. **uff_optimize_kwargs: Additional keyword arguments for UFF optimization: + - maxIters: Maximum number of iterations (default 200). - vdwThresh: Used to exclude long-range van der Waals interactions (default 10.0). @@ -202,95 +205,142 @@ def optimize_conformers( Returns: Mol: The optimized RDKit molecule. + + Note: + If UFF optimization fails (e.g., for molecules containing transition metals + without UFF parameters), the unoptimized conformers are returned with a warning. """ - success = AllChem.UFFOptimizeMoleculeConfs( - mol, - numThreads=numThreads, - maxIters=maxIters, - vdwThresh=vdwThresh, - ignoreInterfragInteractions=ignoreInterfragInteractions, - ) - if not success: - logger.warning("Conformer optimization did not converge.") + try: + success = AllChem.UFFOptimizeMoleculeConfs( + mol, + numThreads=numThreads, + maxIters=maxIters, + vdwThresh=vdwThresh, + ignoreInterfragInteractions=ignoreInterfragInteractions, + ) + if not success: + logger.warning("Conformer optimization did not converge.") + except RuntimeError as e: + logger.warning( + f"UFF optimization failed (possibly due to missing force field parameters for transition metals): {e}. " + "Returning unoptimized conformers." + ) return mol -def get_chiral_centers(mol: Mol) -> list[int]: - """Identify and return the tetrahedral chiral centers in an RDKit molecule. +# We sometimes want to exclude centers with sulphur and oxygen (e.g., when in phosphates or sulphates) as they may interconvert +_TETRAHEDRAL_EXCLUDE_ELEMENTS = {15, 16} +_PLANARITY_THRESHOLD = 0.05 - Finds all tetrahedral chiral centers in the given molecule - and returns their information, including the chiral center atom index and - the indices of the atoms bonded to it. - Args: - mol (rdkit.Chem.Mol): The RDKit molecule to analyze. +def _is_false_positive_tetrahedral(atom: Chem.Atom, mol: Mol) -> bool: + """Detect false-positive tetrahedral assignments. - Returns: - - list[dict]: A list of dictionaries, where each dictionary contains: - - "chiral_center_idx" (int): The index of the chiral center atom. - - "bonded_explicit_atom_idxs" (list[int]): A list of indices of the atoms - bonded to the chiral center. - - "chirality" (str): The chirality of the center ('R' or 'S'). + Excludes: + - P/S bonded to 2+ oxygens (phosphate/sulfate). + - Ring atoms bonded to a metal (η-coordinated aromatics). + - Near-planar centers (normalized triple product < threshold). + """ + # Get the neighbors of the atom along with their indices + neighbors = [(b.GetOtherAtom(atom), b.GetOtherAtomIdx(atom.GetIdx())) for b in atom.GetBonds()] - Note: - This function will generate a 3D conformation if one is not present, as - chirality assignment requires 3D coordinates in RDKit to break the conditional - tie between multiple possible chirality centers. + if ( + atom.GetAtomicNum() in _TETRAHEDRAL_EXCLUDE_ELEMENTS + and sum(1 for a, _ in neighbors if a.GetAtomicNum() == 8) >= 2 + ): + # Case: P/S bonded to 2+ oxygens (e.g., in a phosphate or sulphate) often interconvert between stereoisomers + return True + + if atom.GetIsAromatic() and any(a.GetSymbol().upper() in METAL_ELEMENTS for a, _ in neighbors): + # Case: Aromatic ring atoms bonded to a metal (η-coordinated aromatics, e.g. ferrocene Cp) can be + # misassigned as tetrahedral + return True + + if len(neighbors) >= 3 and mol.GetNumConformers() > 0: + conf = mol.GetConformer() + center = np.array(conf.GetAtomPosition(atom.GetIdx())) + vecs = [np.array(conf.GetAtomPosition(neighbors[i][1])) - center for i in range(3)] + tp = abs(np.dot(vecs[0], np.cross(vecs[1], vecs[2]))) + norm_tp = tp / max(np.linalg.norm(vecs[0]) * np.linalg.norm(vecs[1]) * np.linalg.norm(vecs[2]), 1e-8) + if norm_tp < _PLANARITY_THRESHOLD: + # Case: Near-planar centers (normalized triple product < threshold) can be misassigned as tetrahedral due to noise + return True + + return False + + +def get_stereochemistry(mol: Mol, *, tetrahedral_only: bool = False) -> dict[str, list[dict]]: + """Assign 3D stereochemistry and read atom centers and double bonds together. + + Uses input coordinates when present, otherwise retains supplied stereo tags. + Center records have ``chiral_center_idx``, ``bonded_explicit_atom_idxs``, ``chiral_type``, and + ``chiral_center_atom_name``. ``tetrahedral_only`` filters only atom centers. + + Returns: + ``chiral_centers`` and ``double_bonds`` lists. Each bond record has ``stereo`` + (RDKit BondStereo), ``atom_idxs`` ordered as (left, right, left substituent, + right substituent), and corresponding ``atom_names`` (None if unavailable). + Only assigned stereogenic double bonds are reported; indices refer to ``mol``. """ - # Infer 3D coordinates if not present if mol.GetNumConformers() == 0: generate_conformers(mol, n_conformers=1) - # Assign chiral tags based on the 3D structure Chem.AssignAtomChiralTagsFromStructure(mol) + Chem.AssignStereochemistryFrom3D(mol) + + results: list[dict] = [] + for atom in mol.GetAtoms(): + ct = ChiralType(int(atom.GetChiralTag())) + if not ct.is_stereogenic(): + continue + + if tetrahedral_only and not ct.is_tetrahedral(): + continue + + if ct.is_tetrahedral() and _is_false_positive_tetrahedral(atom, mol): + continue + + idx = atom.GetIdx() + results.append( + { + "chiral_center_idx": idx, + "bonded_explicit_atom_idxs": [b.GetOtherAtomIdx(idx) for b in atom.GetBonds()], + "chiral_type": ct, + "chiral_center_atom_name": atom.GetProp("atom_name") if atom.HasProp("atom_name") else None, + } + ) - # Identify chiral centers - chiral_centers = Chem.FindMolChiralCenters(mol, includeUnassigned=True) - - def _should_exclude_chiral_center(atom: Chem.Atom) -> bool: - """Exclude edge cases for chiral centers.""" - # EDGE CASE: The chiral center is a Phosphorous (P, 15) or Sulfur (S, 16) atom bonded to 2 Oxygens (O, 8) - # For an example of where this case occurs, see the CCD ligand `NAP` (e.g., in `5OCM`) - if atom.GetAtomicNum() == 15 or atom.GetAtomicNum() == 16: - # ... get the atomic numbers of the bonded atoms - atomic_nums_of_bonded_atoms = [bond.GetOtherAtom(atom).GetAtomicNum() for bond in atom.GetBonds()] - # ... check if there are 2 Oxygens (O, 8) bonded to the chiral center - if atomic_nums_of_bonded_atoms.count(8) >= 2: - return True - - return False - - # Filter chiral centers with tetrahedral geometry - tetrahedral_chiral_centers = [] - for center in chiral_centers: - idx, chirality = center - atom = mol.GetAtomWithIdx(idx) - if ( - atom.GetChiralTag() == Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CW - or atom.GetChiralTag() == Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CCW + double_bonds = [] + for bond in mol.GetBonds(): + if bond.GetBondType() != Chem.BondType.DOUBLE or bond.GetStereo() not in ( + Chem.BondStereo.STEREOE, + Chem.BondStereo.STEREOZ, + Chem.BondStereo.STEREOCIS, + Chem.BondStereo.STEREOTRANS, ): - chiral_center_atom_name = atom.GetProp("atom_name") if atom.HasProp("atom_name") else None - if not _should_exclude_chiral_center(atom): - tetrahedral_chiral_centers.append( - { - "chiral_center_idx": idx, - "bonded_explicit_atom_idxs": [bond.GetOtherAtomIdx(idx) for bond in atom.GetBonds()], - "chirality": chirality, - # For debugging - currently unused by the pipeline - "chiral_center_atom_name": chiral_center_atom_name, - "bonded_explicit_atom_names": [ - mol.GetAtomWithIdx(bond.GetOtherAtomIdx(idx)).GetProp("atom_name") - for bond in atom.GetBonds() - if chiral_center_atom_name - ] - if chiral_center_atom_name - else None, - } - ) + continue + indices = (bond.GetBeginAtomIdx(), bond.GetEndAtomIdx(), *bond.GetStereoAtoms()) + double_bonds.append( + { + "atom_idxs": indices, + "atom_names": tuple( + mol.GetAtomWithIdx(i).GetProp("atom_name") if mol.GetAtomWithIdx(i).HasProp("atom_name") else None + for i in indices + ), + "stereo": bond.GetStereo(), + } + ) + return {"chiral_centers": results, "double_bonds": double_bonds} - # Remove chiral centers that are R or S bonded to 2 O - return tetrahedral_chiral_centers +def get_chiral_centers(mol: Mol, *, tetrahedral_only: bool = False) -> list[dict]: + """Return the atom-center records from :py:func:`get_stereochemistry`. + + Supports tetrahedral, square planar, trigonal bipyramidal, and octahedral + centers, excluding false-positive tetrahedral assignments. Uses input + coordinates or supplied stereo tags. ``tetrahedral_only`` limits centers. + """ + return get_stereochemistry(mol, tetrahedral_only=tetrahedral_only)["chiral_centers"] def find_automorphisms_with_rdkit( @@ -413,6 +463,7 @@ def sample_rdkit_conformer_for_atom_array( Note: This function preserves the original atom order and properties of the input AtomArray. + If conformer generation fails, coordinates are set to NaN to avoid leaking ground truth. """ seed = seed or _get_random_seed() atom_array = atom_array.copy() @@ -430,7 +481,7 @@ def sample_rdkit_conformer_for_atom_array( except (TimeoutError, RuntimeError): set_coord_if_available = False logger.warning( - f"Failed to generate {n_conformers} conformers for {atom_array.res_name[0]}. Falling back to zeros." + f"Failed to generate {n_conformers} conformers for {atom_array.res_name[0]}. " "Coordinates will be NaN." ) new_atom_array = atom_array_from_rdkit( @@ -677,18 +728,17 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: return data -def get_rdkit_chiral_centers(rdkit_mols: dict[str, Mol]) -> dict: +def get_rdkit_chiral_centers(rdkit_mols: dict[str, Mol], *, tetrahedral_only: bool = True) -> dict: """Computes the chiral centers for a dictionary of RDKit molecules. See the `GetRDKitChiralCenters` transform for more details. """ chiral_centers = {} - # Get chiral centers for all rdkit mols for resname, rdmol in rdkit_mols.items(): try: # Get the chiral centers (returned are the indices of the chiral center atoms within the `obmol` object) - chiral_centers[resname] = get_chiral_centers(rdmol) + chiral_centers[resname] = get_chiral_centers(rdmol, tetrahedral_only=tetrahedral_only) except Exception as e: logger.warning(f"Failed to find chiral centers for molecule {resname}: {e}") @@ -697,43 +747,28 @@ def get_rdkit_chiral_centers(rdkit_mols: dict[str, Mol]) -> dict: class GetRDKitChiralCenters(Transform): - """Identify chiral centers in the RDKit molecules stored in the data["rdkit"] dictionary. - - Returns a dictionary mapping each residue name to a list of chiral centers, e.g: - - .. code-block:: python - - data["chiral_centers"] = { - ... - "ILE": [ - {'chiral_center_idx': 1, 'bonded_explicit_atom_idxs': [0, 2, 4], 'chirality': 'S'}, - {'chiral_center_idx': 4, 'bonded_explicit_atom_idxs': [1, 5, 6], 'chirality': 'S'} - ], - ... - } - - Each chiral center is a dict with a center atom index, 3 or 4 bonded atom indices, and the - RDKit-determined chirality. - - Uses RDKit molecules first computed in GetAF3ReferenceMoleculeFeatures. + """Identify chiral centers in the RDKit molecules stored in ``data["rdkit"]``. - Args: - data: A dictionary containing the input data, including RDKit molecules - under the "rdkit" key. - - Returns: - The updated data dictionary with chiral_centers containing chiral - centers for each molecule. + Populates ``data["chiral_centers"]`` — a dict mapping residue name to + a list of center dicts (see :func:`get_chiral_centers`). """ requires_previous_transforms: ClassVar[list[str | Transform]] = ["GetAF3ReferenceMoleculeFeatures"] + def __init__(self, tetrahedral_only: bool = True) -> None: + """Initialize the transform. + + Args: + tetrahedral_only: If ``True``, restrict output to tetrahedral stereocenters. + """ + self.tetrahedral_only = tetrahedral_only + def check_input(self, data: dict[str, Any]) -> None: check_contains_keys(data, ["rdkit"]) check_is_instance(data, "rdkit", dict) check_nonzero_length(data, "rdkit") def forward(self, data: dict[str, Any]) -> dict[str, Any]: - data["chiral_centers"] = get_rdkit_chiral_centers(data["rdkit"]) + data["chiral_centers"] = get_rdkit_chiral_centers(data["rdkit"], tetrahedral_only=self.tetrahedral_only) return data diff --git a/src/atomworks/ml/transforms/realize_expandable_segments.py b/src/atomworks/ml/transforms/realize_expandable_segments.py new file mode 100644 index 00000000..4b8b1834 --- /dev/null +++ b/src/atomworks/ml/transforms/realize_expandable_segments.py @@ -0,0 +1,60 @@ +"""Transform that realizes expandable segment sentinels in an AtomArrayPlus.""" + +from typing import Any, ClassVar + +import numpy as np + +from atomworks.ml.transforms.base import Transform +from atomworks.ml.utils.expandable_segment import realize_expandable_segments + + +class RealizeExpandableSegments(Transform): + """Realize all expandable segment sentinels in the ``atom_array`` entry of the data dict. + + Each sentinel (``S_SEGMIN.mask(atom_array) == True`` and ``res_name != MASKED``) + is replaced by N coordinate-free ``MASKED`` atom stubs, where N is sampled + uniformly from ``[seg_min, seg_max]``. + + This transform must be applied **before** any transform that tokenizes or + indexes atoms (e.g. cropping), since it changes the atom count. + + Args: + rng: NumPy random generator. ``None`` defers seeding to each call. + + Examples: + >>> transform = RealizeExpandableSegments(rng=np.random.default_rng(0)) + >>> data = transform({"atom_array": atom_array_with_segments}) + + NOTE: This transform should only be used during inference/validation. Many of the incompatible transforms + are from training, which could cause unusual behavior. + """ + + incompatible_previous_transforms: ClassVar[list[str]] = [ + "UnindexFlaggedTokens", + "RemoveUnresolvedPNUnits", + "CropContiguousLikeAF3", # Cropping needs to be done after realizing segments to avoid OOMs + "CropSpatialLikeAF3", + "LoadPolymerMSAs", # MSAs are not resized by this transform and will therefore be the wrong dimension + "PairAndMergePolymerMSAs", + "SampleDesignTask", # avoid strange behaviors with design tasks + # All featurization should take place after this transform + "FeaturizeMSALikeAF3", + ] + + def __init__(self, rng: np.random.Generator | None = None) -> None: + """Initialize RealizeExpandableSegments. + + Args: + rng: NumPy random generator. ``None`` defers seeding to each call. + """ + # No generator here: the transform is pickled to `spawn` workers, which would all + # inherit the same state. realize_expandable_segments() seeds per call. + self._rng = rng + + def check_input(self, data: dict[str, Any]) -> None: + if "atom_array" not in data: + raise ValueError("RealizeExpandableSegments requires 'atom_array' key in data dict.") + + def forward(self, data: dict[str, Any]) -> dict[str, Any]: + data["atom_array"] = realize_expandable_segments(data["atom_array"], rng=self._rng) + return data diff --git a/src/atomworks/ml/transforms/sasa.py b/src/atomworks/ml/transforms/sasa.py index d8eeb5f0..1adf8a89 100644 --- a/src/atomworks/ml/transforms/sasa.py +++ b/src/atomworks/ml/transforms/sasa.py @@ -4,6 +4,7 @@ import biotite.structure as struc import numpy as np from biotite.structure import AtomArray +from biotite.structure.info import vdw_radius_single from atomworks.ml.transforms._checks import ( check_atom_array_annotation, @@ -14,11 +15,94 @@ logger = logging.getLogger("atomworks.ml") +# User-extension dict for VdW radii of elements not known to biotite. +# Checked before biotite's vdw_radius_single in _get_element_radii and +# _get_protor_element_fallback_radii. Populate at runtime to override or +# extend biotite's built-in Mantina 2009 table for exotic elements. +_ELEMENT_VDW_RADII: dict[str, float] = {} + + +def _resolve_element_radius(elem: str) -> float | None: + """Look up VdW radius for a single element string.""" + return _ELEMENT_VDW_RADII.get(elem) or vdw_radius_single(elem) + + +def _get_element_radii(atom_array: AtomArray) -> np.ndarray: + """Get VdW radii for all atoms based on element type (Mantina 2009). + + Checks ``_ELEMENT_VDW_RADII`` first (for user overrides), then falls back + to biotite's ``vdw_radius_single`` which covers the full periodic table. + Raises ValueError only if neither source has a radius. + + Vectorized: resolves each unique element once, then broadcasts. + """ + elements = atom_array.element + unique_elems = np.unique(elements) + elem_to_radius: dict[str, float] = {} + for e in unique_elems: + r = _resolve_element_radius(e) + if r is None: + idx = int(np.where(elements == e)[0][0]) + raise ValueError( + f"No VdW radius found for element '{e}' at atom index {idx} " + f"(atom_name='{atom_array.atom_name[idx]}', res_name='{atom_array.res_name[idx]}'). " + f"Neither _ELEMENT_VDW_RADII nor biotite's vdw_radius_single cover this element. " + f"To add support, update _ELEMENT_VDW_RADII in atomworks.ml.transforms.sasa." + ) + elem_to_radius[e] = r + + radii = np.array([elem_to_radius[e] for e in elements], dtype=np.float32) + return radii + + +def _resolve_protor_radius(res_name: str, atom_name: str) -> float | None: + """Look up ProtOr VdW radius for a single (res_name, atom_name) pair.""" + try: + return struc.info.radii.vdw_radius_protor(res_name, atom_name) + except Exception: + return None + + +def _get_protor_element_fallback_radii(atom_array: AtomArray) -> np.ndarray: + """Get ProtOr radii where available, falling back to element radii otherwise. + + Best of both worlds: uses ProtOr's implicit-H-aware radii for standard + protein residues and element-based Mantina 2009 radii for ligands, metals, + and non-standard residues. + + Vectorized: resolves each unique (res_name, atom_name, element) triple once, + then broadcasts. + """ + triples = list(zip(atom_array.res_name, atom_array.atom_name, atom_array.element, strict=False)) + unique_triples = set(triples) + triple_to_radius: dict[tuple[str, str, str], float] = {} + for rn, an, elem in unique_triples: + r = _resolve_protor_radius(rn, an) + if r is not None: + triple_to_radius[(rn, an, elem)] = r + continue + r_elem = _resolve_element_radius(elem) + if r_elem is None: + idx = int( + np.where((atom_array.res_name == rn) & (atom_array.atom_name == an) & (atom_array.element == elem))[0][ + 0 + ] + ) + raise ValueError( + f"No radius found for atom at index {idx} " + f"(element='{elem}', atom_name='{an}', res_name='{rn}'). " + f"Neither ProtOr nor element radii cover this atom." + ) + triple_to_radius[(rn, an, elem)] = r_elem + + radii = np.array([triple_to_radius[t] for t in triples], dtype=np.float32) + return radii + def calculate_atomwise_sasa( atom_array: AtomArray, probe_radius: float = 1.4, - atom_radii: str | np.ndarray = "ProtOr", + atom_radii: str | np.ndarray = "auto", point_number: int = 100, atom_filter: np.ndarray | None = None, ) -> np.ndarray: @@ -27,32 +111,51 @@ def calculate_atomwise_sasa( with NaN coordinates. The output will have the same length as the input AtomArray, with NaN values for excluded (invalid) atoms. - Args: - probe_radius (float, optional): Van-der-Waals radius of the probe in Angstrom. Defaults to 1.4 (for water). - atom_radii (str | np.ndarray, optional): Atom radii set to use for calculation. Defaults to "ProtOr". "ProtOr" will not get sasa's for hydrogen atoms and some other atoms, like ions or certain atoms with charges - point_number (int, optional): Number of points in the Shrake-Rupley algorithm to sample for calculating SASA. Defaults to 100. - atom_filter (np.ndarray | None, optional): A boolean array of length `atom_array.array_length()` to filter the atoms to calculate SASA for. Defaults to None. Only calculates SASA for filtered atoms + Args: + atom_array: The input AtomArray containing the atomic coordinates. + probe_radius: Van-der-Waals radius of the probe in Angstrom. Defaults to 1.4 (water). + atom_radii: Atom radii to use. Options: + + - "auto": ProtOr where available, element-based fallback for ligands/metals/ + non-standard residues. Best accuracy for mixed protein-ligand structures. Default. + - "element": Element-based VdW radii (Mantina 2009). Works for all atom types + including ligands and metals. + - "ProtOr": Protein-only radii that account for implicit hydrogens (Tsai 1999). + Only covers standard protein residues; ions and non-standard residues + receive NaN SASA (biotite default). Use "auto" for mixed structures. + - np.ndarray: Custom radii array matching atom_array length. + point_number: Number of points in the Shrake-Rupley algorithm. Defaults to 100. + atom_filter: Boolean mask for atoms to include. Defaults to None (all atoms). """ - # 1) Create a boolean vector for valid atoms (no NaNs in their coordinates) has_resolved_coordinates = ~np.isnan(atom_array.coord).any(axis=-1) if atom_filter is None: atom_filter = has_resolved_coordinates else: atom_filter = atom_filter & has_resolved_coordinates - # 2) Slice the array to keep only valid atoms valid_atom_array = atom_array[atom_filter] - # Early return if no valid atoms remain if len(valid_atom_array) == 0: return np.full(atom_array.array_length(), np.nan, dtype=float) - # Compute SASA on only the valid atoms - valid_sasa = struc.sasa( - valid_atom_array, probe_radius=probe_radius, vdw_radii=atom_radii, point_number=point_number - ) + if atom_radii in ("element", "auto"): + if atom_radii == "auto": + radii = _get_protor_element_fallback_radii(valid_atom_array) + else: + radii = _get_element_radii(valid_atom_array) + valid_sasa = struc.sasa( + valid_atom_array, + probe_radius=probe_radius, + vdw_radii=radii, + point_number=point_number, + ignore_ions=False, + ) + else: + vdw_radii_arg = atom_radii[atom_filter] if isinstance(atom_radii, np.ndarray) else atom_radii + valid_sasa = struc.sasa( + valid_atom_array, probe_radius=probe_radius, vdw_radii=vdw_radii_arg, point_number=point_number + ) - # 3) Place valid SASA values back into their original positions full_sasa = np.full(atom_array.array_length(), np.nan, dtype=float) full_sasa[atom_filter] = valid_sasa @@ -62,27 +165,25 @@ def calculate_atomwise_sasa( def calculate_atomwise_rasa( atom_array: AtomArray, probe_radius: float = 1.4, - atom_radii: str | np.ndarray = "ProtOr", + atom_radii: str | np.ndarray = "auto", point_number: int = 100, atom_filter: np.ndarray | None = None, ) -> np.ndarray: """ - Calculate the Relative Solvent-Accessible Surface Area (RASA) for each atom in `atom_array`. - - The RASA is defined as the ratio of the SASA of a residue in a protein structure - to the SASA of the same residue in an extended conformation. + Calculate the Relative Solvent-Accessible Surface Area (RASA) for each atom. - The output will have the same length as the input AtomArray, with NaN values for excluded (invalid) atoms. + RASA = SASA_in_complex / SASA_extended_conformation, where the extended + conformation SASA is computed from the isolated atom's VdW radius. Args: - atom_array (AtomArray): The input AtomArray containing the atomic coordinates. - probe_radius (float, optional): Van-der-Waals radius of the probe in Angstrom. Defaults to 1.4 (for water). - atom_radii (str | np.ndarray, optional): Atom radii set to use for calculation. Defaults to "ProtOr". "ProtOr" will not get sasa's for hydrogen atoms and some other atoms, like ions or certain atoms with charges - point_number (int, optional): Number of points in the Shrake-Rupley algorithm to sample for calculating SASA. Defaults to 100. - atom_filter (np.ndarray | None, optional): A boolean array of length `atom_array.array_length()` to filter the atoms to calculate SASA for. Defaults to None. Only calculates SASA for filtered atoms + atom_array: The input AtomArray containing the atomic coordinates. + probe_radius: Van-der-Waals radius of the probe in Angstrom. Defaults to 1.4. + atom_radii: Atom radii to use. "auto" (default) uses ProtOr where available, + element fallback for the rest. "element" works for all atom types. + "ProtOr" only works for standard protein residues (Tsai 1999). + point_number: Number of Shrake-Rupley sample points. Defaults to 100. + atom_filter: Boolean mask for atoms to include. Defaults to None. """ - default_vdw_radius = 1.8 - # 1) Calculate the SASA for each atom in the atom array try: sasa = calculate_atomwise_sasa( atom_array, @@ -95,25 +196,18 @@ def calculate_atomwise_rasa( logger.error(f"Error calculating SASA: {e}. Defaulting to NaN.") return np.full(atom_array.array_length(), np.nan, dtype=float) - # 2) Calculate the SASA for each atom in an extended conformation - max_value = np.zeros(atom_array.array_length(), dtype=float) - res_names = atom_array.res_name - atom_names = atom_array.atom_name - for i in range(atom_array.array_length()): - # get the vdw radius - try: - vdw_radius = struc.info.radii.vdw_radius_protor(res_names[i], atom_names[i]) - except Exception: - # if the residue name and atom name are not found, set vdw_radius to 1.8 - vdw_radius = default_vdw_radius - if vdw_radius is None: - # if the vdw radius is None, set it to 1.8 - vdw_radius = default_vdw_radius - # calculate the extended conformation - extended_conformation = 4 * np.pi * (vdw_radius + probe_radius) ** 2 - # set the extended conformation to the sasa - max_value[i] = extended_conformation - # 3) Calculate the RASA + try: + if atom_radii == "element": + vdw_radii_arr = _get_element_radii(atom_array) + elif atom_radii in ("ProtOr", "auto"): + vdw_radii_arr = _get_protor_element_fallback_radii(atom_array) + else: + vdw_radii_arr = atom_radii + except (ValueError, KeyError) as e: + logger.error(f"Error getting VdW radii for RASA normalization: {e}. Defaulting to NaN.") + return np.full(atom_array.array_length(), np.nan, dtype=float) + + max_value = 4 * np.pi * (vdw_radii_arr + probe_radius) ** 2 rasa = sasa / max_value return rasa @@ -124,16 +218,18 @@ class CalculateSASA(Transform): def __init__( self, probe_radius: float = 1.4, - atom_radii: Literal["ProtOr"] | np.ndarray = "ProtOr", + atom_radii: Literal["ProtOr", "element", "auto"] | np.ndarray = "auto", point_number: int = 100, ): """ Initialize the CalculateSASA transform. Args: - probe_radius (float, optional): Van-der-Waals radius of the probe in Angstrom. Defaults to 1.4 (for water). - atom_radii (str | np.ndarray, optional): Atom radii set to use for calculation. Defaults to "ProtOr". "ProtOr" will not get sasa's for hydrogen atoms and some other atoms, like ions or certain atoms with charges - point_number (int, optional): Number of points in the Shrake-Rupley algorithm to sample for calculating SASA. Defaults to 100. + probe_radius: Van-der-Waals radius of the probe in Angstrom. Defaults to 1.4. + atom_radii: Atom radii to use. "auto" (default) uses ProtOr where available, + element fallback for the rest. "element" works for all atom types. + "ProtOr" only works for standard protein residues (Tsai 1999). + point_number: Number of Shrake-Rupley sample points. Defaults to 100. """ self.probe_radius = probe_radius self.atom_radii = atom_radii @@ -145,15 +241,7 @@ def check_input(self, data: dict[str, Any]) -> None: check_atom_array_annotation(data, ["res_name"]) def forward(self, data: dict, key_to_add_sasa_to: str = "atom_array") -> dict: - """Calculates SASA and adds it to the data dictionary under the key "atom_array". - - Args: - data: A dictionary containing the input data atomarray. - key_to_add_sasa_to: The key in the data dictionary to add the SASA values to. - - Returns: - The data dictionary with SASA values added. - """ + """Calculates SASA and adds it to the data dictionary under the key "atom_array".""" atom_array: AtomArray = data[key_to_add_sasa_to] sasa = calculate_atomwise_sasa( atom_array, diff --git a/src/atomworks/ml/transforms/symmetry.py b/src/atomworks/ml/transforms/symmetry.py index d9dd1477..d84e5de5 100644 --- a/src/atomworks/ml/transforms/symmetry.py +++ b/src/atomworks/ml/transforms/symmetry.py @@ -1,7 +1,9 @@ import itertools import logging import math -from collections.abc import Hashable, Sequence +import warnings +from collections import defaultdict +from collections.abc import Callable, Hashable, Sequence from typing import Any, ClassVar import biotite.structure as struc @@ -11,8 +13,12 @@ import numpy as np import torch from biotite.structure import AtomArray +from rdkit.Chem import BondType as RDKitBondType +from atomworks.constants import HYDROGEN_LIKE_SYMBOLS +from atomworks.io.tools.rdkit import atom_array_to_rdkit from atomworks.io.utils.bonds import hash_atom_array +from atomworks.io.utils.ccd import get_standard_ccd_codes from atomworks.ml.encoding_definitions import RF2AA_ATOM36_ENCODING, TokenEncoding from atomworks.ml.transforms._checks import check_atom_array_annotation, check_contains_keys, check_is_instance from atomworks.ml.transforms.atomize import AtomizeByCCDName @@ -32,6 +38,9 @@ def find_automorphisms(atom_array: AtomArray) -> np.ndarray: logger = logging.getLogger(__name__) logger.setLevel(logging.WARNING) +# (The order given to a bond whose real order is arbitrary because the group is delocalized) +RESONANT_BOND_ORDER = 1.5 + def apply_automorphs(data: torch.Tensor, automorphs: np.ndarray | torch.Tensor) -> torch.Tensor: """Create data permutations of the input data for each of the automorphs. @@ -853,28 +862,119 @@ def forward(self, data: dict[str, Any]) -> dict[str, Any]: return data -@cache_based_on_subset_of_args(cache_keys=["hash_key"]) +# ... `BondType` is an enum, not an order: AROMATIC_SINGLE is 5, not a quintuple bond. Every +# aromatic flavour is delocalized by definition, so biotite's own perception is honoured here and +# does not depend on RDKit succeeding -- `atom_array_to_rdkit` swallows its own sanitization +# failure, so a silent miss would otherwise match an aromatic ring as a literal kekule structure. +# Anything unrecognised (ANY, COORDINATION) keeps a label of its own, so it neither matches a real +# order nor looks like a triple bond. +_RESONANT_BOND_TYPES = frozenset( + { + struc.BondType.AROMATIC, + struc.BondType.AROMATIC_SINGLE, + struc.BondType.AROMATIC_DOUBLE, + struc.BondType.AROMATIC_TRIPLE, + } +) +_ORDER_BY_BOND_TYPE = { + struc.BondType.SINGLE: 1.0, + struc.BondType.DOUBLE: 2.0, + struc.BondType.TRIPLE: 3.0, + struc.BondType.QUADRUPLE: 4.0, +} + + +def _bond_order(bond_type: int) -> float: + """Map a biotite `BondType` to a comparable order, with every aromatic flavour collapsed.""" + if bond_type in _RESONANT_BOND_TYPES: + return RESONANT_BOND_ORDER + return _ORDER_BY_BOND_TYPE.get(bond_type, -1.0 - float(bond_type)) + + +def _resonance_normalized_bond_orders( + atom_array: AtomArray, n_heavy_neighbors: np.ndarray +) -> dict[frozenset[int], float]: + """Label each bond with an order under which resonance-equivalent bonds compare equal. + + RDKit equalizes conjugated bonds; a second pass equalizes bonds from an atom to its terminal + same-element neighbours (e.g. phosphate/sulfonate ``P=O``/``S=O``). Terminality is judged on + the whole structure, so a bond severed by unit splitting cannot make an atom look terminal. + + Returns: + Bond order per bond, keyed by ``frozenset({i, j})``, ``RESONANT_BOND_ORDER`` where arbitrary. + """ + # Seed every bond with its literal order and build the heavy-atom adjacency. + orders, raw_orders, neighbours = {}, {}, defaultdict(list) + for i, j, order in atom_array.bonds.as_array(): + bond = frozenset((int(i), int(j))) + orders[bond] = _bond_order(int(order)) + raw_orders[bond] = orders[bond] + neighbours[int(i)].append(int(j)) + neighbours[int(j)].append(int(i)) + + try: + # Sanitize to perceive aromaticity/conjugation, then map RDKit atoms back via `rdkit_atom_id`. + mol = atom_array_to_rdkit(atom_array, hydrogen_policy="keep", sanitize=True) + from_mol_index = { + atom.GetIdx(): atom.GetIntProp("rdkit_atom_id") for atom in mol.GetAtoms() if atom.HasProp("rdkit_atom_id") + } + for bond in mol.GetBonds(): + begin, end = from_mol_index.get(bond.GetBeginAtomIdx()), from_mol_index.get(bond.GetEndAtomIdx()) + if begin is None or end is None: + continue # ... a bond to an atom RDKit added; it has no counterpart to relabel + if bond.GetBondType() == RDKitBondType.AROMATIC or ( + bond.GetIsConjugated() and bond.GetBondType() in (RDKitBondType.SINGLE, RDKitBondType.DOUBLE) + ): + orders[frozenset((begin, end))] = RESONANT_BOND_ORDER + except (ValueError, RuntimeError, AssertionError) as exc: + # atom_array_to_rdkit raises MolSanitizeException (a ValueError) or an AssertionError when it + # cannot perceive aromaticity. Equalizing every bond is permissive (it can over-merge), but + # keeps a hard fragment usable rather than aborting the whole search. + logger.warning("RDKit could not perceive conjugation (%s); ignoring bond order for this fragment", exc) + return dict.fromkeys(orders, RESONANT_BOND_ORDER) + + # Equalize delocalized terminal groups RDKit does not flag as conjugated: an atom's terminal + # same-element neighbours (e.g. phosphate/sulfonate oxygens), which are resonance-equivalent. + for centre, adjacent in neighbours.items(): + terminal_by_element = defaultdict(list) + for neighbour in adjacent: + if n_heavy_neighbors[neighbour] == 1: + terminal_by_element[atom_array.element[neighbour]].append(neighbour) + for element, group in terminal_by_element.items(): + bonds = [frozenset((centre, neighbour)) for neighbour in group] + # ... carbon has no lone pair to delocalize with, so differing orders on terminal + # carbons are real (an isopropenyl =CH2 is not its -CH3); and a triple bond is never + # a resonance form of a single one (cyanamide's nitrile N is not its amine N) + if len(group) < 2 or element == "C" or any(raw_orders[bond] == 3.0 for bond in bonds): + continue + for bond in bonds: + orders[bond] = RESONANT_BOND_ORDER + return orders + + +@cache_based_on_subset_of_args(cache_keys=["hash_key", "normalize_bond_orders", "max_automorphs"]) def generate_automorphisms_from_atom_array_with_networkx( - atom_array: AtomArray, + atom_array: AtomArray | Callable[[], AtomArray], max_automorphs: int = 1000, node_features: str | list = "element", - ignore_bond_type: bool = True, + normalize_bond_orders: bool = True, hash_key: Hashable = None, ) -> np.ndarray: """Generate automorphisms of a molecular graph using NetworkX. - In some cases, the automorphisms generated by RDKit or OpenBabel may be overly strict; - e.g., they do not account for resonance. This function uses NetworkX to generate automorphisms - of a molecular graph, which can be more flexible (but in some cases overly permissive). + Matches like RDKit (bonds must agree) but on resonance-normalized orders, so a bond order the + CCD assigned arbitrarily within a delocalized group does not count as a real difference. Args: - atom_array (AtomArray): The input molecular structure as an AtomArray object. + atom_array: The molecular structure, or a factory evaluated only on a cache miss. max_automorphs (int): The maximum number of automorphisms to generate. Default is 1000. node_features (str or list of str): The node-level features to use for coloring nodes. Can be a single feature (e.g., 'element') or a list of features (e.g., ['element', 'charge']). Default is 'element'. - ignore_bond_type (bool): If True, the bond type is ignored when generating automorphisms. Must - be true in order to detect some resonance-based automorphisms. Default is True. + normalize_bond_orders (bool): Match on :py:func:`_resonance_normalized_bond_orders` (the + default; uses the `n_heavy_neighbors` annotation if present, else computes it). + ``False`` ignores bond order entirely and is deprecated, as it admits chemically + wrong automorphisms. hash_key (Hashable): A hashable key to use for caching automorphisms. If None, no caching is used. Used by the decorator `cache_based_on_subset_of_args`, so cannot be deleted (even if unused in this function). @@ -890,14 +990,33 @@ def generate_automorphisms_from_atom_array_with_networkx( [[0, 1, 2], [0, 2, 1]] # Example output for a simple molecule like H2O """ - # ...convert the AtomArray to a NetworkX graph + if callable(atom_array): + atom_array = atom_array() + # ... convert the AtomArray to a NetworkX graph graph = atom_array.bonds.as_graph() - if ignore_bond_type: - # ...set all bond types to None (but preserve the edge existence) + edge_match = None + if normalize_bond_orders: + # ... compute heavy-atom degrees on the fly when a caller has not annotated them + n_heavy_neighbors = ( + atom_array.n_heavy_neighbors + if "n_heavy_neighbors" in atom_array.get_annotation_categories() + else count_heavy_neighbors(atom_array) + ) + orders = _resonance_normalized_bond_orders(atom_array, n_heavy_neighbors) + nx.set_edge_attributes(graph, {(i, j): orders[frozenset((i, j))] for i, j in graph.edges}, "bond_type") + edge_match = iso.categorical_edge_match("bond_type", None) + else: + warnings.warn( + "normalize_bond_orders=False (ignoring bond order) is deprecated; it admits chemically " + "wrong automorphisms. Pass normalize_bond_orders=True.", + DeprecationWarning, + stacklevel=2, + ) + # None preserves edge existence while making every bond compare equal nx.set_edge_attributes(graph, None, "bond_type") - # ...check if we're missing any atoms (e.g., disconnected ions within Heme groups) + # ... check if we're missing any atoms (e.g., disconnected ions within heme groups) if len(graph.nodes) != len(atom_array): for idx in range(len(atom_array)): if idx not in graph.nodes: @@ -907,16 +1026,19 @@ def generate_automorphisms_from_atom_array_with_networkx( if isinstance(node_features, str): node_features = [node_features] - # ...set node attributes based on the specified features + # ... set node attributes based on the specified features # NOTE: Features must be present in the AtomArray annotations for feature in node_features: nx.set_node_attributes( graph, {idx: atom_array.get_annotation(feature)[idx] for idx in range(len(atom_array))}, feature ) - # ...build the automorphism generator + # ... build the automorphism generator matcher = iso.GraphMatcher( - graph, graph, node_match=iso.categorical_node_match(node_features, [""] * len(node_features)) + graph, + graph, + node_match=iso.categorical_node_match(node_features, [""] * len(node_features)), + edge_match=edge_match, ) automorphism_generator = matcher.isomorphisms_iter() @@ -929,7 +1051,7 @@ def generate_automorphisms_from_atom_array_with_networkx( if i >= max_automorphs: break - # ...convert the mapping dictionary to a permutation list + # ... convert the mapping dictionary to a permutation list permutation = [mapping[i] for i in identity_permutation] if permutation != identity_permutation: # Skip the identity permutation @@ -938,82 +1060,129 @@ def generate_automorphisms_from_atom_array_with_networkx( return np.array(permutations) -def find_automorphisms_with_networkx(atom_array: AtomArray, max_automorphs: int = 1000) -> np.ndarray: +def count_heavy_neighbors(atom_array: AtomArray) -> np.ndarray: + """Count the non-hydrogen bond partners of each atom, across the whole structure. + + The count is a per-atom node colour for the automorphism search: it tells a terminal atom + from an internal one and so keeps distinct residues apart. It is computed on the whole array + (not per unit) so a bond severed by unit splitting cannot lower a degree. An array without a + ``BondList`` has no neighbours, so every count is zero. + """ + if atom_array.bonds is None: + return np.zeros(atom_array.array_length(), dtype=int) + bonds = atom_array.bonds.as_array()[:, :2] + is_heavy = ~np.isin(atom_array.element, HYDROGEN_LIKE_SYMBOLS) + return np.bincount(bonds[is_heavy[bonds].all(axis=1)].ravel(), minlength=len(atom_array)) + + +def build_automorphism_units(atom_array: AtomArray, residue_index: np.ndarray | None = None) -> list[np.ndarray]: + """Split an AtomArray into the fragments searched for automorphisms independently. + + One unit per `pn_unit`, except an all-polymer `pn_unit` is split per residue - so a ligand (or + a residue reassigned onto one by covalent-modification handling) is searched whole, while a + plain chain is searched residue-wise. Automorphisms therefore never span a `pn_unit`. + + Returns: + Sorted global atom indices per unit. + """ + n_atoms = atom_array.array_length() + if n_atoms == 0: + return [] + + is_polymer = np.asarray(atom_array.is_polymer, dtype=bool) + if residue_index is None: + residue_starts = struc.get_residue_starts(atom_array) + residue_index = np.searchsorted(residue_starts, np.arange(n_atoms), side="right") + _, pn_unit_index = np.unique(atom_array.pn_unit_iid, return_inverse=True) + + # ... a pn_unit is split per residue only if every one of its atoms is polymer + n_non_polymer = np.bincount(pn_unit_index, weights=~is_polymer, minlength=pn_unit_index.max() + 1) + split_per_residue = (n_non_polymer == 0)[pn_unit_index] + unit_of_atom = np.where(split_per_residue, residue_index, n_atoms + 1 + pn_unit_index) + + atoms_by_unit = np.argsort(unit_of_atom, kind="stable") + return np.split(atoms_by_unit, np.flatnonzero(np.diff(unit_of_atom[atoms_by_unit])) + 1) + + +def find_automorphisms_with_networkx(atom_array: AtomArray, max_automorphs: int = 1000) -> list[np.ndarray]: """ Finds automorphisms in an AtomArray using NetworkX, returning indices of atoms that can be permuted. + The array is split by :py:func:`build_automorphism_units`, and each unit is matched on element + and whole-structure heavy-atom degree, with bond orders normalized by + :py:func:`_resonance_normalized_bond_orders` so that resonance is honoured but a localized + double bond still distinguishes the atoms it joins. + Args: atom_array (AtomArray): The input AtomArray object. Must have the following annotations: `pn_unit_iid`, `is_polymer`, `res_id`, `res_name`, `atom_name`, and `element`. max_automorphs (int, optional): The maximum number of automorphisms to generate. Default is 1000. Returns: - np.ndarray: A Python list of arrays, each containing indices of atoms that can be permuted within the global - frame of the input `atom_array`. + list[np.ndarray]: One array per unit, each of shape `(n_automorphisms, n_unit_atoms)` holding + global atom indices, with the identity permutation first. Example: >>> automorphisms = find_automorphisms_with_networkx(atom_array) - # Output: # [ - # array([ # E.g., corresponding to the first residue - # [0, 1, 2, 3, 4, 5], # The first row is the identity permutation - # [0, 1, 2, 3, 5, 4] # Atoms with global indices 4 and 5 are swappable - # ]), - # array([ # E.g., corresponding to the second residue - # [6, 7, 8, 9, 10, 11], # The first row is the identity permutation. Indices are global (within the AtomArray). - # ]) + # array([[0, 1, 2, 3, 4, 5], # identity; indices are global + # [0, 1, 2, 3, 5, 4]]), # atoms 4 and 5 are swappable + # array([[6, 7, 8, 9, 10, 11]]), # ] - # Each sub-array represents indices of atoms that can be permuted within the global frame. """ + standard_ccd_codes = get_standard_ccd_codes() + n_heavy_neighbors = count_heavy_neighbors(atom_array) + residue_starts = struc.get_residue_starts(atom_array) + residue_index = np.searchsorted(residue_starts, np.arange(atom_array.array_length()), side="right") + is_polymer = np.asarray(atom_array.is_polymer, dtype=bool) + has_charge = "charge" in atom_array.get_annotation_categories() + all_automorphs = [] + for indices in build_automorphism_units(atom_array, residue_index=residue_index): + unit = None + unit_residues = residue_index[indices] + # Stable unit grouping keeps indices sorted, so equal endpoints imply one residue. + ranks = ( + np.zeros(len(indices), dtype=unit_residues.dtype) + if unit_residues[0] == unit_residues[-1] + else np.unique(unit_residues, return_inverse=True)[1] + ) + residue_rank = np.where(is_polymer[indices], ranks, -1) - # ...iterate through pn_unit_iids - for pn_unit_iid in np.unique(atom_array.pn_unit_iid): - pn_unit_mask = atom_array.pn_unit_iid == pn_unit_iid - - # If a polymer, we find isomorphisms residue-wise (since we don't need to worry about multi-residue or multi-chain ligands) - if atom_array.is_polymer[pn_unit_mask].all(): - # ...iterate through residues - for res_id in np.unique(atom_array.res_id[pn_unit_mask]): - # Global mask for the current residue - residue_mask = pn_unit_mask & (atom_array.res_id == res_id) - - # Create a hashable key using residue name and atom names - hash_key = (atom_array.res_name[residue_mask][0], tuple(atom_array.atom_name[residue_mask])) - - # ...find automorphisms - automorphs = generate_automorphisms_from_atom_array_with_networkx( - atom_array[residue_mask], - max_automorphs=max_automorphs, - node_features=["element"], - ignore_bond_type=True, - hash_key=hash_key, - ) - - # ...get the indices of the atoms with respect to the global frame - global_atom_indices = np.where(residue_mask)[0] - automorphs = global_atom_indices[automorphs] - - all_automorphs.append(automorphs) - # If a non-polymer, find automorphisms for the entire pn_unit (which may include multiple residues) - else: - # Create a hashable key that is all residue ID's concatenated and the atom names - hash_key = (tuple(atom_array.element[pn_unit_mask]), tuple(atom_array.atom_name[pn_unit_mask])) - - # ...find automorphisms - automorphs = generate_automorphisms_from_atom_array_with_networkx( - atom_array[pn_unit_mask], - max_automorphs=max_automorphs, - node_features=["element"], - ignore_bond_type=True, - hash_key=hash_key, + res_names = atom_array.res_name[indices].tolist() + if all(res_name in standard_ccd_codes for res_name in set(res_names)): + bonds = tuple( + sorted((min(i, j), max(i, j), order) for i, j, order in atom_array.bonds[indices].as_array().tolist()) ) - - # ...get the indices of the atoms with respect to the global frame - global_atom_indices = np.where(pn_unit_mask)[0] - automorphs = global_atom_indices[automorphs] - - all_automorphs.append(automorphs) + hash_key = (tuple(res_names), tuple(atom_array.atom_name[indices].tolist()), bonds) + else: + unit = atom_array[indices] + hash_key = hash_atom_array(unit, annotations=["element"], bond_order=True) + + charge_key = tuple(map(int, atom_array.charge[indices].tolist())) if has_charge else () + + def materialize_unit( + indices: np.ndarray = indices, unit: AtomArray | None = unit, residue_rank: np.ndarray = residue_rank + ) -> AtomArray: + annotated = atom_array[indices] if unit is None else unit + annotated.set_annotation("n_heavy_neighbors", n_heavy_neighbors[indices]) + annotated.set_annotation("polymer_residue_rank", residue_rank) + return annotated + + automorphs = generate_automorphisms_from_atom_array_with_networkx( + materialize_unit, + max_automorphs=max_automorphs, + node_features=["element", "n_heavy_neighbors", "polymer_residue_rank"], + normalize_bond_orders=True, + hash_key=( + hash_key, + tuple(n_heavy_neighbors[indices].tolist()), + tuple(residue_rank.tolist()), + charge_key, + max_automorphs, + ), + ) + all_automorphs.append(indices[automorphs]) # We do not concatenate automorphisms to avoid building a large, sparse tensor return all_automorphs diff --git a/src/atomworks/ml/utils/__init__.py b/src/atomworks/ml/utils/__init__.py index e69de29b..6cc05b2d 100644 --- a/src/atomworks/ml/utils/__init__.py +++ b/src/atomworks/ml/utils/__init__.py @@ -0,0 +1,10 @@ +# Re-exported for backwards compatibility +from atomworks.common import ( + ChildProcessError, + _AllowSubprocessForDeamonicProcess, + _TimeoutHandlerStatus, + do_nothing, + timeout, + timeout_using_signal, + timeout_using_subprocess, +) diff --git a/src/atomworks/ml/utils/annotator.py b/src/atomworks/ml/utils/annotator.py new file mode 100644 index 00000000..bc7c7db9 --- /dev/null +++ b/src/atomworks/ml/utils/annotator.py @@ -0,0 +1,168 @@ +""" +An extension of atomworks.io.utils.standard_annotations.annotator. +This module includes annotators that rely on functionality in atomworks.ml +""" + +import functools +import logging +from collections.abc import Generator + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray +from jaxtyping import Bool, Float, Int + +from atomworks.constants import MASKED +from atomworks.io.tools.rdkit import atom_array_to_rdkit, ccd_code_to_rdkit +from atomworks.io.utils.annotator import * # noqa: F403 # re-export all annotators to preserve imports +from atomworks.io.utils.annotator import _register_lazy_annotator, _requires_annotations +from atomworks.io.utils.ccd import _standard_ccd_only_cache +from atomworks.io.utils.scatter import apply_and_spread_segment_wise +from atomworks.io.utils.selection import get_residue_starts +from atomworks.ml.transforms.atom_array import get_within_group_res_idx, get_within_group_source_res_idx +from atomworks.ml.transforms.rdkit_utils import get_stereochemistry +from atomworks.ml.utils.token import get_token_starts + +logger = logging.getLogger(__name__) + +Array = np.ndarray +"""Alias for numpy.ndarray""" + + +@_register_lazy_annotator("is_token_start") +def is_token_start(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F405 + """Mark the first atom of each token.""" + token_starts = get_token_starts(atom_array, add_exclusive_stop=False) + is_token_start = np.zeros(atom_array.array_length(), dtype=bool) + is_token_start[token_starts] = True + return is_token_start + + +@_register_lazy_annotator("within_chain_res_idx") +def within_chain_res_idx(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F405 + """Get the within-chain residue index for the atom array.""" + annotations = atom_array.get_annotation_categories() + chain_group = "chain_iid" if "chain_iid" in annotations else "chain_id" + return get_within_group_res_idx(atom_array, group_by=chain_group) + + +@_register_lazy_annotator("within_chain_source_res_idx") +def within_chain_source_res_idx(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F405 + """Gets the within chain res idx, but relies on the res_id field to preserve gaps that may exist in the original sequence""" + annotations = atom_array.get_annotation_categories() + chain_group = "chain_iid" if "chain_iid" in annotations else "chain_id" + return get_within_group_source_res_idx(atom_array, group_by=chain_group) + + +@_register_lazy_annotator("token_min_occupancy") +@_requires_annotations("is_token_start") +def token_min_occupancy(atom_array: AtomArray) -> Float[Array, "n_atoms"]: # noqa: F405 + """Calculate minimum occupancy for each token.""" + is_token_start = atom_array.get_annotation("is_token_start") + token_start_idxs = np.where(is_token_start)[0] + token_segments = np.concatenate([token_start_idxs, [atom_array.array_length()]]) + return apply_and_spread_segment_wise(token_segments, atom_array.occupancy, np.min) + + +@_register_lazy_annotator("token_id") +@_requires_annotations("is_token_start") +def token_id(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F405 + """Assign a unique ID to each token.""" + is_token_start = atom_array.get_annotation("is_token_start") + token_start_idxs = np.where(is_token_start)[0] + token_id = np.arange(sum(is_token_start)) + token_segments = np.concatenate([token_start_idxs, [atom_array.array_length()]]) + return struc.segments.spread_segment_wise(token_segments, token_id) + + +@_standard_ccd_only_cache(functools.lru_cache(maxsize=1000)) +def _get_stereochemistry_for_ccd_code(ccd_code: str) -> dict[str, list[dict]]: + """Get both stereo readouts for a CCD code via one cached RDKit assignment.""" + mol = ccd_code_to_rdkit(ccd_code) + return get_stereochemistry(mol) + + +def _iter_residue_stereochemistry(atom_array: AtomArray) -> Generator[tuple[int, int, dict], None, None]: + """Yield residue bounds and CCD reference stereochemistry, falling back to its atoms.""" + res_starts = get_residue_starts(atom_array, add_exclusive_stop=True) + starts, stops = res_starts[:-1], res_starts[1:] + + for start, stop in zip(starts, stops, strict=False): + res_name = atom_array.res_name[start] + + if res_name == MASKED: + continue # skip masked residues with unknown chemistry + + try: + # Cached lookup for both stereo readouts based on CCD code, if available + stereo = _get_stereochemistry_for_ccd_code(str(res_name)) + except Exception as e: + # Fallback to RDKit-based computation if CCD code lookup fails (e.g., non-standard residues) + logger.debug("CCD stereochemistry lookup failed for %s: %s", res_name, e, exc_info=True) + try: + mol = atom_array_to_rdkit(atom_array[start:stop], set_coord=True) + stereo = get_stereochemistry(mol) + except Exception as e: + logger.debug("RDKit stereochemistry fallback failed for %s: %s", res_name, e, exc_info=True) + continue + + yield int(start), int(stop), stereo + + +def _iter_chiral_centers(atom_array: AtomArray) -> list[tuple[int, int, int, dict]]: + """Return residue bounds, global center indices, and reference center metadata.""" + results = [] + for start, stop, stereo in _iter_residue_stereochemistry(atom_array): + res_atom_names = atom_array.atom_name[start:stop] + for center_info in stereo["chiral_centers"]: + center_name = center_info.get("chiral_center_atom_name") + if center_name is None: + continue + + # Find the index of the chiral center atom within the residue + matches = np.where(res_atom_names == center_name)[0] + if len(matches) > 0: + # Add the global index of the chiral center atom and its metadata to the results + results.append((int(start), int(stop), start + matches[0], center_info)) + + return results + + +@_register_lazy_annotator("stereogenic_bond_atoms") +def get_stereogenic_bond_atoms(atom_array: AtomArray) -> Bool[Array, "n_atoms"]: # noqa: F405 + """Annotate only the two endpoints of each assigned stereogenic double bond. + + Adds a boolean ``stereogenic_bond_atoms`` annotation in place if absent. + Reference substituents need not be present; both endpoints and their double + bond must be present. Cross-residue stereo perception is not supported. + """ + mask = np.zeros(len(atom_array), dtype=bool) + if atom_array.bonds is None: + return mask + for start, stop, stereo in _iter_residue_stereochemistry(atom_array): + if not stereo["double_bonds"]: + continue + by_name = dict(zip(atom_array.atom_name[start:stop], range(start, stop), strict=True)) + if len(by_name) != stop - start: + raise ValueError("Double-bond stereo requires unique atom names within each residue") + for bond in stereo["double_bonds"]: + names = bond["atom_names"][:2] + if any(name not in by_name for name in names): + continue + left, right = (by_name[name] for name in names) + neighbors, types = atom_array.bonds.get_bonds(left) + if np.any((neighbors == right) & (types == struc.BondType.DOUBLE)): + mask[[left, right]] = True + return mask + + +@_register_lazy_annotator("chiral_type") +def chiral_type(atom_array: AtomArray) -> Int[Array, "n_atoms"]: # noqa: F405 + """Compute ``chiral_type`` annotation (int8) for ``atom_array``. + + Values are :class:`~atomworks.enums.ChiralType` int values; ``0`` = not a chiral center. + """ + types = np.zeros(atom_array.array_length(), dtype=np.int8) + for _, _, idx, info in _iter_chiral_centers(atom_array): + types[idx] = int(info.get("chiral_type", 0)) + return types diff --git a/src/atomworks/ml/utils/antibody.py b/src/atomworks/ml/utils/antibody.py new file mode 100644 index 00000000..312227b5 --- /dev/null +++ b/src/atomworks/ml/utils/antibody.py @@ -0,0 +1,243 @@ +"""Dataclasses and constants for antibody/TCR Ig chain annotation. + +Defines the chain-type classification constants and the :class:`IgAnnotation` +family of dataclasses used to represent ANARCII-numbered CDR/framework boundaries. +""" + +import json +from dataclasses import dataclass + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray + +#### Constants #### + +""" +IG constants for antibody preprocessing and transform pipelines +""" +ANTIBODY_CHAIN_TYPES: frozenset[str] = frozenset({"H", "K", "L"}) +TCR_CHAIN_TYPES: frozenset[str] = frozenset({"A", "B", "G", "D"}) +ALL_IG_CHAIN_TYPES: frozenset[str] = ANTIBODY_CHAIN_TYPES | TCR_CHAIN_TYPES + +IG_CHAIN_TYPE_NAMES: dict[str, str] = { + "H": "heavy", + "K": "light", + "L": "light", + "A": "alpha", + "B": "beta", + "G": "gamma", + "D": "delta", +} + +# ============================================================================= +# North CDR definitions under IMGT numbering (inclusive on both ends). +# ============================================================================= +NORTH_CDR_RANGES_IMGT: dict[str, dict[str, tuple[int, int]]] = { + "heavy": {"cdr1": (24, 40), "cdr2": (55, 66), "cdr3": (105, 117), "cdr4": (80, 87)}, + "light": {"cdr1": (24, 40), "cdr2": (55, 69), "cdr3": (105, 117), "cdr4": (80, 87)}, +} + +# IMGT variable-domain position range (inclusive). IMGT V-domain numbering is 1-128. +VARIABLE_DOMAIN_IMGT_RANGE: tuple[int, int] = (1, 128) + +# Map a normalized Ig chain-type name (see :data:`IG_CHAIN_TYPE_NAMES`) to its North +# CDR group. TCR chains reuse the antibody definitions by heavy/light analogy: +# beta/delta -> heavy-like, alpha/gamma -> light-like. +CDR_CHAIN_GROUP: dict[str, str] = { + "heavy": "heavy", + "light": "light", + "beta": "heavy", + "delta": "heavy", + "alpha": "light", + "gamma": "light", +} + +################### + + +@dataclass +class CdrRegion: + """CDR sequence region with 0-indexed slice boundaries into the canonical sequence.""" + + start_seq_idx: int + end_seq_idx: int + sequence: str + + +@dataclass +class IgDomain: + """One numbered Ig variable domain (VH, VL, VA, VB, etc.).""" + + chain_type: str + scheme: str + fw_start_seq_idx: int + fw_end_seq_idx: int + cdrs: dict[str, CdrRegion] + + +@dataclass +class IgAnnotation: + """Full Ig annotation for one pn_unit, potentially multi-domain (e.g. scFv). + + Examples: + Round-trip through JSON: + + >>> ann = IgAnnotation.from_json(json_str) + >>> ann.concatenated_cdr_sequence + 'GFTFSSYGISHWVRQAPGKGLEWVA...' + """ + + ig_type: str + domains: list[IgDomain] + concatenated_cdr_sequence: str + + def to_dict(self) -> dict: + """Serialize to a JSON-compatible dict.""" + return { + "ig_type": self.ig_type, + "domains": [ + { + "chain_type": d.chain_type, + "scheme": d.scheme, + "fw_start_seq_idx": d.fw_start_seq_idx, + "fw_end_seq_idx": d.fw_end_seq_idx, + "cdrs": { + k: { + "start_seq_idx": v.start_seq_idx, + "end_seq_idx": v.end_seq_idx, + "sequence": v.sequence, + } + for k, v in d.cdrs.items() + }, + } + for d in self.domains + ], + "concatenated_cdr_sequence": self.concatenated_cdr_sequence, + } + + @classmethod + def from_dict(cls, data: dict) -> "IgAnnotation": + """Reconstruct an :class:`IgAnnotation` from its serialized dict form.""" + domains = [ + IgDomain( + chain_type=d["chain_type"], + scheme=d["scheme"], + fw_start_seq_idx=d["fw_start_seq_idx"], + fw_end_seq_idx=d["fw_end_seq_idx"], + cdrs={ + k: CdrRegion( + start_seq_idx=v["start_seq_idx"], + end_seq_idx=v["end_seq_idx"], + sequence=v.get("sequence", ""), + ) + for k, v in d["cdrs"].items() + }, + ) + for d in data["domains"] + ] + return cls( + ig_type=data["ig_type"], + domains=domains, + concatenated_cdr_sequence=data["concatenated_cdr_sequence"], + ) + + @classmethod + def from_json(cls, json_str: str) -> "IgAnnotation": + """Deserialize a single :class:`IgAnnotation` from a JSON string.""" + return cls.from_dict(json.loads(json_str)) + + def get_masks(self, atom_array: "AtomArray", pn_unit_iid: str) -> tuple[dict[str, np.ndarray], np.ndarray]: + """Return per-CDR masks and the Ig chain mask for this pn_unit. + + Extracts atom-level masks by mapping stored sequence indices to residues in + atom_array. Verifies that the residue sequence at each CDR's stored indices + matches the stored CDR sequence before accepting it; CDRs with a mismatching + sequence (e.g. due to cropping) are silently skipped. + + Args: + atom_array: Structure with a ``pn_unit_iid`` annotation. + pn_unit_iid: The pn_unit instance ID for this Ig chain. + + Returns: + Tuple ``(cdr_masks, ig_chain_mask)``: + + - ``cdr_masks``: dict mapping CDR name (e.g. ``"cdr1"``) to a bool + atom-level mask. When multiple domains share a CDR name their masks + are OR-combined. + - ``ig_chain_mask``: bool atom-level mask spanning all framework + CDR + residues for this pn_unit. + + Both arrays have length ``atom_array.array_length()``. + + Raises: + ValueError: If ``pn_unit_iid`` is absent from ``atom_array``. + + Examples: + >>> cdr_masks, ig_chain_mask = annotation.get_masks(atom_array, "H_1") + >>> cdr3_mask = cdr_masks.get("cdr3", np.zeros(len(atom_array), dtype=bool)) + """ + from biotite.sequence import ProteinSequence + + n = atom_array.array_length() + pn_unit_atom_mask = atom_array.pn_unit_iid == pn_unit_iid + if not pn_unit_atom_mask.any(): + raise ValueError(f"pn_unit_iid '{pn_unit_iid}' not found in atom_array") + + pn_unit_atoms = atom_array[pn_unit_atom_mask] + global_indices = np.where(pn_unit_atom_mask)[0] + + # res_starts has shape (n_res + 1,): last element is len(pn_unit_atoms) + res_starts = struc.get_residue_starts(pn_unit_atoms, add_exclusive_stop=True) + n_res = len(res_starts) - 1 + + def _one_letter(local_res_idx: int) -> str: + atom_idx = res_starts[local_res_idx] + try: + return ProteinSequence.convert_letter_3to1(pn_unit_atoms.res_name[atom_idx]) + except Exception: + return "X" + + def _global_atom_indices(local_res_idx: int) -> np.ndarray: + return global_indices[res_starts[local_res_idx] : res_starts[local_res_idx + 1]] + + cdr_masks: dict[str, np.ndarray] = {} + ig_chain_mask = np.zeros(n, dtype=bool) + + for domain in self.domains: + fw_s = domain.fw_start_seq_idx + fw_e = domain.fw_end_seq_idx # stored as exclusive + + # Mark Ig chain atoms over the full framework span + for ri in range(fw_s, min(fw_e, n_res)): + ig_chain_mask[_global_atom_indices(ri)] = True + + for cdr_name, cdr_region in domain.cdrs.items(): + cs = cdr_region.start_seq_idx + ce = cdr_region.end_seq_idx + expected = cdr_region.sequence + + if cs >= n_res: + continue + + # Try inclusive end, then exclusive end (handles off-by-one in source data) + seq_inc = "".join(_one_letter(i) for i in range(cs, min(ce + 1, n_res))) + seq_exc = "".join(_one_letter(i) for i in range(cs, min(ce, n_res))) + + if seq_inc == expected: + cdr_res_range = range(cs, min(ce + 1, n_res)) + elif seq_exc == expected: + cdr_res_range = range(cs, min(ce, n_res)) + else: + continue # sequence mismatch after crop or numbering change + + cdr_atom_mask = np.zeros(n, dtype=bool) + for ri in cdr_res_range: + cdr_atom_mask[_global_atom_indices(ri)] = True + + if cdr_name in cdr_masks: + cdr_masks[cdr_name] |= cdr_atom_mask + else: + cdr_masks[cdr_name] = cdr_atom_mask + + return cdr_masks, ig_chain_mask diff --git a/src/atomworks/ml/utils/blob_store.py b/src/atomworks/ml/utils/blob_store.py new file mode 100644 index 00000000..8ad97b7f --- /dev/null +++ b/src/atomworks/ml/utils/blob_store.py @@ -0,0 +1,117 @@ +"""Ranged-read store and memory-mapped index for per-record-zstd ``.bin`` shards on local disk or a remote store.""" + +import os + +import pyarrow as pa +import pyarrow.feather as feather + +from atomworks.ml.utils.io import ( + _decompress, + build_feather_once, + job_scoped_feather_path, + read_parquet_with_metadata, + read_s3_bytes, +) + + +class BlobStore: + """Read one compressed record by ``(shard, offset, length)`` from a local directory or a remote store. + + A remote ``shard`` (an object-store URL) is fetched with a ranged ``GET`` via ``endpoint_url``; only + config is stored, so the store is picklable across DataLoader workers. + """ + + def __init__(self, data_dir: str, *, endpoint_url: str | None = None): + self.data_dir = str(data_dir).rstrip("/") + self.endpoint_url = endpoint_url + + def get_bytes(self, shard: str, offset: int, length: int) -> bytes: + """Fetch and decompress the record at ``[offset, offset+length)`` of ``shard``.""" + url = f"{self.data_dir}/{shard}" + if "://" in url: + raw = read_s3_bytes(url, offset=offset, length=length, endpoint_url=self.endpoint_url) + else: + with open(url, "rb") as f: + f.seek(offset) + raw = f.read(length) + return _decompress(raw) + + +class BlobIndex: + """Memory-mapped ``id_column`` -> ``(shard, offset, length)`` lookup for a blob store. + + Builds a node-local sorted feather once (under a lock), then mmaps it read-only and binary-searches it — + so even a huge index is shared across DataLoader workers with ~no resident heap (``O(log N)`` lookups). + """ + + def __init__( + self, + index_path: str, + *, + id_column: str = "example_id", + local_drive_mount: str | None = None, + job_id_env_var: str = "SLURM_JOB_ID", + ): + self.index_path = str(index_path) + self.id_column = id_column + self.local_drive_mount = local_drive_mount or os.environ.get("LOCAL_DRIVE_MOUNT", "/tmp") + self.job_id_env_var = job_id_env_var + self.feather_path = self._build() + self._open() + + def _build(self) -> str: + """Build the sorted feather once (under a lock); return its path.""" + name = "blobidx_" + self.index_path.replace("://", "_").replace("/", "_").replace(":", "_") + feather_path = job_scoped_feather_path( + name, + local_drive_mount=self.local_drive_mount, + job_id_env_var=self.job_id_env_var, + # `name` covers index_path only; build() also selects and sorts by id_column. + content_key=self.id_column, + ) + + def build() -> pa.Table: + df = read_parquet_with_metadata(self.index_path, columns=[self.id_column, "shard", "offset", "length"]) + tbl = pa.Table.from_pandas(df, preserve_index=False) + # Promote string -> large_string (int64 offsets): a huge id column exceeds Arrow's 2 GB + # int32 string-offset limit, which would overflow in combine_chunks below. + fields = [f.with_type(pa.large_string()) if f.type == pa.string() else f for f in tbl.schema] + tbl = tbl.cast(pa.schema(fields)) + # Sort by id + collapse to one chunk so mmapped positional indexing is O(1) (binary search). + return tbl.sort_by(self.id_column).combine_chunks() + + return build_feather_once(feather_path, build) + + def _open(self) -> None: + self._table = feather.read_table(self.feather_path, memory_map=True) + self._ids = self._table.column(self.id_column).combine_chunks() # single mmapped StringArray + self._shard = self._table.column("shard").combine_chunks() + self._offset = self._table.column("offset").combine_chunks() + self._length = self._table.column("length").combine_chunks() + + def __getstate__(self) -> dict: + # Pickle the built feather path, not the mmapped arrays (re-mmapped per worker in __setstate__). + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} + + def __setstate__(self, state: dict) -> None: + self.__dict__.update(state) + self._open() + + def __len__(self) -> int: + return self._table.num_rows + + def lookup(self, example_id: str) -> tuple[str, int, int]: + """Return ``(shard, offset, length)`` for ``example_id``; raise ``KeyError`` if absent.""" + # Binary search over the build-time-sorted id column (Arrow byte-order sort == Python str compare + # for UTF-8); a null id sorts first, so treat it as "less than". + lo, hi, ids = 0, len(self._ids), self._ids + while lo < hi: + mid = (lo + hi) // 2 + mid_id = ids[mid].as_py() + if mid_id is None or mid_id < example_id: + lo = mid + 1 + else: + hi = mid + if lo >= len(ids) or ids[lo].as_py() != example_id: + raise KeyError(f"{example_id!r} not found in blob index {self.index_path}") + return self._shard[lo].as_py(), self._offset[lo].as_py(), self._length[lo].as_py() diff --git a/src/atomworks/ml/utils/condition.py b/src/atomworks/ml/utils/condition.py index cdafefe2..9dc27bad 100644 --- a/src/atomworks/ml/utils/condition.py +++ b/src/atomworks/ml/utils/condition.py @@ -1,335 +1,68 @@ -"""Utilities for saving and loading atom arrays with condition annotations to/from CIF files.""" +"""Utilities for condition annotations on AtomArray objects. + +The canonical save/load path is now :py:func:`atomworks.io.utils.io_utils.to_cif_file` +(writing) and :py:func:`atomworks.io.parse` / :py:func:`atomworks.io.utils.io_utils.load_any` +(reading), both of which handle all registered +:py:class:`~atomworks.io.utils.standard_annotations.base.StandardAnnotationBase` annotations automatically +when ``save_standard_annotations=True`` / ``load_standard_annotations=True``. +""" -import logging import warnings -from os import PathLike -import numpy as np -from biotite.structure import AtomArray, AtomArrayStack -from biotite.structure.io import pdbx +from biotite.structure import AtomArray -from atomworks.io import parse -from atomworks.io.parser import STANDARD_PARSER_ARGS +from atomworks.io.utils.annotator import ensure_annotations from atomworks.io.utils.atom_array_plus import ( - AnnotationList2D, AtomArrayPlus, - AtomArrayPlusStack, as_atom_array_plus, ) -from atomworks.io.utils.io_utils import suppress_logging_messages, to_cif_file -from atomworks.io.utils.selection import get_annotation, get_annotation_categories -from atomworks.ml.conditions.annotator import ANNOTATOR_REGISTRY, ensure_annotations -from atomworks.ml.conditions.base import CONDITIONS, ConditionBase - - -def save_atom_array_with_conditions_to_cif(atom_array: AtomArray, path: PathLike) -> None: - """ - Saves an annotated atom array to a CIF file. Uses Condition registry to get all possible - annotations and saves them as extra_categories. - - Args: - atom_array: The atom array to save. - path: The path to save the CIF file to. - """ - annotations = set(get_annotation_categories(atom_array, n_body="all")) +from atomworks.ml.conditions import CONDITIONS - extra_categories = {} - for condition_cls in CONDITIONS: - if condition_cls.mask_name in annotations: # always save mask if available - mask = condition_cls.mask(atom_array) - idx, value = _condition_to_idxs_values(mask, mask, n_body=condition_cls.n_body) - extra_categories[condition_cls.mask_name] = _idxs_values_to_cif_dict(idx, value) - if condition_cls.full_name in annotations and not condition_cls.is_mask: - annotation = condition_cls.annotation(atom_array) - mask = condition_cls.mask(atom_array) - idx, value = _condition_to_idxs_values(mask, annotation, n_body=condition_cls.n_body) - extra_categories[condition_cls.full_name] = _idxs_values_to_cif_dict(idx, value) - - if "atomize" in annotations: - idx = np.where(atom_array.atomize)[0] - value = np.ones(len(idx), dtype=int) - extra_categories["atomize"] = _idxs_values_to_cif_dict(idx, value) - - to_cif_file( - atom_array, - path=path, - extra_categories=extra_categories, - ) - - -def load_atom_array_with_conditions_from_cif( - file: PathLike, - *, - assembly_id: str = "1", - cif_parser_args: dict | None = None, - return_data_dict: bool = False, - fill_missing_conditions: bool = False, -) -> AtomArray | dict: - """Loads an atom array from a CIF file with condition annotations. +def fill_missing_conditions_with_defaults(atom_array: AtomArray) -> AtomArray: + """Fill missing condition annotations with their defaults. - Uses the Condition registry to get all possible annotations and loads them from the CIF file. + .. deprecated:: + Use :py:meth:`~atomworks.io.utils.standard_annotations.base.ConditionAccessor.fill_missing_conditions_with_defaults` + instead. Args: - file: The path to the CIF file to load. - assembly_id: The assembly ID to load. - cif_parser_args: Additional CIF parser arguments. - return_data_dict: Whether to return the data dictionary. If false will return the atom array. - fill_missing_conditions: Whether to fill missing conditions (as annotations in the atom array) with defaults. + atom_array: The AtomArray to fill. Modified in-place and returned. Returns: - The loaded atom array with condition annotations, or the full data dictionary if ``return_data_dict`` is True. - - Note: - Warnings about missing extra fields are suppressed unless the logging level is set to DEBUG. + The atom array with all missing condition defaults applied. """ - - # Default cif_parser_args to an empty dictionary if not provided - if cif_parser_args is None: - cif_parser_args = {} - - # Convenience utilities to default to loading from and saving to cache if a cache_dir is provided, unless explicitly overridden - if cif_parser_args.get("cache_dir"): - cif_parser_args.setdefault("load_from_cache", True) - cif_parser_args.setdefault("save_to_cache", True) - - merged_cif_parser_args = { - **STANDARD_PARSER_ARGS, - **{ - "fix_arginines": False, - "add_missing_atoms": False, # this is crucial otherwise the annotations are deleted - "remove_ccds": [], - }, - **cif_parser_args, - } - - # Use Condition registry to get all possible annotations - possible_annotations = CONDITIONS.get_valid_full_names() - possible_masks = CONDITIONS.get_valid_mask_names() - - # Get the logger for atomworks.io to check its level - io_logger = logging.getLogger("atomworks.io") - suppress_missing_field_warnings = io_logger.getEffectiveLevel() > logging.DEBUG - - # Filter logging warnings for missing extra fields unless logging level is DEBUG - if suppress_missing_field_warnings: - # Suppress only the specific "Field ... not found in file" logging warnings - with suppress_logging_messages("atomworks.io", "not found in file"): - result_dict = parse( - filename=file, - keep_cif_block=True, - build_assembly=(assembly_id,), # Convert list to tuple (make hashable), - extra_fields=possible_annotations | possible_masks, - **merged_cif_parser_args, - ) - else: - result_dict = parse( - filename=file, - keep_cif_block=True, - build_assembly=(assembly_id,), # Convert list to tuple (make hashable), - extra_fields=possible_annotations | possible_masks, - **merged_cif_parser_args, - ) - - _ = result_dict.pop("asym_unit") - cif_block = result_dict.pop("cif_block") - atom_array = result_dict["assemblies"][assembly_id][0] - atom_array = _add_design_annotations_from_cif_block_metadata(atom_array, cif_block) - if fill_missing_conditions: - atom_array = fill_missing_conditions_with_defaults(atom_array) - - if return_data_dict: - result_dict["atom_array"] = atom_array - return result_dict - else: - return atom_array - - -def fill_missing_conditions_with_defaults(atom_array: AtomArray) -> AtomArray: - """ - Checks to see if any conditions are missing from the atom array and fills them with defaults. - """ - all_annotation_categories = set(get_annotation_categories(atom_array, n_body="all")) - for condition_cls in CONDITIONS: - # Check if mask / annotation exists: - is_mask_annotated = condition_cls.mask_name in all_annotation_categories - is_condition_annotated = condition_cls.full_name in all_annotation_categories - - if is_mask_annotated and (condition_cls.is_mask or is_condition_annotated): - # skip if both are annotated or we only need the mask - continue - - # if we're here, either the mask or the annotation is missing or both - if is_mask_annotated: - default_mask = condition_cls.mask(atom_array, default="generate") - condition_cls.set_mask(atom_array, default_mask) - - elif is_condition_annotated: - default_annotation = condition_cls.annotation(atom_array, default="generate") - condition_cls.set_annotation(atom_array, default_annotation) - - else: - default_annotation = condition_cls.annotation(atom_array, default="generate") - default_mask = condition_cls.mask(atom_array, default="generate") - condition_cls.set_mask(atom_array, default_mask) - condition_cls.set_annotation(atom_array, default_annotation) - - return atom_array - - -def _condition_to_idxs_values(mask: np.ndarray, annotation: np.ndarray, n_body: int) -> tuple[np.ndarray, np.ndarray]: - """Convert a condition to a pair of index arrays and a value array""" - if n_body == 1: - idx = np.where(mask)[0] - value = annotation[mask] - elif n_body == 2: - idx = annotation.pairs - value = annotation.values - else: - raise NotImplementedError(f"Condition with {n_body} bodies is not supported.") - - value = value.astype(int) if value.dtype == bool else value - return idx, value - - -def _add_condition_from_idxs_values( - idx: np.ndarray, value: np.ndarray, atom_array: AtomArray, condition_cls: ConditionBase, mask: bool -) -> np.ndarray: - """Add a condition from a pair of index arrays and a value array""" - if condition_cls.n_body == 1: - if mask: - annotation = np.zeros(atom_array.array_length(), dtype=bool) - annotation[idx] = value - condition_cls.set_mask(atom_array, annotation) - else: - annotation = condition_cls.default_annotation(atom_array) - annotation[idx] = value - condition_cls.set_annotation(atom_array, annotation) - elif condition_cls.n_body == 2: - if mask: - mask_annot = AnnotationList2D( - n_atoms=atom_array.array_length(), - pairs=idx, - values=value, - ) - condition_cls.set_mask(atom_array, mask_annot) - else: - condition_cls.set_annotation(atom_array, pairs=idx, values=value) - else: - raise NotImplementedError(f"Condition with {condition_cls.n_body} bodies is not supported.") - - -def _add_design_annotations_from_cif_block_metadata( - atom_array: AtomArray | AtomArrayStack, - cif_block: pdbx.CIFBlock, -) -> AtomArrayPlus | AtomArrayPlusStack: - if isinstance(atom_array, AtomArray): - atom_array = as_atom_array_plus(atom_array) - elif isinstance(atom_array, AtomArrayStack): - atom_array = AtomArrayPlusStack.from_atom_array_stack(atom_array) - else: - raise ValueError(f"AtomArrayPlus or AtomArrayStack expected, got {type(atom_array)}") - - for key in cif_block: - if key == "atomize": - atomize = get_annotation( - atom_array, - "atomize", - default=np.zeros(atom_array.array_length(), dtype=bool), - ) - idxs, values = _cif_dict_to_idxs_values(cif_block[key], dtype=bool) - atomize[idxs] = values - atom_array.set_annotation("atomize", atomize) - continue - - if not key.startswith("condition_") and not key.startswith("mask_"): - # ... skip if it does not start with condition_ or mask_ - continue - - mask = key.startswith("mask_") - condition_cls = CONDITIONS.from_full_name(key) - idxs, values = _cif_dict_to_idxs_values(cif_block[key], dtype=bool if mask else condition_cls.dtype) - - _add_condition_from_idxs_values(idxs, values, atom_array, condition_cls, mask) - - return atom_array - - -def _cif_dict_to_idxs_values(cif_dict: dict, dtype: np.dtype | None = None) -> tuple[np.ndarray, np.ndarray]: - """Convert a dictionary of CIF categories to a pair of index arrays and a value array""" - idxs, values = [], [] - for key in cif_dict: - if key.startswith("idx"): - idxs.append(cif_dict[key].as_array(int)) - elif key.startswith("val"): - values.append(cif_dict[key].as_array(dtype)) - idxs = np.stack(idxs, axis=1).squeeze() - values = np.stack(values, axis=1).squeeze() - return idxs, values - - -def _atleast_2d_last(arr: np.ndarray) -> np.ndarray: - """Same as np.atleast_2d but adds a dimension at the end instead of the beginning""" - arr = np.asarray(arr) - if arr.ndim == 0: - return arr.reshape(1, 1) - elif arr.ndim == 1: - return np.expand_dims(arr, axis=-1) # Add dimension at the end - else: - return arr - - -def _idxs_values_to_cif_dict(idxs: np.ndarray, values: np.ndarray) -> dict: - """Convert a pair of index arrays and a value array to a dictionary of CIF categories""" - idxs = _atleast_2d_last(idxs) # (N, n_body) - values = _atleast_2d_last(values) # (N, n_values) - return {f"idx{i}": idxs[:, i] for i in range(idxs.shape[1])} | { - f"val{i}": values[:, i] for i in range(values.shape[1]) - } + warnings.warn( + "fill_missing_conditions_with_defaults() is deprecated. " + "Use CONDITIONS.fill_missing_conditions_with_defaults() instead.", + DeprecationWarning, + stacklevel=2, + ) + return CONDITIONS.fill_missing_conditions_with_defaults(atom_array) def default_annotations_and_conditions_from_registry( atom_array: AtomArray, annotations: list[str], ) -> AtomArrayPlus: - """Add all registered annotations and conditions with default values. + """Add annotations with default values using the annotator and standard annotation registries. - Generates defaults from both ``ANNOTATOR_REGISTRY`` and ``CONDITIONS`` registry. + Generates defaults from ``ANNOTATOR_REGISTRY`` and + :py:data:`~atomworks.io.utils.standard_annotations.base.STANDARD_ANNOTATIONS` registry. Ignores annotations without known default generation methods. - Promotes the input ``atom_array`` to an ``AtomArrayPlus`` in order to handle n-body annotations. + Promotes the input ``atom_array`` to an :py:class:`~atomworks.io.utils.atom_array_plus.AtomArrayPlus` + in order to handle n-body annotations. Args: - atom_array: AtomArray to annotate (modified in-place). - annotations: Specific annotation names to generate. + atom_array: AtomArray to annotate (modified in-place). + annotations: Specific annotation names to generate. """ atom_array = as_atom_array_plus(atom_array) - # Filter to requested annotations - annotations_set = set(annotations) - target_annotator_names = annotations_set & set(ANNOTATOR_REGISTRY.keys()) - target_condition_names = annotations_set - target_annotator_names - - # Generate ANNOTATOR_REGISTRY annotations using ensure_annotations - if target_annotator_names: - ensure_annotations(atom_array, *target_annotator_names) - - # Generate CONDITIONS annotations and masks - for condition in CONDITIONS: - # Handle mask - mask_name = condition.mask_name - if mask_name in target_condition_names: - try: - condition.set_mask(atom_array, condition.default_mask(atom_array)) - except Exception as e: - warnings.warn(f"Failed to generate mask '{mask_name}': {e}", stacklevel=2) - - # Handle annotation (if not a mask-only condition) - if not condition.is_mask: - full_name = condition.full_name - if full_name in target_condition_names: - try: - condition.set_annotation(atom_array, condition.default_annotation(atom_array)) - except Exception as e: - warnings.warn(f"Failed to generate annotation '{full_name}': {e}", stacklevel=2) + for name in annotations: + try: + ensure_annotations(atom_array, name) + except ValueError as e: + warnings.warn(f"Failed to generate annotation '{name}': {e}; skipping.", stacklevel=2) return atom_array diff --git a/src/atomworks/ml/utils/expandable_segment.py b/src/atomworks/ml/utils/expandable_segment.py new file mode 100644 index 00000000..d6c44230 --- /dev/null +++ b/src/atomworks/ml/utils/expandable_segment.py @@ -0,0 +1,825 @@ +"""Expandable segment sentinels for AtomArrayPlus structures. + +An *expandable segment sentinel* is a single-atom placeholder residue that +represents a stretch of unknown length between two anchoring +residues. Sentinels can be inserted, realized into concrete (but +coordinate-free) atom stubs, and collapsed back into sentinels. + +Sentinel identification uses the mask derived from ``annotation_expsegmin_1_residue``: + +* ``mask == False`` → regular residue (not an expandable segment) +* ``mask == True``, ``res_name != MASKED`` → unrealized sentinel (e.g. ``"<5-15>"``) +* ``mask == True``, ``res_name == MASKED`` → realized segment atom stub + +Public API +---------- +- :py:func:`insert_expandable_segment` +- :py:func:`realize_expandable_segments` +- :py:func:`collapse_expandable_segments` +""" + +import logging + +import biotite.structure as struc +import numpy as np + +from atomworks.constants import ( + DEFAULT_ALTLOC_ID, + DEFAULT_B_FACTOR, + DEFAULT_CHARGE, + DEFAULT_INS_CODE, + DEFAULT_OCCUPANCY, + MASKED, + MOLECULE_LEVEL_ANNOTATIONS, + PN_UNIT_LEVEL_ANNOTATIONS, + UNKNOWN_ATOM_NAME, + UNKNOWN_COORD_VALUE, + UNKNOWN_ELEMENT, +) +from atomworks.enums import ChainType, ChainTypeInfo +from atomworks.io.transforms import atom_array as io_ta +from atomworks.io.utils.annotator import ANNOTATOR_REGISTRY, ensure_annotations +from atomworks.io.utils.atom_array_plus import ( + AtomArrayPlus, + as_atom_array_plus, + concatenate_atom_array_plus, + insert_atoms, +) +from atomworks.io.utils.selection import get_residue_starts +from atomworks.io.utils.standard_annotations import S_SEGMAX, S_SEGMIN +from atomworks.io.utils.standard_annotations.base import STANDARD_ANNOTATIONS +from atomworks.ml.conditions import C_CTR, C_NTR +from atomworks.ml.transforms.atom_array import ( + add_global_atom_id_annotation, + add_global_token_id_annotation, + get_within_group_res_idx, + get_within_group_source_res_idx, + get_within_poly_res_idx, +) + +logger = logging.getLogger("atomworks.ml") + +# Internal annotation used to uniquely identify otherwise-identical expandable segment sentinels. +_SEG_UID_ANNOT = "_expseg_uid" + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _reset_present_standard_annotations_on_sentinel( + sentinel: AtomArrayPlus, + existing_annots: set[str], +) -> set[str]: + """Reset all present StandardAnnotations on sentinel to defaults (except expandable segment annotations).""" + handled: set[str] = set() + for sa_cls in STANDARD_ANNOTATIONS: + if sa_cls in {S_SEGMIN, S_SEGMAX}: + continue + + field_names = (sa_cls.full_name, *sa_cls.aliases) + if any(name in existing_annots for name in field_names): + # Delete the copied value before generating a fresh structure-dependent default. + present = sentinel.get_annotation_categories(n_body=sa_cls.n_body) + for name in field_names: + if name in present: + sentinel.del_annotation(name, n_body=sa_cls.n_body) + sa_cls.set_annotation(sentinel, sa_cls.default_annotation(sentinel)) + handled.add(sa_cls.full_name) + + return handled + + +def _ensure_seg_uids(array: AtomArrayPlus, is_sentinel: np.ndarray) -> None: + """Assign a fresh, unique ``_SEG_UID_ANNOT`` to any sentinel atom missing one.""" + if _SEG_UID_ANNOT not in array.get_annotation_categories(): + array.set_annotation(_SEG_UID_ANNOT, np.full(array.array_length(), -1, dtype=np.int64)) + uid_ann = array.get_annotation(_SEG_UID_ANNOT) + missing = is_sentinel & (uid_ann == -1) + if np.any(missing): + next_uid = int(uid_ann.max()) + 1 + for idx in np.where(missing)[0]: + uid_ann[idx] = next_uid + next_uid += 1 + array.set_annotation(_SEG_UID_ANNOT, uid_ann) + + +def _make_expandable_segment_sentinel_from_copied_atom( + array: AtomArrayPlus, + source_atom_idx: int, + seg_min: int, + seg_max: int, + chain_id: str, + sentinel_res_id: int, + seg_uid: int, + chain_type: ChainType | None = None, +) -> tuple[AtomArrayPlus, set[str]]: + """Create a 1-atom sentinel by copying an existing atom and normalizing fields.""" + sentinel = as_atom_array_plus(array[source_atom_idx : source_atom_idx + 1].copy()) + existing_annots = set(array.get_annotation_categories()) + handled: set[str] = set() + + # Core identity fields. + sentinel.chain_id[:] = chain_id + seg_name = f"{seg_min}-{seg_max}" + _ensure_res_name_width(sentinel, max(9, len(seg_name))) + sentinel.res_name[:] = seg_name + sentinel.atom_name[:] = UNKNOWN_ATOM_NAME + sentinel.element[:] = UNKNOWN_ELEMENT + sentinel.coord[:] = UNKNOWN_COORD_VALUE + sentinel.hetero[:] = True + sentinel.res_id[:] = int(sentinel_res_id) + handled |= {"chain_id", "res_name", "atom_name", "element", "coord", "hetero", "res_id"} + + # Common parse-level optional fields. + if "ins_code" in existing_annots: + sentinel.ins_code[:] = DEFAULT_INS_CODE + handled.add("ins_code") + if "occupancy" in existing_annots: + sentinel.occupancy[:] = DEFAULT_OCCUPANCY + handled.add("occupancy") + if "b_factor" in existing_annots: + sentinel.b_factor[:] = DEFAULT_B_FACTOR + handled.add("b_factor") + if "altloc_id" in existing_annots: + sentinel.altloc_id[:] = DEFAULT_ALTLOC_ID + handled.add("altloc_id") + if "charge" in existing_annots: + sentinel.charge[:] = DEFAULT_CHARGE + handled.add("charge") + + if chain_type is not None: + if "chain_type" in existing_annots: + sentinel.chain_type[:] = np.int8(chain_type) + handled.add("chain_type") + if "is_polymer" in existing_annots: + sentinel.is_polymer[:] = chain_type.is_polymer() + handled.add("is_polymer") + + # Update the entity id if creating a new chain. Use the global max (not the + # source atom's own entity id) so the new id is guaranteed unused regardless + # of whether entity ids happen to increase monotonically with array/chain order. + if "label_entity_id" in existing_annots and chain_id != array.chain_id[source_atom_idx]: + new_entity_id = int(array.label_entity_id.max()) + 1 + sentinel.label_entity_id[:] = new_entity_id + + # Set expandable segment annotations BEFORE the standard-annotation reset, so that + # Atomize.default_annotation can see SEGMIN/SEGMAX and treat this sentinel as a standard polymer (not atomized). + S_SEGMIN.set_annotation(sentinel, np.array([seg_min], dtype=int)) + S_SEGMAX.set_annotation(sentinel, np.array([seg_max], dtype=int)) + sentinel.set_annotation(_SEG_UID_ANNOT, np.array([seg_uid], dtype=np.int64)) + handled |= {S_SEGMIN.full_name, S_SEGMAX.full_name, _SEG_UID_ANNOT} + + # Reset all standard annotations that are present on source array. + handled |= _reset_present_standard_annotations_on_sentinel(sentinel, existing_annots) + + return sentinel, handled + + +def _compute_insert_res_id( + array: AtomArrayPlus, + *, + insert_position: int, + chain_identifier_key: str, + chain_identifier_value: str | int | np.integer, +) -> tuple[int, bool]: + """Compute sentinel ``res_id`` and whether downstream shifting is needed. + + Returns: + ``(sentinel_res_id, needs_downstream_shift)``. When a gap exists between + the flanking residues the sentinel is placed in the gap (``next_res_id - 1``) + and no shifting is required. Otherwise ``prev_res_id + 1`` is used and the + caller must shift downstream residues up by one. + """ + chain_mask = array.get_annotation(chain_identifier_key) == chain_identifier_value + chain_indices = np.where(chain_mask)[0] + if len(chain_indices) == 0: + raise ValueError("Cannot compute insert res_id: selected chain instance has no atoms.") + + # Assert res_ids are monotonically increasing within chain + assert np.all(np.diff(array.res_id[chain_indices]) >= 0), "res_ids must be monotonically increasing within chain" + + n_chain_atoms_before_insert = int(np.sum(chain_indices < insert_position)) + n_chain_atoms_at_or_after = len(chain_indices) - n_chain_atoms_before_insert + + if n_chain_atoms_before_insert > 0: + prev_atom_idx = int(chain_indices[n_chain_atoms_before_insert - 1]) + prev_res_id = int(array.res_id[prev_atom_idx]) + if n_chain_atoms_at_or_after > 0: + next_atom_idx = int(chain_indices[n_chain_atoms_before_insert]) + next_res_id = int(array.res_id[next_atom_idx]) + if next_res_id > prev_res_id + 1: + # Gap between flanking residues: fit sentinel in without shifting downstream. + return next_res_id - 1, False + return prev_res_id + 1, True + + # Inserting before the first residue of this chain instance. + first_res_id = int(array.res_id[chain_indices[0]]) + if first_res_id > 0: + return first_res_id - 1, False + return first_res_id, True + + +def _shift_chain_res_ids_after_insert( + result: AtomArrayPlus, + *, + sentinel_idx: int, + sentinel_res_id: int, + chain_identifier_key: str, + chain_identifier_value: str | int | np.integer, +) -> None: + """Shift downstream residues in the same chain instance by +1.""" + chain_mask = result.get_annotation(chain_identifier_key) == chain_identifier_value + downstream_mask = np.arange(result.array_length()) > sentinel_idx + to_shift = chain_mask & downstream_mask & (result.res_id >= sentinel_res_id) + result.res_id[to_shift] = result.res_id[to_shift] + 1 + + +def _ensure_res_name_width(array: AtomArrayPlus, min_width: int = 9) -> None: + """Widen the ``res_name`` annotation dtype in-place if it is narrower than *min_width* characters.""" + current_width = array.res_name.dtype.itemsize // 4 + if current_width < min_width: + array.set_annotation("res_name", array.res_name.astype(f" list[str]: + """Get a list of annotations copied from source atom without explicit recomputation logic.""" + rederived_or_registry = { + "atom_id", + "token_id", + "within_chain_res_idx", + "within_chain_source_res_idx", + "within_poly_res_idx", + "chain_entity", + "chain_iid", + *MOLECULE_LEVEL_ANNOTATIONS, + *PN_UNIT_LEVEL_ANNOTATIONS, + *ANNOTATOR_REGISTRY.keys(), + } + standard_annotation_names = set(STANDARD_ANNOTATIONS.get_field_names(include_aliases=True)) + known_safe = explicitly_handled | rederived_or_registry | standard_annotation_names + blindly_copied = sorted(original_annots - known_safe) + + return blindly_copied + + +def _find_flanking_bond_atom_idxs( + array: AtomArrayPlus, + sentinel_idx: int, + chain_key: str, +) -> tuple[int | None, int | None]: + """Return the global indices of the polymer-bond endpoints flanking a sentinel. + + A neighbouring expandable-segment placeholder stands in for its missing + polymerisation atom. This keeps separately named adjacent segments connected. + + Args: + array: Structure containing the sentinel. + sentinel_idx: Index of the sentinel atom. + chain_key: Annotation name used to identify chain instances (``"chain_iid"`` or ``"chain_id"``). + + Returns: + ``(prev_terminal_idx, next_start_idx)`` — either may be ``None`` if the sentinel + is at a chain boundary or the chain type is not polymer. + """ + if "chain_type" not in array.get_annotation_categories(): + return None, None + sentinel_chain_type = ChainType(int(array.chain_type[sentinel_idx])) + bond_atom_names = ChainTypeInfo.ATOMS_AT_POLYMER_BOND.get(sentinel_chain_type) + if bond_atom_names is None: + return None, None + term_name, start_name = bond_atom_names # e.g. ("C", "N") for polypeptides + + chain_val = array.get_annotation(chain_key)[sentinel_idx] + chain_vals = array.get_annotation(chain_key) + + res_bounds = get_residue_starts(array, add_exclusive_stop=True) + res_starts_arr, res_stops_arr = res_bounds[:-1], res_bounds[1:] + sentinel_res_idx = int(np.searchsorted(res_starts_arr, sentinel_idx, side="right")) - 1 + is_expandable = S_SEGMIN.mask(array) + + prev_terminal_idx = None + if sentinel_res_idx > 0: + ps, pe = int(res_starts_arr[sentinel_res_idx - 1]), int(res_stops_arr[sentinel_res_idx - 1]) + if chain_vals[ps] == chain_val: + for ai in range(ps, pe): + if array.atom_name[ai] == term_name or (pe - ps == 1 and is_expandable[ai]): + prev_terminal_idx = ai + break + + next_start_idx = None + if sentinel_res_idx < len(res_starts_arr) - 1: + ns, ne = int(res_starts_arr[sentinel_res_idx + 1]), int(res_stops_arr[sentinel_res_idx + 1]) + if chain_vals[ns] == chain_val: + for ai in range(ns, ne): + if array.atom_name[ai] == start_name or (ne - ns == 1 and is_expandable[ai]): + next_start_idx = ai + break + + return prev_terminal_idx, next_start_idx + + +def _install_sentinel_bonds( + array: AtomArrayPlus, + sentinel_idx: int, + chain_key: str, +) -> None: + """Wire a sentinel into the polymer bond graph. + + Removes the existing inter-residue bond between the flanking residues (if + present) and ensures single bonds exist from the sentinel to each flanking + polymer-bond atom. Safe to call multiple times — existing sentinel bonds + are not duplicated. + """ + prev_idx, next_idx = _find_flanking_bond_atom_idxs(array, sentinel_idx, chain_key) + if prev_idx is None and next_idx is None: + return + + existing = {(min(int(b[0]), int(b[1])), max(int(b[0]), int(b[1]))) for b in array.bonds.as_array()} + + # Remove existing bond between flanking residues + if prev_idx is not None and next_idx is not None: + direct = (min(prev_idx, next_idx), max(prev_idx, next_idx)) + if direct in existing: + array.bonds.remove_bond(prev_idx, next_idx) + + # Add new bonds from sentinel to flanking residues + if prev_idx is not None and (min(prev_idx, sentinel_idx), max(prev_idx, sentinel_idx)) not in existing: + array.bonds.add_bond(prev_idx, sentinel_idx, struc.BondType.SINGLE) + if next_idx is not None and (min(sentinel_idx, next_idx), max(sentinel_idx, next_idx)) not in existing: + array.bonds.add_bond(sentinel_idx, next_idx, struc.BondType.SINGLE) + + +def _post_insert_rederive_common_annotations( + array: AtomArrayPlus, + original_annots: set[str], +) -> tuple[AtomArrayPlus, list[str]]: + """Re-derive common parser/feature annotations after expandable segment insertion.""" + # Recompute connectivity-derived IDs/entities if they existed. + entity_annots = {"pn_unit_id", "molecule_id", "chain_entity", "pn_unit_entity", "molecule_entity"} + if entity_annots & original_annots: + array = io_ta.add_id_and_entity_annotations(array, overwrite=True) + + iid_annots = {"chain_iid", "pn_unit_iid", "molecule_iid"} + if "transformation_id" in array.get_annotation_categories() and (iid_annots & original_annots): + array = io_ta.add_iid_annotations(array, overwrite=True) + + # Recompute global/index IDs through one canonical helper. + _renumber_ids_and_termini(array) + + # Re-derive all lazily computed annotations that were present originally. + # The chain type is not re-derived since we set it manually for the sentinel + recomputed_annots = [] + for annot_name in ANNOTATOR_REGISTRY: + if annot_name in original_annots and annot_name != "chain_type": + recomputed_annots.append(annot_name) + if annot_name in array.get_annotation_categories(): + array.del_annotation(annot_name) + ensure_annotations(array, annot_name) + + return array, recomputed_annots + + +def _renumber_ids_and_termini(array: AtomArrayPlus) -> None: + """ + Renumber ``atom_id`` and ``token_id`` (if present) in-place. + + ``atom_id`` and ``token_id`` are renumbered sequentially (1-based) across the whole array. + ``within_chain_res_idx`` is renumbered sequentially (0-based) within each chain instance (or chain). + ``within_poly_res_idx`` is renumbered sequentially (0-based) within each polymer chain. + + Args: + array: Array to renumber in-place. + """ + annots = set(array.get_annotation_categories()) + + if "atom_id" in annots: + add_global_atom_id_annotation(array) + if "token_id" in annots: + # Recompute token boundaries from residue identity and atomization. + array.del_annotation("token_id") + add_global_token_id_annotation(array) + if "within_chain_res_idx" in annots: + group_by = "chain_iid" if "chain_iid" in array.get_annotation_categories() else "chain_id" + array.set_annotation("within_chain_res_idx", get_within_group_res_idx(array, group_by=group_by)) + if "within_chain_source_res_idx" in annots: + group_by = "chain_iid" if "chain_iid" in array.get_annotation_categories() else "chain_id" + array.set_annotation("within_chain_source_res_idx", get_within_group_source_res_idx(array, group_by=group_by)) + if "within_poly_res_idx" in annots: + array.set_annotation("within_poly_res_idx", get_within_poly_res_idx(array)) + if "is_chain_start" in annots: + ensure_annotations(array, "is_chain_start") + if C_CTR.full_name in annots: + C_CTR.set_annotation(array, C_CTR.default_annotation(array)) + if C_NTR.full_name in annots: + C_NTR.set_annotation(array, C_NTR.default_annotation(array)) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def insert_expandable_segment( + array: AtomArrayPlus, + seg_min: int, + seg_max: int, + atom_idx: int | None = None, + insert_before: bool = True, + new_chain: bool = False, + new_chain_id: str | None = None, + new_chain_type: ChainType | str | int | None = None, +) -> AtomArrayPlus: + """Insert an expandable segment sentinel into an existing chain or as a new chain. + + Args: + array: The structure to insert the sentinel into. + seg_min: Minimum realized segment length (≥ 0). + seg_max: Maximum realized segment length (≥ seg_min). + atom_idx: Reference atom index for insertion into an existing chain. + Required when ``new_chain=False``. + insert_before: If ``True`` (default), insert before ``atom_idx``. + If ``False``, insert after ``atom_idx``. + new_chain: If ``True``, append sentinel as a new chain. + new_chain_id: Required when ``new_chain=True``. New chain ID. + new_chain_type: Required when ``new_chain=True``. Must be a valid ``ChainType``. + + Returns: + New :py:class:`~atomworks.io.utils.atom_array_plus.AtomArrayPlus` with + the sentinel inserted. + + Raises: + ValueError: If arguments are invalid or insertion index is out of range. + """ + array = as_atom_array_plus(array) + n = array.array_length() + if n == 0: + raise ValueError("Cannot insert an expandable segment into an empty array.") + if seg_min < 0: + raise ValueError(f"seg_min must be >= 0, got {seg_min}.") + if seg_max < seg_min: + raise ValueError(f"seg_max ({seg_max}) must be >= seg_min ({seg_min}).") + + if new_chain and atom_idx is not None: + raise ValueError("Specify either atom_idx (existing-chain insertion) OR new_chain=True, not both.") + if not new_chain and atom_idx is None: + raise ValueError("atom_idx is required when new_chain=False.") + + original_annots = set(array.get_annotation_categories()) + + if new_chain: + if not new_chain_id: + raise ValueError("new_chain_id is required when new_chain=True.") + if new_chain_type is None: + raise ValueError("new_chain_type is required when new_chain=True.") + chain_type = ChainType.as_enum(new_chain_type) + insert_position = n + source_atom_idx = n - 1 + chain_id = new_chain_id + sentinel_res_id = 1 + chain_identifier_key = "chain_iid" if "chain_iid" in array.get_annotation_categories() else "chain_id" + chain_identifier_value: str | int | np.integer = chain_id + else: + assert atom_idx is not None # for type narrowing + if not (0 <= atom_idx < n): + raise ValueError(f"atom_idx={atom_idx} is out of range [0, {n}).") + res_bounds = get_residue_starts(array, add_exclusive_stop=True) + res_starts, res_stops = res_bounds[:-1], res_bounds[1:] + res_idx = int(np.searchsorted(res_starts, atom_idx, side="right")) - 1 + residue_start, residue_stop = int(res_starts[res_idx]), int(res_stops[res_idx]) + if insert_before and atom_idx != residue_start: + raise ValueError( + f"atom_idx={atom_idx} is not the first atom of its residue " + f"(residue spans [{residue_start}, {residue_stop})) -- insert_before=True " + "requires atom_idx to be the residue's first atom, or the sentinel would " + "be spliced into the middle of that residue." + ) + if not insert_before and atom_idx != residue_stop - 1: + raise ValueError( + f"atom_idx={atom_idx} is not the last atom of its residue " + f"(residue spans [{residue_start}, {residue_stop})) -- insert_before=False " + "requires atom_idx to be the residue's last atom, or the sentinel would " + "be spliced into the middle of that residue." + ) + insert_position = atom_idx if insert_before else atom_idx + 1 + source_atom_idx = atom_idx + chain_id = str(array.chain_id[atom_idx]) + chain_identifier_key = "chain_iid" if "chain_iid" in array.get_annotation_categories() else "chain_id" + chain_identifier_value = array.get_annotation(chain_identifier_key)[atom_idx] + sentinel_res_id, needs_shift = _compute_insert_res_id( + array, + insert_position=insert_position, + chain_identifier_key=chain_identifier_key, + chain_identifier_value=chain_identifier_value, + ) + chain_type = None + + # Ensure expandable segment annotations exist on source array. + if not S_SEGMIN.has_annotation(array): + S_SEGMIN.set_annotation(array, S_SEGMIN.default_annotation(array)) + if not S_SEGMAX.has_annotation(array): + S_SEGMAX.set_annotation(array, S_SEGMAX.default_annotation(array)) + if _SEG_UID_ANNOT not in array.get_annotation_categories(): + array.set_annotation(_SEG_UID_ANNOT, np.full(n, -1, dtype=np.int64)) + seg_uid = int(array.get_annotation(_SEG_UID_ANNOT).max()) + 1 + + sentinel, handled = _make_expandable_segment_sentinel_from_copied_atom( + array=array, + source_atom_idx=source_atom_idx, + seg_min=seg_min, + seg_max=seg_max, + chain_id=chain_id, + sentinel_res_id=sentinel_res_id, + seg_uid=seg_uid, + chain_type=chain_type, + ) + blindly_copied = _get_blindly_copied_annotations(original_annots, handled) + + result = insert_atoms( + array, + [sentinel], + [insert_position], + on_annotation_mismatch_policy="drop", + fill_missing_standard_annotations=True, + ) + if not new_chain: + if needs_shift: + # Shift res_ids BEFORE installing bonds so that get_residue_starts + # sees the sentinel as its own residue group (otherwise the sentinel + # shares res_id with the downstream residue and they merge). + _shift_chain_res_ids_after_insert( + result, + sentinel_idx=insert_position, + sentinel_res_id=sentinel_res_id, + chain_identifier_key=chain_identifier_key, + chain_identifier_value=chain_identifier_value, + ) + _install_sentinel_bonds(result, sentinel_idx=insert_position, chain_key=chain_identifier_key) + + result, recomputed_annots = _post_insert_rederive_common_annotations(result, original_annots=original_annots) + + blindly_copied = [annot for annot in blindly_copied if annot not in recomputed_annots] + if blindly_copied: + logger.warning( + "Expandable segment sentinel insertion copied annotation values without explicit re-derivation for source atom_idx=%d: %s", + source_atom_idx, + blindly_copied, + ) + + return result + + +def realize_expandable_segments( + array: AtomArrayPlus, + rng: np.random.Generator | None = None, +) -> AtomArrayPlus: + """Replace each expandable segment sentinel with N concrete (coordinate-free) atom stubs. + + For each sentinel (identified by ``S_SEGMIN.mask(array) == True`` and + ``res_name != MASKED``), a random length N is sampled uniformly from + ``[seg_min, seg_max]`` using *rng*. N single-atom residues with + ``res_name = MASKED``, ``element = "X"``, ``occupancy = 0``, and + ``coord = NaN`` are inserted in place of the sentinel. + + The segment mask and ``expsegmin``/``expsegmax`` annotations are preserved + on the realized stubs so that :py:func:`collapse_expandable_segments` can + reconstruct the original sentinel range. + + Args: + array: Input structure, possibly containing expandable segment sentinels. + rng: NumPy random generator. ``None`` seeds one from the global numpy stream. + + Returns: + New array with all sentinels realized. + + Examples: + >>> realized = realize_expandable_segments(arr, rng=np.random.default_rng(42)) + >>> assert MASKED in realized.res_name + """ + array = as_atom_array_plus(array) + + if not S_SEGMIN.has_annotation(array): + return array + + # Sentinels are atoms where the segment mask is True and res_name is not yet realized (not MASKED stubs) + is_sentinel = S_SEGMIN.mask(array) & (array.res_name != MASKED) + assert np.array_equal( + S_SEGMIN.mask(array), S_SEGMAX.mask(array) + ), "S_SEGMIN and S_SEGMAX masks must agree on every atom." + + if not np.any(is_sentinel): + return array + + _ensure_seg_uids(array, is_sentinel) + + if rng is None: + # Seeded past both early returns so arrays with nothing to realize draw no randomness. + rng = np.random.default_rng(np.random.randint(0, 2**32)) + + segmin_ann = S_SEGMIN.annotation(array) + segmax_ann = S_SEGMAX.annotation(array) + + sentinel_indices = np.where(is_sentinel)[0] + n_realized_by_idx = { + int(idx): int(rng.integers(int(segmin_ann[idx]), int(segmax_ann[idx]) + 1)) for idx in sentinel_indices + } + + result = array + chain_key = "chain_iid" if "chain_iid" in result.get_annotation_categories() else "chain_id" + + # Process right-to-left so indices of yet-to-process sentinels remain valid. + for idx in sorted(n_realized_by_idx.keys(), reverse=True): + n_realized = n_realized_by_idx[idx] + sentinel_res_id = int(result.res_id[idx]) + chain_value = result.get_annotation(chain_key)[idx] + + if n_realized == 0: # Edge case where we sample 0-length segments + # Capture flanking bond atoms before removing the sentinel. + prev_idx, next_idx = _find_flanking_bond_atom_idxs(result, idx, chain_key) + + # Remove sentinel entirely; downstream residues shift by -1. + keep = np.ones(result.array_length(), dtype=bool) + keep[idx] = False + result = result[keep] + # Biotite drops all sentinel bonds automatically; next_idx shifts down by 1. + if prev_idx is not None and next_idx is not None: + result.bonds.add_bond(prev_idx, next_idx - 1, struc.BondType.SINGLE) + + downstream_mask = np.arange(result.array_length()) >= int(idx) + same_chain_mask = result.get_annotation(chain_key) == chain_value + shift_mask = downstream_mask & same_chain_mask + result.res_id[shift_mask] = result.res_id[shift_mask] - 1 + continue + + # Keep the original sentinel atom as first realized stub (guarantees no leftover ""). + result.res_name[idx] = MASKED + result.element[idx] = "X" + result.res_id[idx] = sentinel_res_id + + # Insert the remaining realized stubs (n_realized - 1) immediately after idx. + extra = n_realized - 1 + if extra > 0: + stubs = _make_realized_stubs(result, int(idx), extra) + stubs.res_id = np.arange( + sentinel_res_id + 1, + sentinel_res_id + 1 + extra, + dtype=result.res_id.dtype, + ) + result = insert_atoms(result, [stubs], [int(idx + 1)]) + # Biotite remaps all bond indices through insert_atoms. The sentinel's + # bond to next_start now points to next_start+extra. Move it to the + # last stub. _make_realized_stubs already wired stub-to-stub bonds, + # so we only need to bridge sentinel→first-stub and last-stub→next_start. + bonds_arr = result.bonds.as_array() + sentinel_bond_mask = (bonds_arr[:, 0] == idx) | (bonds_arr[:, 1] == idx) + next_start_new = None + for b in bonds_arr[sentinel_bond_mask]: + partner = int(b[1]) if int(b[0]) == idx else int(b[0]) + if partner > idx + extra: + next_start_new = partner + break + if next_start_new is not None: + result.bonds.remove_bond(idx, next_start_new) + result.bonds.add_bond(idx + extra, next_start_new, struc.BondType.SINGLE) + result.bonds.add_bond(idx, idx + 1, struc.BondType.SINGLE) + + downstream_mask = np.arange(result.array_length()) >= int(idx + 1 + extra) + same_chain_mask = result.get_annotation(chain_key) == chain_value + shift_mask = downstream_mask & same_chain_mask + result.res_id[shift_mask] = result.res_id[shift_mask] + extra + + _renumber_ids_and_termini(result) + return result + + +def _make_realized_stubs( + template: AtomArrayPlus, + sentinel_idx: int, + n: int, +) -> AtomArrayPlus: + """Build *n* MASKED stub atoms from the sentinel at *sentinel_idx*. + + Args: + template: Full array (used for annotation schema and chain info). + sentinel_idx: Index of the sentinel in *template*. + n: Number of stubs to produce. + + Returns: + AtomArrayPlus with *n* realized MASKED stubs. + """ + # Slice the sentinel atom to inherit all its annotations (including segment mask=True). + # Use a range slice (not integer index) so the result is an AtomArrayPlus, not an Atom. + stub = template[sentinel_idx : sentinel_idx + 1] + + # Override identity fields for a realized stub but only change what we need + stub.res_name = np.array([MASKED]) + stub.element = np.array(["X"]) + + stubs = concatenate_atom_array_plus([stub] * n) + for i in range(n - 1): + stubs.bonds.add_bond(i, i + 1, struc.BondType.SINGLE) + return stubs + + +def collapse_expandable_segments(array: AtomArrayPlus) -> AtomArrayPlus: + """Collapse runs of realized segment stubs back into single sentinels. + + Atoms with ``S_SEGMIN.mask(array) == True`` and ``res_name == MASKED`` + are treated as realized expandable segment stubs. Contiguous runs sharing + the same ``(expsegmin, expsegmax)`` range are collapsed by: + + 1. Keeping only the first atom of each run (via slicing, preserving all + annotations and 2D pairs). + 2. Renaming that atom's ``res_name`` back to ``""``. + + Args: + array: Array potentially containing realized expandable segment stubs. + + Returns: + New array with all realized segment runs collapsed into sentinels. + + Examples: + >>> collapsed = collapse_expandable_segments(realized) + >>> assert not np.any(collapsed.res_name == MASKED) + """ + array = as_atom_array_plus(array) + + if not S_SEGMIN.has_annotation(array): + return array + + assert np.array_equal( + S_SEGMIN.mask(array), S_SEGMAX.mask(array) + ), "S_SEGMIN and S_SEGMAX masks must agree on every atom." + + is_segment = S_SEGMIN.mask(array) & (array.res_name == MASKED) + + if not np.any(is_segment): + return array + + segmin_ann = S_SEGMIN.annotation(array) + segmax_ann = S_SEGMAX.annotation(array) + chain_key = "chain_iid" if "chain_iid" in array.get_annotation_categories() else "chain_id" + if _SEG_UID_ANNOT in array.get_annotation_categories(): + seg_uid_ann = array.get_annotation(_SEG_UID_ANNOT) + else: + seg_uid_ann = None + logger.warning( + "collapse_expandable_segments: no %r annotation found -- falling back to grouping " + "realized stub runs by (seg_min, seg_max, chain, contiguity) alone. Two independently " + "inserted segments sharing an identical range that ended up chain-adjacent will be " + "silently merged into one sentinel.", + _SEG_UID_ANNOT, + ) + + segment_idxs = np.where(is_segment)[0] + runs: list[tuple[int, int]] = [] + run_start = int(segment_idxs[0]) + run_prev = int(segment_idxs[0]) + for idx in map(int, segment_idxs[1:]): + is_contiguous = idx == run_prev + 1 + same_range = (segmin_ann[idx] == segmin_ann[run_prev]) and (segmax_ann[idx] == segmax_ann[run_prev]) + same_chain = array.get_annotation(chain_key)[idx] == array.get_annotation(chain_key)[run_prev] + # Two independently-inserted segments can share an identical range and + # end up chain-adjacent after realization -- same_uid tells them apart. + same_uid = seg_uid_ann is None or seg_uid_ann[idx] == seg_uid_ann[run_prev] + if is_contiguous and same_range and same_chain and same_uid: + run_prev = idx + continue + runs.append((run_start, run_prev)) + run_start = idx + run_prev = idx + runs.append((run_start, run_prev)) + + result = array + for run_start, run_end in reversed(runs): + run_len = run_end - run_start + 1 + segmin = int(S_SEGMIN.annotation(result)[run_start]) + segmax = int(S_SEGMAX.annotation(result)[run_start]) + chain_value = result.get_annotation(chain_key)[run_start] + + # Keep first realized stub as sentinel. + seg_name = f"{segmin}-{segmax}" + _ensure_res_name_width(result, max(9, len(seg_name))) + result.res_name[run_start] = seg_name + + if run_len > 1: + keep = np.ones(result.array_length(), dtype=bool) + keep[run_start + 1 : run_end + 1] = False + result = result[keep] + + # Restore res_ids BEFORE fixing bonds so that get_residue_starts + # sees the same residue grouping as the original sentinel array. + shift = run_len - 1 + downstream_mask = np.arange(result.array_length()) > run_start + same_chain_mask = result.get_annotation(chain_key) == chain_value + shift_mask = downstream_mask & same_chain_mask + result.res_id[shift_mask] = result.res_id[shift_mask] - shift + + # Fix bonds + _install_sentinel_bonds(result, run_start, chain_key) + + _renumber_ids_and_termini(result) + return result diff --git a/src/atomworks/ml/utils/geometry.py b/src/atomworks/ml/utils/geometry.py index b52159c7..3ef72d89 100644 --- a/src/atomworks/ml/utils/geometry.py +++ b/src/atomworks/ml/utils/geometry.py @@ -206,16 +206,20 @@ def apply_inverse_rigid( return apply_rigid(inv_rigid, points) -def get_random_rots(batch_size: int, **tensor_kwargs) -> torch.Tensor: - """ - Generate random 3D rotation matrices. +def get_random_rots( + batch_size: int, + mode: str = "torch", + device: torch.device | None = None, +) -> torch.Tensor | np.ndarray: + """Generate uniformly distributed 3D rotation matrices via QR decomposition. Args: - - batch_size (int): Number of rotation matrices to generate. - - device (torch.device | None): Device to place the tensors on. Defaults to None. + batch_size: Number of rotation matrices to generate. + mode: ``"torch"`` or ``"numpy"``. Defaults to ``"torch"``. + device: Torch device. Ignored when ``mode="numpy"``. Returns: - - torch.Tensor: Batch of random rotation matrices with shape (batch_size, 3, 3). + Batch of random rotation matrices with shape (batch_size, 3, 3). Example: >>> R = get_random_rots(5) @@ -224,26 +228,37 @@ def get_random_rots(batch_size: int, **tensor_kwargs) -> torch.Tensor: >>> print(torch.allclose(torch.det(R), torch.ones(5))) True """ - # Generate random matrices - rand_mat = torch.randn(batch_size, 3, 3, **tensor_kwargs) - - # Compute QR decomposition - q_decomp, _ = torch.linalg.qr(rand_mat) - - # Ensure proper rotation (determinant = 1) - det = torch.det(q_decomp) - q_decomp *= det.unsqueeze(-1).unsqueeze(-1).sign() - - return q_decomp - - -def get_random_rigid(batch_size: int, scale: float = 1.0, **tensor_kwargs) -> tuple[torch.Tensor, torch.Tensor]: + if mode == "numpy": + rand_mat = np.random.standard_normal((batch_size, 3, 3)).astype(np.float32) + q_decomp, r = np.linalg.qr(rand_mat) + diagonal_sign = np.where(np.diagonal(r, axis1=-2, axis2=-1) < 0, -1.0, 1.0) + q_decomp *= diagonal_sign[:, np.newaxis, :] + orientation = np.where(np.linalg.det(q_decomp) < 0, -1.0, 1.0) + q_decomp[:, :, -1] *= orientation[:, np.newaxis] + return q_decomp + elif mode == "torch": + rand_mat = torch.randn(batch_size, 3, 3, device=device) + q_decomp, r = torch.linalg.qr(rand_mat) + diagonal_sign = torch.where(torch.diagonal(r, dim1=-2, dim2=-1) < 0, -1.0, 1.0) + q_decomp *= diagonal_sign.unsqueeze(-2) + orientation = torch.where(torch.det(q_decomp) < 0, -1.0, 1.0) + q_decomp[:, :, -1] *= orientation.unsqueeze(-1) + return q_decomp + else: + raise ValueError(f"Invalid mode {mode}, expected 'torch' or 'numpy'") + + +def get_random_rigid( + batch_size: int, + scale: float = 1.0, + device: torch.device | None = None, +) -> tuple[torch.Tensor, torch.Tensor]: """Generate random rigid body transformations (R, t). Args: batch_size: Number of rigid transformations to generate. scale: Scale factor for the translation vectors. Defaults to 1.0. - **tensor_kwargs: Additional keyword arguments to pass to tensor creation functions. + device: Torch device. Returns: A rigid tuple containing: @@ -254,8 +269,8 @@ def get_random_rigid(batch_size: int, scale: float = 1.0, **tensor_kwargs) -> tu Note: If batch_size is 1, the output tensors are squeezed to remove the batch dimension. """ - rots = get_random_rots(batch_size, **tensor_kwargs) - trans = scale * torch.randn(batch_size, 3, **tensor_kwargs) + rots = get_random_rots(batch_size, device=device) + trans = scale * torch.randn(batch_size, 3, device=device) if batch_size == 1: rots, trans = rots.squeeze(0), trans.squeeze(0) return rots, trans @@ -280,7 +295,7 @@ def random_rigid_augmentation(coord_atom_lvl: torch.Tensor, batch_size: int, s: torch.Tensor: A tensor of the same shape as `coord_atom_lvl`, containing the transformed atomic coordinates. """ - rigid = get_random_rigid(batch_size, scale=s) + rigid = get_random_rigid(batch_size, scale=s, device=coord_atom_lvl.device) # (`get_random_rigid` squeezes dimension for batch_size=1) if batch_size == 1: @@ -330,3 +345,78 @@ def align_atom_arrays(mbl_sele: AtomArray, tgt_sele: AtomArray, mbl_full: AtomAr mbl_fitted, xform = superimpose(tgt_sele, mbl_sele) mbl_full_xformed = xform.apply(mbl_full) return mbl_full_xformed, rmsd(mbl_fitted, tgt_sele) + + +# --------------------------------------------------------------------------- +# Scalar vector geometry (numpy) +# --------------------------------------------------------------------------- + + +def angle_between_vectors(v1: np.ndarray, v2: np.ndarray) -> float: + """Return the angle in degrees between two vectors.""" + cos = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-12) + return float(np.degrees(np.arccos(np.clip(cos, -1, 1)))) + + +def dihedral_angle(p0: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> float: + """Return the dihedral angle (degrees) between planes ``(p0, p1, p2)`` and ``(p1, p2, p3)``.""" + b1 = p1 - p0 + b2 = p2 - p1 + b3 = p3 - p2 + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + norm1, norm2 = np.linalg.norm(n1), np.linalg.norm(n2) + if norm1 < 1e-12 or norm2 < 1e-12: + return 0.0 + n1, n2 = n1 / norm1, n2 / norm2 + return float(np.degrees(np.arccos(np.clip(np.dot(n1, n2), -1, 1)))) + + +def plane_normal(points: np.ndarray) -> np.ndarray: + """Return the unit normal of the plane through the first three of *points*.""" + v1 = points[1] - points[0] + v2 = points[2] - points[0] + n = np.cross(v1, v2) + norm = np.linalg.norm(n) + return n / norm if norm > 1e-12 else np.array([0.0, 0.0, 1.0]) + + +def in_plane_offset(normal: np.ndarray, plane_origin: np.ndarray, point: np.ndarray) -> float: + """Project *point* onto the plane defined by *normal* through *plane_origin*. + + Returns the distance between the projection and *plane_origin*. + """ + proj = point - np.dot(point - plane_origin, normal) * normal + return float(np.linalg.norm(proj - plane_origin)) + + +def is_planar(points: np.ndarray, max_dev: float = 5.0) -> bool: + """Return whether *points* are coplanar within *max_dev* degrees. + + Computes the local normal at each point (from its two neighbours, treating + *points* as a closed loop) and checks that all pairwise normal deviations + are below *max_dev*. + + Args: + points: Point coordinates ``(n, 3)``. + max_dev: Max allowed deviation between local normals (degrees). + """ + if len(points) < 3: + return False + normals = [] + for k in range(len(points)): + v1 = points[(k + 1) % len(points)] - points[k] + v2 = points[(k - 1) % len(points)] - points[k] + n = np.cross(v1, v2) + norm = np.linalg.norm(n) + if norm > 1e-12: + normals.append(n / norm) + if len(normals) < 2: + return False + for i in range(len(normals)): + for j in range(i + 1, len(normals)): + angle = angle_between_vectors(normals[i], normals[j]) + angle = min(angle, 180.0 - angle) + if angle > max_dev: + return False + return True diff --git a/src/atomworks/ml/utils/io.py b/src/atomworks/ml/utils/io.py index 0f42e586..09e900ed 100644 --- a/src/atomworks/ml/utils/io.py +++ b/src/atomworks/ml/utils/io.py @@ -3,25 +3,78 @@ Provides functions for file operations, directory scanning, and data loading. """ +import contextlib import gzip import hashlib import io import os -import pickle -from collections.abc import Callable -from functools import wraps +import time +from collections.abc import Callable, Iterator +from functools import cache, wraps from os import PathLike from pathlib import Path from typing import Any, TextIO import pandas as pd import pyarrow as pa +import pyarrow.feather as feather import pyarrow.parquet as pq import zstandard as zstd +from filelock import FileLock +from atomworks.common import string_to_md5_hash from atomworks.io.utils.io_utils import apply_sharding_pattern, build_sharding_pattern from atomworks.ml.utils.misc import logger +try: + import boto3 + from botocore.client import BaseClient + from botocore.config import Config +except ImportError: + boto3 = None + BaseClient = None + + +@cache +def _s3_client(endpoint_url: str | None = None) -> "BaseClient": + """Process-wide cached boto3 S3 client. + + Region/credentials come from the ambient AWS config; ``endpoint_url`` defaults to ``$AWS_ENDPOINT_URL``. + """ + if boto3 is None: + raise ImportError("boto3 is required for s3:// paths. Install it with: uv pip install boto3") + # virtual-host addressing, retry throttling with backoff, larger pool for DataLoader fan-out + config = Config( + max_pool_connections=50, + retries={"max_attempts": 10, "mode": "standard"}, + s3={"addressing_style": "virtual"}, + ) + return boto3.client("s3", endpoint_url=endpoint_url or os.environ.get("AWS_ENDPOINT_URL"), config=config) + + +def read_s3_bytes( + url: str, *, offset: int | None = None, length: int | None = None, endpoint_url: str | None = None +) -> bytes: + """Read an ``s3://`` object (whole, or a ``[offset, offset+length)`` byte range) into memory.""" + bucket, key = url[len("s3://") :].split("/", 1) + kwargs = {} if offset is None else {"Range": f"bytes={offset}-{offset + length - 1}"} + return _s3_client(endpoint_url).get_object(Bucket=bucket, Key=key, **kwargs)["Body"].read() + + +_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd" +_GZIP_MAGIC = b"\x1f\x8b" + + +def _decompress(raw: bytes) -> bytes: + """Auto-detect and decompress zstd/gzip data, or return the raw bytes.""" + if raw[:4] == _ZSTD_MAGIC: + # stream_reader (not .decompress) so frames without an embedded content size still read + with zstd.ZstdDecompressor().stream_reader(io.BytesIO(raw)) as r: + return r.read() + if raw[:2] == _GZIP_MAGIC: + return gzip.decompress(raw) + return raw + def open_file(filename: PathLike) -> TextIO: """Open a file, handling compressed files if necessary. @@ -35,6 +88,9 @@ def open_file(filename: PathLike) -> TextIO: Raises: AssertionError: If the file does not exist. """ + # Pass an already-open text stream straight through (e.g. in-memory MSA bytes) — nothing on disk to open. + if hasattr(filename, "read"): + return filename filename = Path(filename) # ...assert that the file exists assert filename.exists(), f"File {filename} does not exist" @@ -51,6 +107,20 @@ def open_file(filename: PathLike) -> TextIO: return filename.open("r") +@contextlib.contextmanager +def opened_file(filename: PathLike) -> "Iterator[TextIO]": + """Open `filename` for reading, closing it on exit only if this call opened it. + + A caller-supplied stream is passed through by `open_file` and stays the caller's to close. + """ + stream = open_file(filename) + try: + yield stream + finally: + if stream is not filename: + stream.close() + + def scan_directory(dir_path: PathLike, max_depth: int) -> list[str]: """Fast, order-independent directory scan for files up to max_depth levels deep. @@ -140,11 +210,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return decorator -def cache_to_disk_as_pickle( - cache_dir: PathLike | None = None, use_gzip: bool = True, directory_depth: int = 2 -) -> Callable: +def cache_to_disk_as_pickle(cache_dir: PathLike | None = None, *, directory_depth: int = 2) -> Callable: """ - A decorator to cache the results of a function to disk as a pickle file. + Cache function results to disk as zstd-3 compressed pickle files. Creates a unique cached pickle file for each set of function arguments using an MD5 hash. If the cache file exists, the result is loaded from the file. Otherwise, the @@ -155,7 +223,6 @@ def cache_to_disk_as_pickle( Args: cache_dir (PathLike or None): The directory where cache files will be stored, or `None` to disable caching. - use_gzip (bool): Whether to use gzip compression for the cache files. directory_depth (int): The depth of the directory structure for sharding cache files. Returns: @@ -176,19 +243,15 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # ... create a unique cache file path based on the MD5 hash of function arguments args_repr = f"{args}_{kwargs}" hash_hex = hashlib.md5(args_repr.encode()).hexdigest() - file_extension = ".pkl.gz" if use_gzip else ".pkl" sharding_pattern = build_sharding_pattern(depth=directory_depth, chars_per_dir=2) sharded_path = apply_sharding_pattern(hash_hex, sharding_pattern) - cache_file = Path(cache_dir) / sharded_path.with_suffix(file_extension) + cache_file = Path(cache_dir) / sharded_path.with_suffix(".pkl.zst") # ... check if cache file exists - open_func = gzip.open if use_gzip else open if cache_file.exists(): try: # ... try to load the result from cache file - with open_func(cache_file, "rb") as f: - result = pickle.load(f) - return result + return pd.read_pickle(cache_file) except Exception as e: # (Fallback to executing the function, with a warning) @@ -199,8 +262,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # ... save the result to cache file, creating directories if necessary cache_file.parent.mkdir(parents=True, exist_ok=True) - with open_func(cache_file, "wb") as f: - pickle.dump(result, f) + pd.to_pickle(result, cache_file, compression={"method": "zstd", "level": 3}) return result @@ -237,6 +299,17 @@ def to_parquet_with_metadata(df: pd.DataFrame, filepath: PathLike, **kwargs: Any pq.write_table(table, filepath, **kwargs) +def _readable_source(filepath: PathLike) -> Any: + """A pandas/pyarrow-readable source for ``filepath``: the local path, or an in-memory buffer for an + ``s3://`` URL (downloaded once via boto3, since the bare readers don't accept ``s3://``).""" + return io.BytesIO(read_s3_bytes(str(filepath))) if str(filepath).startswith("s3://") else filepath + + +def read_csv(filepath: PathLike, **kwargs: Any) -> pd.DataFrame: + """``pd.read_csv`` that also accepts an ``s3://`` URL (read via boto3, like :func:`read_parquet_with_metadata`).""" + return pd.read_csv(_readable_source(filepath), **kwargs) + + def read_parquet_with_metadata(filepath: PathLike, **kwargs: Any) -> pd.DataFrame: """Convenience wrapper around pd.read_parquet that preserves metadata. @@ -246,20 +319,70 @@ def read_parquet_with_metadata(filepath: PathLike, **kwargs: Any) -> pd.DataFram Returns: pandas DataFrame with metadata in .attrs attribute - """ - # Read the parquet file using pyarrow - table = pq.read_table(filepath) - # Extract metadata - raw_metadata = table.schema.metadata + ``filepath`` may be a local path or an ``s3://`` URL (downloaded once via boto3 — which reads the ambient + AWS profile, incl. endpoint + addressing_style — since bare ``pq.read_schema`` does not accept ``s3://``). + """ + src = _readable_source(filepath) - # Convert bytes keys and values back to strings + # Read the parquet schema using pyarrow, then the DataFrame using pandas (from the same buffer for s3). + schema = pq.read_schema(src) + raw_metadata = schema.metadata or {} metadata_dict = {k.decode(): v.decode() for k, v in raw_metadata.items() if k not in (b"pandas", b"pyarrow_schema")} - # Read the DataFrame using pandas - df = pd.read_parquet(filepath, **kwargs) - - # Attach metadata to DataFrame's attrs + if isinstance(src, io.BytesIO): + src.seek(0) + df = pd.read_parquet(src, **kwargs) df.attrs = metadata_dict - return df + + +def job_scoped_feather_path( + name: str, + *, + local_drive_mount: str, + job_id_env_var: str = "SLURM_JOB_ID", + content_key: Any = None, +) -> str: + """Return ``//[-].feather``, namespaced per job. + + Args: + name: Human-readable dataset name; the leading path component. + local_drive_mount: Root directory for feather files. + job_id_env_var: Environment variable used to namespace files per job. + content_key: Anything that decides what the feather *contains* (source path, + filters, columns). Two datasets sharing a ``name`` within one job must not + share a file unless this matches, so it is hashed into the filename. + + Examples: + >>> a = job_scoped_feather_path("pdb", local_drive_mount="/tmp", content_key=["n_nuc == 0"]) + >>> b = job_scoped_feather_path("pdb", local_drive_mount="/tmp", content_key=[]) + >>> a != b + True + """ + job_id = os.environ.get(job_id_env_var, f"manual_{os.getpid()}_{int(time.time())}") + # 16 hex chars, not 8: a collision here silently serves one dataset's rows to another, + # which is the failure this key exists to prevent. + suffix = f"-{string_to_md5_hash(repr(content_key), truncate=16)}" if content_key is not None else "" + return os.path.join(local_drive_mount, job_id, f"{name}{suffix}.feather") + + +def build_feather_once(feather_path: str, build_table: Callable[[], pa.Table]) -> str: + """Build ``feather_path`` once under a lock, publishing atomically; return the path. + + Ranks/workers race on the same path: the first to win the lock builds via ``build_table`` and + ``os.replace``s it into place (atomic on the same filesystem); the rest see the finished file and + skip. ``build_table`` is called only when the feather is missing, so the source read happens once. + """ + os.makedirs(os.path.dirname(feather_path), exist_ok=True) + with FileLock(feather_path + ".lock"): + if not os.path.exists(feather_path): + tmp_path = f"{feather_path}.tmp.{os.getpid()}" + try: + feather.write_feather(build_table(), tmp_path, compression="uncompressed") + os.replace(tmp_path, feather_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_path) # don't leave a partial temp behind on failure + raise + return feather_path diff --git a/src/atomworks/ml/utils/misc.py b/src/atomworks/ml/utils/misc.py index 28d38284..2cd0845b 100644 --- a/src/atomworks/ml/utils/misc.py +++ b/src/atomworks/ml/utils/misc.py @@ -78,19 +78,31 @@ def cumcount(a: np.ndarray) -> np.ndarray: return (np.arange(n) - dfill(b))[i] -def hash_sequence(sequence: str) -> str: +def hash_sequence(sequence: str, length: int | None = 11) -> str: + """SHA-256 hex digest of ``sequence``, truncated to ``length`` chars (``None`` = full 64-char digest). + + The default 11 is the legacy hashed-file-directory key; the packed MSA store passes a longer prefix + (``MSA_KEY_LEN``, collision-safe at scale). Same hash everywhere — only the truncation differs. """ - Generate a SHA-256 hash for the given sequence and return a compressed string format of the hash. + digest = hashlib.sha256(sequence.encode()).hexdigest() + return digest if length is None else digest[:length] + + +def get_complex_id(sequences: list[str]) -> str: + """Compute a stable, order-independent, fixed-length identifier for a multi-chain + complex. + + Hashes each sequence with `hash_sequence`, sorts and joins the unique hashes, + then re-hashes the joined string. Repeated chains don't change the id, e.g. an + H2L2 antibody gets the same id as its HL pair. Args: - sequence (str): The sequence to be hashed. + sequences: The protein sequences in the complex; repeats are ignored. Returns: - str: The compressed hash string format. + The complex_id string. """ - sha256_hash = hashlib.sha256(sequence.encode()).hexdigest() - compressed_name = sha256_hash[:11] # Using first 11 characters for simplicity - return compressed_name + return hash_sequence("-".join(sorted({hash_sequence(seq) for seq in sequences}))) @lru_cache(maxsize=1) diff --git a/src/atomworks/ml/utils/rng.py b/src/atomworks/ml/utils/rng.py index 7bd00701..b16c70ea 100644 --- a/src/atomworks/ml/utils/rng.py +++ b/src/atomworks/ml/utils/rng.py @@ -3,8 +3,6 @@ Inspired by: https://github.com/Lightning-AI/pytorch-lightning/blob/709a2a9d3b79b0a436eb2d271fbeecf8a7ba1352/src/lightning/fabric/utilities/seed.py """ -from __future__ import annotations - import random from collections.abc import Generator from contextlib import contextmanager @@ -71,6 +69,7 @@ def rng_state( If no rng_state_dict is provided, the RNG states are set to the current state of the RNGs. If the rng_state_dict only contains a subset of the RNG states, the other RNG states are set to the current state of the RNGs. + include_cuda: Whether to allow this function to also control the torch.cuda random number generator. Set this to False when using the function in a forked process where CUDA re-initialization is prohibited. Defaults to True. @@ -88,7 +87,7 @@ def rng_state( # Inside the context manager with fixed seeds with rng_state(create_rng_state_from_seeds(np_seed=42, torch_seed=42, py_seed=42)) as rng_state_dict: my_state = serialize_rng_state_dict(rng_state_dict) - print("\nWithin context manager:") + print("\\nWithin context manager:") print("NumPy:", np.random.random(3)) # [0.37454012 0.95071431 0.73199394] print("PyTorch:", torch.rand(3)) # tensor([0.8823, 0.9150, 0.3829]) print( @@ -96,7 +95,7 @@ def rng_state( ) # [0.6394267984578837, 0.025010755222666936, 0.27502931836911926] # Back to the original state outside the context manager - print("\nBack outside the context manager:") + print("\\nBack outside the context manager:") print("NumPy:", np.random.random(3)) # [0.75479377 0.99594641 0.70411424] print("PyTorch:", torch.rand(3)) # tensor([0.2757, 0.5345, 0.1754]) print( @@ -105,7 +104,7 @@ def rng_state( # Inside the context manager with fixed seeds with rng_state(eval(my_state)): - print("\nWithin context manager:") + print("\\nWithin context manager:") print("NumPy:", np.random.random(3)) # [0.37454012 0.95071431 0.73199394] print("PyTorch:", torch.rand(3)) # tensor([0.8823, 0.9150, 0.3829]) print( diff --git a/src/atomworks/ml/utils/testing.py b/src/atomworks/ml/utils/testing.py index a48fb098..e9cc230f 100644 --- a/src/atomworks/ml/utils/testing.py +++ b/src/atomworks/ml/utils/testing.py @@ -1,13 +1,25 @@ import os +from pathlib import Path +from tempfile import TemporaryDirectory import numpy as np -from biotite.structure import AtomArray, CellList +import torch +from biotite.structure import AtomArray +from scipy.spatial import cKDTree from atomworks.common import immutable_lru_cache from atomworks.constants import PDB_MIRROR_PATH from atomworks.io import parse -from atomworks.ml.preprocessing.constants import CELL_SIZE -from atomworks.ml.preprocessing.utils.structure_utils import get_atom_mask_from_cell_list +from atomworks.io.config import ParseConfig +from atomworks.io.utils.atom_array_plus import ( + AnnotationList2D, + AtomArrayPlus, +) +from atomworks.io.utils.io_utils import ( + to_cif_file, +) +from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS +from atomworks.ml.conditions import C_CTR, C_NTR, CONDITIONS def get_pdb_mirror_path(pdbid: str, base_dir: str = PDB_MIRROR_PATH) -> str: @@ -26,7 +38,7 @@ def get_pdb_mirror_path(pdbid: str, base_dir: str = PDB_MIRROR_PATH) -> str: @immutable_lru_cache(maxsize=1000, deepcopy=True) def cached_parse(pdb_id: str, **kwargs) -> dict: """Wrapper around parse with caching to return an independent copy of the output dict.""" - data = parse(filename=get_pdb_mirror_path(pdb_id), **kwargs) + data = parse(get_pdb_mirror_path(pdb_id), config=ParseConfig(**kwargs) if kwargs else None) if "atom_array" not in data: assembly_ids = list(data["assemblies"].keys()) data["atom_array"] = data["assemblies"][assembly_ids[0]][0] @@ -35,12 +47,197 @@ def cached_parse(pdb_id: str, **kwargs) -> dict: def is_clash(atom_array_1: AtomArray, atom_array_2: AtomArray, clash_distance: float = 1.0) -> bool: + """Check for clashes between two atom arrays.""" + tree = cKDTree(atom_array_2.coord) + return len(tree.query_ball_point(atom_array_1.coord, r=clash_distance, return_length=True).nonzero()[0]) > 0 + + +def add_randomized_standard_annotations(atom_array: AtomArrayPlus) -> AtomArrayPlus: + """Add randomized standard annotations to an AtomArray""" + original_categories = set(atom_array.get_annotation_categories()) + for sa_cls in STANDARD_ANNOTATIONS: + if sa_cls in {C_CTR, C_NTR}: + continue + if sa_cls.n_body == 1: + random_indices = np.random.choice(len(atom_array), size=5, replace=False) + annotation_arr = sa_cls.default_annotation(atom_array).copy() + modification_shape = (5, *annotation_arr.shape[1:]) + storage_dtype = sa_cls.storage_dtype() + if np.issubdtype(storage_dtype, np.bool_): + random_values = np.ones(modification_shape, dtype=bool) + elif np.issubdtype(storage_dtype, np.integer): + random_values = np.random.randint(1, 100, size=modification_shape) + elif np.issubdtype(storage_dtype, np.floating): + random_values = np.random.random(modification_shape) + else: + annotation_arr = annotation_arr.astype(storage_dtype) + random_values = np.full(modification_shape, "TEST", dtype=storage_dtype) + annotation_arr[random_indices, ...] = random_values + sa_cls.set_annotation(atom_array, array=annotation_arr) + + elif sa_cls.n_body == 2: + random_indices = np.random.choice(len(atom_array), size=(5, 2), replace=False) + annotation_shape = sa_cls.default_annotation(atom_array).values.shape + modification_shape = (5, *annotation_shape[1:]) + storage_dtype = sa_cls.storage_dtype() + if np.issubdtype(storage_dtype, np.bool_): + values = np.ones(modification_shape, dtype=bool) + elif np.issubdtype(storage_dtype, np.integer): + values = np.random.randint(1, 100, size=modification_shape).astype(storage_dtype) + else: + values = np.random.random(modification_shape) + annotation_arr = AnnotationList2D( + n_atoms=atom_array.array_length(), + pairs=random_indices, + values=values, + ) + sa_cls.set_annotation(atom_array, pairs=annotation_arr.pairs, values=annotation_arr.values) + + # Remove ordinary annotations generated only as prerequisites of SA defaults. + standard_annotation_names = set(STANDARD_ANNOTATIONS.get_field_names(n_body=1, include_aliases=True)) + for category in set(atom_array.get_annotation_categories()) - original_categories - standard_annotation_names: + atom_array.del_annotation(category) + + return atom_array + + +def assert_tensor_or_array_equal( + actual: np.ndarray | torch.Tensor | int | float | str | bool, + expected: np.ndarray | torch.Tensor | int | float | str | bool, + error_msg: str, + atol: float = 1e-4, + rtol: float = 1e-4, +) -> None: + """Assert tensors, arrays, or scalars are equal with type-aware tolerance. + + Uses exact equality for int/bool, tolerances for float, and treats NaN as equal. + + Args: + atol: Absolute tolerance for floats. Defaults to ``1e-4``. + rtol: Relative tolerance for floats. Defaults to ``1e-4``. + + Raises: + AssertionError: If values don't match. """ - Checks for clashes between two arrays. Based on atomworks.ml.preprocessing.process.DataPreprocessor. - Recommended to pass in minimal masks of arrays to check to reduce runtime. + if torch.is_tensor(actual): + if actual.dtype == torch.bool or actual.dtype in [torch.int32, torch.int64]: + torch.testing.assert_close( + actual, expected, atol=0, rtol=0, equal_nan=True, msg=lambda x: error_msg + ": " + x + ) + else: + torch.testing.assert_close( + actual, expected, atol=atol, rtol=rtol, equal_nan=True, msg=lambda x: error_msg + ": " + x + ) + elif isinstance(actual, np.ndarray): + if actual.dtype.kind in ["U", "S"] or actual.dtype == bool or np.issubdtype(actual.dtype, np.integer): + np.testing.assert_array_equal(actual, expected, err_msg=error_msg) + else: + np.testing.assert_allclose(actual, expected, atol=atol, rtol=rtol, equal_nan=True, err_msg=error_msg) + else: + # Handle numpy string conversion for scalars + if isinstance(actual, np.str_): + actual = str(actual) + if isinstance(expected, np.str_): + expected = str(expected) + assert actual == expected, error_msg + + +def assert_equal( + obtained: dict | list | np.ndarray | torch.Tensor | int | float | str | bool, + expected: dict | list | np.ndarray | torch.Tensor | int | float | str | bool, + path: str = "", + allow_extra_keys: bool = False, + atol: float = 1e-4, + rtol: float = 1e-4, +) -> None: + """Recursively assert nested structures are equal with type-aware comparison. + + Handles dicts, lists, tensors, arrays, and scalars. Uses :py:func:`assert_tensor_or_array_equal` + for leaf value comparisons. + + Args: + path: Current path in structure for error messages. Defaults to ``""``. + allow_extra_keys: Allow extra keys in ``obtained`` dicts. Defaults to ``False``. + atol: Absolute tolerance for floats. Defaults to ``1e-4``. + rtol: Relative tolerance for floats. Defaults to ``1e-4``. + + Raises: + AssertionError: If values don't match. """ - cell_list = CellList(atom_array_1, cell_size=CELL_SIZE) + error_msg = f"Mismatch at {path}" if path else "Values don't match" + + # Handle dictionaries recursively + if isinstance(expected, dict): + if not isinstance(obtained, dict): + raise AssertionError(f"{error_msg}: expected dict, got {type(obtained)}") + + # Check keys + missing = set(expected.keys()) - set(obtained.keys()) + if missing: + raise AssertionError(f"{error_msg}: missing keys {missing}") + if not allow_extra_keys: + extra = set(obtained.keys()) - set(expected.keys()) + if extra: + raise AssertionError(f"{error_msg}: extra keys {extra}") + + # Recurse on values + for key in expected: + new_path = f"{path}[{key!r}]" if path else repr(key) + assert_equal(obtained[key], expected[key], new_path, allow_extra_keys, atol, rtol) + return + + # Handle lists recursively + if isinstance(expected, list): + if not isinstance(obtained, list): + raise AssertionError(f"{error_msg}: expected list, got {type(obtained)}") + if len(obtained) != len(expected): + raise AssertionError(f"{error_msg}: length mismatch {len(obtained)} != {len(expected)}") + + # Convert to numpy arrays for comparison if all elements are numeric + try: + obtained_arr = np.array(obtained) + expected_arr = np.array(expected) + # If conversion succeeded and types are comparable, use array comparison + if obtained_arr.dtype.kind in ["i", "u", "f"] and expected_arr.dtype.kind in ["i", "u", "f"]: + assert_tensor_or_array_equal(obtained_arr, expected_arr, error_msg, atol, rtol) + return + except (ValueError, TypeError): + pass + + # Otherwise, compare element by element + for i, (obtained_val, expected_val) in enumerate(zip(obtained, expected, strict=False)): + new_path = f"{path}[{i}]" if path else f"[{i}]" + assert_equal(obtained_val, expected_val, new_path, allow_extra_keys, atol, rtol) + return + + # All leaf values (tensors, arrays, scalars) handled by assert_tensor_or_array_equal + assert_tensor_or_array_equal(obtained, expected, error_msg, atol, rtol) - clashing_atom_mask = get_atom_mask_from_cell_list(atom_array_2.coord, cell_list, len(atom_array_1), clash_distance) - return np.any(clashing_atom_mask) +def save_and_load_atom_array( + atom_array: AtomArrayPlus, + extra_fields_to_load: list[str] | None = "all", + extra_annotations_to_save: list[str] | None = "all", +) -> AtomArrayPlus: + """Save an atom array to a temporary CIF and load it back.""" + with TemporaryDirectory() as temp_dir: + temp_file = Path(temp_dir) / "test.cif" + to_cif_file( + atom_array, + temp_file, + extra_fields=extra_annotations_to_save or [], + save_standard_annotations=True, + ) + result = parse( + temp_file, + config=ParseConfig.from_preset( + "minimal", + load_standard_annotations=True, + return_atom_array_plus=True, + extra_fields=extra_fields_to_load or [], + ), + ) + assembly_ids = list(result["assemblies"].keys()) + loaded = result["assemblies"][assembly_ids[0]][0] + CONDITIONS.fill_missing_conditions_with_defaults(loaded) + return loaded diff --git a/src/atomworks/ml/utils/token.py b/src/atomworks/ml/utils/token.py index ba9e328c..5979cea3 100644 --- a/src/atomworks/ml/utils/token.py +++ b/src/atomworks/ml/utils/token.py @@ -4,6 +4,7 @@ import numpy as np from biotite.structure import AtomArray +from atomworks.io.utils.scatter import NUMPY_REDUCE_FUNCS from atomworks.io.utils.sequence import ( is_glycine, is_protein_unknown, @@ -21,7 +22,9 @@ def get_token_starts(array: AtomArray, add_exclusive_stop: bool = False) -> np.n Inspired by `biotite.structure.get_residue_starts`. - A new token starts: + If a ``token_id`` annotation is present, token boundaries are determined solely by changes in ``token_id``. + + Otherwise, a new token starts: - If `atomize` is True - If either the chain ID, residue ID, insertion code or residue name changes from one to the next atom. @@ -38,36 +41,39 @@ def get_token_starts(array: AtomArray, add_exclusive_stop: bool = False) -> np.n # ... early exit if the array is empty return np.array([]) - # These mask are 'true' at indices where the value changes - if "atomize" in array.get_annotation_categories(): - atomize_positions = array.atomize - else: - atomize_positions = np.zeros(array.array_length(), dtype=bool) - - if "chain_iid" in array.get_annotation_categories(): - chain_id_changes = array.chain_iid[1:] != array.chain_iid[:-1] + if "token_id" in array.get_annotation_categories(): + token_change_mask = array.token_id[1:] != array.token_id[:-1] else: - chain_id_changes = array.chain_id[1:] != array.chain_id[:-1] - - res_id_changes = array.res_id[1:] != array.res_id[:-1] - ins_code_changes = array.ins_code[1:] != array.ins_code[:-1] - res_name_changes = array.res_name[1:] != array.res_name[:-1] - - # If any of these annotation arrays change, a new residue starts - residue_change_mask = ( - chain_id_changes | res_id_changes | ins_code_changes | res_name_changes | atomize_positions[1:] - ) + # ... default: a token boundary is any change in chain / residue / + # insertion / residue name, or an explicit ``atomize`` flag. + if "atomize" in array.get_annotation_categories(): + atomize_positions = array.atomize + else: + atomize_positions = np.zeros(array.array_length(), dtype=bool) + + if "chain_iid" in array.get_annotation_categories(): + chain_id_changes = array.chain_iid[1:] != array.chain_iid[:-1] + else: + chain_id_changes = array.chain_id[1:] != array.chain_id[:-1] + + res_id_changes = array.res_id[1:] != array.res_id[:-1] + ins_code_changes = array.ins_code[1:] != array.ins_code[:-1] + res_name_changes = array.res_name[1:] != array.res_name[:-1] + + token_change_mask = ( + chain_id_changes | res_id_changes | ins_code_changes | res_name_changes | atomize_positions[1:] + ) # Convert mask to indices # Add 1, to shift the indices from the end of a residue # to the start of a new residue - residue_starts = np.where(residue_change_mask)[0] + 1 + token_starts = np.where(token_change_mask)[0] + 1 # The first residue is not included yet -> Insert '[0]' if add_exclusive_stop: - return np.concatenate(([0], residue_starts, [array.array_length()])) + return np.concatenate(([0], token_starts, [array.array_length()])) else: - return np.concatenate(([0], residue_starts)) + return np.concatenate(([0], token_starts)) def get_token_count(array: AtomArray) -> int: @@ -167,6 +173,20 @@ def apply_token_wise( """Analogous to biotite's `apply_residue_wise`.""" if token_starts is None: token_starts = get_token_starts(array, add_exclusive_stop=True) + # Fast path: reduce boolean tokens in NumPy without a Python loop; other inputs use the generic implementation. + if ( + type(data) is np.ndarray + and data.dtype == bool + and data.ndim == 1 + and axis in (None, 0, -1) + and len(data) + and any(function is candidate for candidate in (np.any, np.all, np.min, np.max)) + and len(token_starts) > 1 + and token_starts[0] == 0 + and token_starts[-1] == len(data) + and np.all(np.diff(token_starts) > 0) + ): + return NUMPY_REDUCE_FUNCS[function](data, token_starts[:-1]) return struc.segments.apply_segment_wise(token_starts, data, function, axis) diff --git a/src/atomworks/ml/utils/visualize.py b/src/atomworks/ml/utils/visualize.py new file mode 100644 index 00000000..47abf30d --- /dev/null +++ b/src/atomworks/ml/utils/visualize.py @@ -0,0 +1,396 @@ +import logging +from itertools import pairwise +from typing import Literal + +import biotite.structure as struc +import numpy as np + +from atomworks.io.utils.standard_annotations import S_ATM, S_SEGMAX, S_SEGMIN +from atomworks.io.utils.standard_annotations.base import Level +from atomworks.io.utils.visualize import get_pymol_session, view_pymol +from atomworks.ml.conditions import C_DIS, Condition + +logger = logging.getLogger(__name__) +logger.setLevel(logging.WARNING) + + +# Conditions opted out of automatic mask-display selection +# generation in :py:func:`get_atom_array_style_cmd`. Extensions (e.g. +# proteinfoundation) register entries via :py:func:`hide_condition_display` +_HIDE_MASKS_FOR_CONDITIONS: set[type] = set() + + +def hide_condition_display(cond) -> None: # noqa: ANN001 - cond is a condition class + """Opt *cond* out of automatic mask-display selection generation. + + Args: + cond: A condition class (e.g. ``C_IDX``) whose display selection should + not be auto-created by :py:func:`get_atom_array_style_cmd`. + """ + _HIDE_MASKS_FOR_CONDITIONS.add(cond) + + +def show_condition_display(cond) -> None: # noqa: ANN001 - cond is a condition class + """Re-enable automatic mask-display for *cond* (undo :py:func:`hide_condition_display`).""" + _HIDE_MASKS_FOR_CONDITIONS.discard(cond) + + +# Marks atoms whose true coordinates are NaN (unresolved / filled-missing atoms). +_DISPLAY_PLACEHOLDER = "_display_placeholder" + + +def display_atom_ids(array: struc.AtomArray) -> np.ndarray: + """Return the PyMOL atom ids for *array* — the ``atom_id`` annotation if present, else 1-indexed.""" + if "atom_id" in array.get_annotation_categories(): + return array.get_annotation("atom_id") + return np.arange(1, array.array_length() + 1) + + +def _fill_nan_with_residue_centroid(array: struc.AtomArray, nan_mask: np.ndarray) -> None: + """Move each NaN-coord atom to its residue's resolved-atom centroid, in place. + + Fully-unresolved residues (no resolved atom to average) fall back to the origin. + + Args: + array: Array to modify (``array.coord`` is mutated). + nan_mask: Boolean mask of the atoms with NaN coordinates. + """ + starts = struc.get_residue_starts(array, add_exclusive_stop=True) + for s, e in pairwise(starts): + res_nan = nan_mask[s:e] + if not res_nan.any(): + continue + centroid = array.coord[s:e][~res_nan].mean(axis=0) if not res_nan.all() else np.zeros(3) + array.coord[np.arange(s, e)[res_nan]] = centroid + + +def prepare_for_display( + array: struc.AtomArray, + nan_coord_policy: Literal["remove", "zero", "centroid"] | None = None, +) -> struc.AtomArray: + """Return a display copy of the array with expandable segment sentinel coordinates filled in. + + Expandable segment sentinels carry NaN coordinates internally (correct for + atomworks and file I/O), but PyMOL cannot render NaN positions. This function + copies the array and assigns each sentinel a display position: + + - **In-chain sentinel**: midpoint of the nearest flanking CA atoms on either side. + Falls back to whichever flanking atom is available. + - **New-chain sentinel** (no flanking atoms): placed at the origin. + + Args: + array: AtomArrayPlus to prepare. Not modified. + nan_coord_policy: How to handle atoms whose coords are still NaN after + sentinel interpolation. ``"remove"`` drops them (PyMOL never sees + them). ``"zero"`` sets their coords to the origin. ``"centroid"`` + keeps them (so they stay selectable/editable), moves each to its + residue centroid, and marks them via :py:data:`_DISPLAY_PLACEHOLDER` + so the viewer can hide them. ``None`` leaves them as NaN (caller is + responsible for downstream handling). + + Returns: + Copy of the array with sentinel coordinates set for display. + """ + array = array.copy() # never change the main array + + # always disambiguate chain_id using chain_iid + if "chain_iid" in array.get_annotation_categories(): + array.chain_id = array.get_annotation("chain_iid").astype(str) + else: + raise ValueError("Expected chain_iid annotation for display disambiguation, but not found.") + + segment_mask = S_SEGMIN.mask(array, default="generate") + for segment_idx in np.where(segment_mask)[0]: + prev_coord = next_coord = None + for j in range(int(segment_idx) - 1, -1, -1): + if not segment_mask[j] and array.atom_name[j] == "CA" and not np.any(np.isnan(array.coord[j])): + prev_coord = array.coord[j] + break + for j in range(int(segment_idx) + 1, len(array)): + if not segment_mask[j] and array.atom_name[j] == "CA" and not np.any(np.isnan(array.coord[j])): + next_coord = array.coord[j] + break + + if prev_coord is not None and next_coord is not None: + array.coord[segment_idx] = (prev_coord + next_coord) / 2.0 + elif prev_coord is not None: + array.coord[segment_idx] = prev_coord + elif next_coord is not None: + array.coord[segment_idx] = next_coord + else: + array.coord[segment_idx] = np.zeros(3) + + if nan_coord_policy is not None: + nan_mask = np.any(np.isnan(array.coord), axis=1) + if nan_coord_policy == "centroid": + if nan_mask.any(): + _fill_nan_with_residue_centroid(array, nan_mask) + prior = ( + array.get_annotation(_DISPLAY_PLACEHOLDER).astype(bool) + if _DISPLAY_PLACEHOLDER in array.get_annotation_categories() + else np.zeros(array.array_length(), dtype=bool) + ) + array.set_annotation(_DISPLAY_PLACEHOLDER, prior | nan_mask) + elif nan_mask.any(): + if nan_coord_policy == "remove": + array = array[~nan_mask] + elif nan_coord_policy == "zero": + array.coord[nan_mask] = 0.0 + + return array + + +def count_hidden_nan_atoms(array: struc.AtomArray) -> int: + """Return the number of atoms :py:func:`prepare_for_display` would drop. + + Sentinels (``S_SEGMIN``-masked atoms) are excluded because they receive + interpolated display coordinates rather than being hidden. + """ + nan_mask = np.any(np.isnan(array.coord), axis=1) + if S_SEGMIN.has_annotation(array): + nan_mask = nan_mask & ~S_SEGMIN.mask(array) + return int(nan_mask.sum()) + + +def get_atom_array_style_cmd( + atom_array: struc.AtomArray | struc.AtomArrayStack, + obj: str, + label: bool = False, + max_distances: int = 100, + grid_slot: int | None = None, + nan_coord_policy: Literal["remove", "zero", "centroid"] = "remove", +) -> str: + """Generate PyMOL commands to style an atom array visualization. + + Creates a series of PyMOL commands that style different parts of a molecular structure, including: + - Applying a color spectrum to polymer chains + - Showing backbone atoms as sticks and CA atoms as spheres + - Styling non-polymer atoms with different colors and representation + - Highlighting specially annotated atoms with different colors and visualizations + + Args: + atom_array: The biotite AtomArray or AtomArrayStack to be styled + obj: PyMOL object name to apply the styling to + label: Whether to label all atoms with their 0-indexed atom_id + max_distances: Maximum number of distance lines to show (pymol hangs when there's too many distance objects) + + Returns: + str: A PyMOL command string that styles the atom array + """ + # Work on a display copy: expandable segment sentinels get interpolated coordinates so + # PyMOL can position them. The original array is never modified. + # NaN coords must match whatever policy was used when loading into PyMOL, + # otherwise atom IDs between the commands below and the PyMOL object diverge. + atom_array = prepare_for_display(atom_array, nan_coord_policy=nan_coord_policy) + + grid_slot = grid_slot or np.random.randint(0, 10_000) + commands = [f"hide everything, {obj}"] + annotations = atom_array.get_annotation_categories() + + # pymol 1-indexes atom ids unless an explicit atom_id annotation is present + offset = 0 if "atom_id" in annotations else 1 + atom_ids = display_atom_ids(atom_array) + + # Style the backbone for each polymer chain with a color spectrum + for chain_id in struc.get_chains(atom_array): + if (~atom_array.hetero[atom_array.chain_id == chain_id]).any(): + commands.append( + f"spectrum resi, RFd_darkblue RFd_blue RFd_lightblue RFd_purple RFd_pink RFd_melon RFd_navaho, " + f"{obj} and chain {chain_id} and elem C" + ) + + # Add basic styling commands for protein backbone and non-polymer components + commands.extend( + [ + f"show sticks, model {obj} and name n+c+ca+cb", + f"show spheres, model {obj} and name ca", + f"set sphere_scale, 0.23, model {obj} and name ca", + f"set sphere_transparency, 0, model {obj} and name ca", + f"color grey60, model {obj} and not polymer and elem C", + f"show nb_spheres, model {obj} and not polymer", + f"show sticks, model {obj} and not polymer", + ] + ) + if label: + # label 0-indexed for correspondence with biotite + commands.append("label all, ID") if offset == 0 else commands.append(f"label all, ID-{offset}") + + # Style atoms marked for "atomize" if present. + # Delete any prior selection first so it never lingers when atomize is absent. + commands.append(f"delete {obj}_atomize") + if S_ATM.has_annotation(atom_array) and S_ATM.mask(atom_array).any(): + atomize_ids = np.where(S_ATM.mask(atom_array))[0] + atomize_ids = atom_ids[atomize_ids] + commands.extend( + [ + f"select {obj}_atomize, model {obj} and id {'+'.join(str(id) for id in atomize_ids)}", + f"show sticks, {obj}_atomize and byres {obj}_atomize", + ] + ) + + # Style constraints, if present: + # 2-body: + # ... add distance lines between atoms if specified in annotations + if hasattr(atom_array, "_annot_2d"): + distance_commands = [] + if C_DIS.full_name in atom_array._annot_2d: + constraint_data = C_DIS.annotation(atom_array).as_array() + if len(constraint_data) > 0: + _atom_idxs = np.unique(constraint_data[:, :2].flatten()).astype(int) + _atom_ids = atom_ids[_atom_idxs] + _selection = f'{obj} and id {"+".join(str(id) for id in _atom_ids)}' + commands.extend( + [ + f"delete m2d_{obj}", + f"select m2d_{obj}, {_selection}", + f"show spheres, m2d_{obj}", + f"set sphere_color, lime, m2d_{obj}", + f"set sphere_scale, 0.25, m2d_{obj}", + f"set sphere_transparency, 0.5, m2d_{obj}", + f"show sticks, byres m2d_{obj}", + ] + ) + + if len(constraint_data) > max_distances: + logger.warning(f"Too many distance conditions ({len(constraint_data)}), sampling {max_distances}.") + constraint_idxs = np.arange(len(constraint_data)) + constraint_idxs = np.random.choice(constraint_idxs, max_distances, replace=False) + constraint_data = constraint_data[constraint_idxs] + + for row in constraint_data: + idx_i, idx_j, value = row + if (idx_i > idx_j) or (value == 0): + continue + + i, j = atom_ids[idx_i], atom_ids[idx_j] + # ... if we have a stack, we grab the last frame for the distance computation + if isinstance(atom_array, struc.AtomArrayStack): + distance = struc.distance(atom_array[0, idx_i], atom_array[0, idx_j]) + else: + distance = struc.distance(atom_array[idx_i], atom_array[idx_j]) + + distance_name = f"d{idx_i}-{idx_j}_{value:.2f}_{distance:.2f}" + distance_commands.extend( + [ + f"distance {distance_name}, model {obj} and id {i}, model {obj} and id {j}", + f"set grid_slot, {grid_slot}, {distance_name}", + ] + ) + + commands.extend(distance_commands) + + # Expandable segment sentinel visualization — coordinates were filled in by prepare_for_display above. + # Delete any prior selection first so it never lingers when no sentinels remain. + commands.append(f"delete {obj}_segments") + if S_SEGMIN.has_annotation(atom_array): + segment_mask = S_SEGMIN.mask(atom_array) + if segment_mask.any(): + segment_indices = np.where(segment_mask)[0] + segment_atom_ids = atom_ids[segment_indices] + segment_sel = f'model {obj} and id {"+".join(str(i) for i in segment_atom_ids)}' + commands.extend( + [ + f"select {obj}_segments, {segment_sel}", + f"show spheres, {obj}_segments", + f"color yellow, {obj}_segments", + f"set sphere_scale, 0.6, {obj}_segments", + f"set sphere_transparency, 0.3, {obj}_segments", + ] + ) + # Label each sentinel using annotation values so realized stubs + # (res_name == MASKED) also get the correct range shown. + segmin_annot = S_SEGMIN.annotation(atom_array) + segmax_annot = S_SEGMAX.annotation(atom_array) + for segment_idx, atom_id in zip(segment_indices, segment_atom_ids, strict=False): + segmin = int(segmin_annot[segment_idx]) + segmax = int(segmax_annot[segment_idx]) + commands.append(f'label model {obj} and id {atom_id}, "{segmin}-{segmax}"') + + # Handle 1-D conditions: surface a named selection for any condition whose + # (possibly generated) default mask is non-empty. Conditions registered via + # hide_condition_display() are skipped + for cond in Condition: + if cond.n_body != 1: + continue + sel_name = f"mask_{cond.name}_{cond.n_body}_{cond.level}_{obj}" + # Always delete the prior selection first so nothing stale ever lingers — + # every recompute reflects the current array exactly. + commands.append(f"delete {sel_name}") + if cond in _HIDE_MASKS_FOR_CONDITIONS: + continue + mask = cond.mask(atom_array, default="generate") + if not mask.any(): + continue + + _atom_ids = atom_ids[np.where(mask)[0]] + # enable=0: create the selection without activating it, so a condition + # mask never masquerades as the live ``sele`` selection. + if cond.level == Level.ATOM: + _selection = f'model {obj} and id {"+".join(str(id) for id in _atom_ids)}' + commands.append(f"select {sel_name}, {_selection}, enable=0") + elif cond.level == Level.RESIDUE or cond.level == Level.TOKEN: + _selection = f'model {obj} and byres (id {"+".join(str(id) for id in _atom_ids)})' + commands.append(f"select {sel_name}, {_selection}, enable=0") + + return "\n".join(commands) + + +def viz( + atom_array: struc.AtomArray | struc.AtomArrayStack, + id: str = "obj", + clear: bool = False, + label: bool = False, + max_distances: int = 100, + view_ori_token: bool = False, + stylize: bool = False, +) -> None: + """Quickly visualize a molecular structure in PyMOL with predefined styling. + + This function creates a PyMOL session, loads the atom array structure, and applies + a set of styling commands to make the visualization informative and aesthetically pleasing. + The styling highlights different structural components and annotated features. + + Args: + atom_array: The biotite AtomArray or AtomArrayStack to visualize + id: PyMOL object identifier (default: "obj") + clear: Whether to clear existing PyMOL objects before visualization (default: True) + label: Whether to label all atoms with their 0-indexed atom_id (default: True) + view_ori_token: Whether to view the ori token (default: False) + stylize: Whether to apply styling as defined in `get_atom_array_style_cmd` (default: False) + + Example: + >>> from biotite.structure import AtomArray + >>> # Create or load an atom array + >>> structure = AtomArray(...) + >>> # Visualize the structure in PyMOL + >>> viz(structure) + """ + atom_array = atom_array.copy() + # pymol only considers chain_id annotation, which can make weird looking artifacts if we have two different chains with the same chain_id + # We always disambiguate by using the chain_iid annotation, so we need to have pymol use that to do the same + if "chain_iid" in atom_array.get_annotation_categories(): + atom_array.chain_id = atom_array.get_annotation("chain_iid") + atom_array.chain_id = atom_array.chain_id.astype(str) + + pymol = get_pymol_session() + pymol.do("set valence, 1; set connect_mode, 2;") + if clear: + pymol.do("delete d*") + pymol.delete("all") + slot = np.random.randint(0, 10_000) + obj_name = view_pymol(atom_array, id=id, grid_slot=slot) + if stylize: + cmd = get_atom_array_style_cmd(atom_array, obj_name, label=label, grid_slot=slot, max_distances=max_distances) + pymol.do(cmd) + + if view_ori_token: + pymol.do(f"pseudoatom ori_{obj_name}, pos=[0,0,0]") + pymol.do( + [ + f"set grid_slot, {slot}, ori_{obj_name}", + f"show spheres, ori_{obj_name}", + f"set sphere_color, white, ori_{obj_name}", + f"set sphere_scale, 0.5, ori_{obj_name}", + f"set sphere_transparency, 0.5, ori_{obj_name}", + ] + ) diff --git a/src/atomworks_cli/__main__.py b/src/atomworks_cli/__main__.py index 2d361daa..a591df09 100644 --- a/src/atomworks_cli/__main__.py +++ b/src/atomworks_cli/__main__.py @@ -13,12 +13,14 @@ # Import commands to register them with the root app and expose sub-apps from . import ccd as _ccd # noqa: E402 +from . import cluster as _cluster # noqa: E402 from . import msa as _msa # noqa: E402 from . import pdb as _pdb # noqa: E402, T100 from . import setup as _setup # noqa: E402 # Expose namespaced groups: `atomworks ccd ...`, ... app.add_typer(_ccd.app, name="ccd") +app.add_typer(_cluster.app, name="cluster") app.add_typer(_msa.app, name="msa") app.add_typer(_pdb.app, name="pdb") app.add_typer(_setup.app, name="setup") diff --git a/src/atomworks_cli/ccd.py b/src/atomworks_cli/ccd.py index 89280e29..a8a19861 100644 --- a/src/atomworks_cli/ccd.py +++ b/src/atomworks_cli/ccd.py @@ -2,8 +2,6 @@ # ruff: noqa: B008 -from __future__ import annotations - import functools import operator import os diff --git a/src/atomworks_cli/cluster.py b/src/atomworks_cli/cluster.py new file mode 100644 index 00000000..aac17abf --- /dev/null +++ b/src/atomworks_cli/cluster.py @@ -0,0 +1,76 @@ +"""CLI commands for sequence clustering.""" + +from pathlib import Path + +import typer + +from .common import enable_logging + +app = typer.Typer(help="Sequence clustering utilities") + + +@app.command(name="add") +def add( + input_file: Path = typer.Argument( + ..., + exists=True, + readable=True, + resolve_path=True, + help="Path to parquet file with sequences", + ), + sequence_column: str = typer.Option( + ..., + "-s", + "--sequence-column", + help="Name of column containing sequences", + ), + output_column: str = typer.Option( + "cluster", + "-o", + "--output-column", + help="Name of output cluster column", + ), + identity: float = typer.Option( + 0.4, + "-i", + "--identity", + help="Sequence identity threshold (0.0-1.0)", + ), + coverage: float = typer.Option( + 0.8, + "-c", + "--coverage", + help="Coverage threshold (0.0-1.0)", + ), + output_file: Path | None = typer.Option( + None, + "--output", + help="Output file path. If not specified, modifies input in place.", + ), + verbose: bool = typer.Option(False, "-v", "--verbose", help="Verbose logging"), +) -> None: + """Cluster sequences and add cluster column using MMseqs2. + + Examples: + atomworks cluster add data.parquet -s sequence -i 0.4 -o cluster_40 + atomworks cluster add proteins.parquet -s seq -i 0.9 -o cluster_90 + """ + enable_logging(verbose) + + from atomworks.ml.preprocessing.clustering.cluster import add_cluster_column_to_file + from atomworks.ml.preprocessing.clustering.config import MMseqs2Config + + typer.echo(f"Clustering {input_file}") + typer.echo(f" Sequence column: {sequence_column}") + typer.echo(f" Identity: {identity}") + typer.echo(f" Output column: {output_column}") + + config = MMseqs2Config(cluster_identity=identity, coverage=coverage) + result = add_cluster_column_to_file( + input_file, + sequence_column, + output_column, + config, + output_file, + ) + typer.secho(f"Done! Processed {len(result):,} rows", fg=typer.colors.GREEN) diff --git a/src/atomworks_cli/filter.py b/src/atomworks_cli/filter.py index 508efc01..c7931059 100644 --- a/src/atomworks_cli/filter.py +++ b/src/atomworks_cli/filter.py @@ -1,7 +1,5 @@ """MSA filtering command using HHfilter.""" -from __future__ import annotations - import logging from glob import glob from pathlib import Path diff --git a/src/atomworks_cli/find.py b/src/atomworks_cli/find.py index 2d1f598d..572d002b 100644 --- a/src/atomworks_cli/find.py +++ b/src/atomworks_cli/find.py @@ -1,7 +1,5 @@ """Finding pre-computed MSAs on disk, and reporting missing sequences.""" -from __future__ import annotations - import logging from pathlib import Path @@ -36,7 +34,7 @@ def find( existing_msa_dirs: str | None = typer.Option( None, "--existing-msa-dirs", - help="Comma-separated MSA directories to find (uses LOCAL_MSA_DIRS env var if not specified)", + help="Comma-separated MSA directories to find (uses PROTEIN_MSA_DIRS env var if not specified)", ), missing_output: Path | None = typer.Option( None, @@ -95,7 +93,7 @@ def find( if msa_dirs: typer.echo(f" MSA Directories: {msa_dirs}") else: - typer.echo(" MSA Directories: LOCAL_MSA_DIRS env var") + typer.echo(" MSA Directories: PROTEIN_MSA_DIRS env var") typer.secho("=" * 35, fg=typer.colors.CYAN) try: @@ -145,7 +143,7 @@ def find( ) typer.secho(f" Found MSAs: {found_count:,} ({coverage_percent:.1f}%)", fg=found_color) typer.secho( - f" Missing MSAs: {missing_count:,} ({100 - coverage_percent:.1f}%)", + f" Missing MSAs: {missing_count:,} ({100-coverage_percent:.1f}%)", fg=typer.colors.RED if missing_count > 0 else typer.colors.GREEN, ) diff --git a/src/atomworks_cli/generate.py b/src/atomworks_cli/generate.py index 43aa4f8d..d9a9f929 100644 --- a/src/atomworks_cli/generate.py +++ b/src/atomworks_cli/generate.py @@ -1,6 +1,4 @@ -"""MSA generation command using MMseqs2.""" - -from __future__ import annotations +"""MSA generation command using MMseqs2 or HHblits.""" import logging from pathlib import Path @@ -10,6 +8,7 @@ from atomworks.enums import MSAFileExtension from atomworks.ml.preprocessing.msa.generating import ( + HHblitsSearchConfig, MMseqs2SearchConfig, MSAGenerationConfig, make_msas_from_csv, @@ -63,13 +62,13 @@ def generate( gpu: bool | None = typer.Option( None, "--gpu/--no-gpu", - help="Use GPU acceleration (auto-detects if not specified)", + help="(MMseqs2) Use GPU acceleration (auto-detects if not specified)", ), num_iterations: int = typer.Option( 3, "--num-iterations", "-n", - help="Number of MMseqs2 search iterations", + help="(MMseqs2) Number of search iterations", ), max_final_sequences: int = typer.Option( 10_000, @@ -79,18 +78,18 @@ def generate( use_env: bool = typer.Option( True, "--use-env/--no-env", - help="Include environmental (metagenomic) database", + help="(MMseqs2) Include environmental (metagenomic) database", ), - num_workers: int = typer.Option( - 32, - "--num-workers", + threads: int = typer.Option( + 4, + "--threads", "-j", - help="Number of CPU threads", + help="Number of CPU threads for search operations (used by both MMseqs2 and HHblits)", ), sensitivity: float | None = typer.Option( 8.0, "--sensitivity", - help="MMseqs2 sensitivity (lower = faster, sparser MSAs)", + help="(MMseqs2) Sensitivity (lower = faster, sparser MSAs)", ), verbose: bool = typer.Option( False, @@ -106,10 +105,21 @@ def generate( existing_msa_dirs: str | None = typer.Option( None, "--existing-msa-dirs", - help="Comma-separated MSA directories to check (uses LOCAL_MSA_DIRS env var if not specified)", + help="Comma-separated MSA directories to check (uses PROTEIN_MSA_DIRS env var if not specified)", + ), + backend: str = typer.Option( + "mmseqs2", + "--backend", + "-b", + help="MSA generation backend: 'mmseqs2' (default) or 'hhblits' (CPU-only)", + ), + hhblits_mem: int = typer.Option( + 64, + "--hhblits-mem", + help="(HHblits) Memory limit in GB", ), ) -> None: - """Generate MSAs from sequences in a CSV file using MMseqs2. + """Generate MSAs from sequences in a CSV file using MMseqs2 or HHblits. Examples: # Single-column CSV @@ -132,9 +142,17 @@ def generate( if existing_msa_dirs: msa_dirs = [Path(d.strip()) for d in existing_msa_dirs.split(",")] - # Create search config with only sensitivity control + # Build backend-specific config + hhblits_search_config = None + if backend == "hhblits": + hhblits_search_config = HHblitsSearchConfig(mem=hhblits_mem) + if gpu: + logger.info("Note: HHblits is CPU-only; --gpu flag will be ignored for the HHblits backend") + + # Create search config with sensitivity and iterations control search_config = MMseqs2SearchConfig( s=sensitivity, + num_iterations=num_iterations, ) # Create generation config @@ -142,13 +160,14 @@ def generate( sharding_pattern=sharding_pattern, output_extension=output_extension, gpu=gpu, - num_iterations=num_iterations, use_env=use_env, - threads=num_workers, + threads=threads, max_final_sequences=max_final_sequences, check_existing=check_existing, existing_msa_dirs=msa_dirs, search_config=search_config, + backend=backend, + hhblits_search_config=hhblits_search_config, ) # Display configuration @@ -156,18 +175,23 @@ def generate( typer.echo(f" CSV File: {csv_file}") typer.echo(f" Sequence Column: {sequence_column or 'auto-detect'}") typer.echo(f" Output Directory: {output_dir}") - typer.echo(f" GPU Enabled: {config.gpu}") + typer.echo(f" Backend: {config.backend}") typer.echo(f" Max Final Sequences: {config.max_final_sequences}") - typer.echo(f" Iterations: {config.num_iterations}") - typer.echo(f" Threads: {config.threads}") - typer.echo(f" Use Environmental DB: {config.use_env}") typer.echo(f" Output Extension: {config.output_extension}") typer.echo(f" Sharding Pattern: {config.sharding_pattern}") - typer.echo(f" Sensitivity: {config.search_config.s}") typer.echo(f" Check Existing: {config.check_existing}") if config.check_existing: - dirs_display = config.existing_msa_dirs if config.existing_msa_dirs else "LOCAL_MSA_DIRS env var" + dirs_display = config.existing_msa_dirs if config.existing_msa_dirs else "PROTEIN_MSA_DIRS env var" typer.echo(f" MSA Directories: {dirs_display}") + if config.backend == "hhblits": + typer.echo(f" Threads: {config.threads}") + typer.echo(f" HHblits Memory: {hhblits_search_config.mem} GB") + else: + typer.echo(f" GPU Enabled: {config.gpu}") + typer.echo(f" Iterations: {config.search_config.num_iterations}") + typer.echo(f" Threads: {config.threads}") + typer.echo(f" Use Environmental DB: {config.use_env}") + typer.echo(f" Sensitivity: {config.search_config.s}") try: typer.secho("\n🚀 Starting MSA generation...", fg=typer.colors.CYAN, bold=True) diff --git a/src/atomworks_cli/msa.py b/src/atomworks_cli/msa.py index 05368018..0cb6f8b4 100644 --- a/src/atomworks_cli/msa.py +++ b/src/atomworks_cli/msa.py @@ -1,18 +1,27 @@ """Command line interface for MSA (Multiple Sequence Alignment) operations.""" -from __future__ import annotations - import typer -from .filter import filter -from .find import find -from .generate import generate -from .organize import organize - app = typer.Typer(help="MSA (Multiple Sequence Alignment) utilities") -# Add subcommands -app.command(name="find", help="Find MSA files for sequences in a CSV file")(find) -app.command(name="filter", help="Filter MSA files using HHfilter")(filter) -app.command(name="generate", help="Generate MSAs from sequences using MMseqs2")(generate) -app.command(name="organize", help="Organize MSA files into standardized structure")(organize) +try: + from .filter import filter + from .find import find + from .generate import generate + from .organize import organize +except ModuleNotFoundError as error: + if error.name not in {"torch", "einops", "beartype", "numba", "filelock"}: + raise + app.info.help = r"Install 'atomworks\[ml]' to use MSA commands." + + @app.callback(invoke_without_command=True) + def require_ml() -> None: + """Install 'atomworks[ml]' to use MSA commands.""" + typer.echo("MSA commands require the ML extra: pip install 'atomworks[ml]'", err=True) + raise typer.Exit(1) + +else: + app.command(name="find", help="Find MSA files for sequences in a CSV file")(find) + app.command(name="filter", help="Filter MSA files using HHfilter")(filter) + app.command(name="generate", help="Generate MSAs from sequences using MMseqs2 or HHblits")(generate) + app.command(name="organize", help="Organize MSA files into standardized structure")(organize) diff --git a/src/atomworks_cli/organize.py b/src/atomworks_cli/organize.py index d2448c98..ada218bd 100644 --- a/src/atomworks_cli/organize.py +++ b/src/atomworks_cli/organize.py @@ -1,7 +1,5 @@ """MSA organization command for standardized AtomWorks-compatible MSA directory structures.""" -from __future__ import annotations - import logging from pathlib import Path @@ -73,7 +71,7 @@ def organize( existing_msa_dirs: str | None = typer.Option( None, "--existing-msa-dirs", - help="Comma-separated MSA directories to check (uses LOCAL_MSA_DIRS env var if not specified)", + help="Comma-separated MSA directories to check (uses PROTEIN_MSA_DIRS env var if not specified)", ), verbose: bool = typer.Option( False, @@ -144,7 +142,7 @@ def organize( typer.echo(f"{value}") typer.secho("=" * 40, fg=typer.colors.CYAN) if config.check_existing: - dirs_display = config.existing_msa_dirs if config.existing_msa_dirs else "LOCAL_MSA_DIRS env var" + dirs_display = config.existing_msa_dirs if config.existing_msa_dirs else "PROTEIN_MSA_DIRS env var" typer.secho(f"MSA Directories: {dirs_display}", fg=typer.colors.BLUE) enable_logging(verbose) diff --git a/src/atomworks_cli/pdb.py b/src/atomworks_cli/pdb.py index 8a5908f9..e6f53cc5 100644 --- a/src/atomworks_cli/pdb.py +++ b/src/atomworks_cli/pdb.py @@ -1,8 +1,6 @@ """Command line interface for managing a local PDB mmCIF mirror.""" # ruff: noqa: B008 -from __future__ import annotations - import functools import operator import os diff --git a/src/atomworks_cli/setup.py b/src/atomworks_cli/setup.py index a7550181..ecc47018 100644 --- a/src/atomworks_cli/setup.py +++ b/src/atomworks_cli/setup.py @@ -1,9 +1,6 @@ """Setup utilities for AtomWorks.""" # ruff: noqa: B008 -from __future__ import annotations - -import os import tarfile import tempfile import urllib.request @@ -84,12 +81,12 @@ def setup_tests( 3) Download any missing PDB mmCIFs listed there into the given PDB mirror path using rsync. Example: - atomworks setup tests --pdb-mirror-path /data/rcsb/mmcif + atomworks setup tests """ typer.echo("Setting up AtomWorks test environment...") # Resolve PDB mirror path - pdb_mirror_path = Path(os.getenv("PDB_MIRROR_PATH") or tests_data_dir / "pdb") + pdb_mirror_path = tests_data_dir / "pdb" # Download and extract test pack tests_data_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/conftest.py b/tests/conftest.py index bfaf97e3..c80dc0fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,28 @@ import pathlib import socket +# Standardize test environment before sub-conftest files import atomworks. +# Use Biotite's built-in CCD. For PDB_MIRROR_PATH, resolve in priority order: +# 1. An existing PDB_MIRROR_PATH env var that points at a real directory +# (local devs with a custom mirror). +# 2. The DIGS-specific frozen PDB mirror, if present (used for the CI and available to devs with access to the DIGS cluster) +# 3. The local test data bundle (populated by `atomworks setup tests` or CI). +os.environ["ALLOW_BIOTITE_CCD"] = "True" +_ccd_mirror = os.environ.get("CCD_MIRROR_PATH", "") +if _ccd_mirror and not os.path.exists(_ccd_mirror): + del os.environ["CCD_MIRROR_PATH"] + +_LAB_PDB_MIRROR = "/projects/ml/frozen_pdb_copies/2026_01_06_pdb" +_LOCAL_PDB_MIRROR = pathlib.Path(__file__).resolve().parent / "data" / "pdb" +_pdb_mirror = os.environ.get("PDB_MIRROR_PATH", "") +if not _pdb_mirror or not os.path.exists(_pdb_mirror): + if os.path.isdir(_LAB_PDB_MIRROR): + os.environ["PDB_MIRROR_PATH"] = _LAB_PDB_MIRROR + elif _LOCAL_PDB_MIRROR.is_dir(): + os.environ["PDB_MIRROR_PATH"] = str(_LOCAL_PDB_MIRROR) + # Otherwise leave PDB_MIRROR_PATH unset so tests surface a clearer + # "run `atomworks setup tests`" error rather than pointing at a missing dir. + import pytest logger = logging.getLogger(__name__) @@ -14,24 +36,8 @@ # Conditional skip markers ---------------------------------------------------------- -def _is_on_digs() -> bool: - """Check if running on DIGS infrastructure. - - Returns: - True if running on DIGS infrastructure, False otherwise. - """ - return os.path.exists("/software/containers/versions/rf_diffusion_aa/ipd.txt") - - -skip_if_not_on_digs = pytest.mark.skipif(not _is_on_digs(), reason="Test requires DIGS infrastructure") - - def _is_on_github_runner() -> bool: - """Check if running on GitHub Actions runner. - - Returns: - True if running on GitHub Actions runner, False otherwise. - """ + """Check if running on GitHub Actions runner.""" return os.environ.get("GITHUB_ACTIONS", "false") == "true" @@ -72,6 +78,33 @@ def _has_gpu() -> bool: skip_if_no_gpu = pytest.mark.skipif(not _has_gpu(), reason="Test requires a GPU") +def _has_ccd_mirror() -> bool: + """Check whether a usable local CCD mirror is configured.""" + path = os.environ.get("CCD_MIRROR_PATH", "") + return bool(path) and os.path.isdir(path) + + +skip_if_no_ccd_mirror = pytest.mark.skipif( + not _has_ccd_mirror(), + reason="Test requires a local CCD mirror (set CCD_MIRROR_PATH to a valid directory).", +) + + +def _has_catcif_tools() -> bool: + try: + import catcif_tools # noqa: F401 + + return True + except ImportError: + return False + + +skip_if_no_catcif = pytest.mark.skipif( + not _has_catcif_tools(), + reason="Test requires catcif_tools (install atomworks[catcif]).", +) + + @pytest.fixture(autouse=True) def cleanup_memory(): """Force garbage collection after each test""" diff --git a/tests/data/automorphism_reference.json b/tests/data/automorphism_reference.json new file mode 100644 index 00000000..e8df08b3 --- /dev/null +++ b/tests/data/automorphism_reference.json @@ -0,0 +1,138 @@ +{ + "01W": {"n_atoms": 20, "n_automorphisms": 8, "swapped_atoms": ["O", "O3", "O32", "O5", "O52", "OXT"]}, + "0JL": {"n_atoms": 33, "n_automorphisms": 1, "swapped_atoms": []}, + "0SP": {"n_atoms": 30, "n_automorphisms": 6, "swapped_atoms": ["OP1", "OP2", "OXT"]}, + "0VV": {"n_atoms": 15, "n_automorphisms": 2, "swapped_atoms": ["O14", "O15"]}, + "1M9": {"n_atoms": 33, "n_automorphisms": 48, "swapped_atoms": ["C1", "C2", "C29", "C30", "C4", "C5", "F31", "F32", "F33", "O18", "O19"]}, + "1RO": {"n_atoms": 24, "n_automorphisms": 1, "swapped_atoms": []}, + "1XR": {"n_atoms": 16, "n_automorphisms": 6, "swapped_atoms": ["C11", "C12", "C13"]}, + "2LI": {"n_atoms": 37, "n_automorphisms": 12, "swapped_atoms": ["C14", "C15", "C16", "C28", "C29", "C31", "C32", "F33", "F34"]}, + "311": {"n_atoms": 24, "n_automorphisms": 2, "swapped_atoms": ["C2", "C3"]}, + "34L": {"n_atoms": 19, "n_automorphisms": 1, "swapped_atoms": []}, + "3JU": {"n_atoms": 29, "n_automorphisms": 12, "swapped_atoms": ["CAB", "CAC", "CAD", "CAH", "CAI", "CAJ", "CAK"]}, + "3TC": {"n_atoms": 15, "n_automorphisms": 1, "swapped_atoms": []}, + "49I": {"n_atoms": 27, "n_automorphisms": 4, "swapped_atoms": ["C18", "C19", "C21", "C22", "O1", "O2"]}, + "4LP": {"n_atoms": 37, "n_automorphisms": 8, "swapped_atoms": ["C11", "C12", "C17", "C18", "C31", "C32", "C34", "C35"]}, + "52B": {"n_atoms": 19, "n_automorphisms": 12, "swapped_atoms": ["F1", "F2", "F3", "O12", "O13"]}, + "5DQ": {"n_atoms": 28, "n_automorphisms": 48, "swapped_atoms": ["C1", "C2", "C21", "C22", "C23", "C24", "C4", "C5", "F26", "F27", "F28", "O17", "O18"]}, + "5QV": {"n_atoms": 29, "n_automorphisms": 2, "swapped_atoms": ["C25", "C29", "N26", "N28"]}, + "69J": {"n_atoms": 20, "n_automorphisms": 2, "swapped_atoms": ["OAH", "OAM"]}, + "6IR": {"n_atoms": 29, "n_automorphisms": 4, "swapped_atoms": ["C12", "C13", "C14", "C15", "O3", "O4"]}, + "6SI": {"n_atoms": 25, "n_automorphisms": 1, "swapped_atoms": []}, + "76O": {"n_atoms": 34, "n_automorphisms": 4, "swapped_atoms": ["C26", "C27", "C29", "C30", "O21", "O22"]}, + "7BX": {"n_atoms": 15, "n_automorphisms": 4, "swapped_atoms": ["C6", "C7", "O1", "O3"]}, + "7L8": {"n_atoms": 38, "n_automorphisms": 2, "swapped_atoms": ["C14", "C15", "C17", "C18", "C19", "C20"]}, + "7QS": {"n_atoms": 13, "n_automorphisms": 2, "swapped_atoms": ["C2", "C3", "C5", "C6"]}, + "7RZ": {"n_atoms": 48, "n_automorphisms": 12, "swapped_atoms": ["C27", "C28", "C29", "C30", "OP1", "OP2", "OP3"]}, + "86H": {"n_atoms": 35, "n_automorphisms": 4, "swapped_atoms": ["C13", "C14", "C16", "C17", "O20", "O21"]}, + "93Y": {"n_atoms": 34, "n_automorphisms": 8, "swapped_atoms": ["C13", "C14", "C19", "C20", "C23", "C24", "C25", "C26", "C27", "C28", "C29", "C30", "C31", "C32", "C33", "C34", "C5", "C6", "C7", "C8", "CL21", "CL22", "N17", "N18", "N3", "N4", "O10", "O11", "O12", "O15", "O16", "O9", "S1", "S2"]}, + "9LK": {"n_atoms": 24, "n_automorphisms": 2, "swapped_atoms": ["CAE", "CAF", "CAH", "CAI"]}, + "9P6": {"n_atoms": 33, "n_automorphisms": 24, "swapped_atoms": ["C12", "C13", "C15", "C16", "C18", "C19", "C28", "C29", "C31", "C32", "C33"]}, + "A1AA2": {"n_atoms": 34, "n_automorphisms": 6, "swapped_atoms": ["F18", "F19", "F20"]}, + "A1APK": {"n_atoms": 24, "n_automorphisms": 2, "swapped_atoms": ["O", "OXT"]}, + "A1AYV": {"n_atoms": 14, "n_automorphisms": 2, "swapped_atoms": ["O1", "O2"]}, + "A1BHP": {"n_atoms": 30, "n_automorphisms": 2, "swapped_atoms": ["C15", "C16", "C18", "C19"]}, + "A1BL8": {"n_atoms": 40, "n_automorphisms": 48, "swapped_atoms": ["C19", "C20", "C22", "C23", "C32", "C33", "C35", "C36", "F38", "F39", "F40", "O26", "O27"]}, + "A1BNX": {"n_atoms": 34, "n_automorphisms": 6, "swapped_atoms": ["F24", "F25", "F26"]}, + "A1EE4": {"n_atoms": 26, "n_automorphisms": 4, "swapped_atoms": ["C17", "C18", "C21", "C22", "CL01", "CL02", "F04", "F05"]}, + "A1H23": {"n_atoms": 25, "n_automorphisms": 4, "swapped_atoms": ["CAD", "CAE", "CAF", "CAG", "CAI", "CAJ", "CAK", "CAL", "CAM", "CBD", "CBE", "CBF", "CBG", "CBI", "CBJ", "CBK", "CBL", "CBM", "NAH", "NBH", "O1", "O2", "OAA", "OBA"]}, + "A1IIG": {"n_atoms": 11, "n_automorphisms": 2, "swapped_atoms": ["O03", "O04"]}, + "A1ITR": {"n_atoms": 28, "n_automorphisms": 4, "swapped_atoms": ["C27", "C28", "C5", "C6", "O1", "O3"]}, + "A1JN2": {"n_atoms": 39, "n_automorphisms": 36, "swapped_atoms": ["C23", "C24", "C25", "F", "F1", "F2"]}, + "A1LZY": {"n_atoms": 39, "n_automorphisms": 16, "swapped_atoms": ["C09", "C10", "C11", "C12", "C13", "C14", "C16", "C17", "C34", "C35", "C37", "C38", "O23", "O24"]}, + "A6E": {"n_atoms": 18, "n_automorphisms": 4, "swapped_atoms": ["C1", "C10", "C11", "C12", "C2", "C7", "C8", "C9"]}, + "ADP": {"n_atoms": 27, "n_automorphisms": 12, "swapped_atoms": ["O1A", "O1B", "O2A", "O2B", "O3B"]}, + "APT": {"n_atoms": 23, "n_automorphisms": 4, "swapped_atoms": ["C1", "C2", "C4", "C5", "O2", "O3"]}, + "ATP": {"n_atoms": 31, "n_automorphisms": 24, "swapped_atoms": ["O1A", "O1B", "O1G", "O2A", "O2B", "O2G", "O3G"]}, + "B07": {"n_atoms": 17, "n_automorphisms": 4, "swapped_atoms": ["C10", "C11", "C13", "C14", "C15", "C8", "O15", "O9", "OT1", "OT2"]}, + "B64": {"n_atoms": 22, "n_automorphisms": 2, "swapped_atoms": ["O1", "O2"]}, + "BIN": {"n_atoms": 28, "n_automorphisms": 8, "swapped_atoms": ["C20", "C21", "O3A", "O3B", "O4A", "O4B"]}, + "BMF": {"n_atoms": 28, "n_automorphisms": 2, "swapped_atoms": ["O07", "O08"]}, + "BVE": {"n_atoms": 36, "n_automorphisms": 64, "swapped_atoms": ["C10", "C11", "C12", "C13", "C14", "C15", "C16", "C17", "C18", "C19", "C2", "C20", "C22", "C23", "C25", "C26", "C28", "C29", "C3", "C5", "C6", "C7", "C8", "C9", "O1", "O2"]}, + "CDH": {"n_atoms": 24, "n_automorphisms": 4, "swapped_atoms": ["O20", "O21", "O42", "O43"]}, + "CNN": {"n_atoms": 3, "n_automorphisms": 1, "swapped_atoms": []}, + "CPF": {"n_atoms": 24, "n_automorphisms": 8, "swapped_atoms": ["C12", "C13", "C14", "C15", "C16", "C17", "O1", "O2"]}, + "CX8": {"n_atoms": 26, "n_automorphisms": 2, "swapped_atoms": ["C11", "C12", "C13", "C14", "C15", "C17", "C18", "C19", "C8", "C9", "O1", "O2"]}, + "DC7": {"n_atoms": 22, "n_automorphisms": 2, "swapped_atoms": ["C12", "C13", "C14", "C15"]}, + "DNO": {"n_atoms": 12, "n_automorphisms": 1, "swapped_atoms": []}, + "E4X": {"n_atoms": 30, "n_automorphisms": 4, "swapped_atoms": ["C23", "C24", "C26", "C27", "O28", "O29"]}, + "EDM": {"n_atoms": 15, "n_automorphisms": 4, "swapped_atoms": ["C23", "C25", "O19", "O27"]}, + "EIO": {"n_atoms": 22, "n_automorphisms": 4, "swapped_atoms": ["C19", "C20", "N01", "N21"]}, + "EQW": {"n_atoms": 14, "n_automorphisms": 4, "swapped_atoms": ["C11", "C12", "C14", "C3", "C8", "C9", "N13", "N4"]}, + "F8T": {"n_atoms": 25, "n_automorphisms": 8, "swapped_atoms": ["C11", "C12", "C17", "C18", "C20", "C21", "C24", "C25", "C8", "C9"]}, + "FMK": {"n_atoms": 30, "n_automorphisms": 1, "swapped_atoms": []}, + "FUM": {"n_atoms": 8, "n_automorphisms": 8, "swapped_atoms": ["C", "C4", "C5", "C6", "O", "O7", "O8", "OXT"]}, + "G1U": {"n_atoms": 24, "n_automorphisms": 2, "swapped_atoms": ["C18", "C19", "C22", "C23"]}, + "GCE": {"n_atoms": 29, "n_automorphisms": 2, "swapped_atoms": ["C23", "C24", "C26", "C27"]}, + "GJC": {"n_atoms": 32, "n_automorphisms": 2, "swapped_atoms": ["C18", "C19", "C31", "C32"]}, + "GNB": {"n_atoms": 33, "n_automorphisms": 16, "swapped_atoms": ["CL1", "CL2", "CM1", "CM2", "O31", "O32", "O8", "O9", "OT1", "OT2"]}, + "GSI": {"n_atoms": 34, "n_automorphisms": 4, "swapped_atoms": ["C11", "C16", "O1", "O2"]}, + "GXN": {"n_atoms": 29, "n_automorphisms": 2, "swapped_atoms": ["O05", "O06"]}, + "H9H": {"n_atoms": 37, "n_automorphisms": 32, "swapped_atoms": ["C10", "C11", "C13", "C14", "C16", "C17", "C19", "C20", "C23", "C24", "C26", "C27", "C31", "C32", "C34", "C35", "O29", "O30"]}, + "HFY": {"n_atoms": 28, "n_automorphisms": 1, "swapped_atoms": []}, + "I5D": {"n_atoms": 27, "n_automorphisms": 4, "swapped_atoms": ["C10", "C14", "C15", "C16", "C17", "C5'"]}, + "IAO": {"n_atoms": 17, "n_automorphisms": 1, "swapped_atoms": []}, + "ILA": {"n_atoms": 31, "n_automorphisms": 2, "swapped_atoms": ["O1A", "O2A"]}, + "IQX": {"n_atoms": 28, "n_automorphisms": 2, "swapped_atoms": ["O19", "O20"]}, + "IWV": {"n_atoms": 26, "n_automorphisms": 6, "swapped_atoms": ["F20", "F21", "F22"]}, + "JHB": {"n_atoms": 31, "n_automorphisms": 24, "swapped_atoms": ["C11", "C12", "C14", "C15", "C18", "C19", "C20", "C21", "F", "F1", "F2"]}, + "JMW": {"n_atoms": 27, "n_automorphisms": 2, "swapped_atoms": ["C16", "C17", "C20", "C21"]}, + "K1I": {"n_atoms": 33, "n_automorphisms": 4, "swapped_atoms": ["F25", "F26", "O22", "O23"]}, + "KA2": {"n_atoms": 31, "n_automorphisms": 12, "swapped_atoms": ["C1", "C3", "C4", "O6", "O7"]}, + "KKV": {"n_atoms": 24, "n_automorphisms": 8, "swapped_atoms": ["C08", "C09", "C14", "C15", "C18", "C19", "C22", "C23", "O12", "O13"]}, + "KTQ": {"n_atoms": 29, "n_automorphisms": 8, "swapped_atoms": ["C17", "C18", "C2", "C20", "C21", "C3", "C5", "C6", "O1", "O2"]}, + "LEU": {"n_atoms": 9, "n_automorphisms": 4, "swapped_atoms": ["CD1", "CD2", "O", "OXT"]}, + "M5R": {"n_atoms": 26, "n_automorphisms": 4, "swapped_atoms": ["C", "C2", "O2", "O3"]}, + "MLI": {"n_atoms": 7, "n_automorphisms": 8, "swapped_atoms": ["C2", "C3", "O6", "O7", "O8", "O9"]}, + "MPM": {"n_atoms": 24, "n_automorphisms": 36, "swapped_atoms": ["O3", "O4", "O5", "O6", "O7", "O8"]}, + "N76": {"n_atoms": 28, "n_automorphisms": 4, "swapped_atoms": ["C12", "C13", "C15", "C16", "O24", "O25"]}, + "NEF": {"n_atoms": 36, "n_automorphisms": 16, "swapped_atoms": ["O17", "O18", "O2", "O24", "O25", "O26", "O27", "OXY"]}, + "NJ0": {"n_atoms": 38, "n_automorphisms": 4, "swapped_atoms": ["C17", "C18", "C20", "C21", "C34", "C35", "C37", "C38"]}, + "O55": {"n_atoms": 26, "n_automorphisms": 4, "swapped_atoms": ["C06", "C07", "C09", "C10", "O03", "O04"]}, + "OAQ": {"n_atoms": 20, "n_automorphisms": 4, "swapped_atoms": ["C12", "C13", "O1", "O2"]}, + "OGH": {"n_atoms": 31, "n_automorphisms": 4, "swapped_atoms": ["C17", "C18", "C19", "C20", "C3", "C5", "N1", "N2"]}, + "OQO": {"n_atoms": 38, "n_automorphisms": 2, "swapped_atoms": ["C17", "C18"]}, + "OYJ": {"n_atoms": 37, "n_automorphisms": 2, "swapped_atoms": ["O19", "O20"]}, + "P14": {"n_atoms": 25, "n_automorphisms": 4, "swapped_atoms": ["NJ1", "NJ2", "NZ1", "NZ2"]}, + "PG1": {"n_atoms": 30, "n_automorphisms": 16, "swapped_atoms": ["C10", "C19", "C20", "C22", "C23", "C9", "O", "O12", "O13", "OXT"]}, + "PNJ": {"n_atoms": 21, "n_automorphisms": 4, "swapped_atoms": ["C11", "C12", "C8", "C9", "O7", "O8"]}, + "PSH": {"n_atoms": 15, "n_automorphisms": 4, "swapped_atoms": ["O", "O1", "O2", "OXT"]}, + "Q2N": {"n_atoms": 22, "n_automorphisms": 2, "swapped_atoms": ["C16", "C17", "C19", "C20"]}, + "QBZ": {"n_atoms": 21, "n_automorphisms": 8, "swapped_atoms": ["O01", "O08", "O09", "O1", "O18", "O19"]}, + "QJ0": {"n_atoms": 19, "n_automorphisms": 2, "swapped_atoms": ["N14", "N15", "N16", "N17"]}, + "QQA": {"n_atoms": 36, "n_automorphisms": 2, "swapped_atoms": ["O21", "O22"]}, + "R15": {"n_atoms": 35, "n_automorphisms": 8, "swapped_atoms": ["C10", "C15", "C17", "C23", "C24", "C27", "C29", "C31", "C33", "C35", "C38", "C41", "C43", "C45", "C48", "C51", "C54", "C7", "C8", "C9", "CL25", "CL26", "N21", "N22", "N3", "N5", "O11", "O12", "O13", "O14", "O19", "O20", "S1", "S2"]}, + "RI5": {"n_atoms": 21, "n_automorphisms": 1, "swapped_atoms": []}, + "RJB": {"n_atoms": 26, "n_automorphisms": 4, "swapped_atoms": ["C13", "C14", "C15", "C16", "C17", "C18", "C19", "C20", "O1", "O2"]}, + "RW2": {"n_atoms": 27, "n_automorphisms": 24, "swapped_atoms": ["O14", "O2", "O3", "O4", "O5", "O6", "O7"]}, + "S2E": {"n_atoms": 31, "n_automorphisms": 8, "swapped_atoms": ["C18", "C19", "C21", "C22", "C25", "C26", "C29", "C30", "N01", "N03"]}, + "SF4": {"n_atoms": 8, "n_automorphisms": 24, "swapped_atoms": ["FE1", "FE2", "FE3", "FE4", "S1", "S2", "S3", "S4"]}, + "SPU": {"n_atoms": 31, "n_automorphisms": 12, "swapped_atoms": ["C70", "C74", "C78", "O59", "O60"]}, + "SXZ": {"n_atoms": 32, "n_automorphisms": 4, "swapped_atoms": ["C10", "C9", "O", "OXT"]}, + "T0Y": {"n_atoms": 13, "n_automorphisms": 2, "swapped_atoms": ["C03", "C04", "C12", "C13"]}, + "TJF": {"n_atoms": 31, "n_automorphisms": 2, "swapped_atoms": ["C19", "C20", "C25", "C26"]}, + "TNL": {"n_atoms": 16, "n_automorphisms": 16, "swapped_atoms": ["C2", "C3", "C5", "C6", "N2", "N6", "O21", "O22", "O41", "O42", "O61", "O62"]}, + "TYR": {"n_atoms": 13, "n_automorphisms": 4, "swapped_atoms": ["CD1", "CD2", "CE1", "CE2", "O", "OXT"]}, + "U2Q": {"n_atoms": 14, "n_automorphisms": 4, "swapped_atoms": ["O09", "O10", "O13", "O14"]}, + "UBX": {"n_atoms": 25, "n_automorphisms": 8, "swapped_atoms": ["C2", "C28", "C29", "C3", "C5", "C6", "O20", "O21"]}, + "UN5": {"n_atoms": 34, "n_automorphisms": 36, "swapped_atoms": ["O1", "O2", "O3", "O5", "O6", "O7"]}, + "UUE": {"n_atoms": 29, "n_automorphisms": 4, "swapped_atoms": ["C1", "C2", "C21", "C22", "C23", "C24", "C25", "C26", "C3", "C4", "C5", "C6", "F1", "F2", "N1", "N5"]}, + "VA7": {"n_atoms": 32, "n_automorphisms": 48, "swapped_atoms": ["CL1", "CL2", "O1A", "O1B", "O1G", "O2A", "O2B", "O2G", "O3G"]}, + "VKP": {"n_atoms": 24, "n_automorphisms": 2, "swapped_atoms": ["C5", "C6", "C8", "C9"]}, + "VUJ": {"n_atoms": 27, "n_automorphisms": 2, "swapped_atoms": ["C07", "C08", "C09", "C10"]}, + "W36": {"n_atoms": 15, "n_automorphisms": 2, "swapped_atoms": ["O", "O1"]}, + "WQ9": {"n_atoms": 24, "n_automorphisms": 2, "swapped_atoms": ["O2", "O3"]}, + "X2J": {"n_atoms": 34, "n_automorphisms": 2, "swapped_atoms": ["O1C", "O1D"]}, + "X5W": {"n_atoms": 37, "n_automorphisms": 4, "swapped_atoms": ["C30", "C31", "C33", "C34", "O36", "O37"]}, + "XCM": {"n_atoms": 30, "n_automorphisms": 12, "swapped_atoms": ["C19", "C20", "F26", "F27", "F28"]}, + "XKL": {"n_atoms": 28, "n_automorphisms": 4, "swapped_atoms": ["C11", "C12", "C14", "C15", "O17", "O18"]}, + "XQR": {"n_atoms": 34, "n_automorphisms": 48, "swapped_atoms": ["C13", "C14", "C16", "C17", "C34", "C35", "C6", "C7", "F3", "F4", "F5", "O2", "O3"]}, + "XZK": {"n_atoms": 22, "n_automorphisms": 4, "swapped_atoms": ["C01", "C02", "C04", "C05", "O21", "O22"]}, + "Y8S": {"n_atoms": 36, "n_automorphisms": 12, "swapped_atoms": ["C16", "C17", "O1", "O2", "O3"]}, + "YM1": {"n_atoms": 38, "n_automorphisms": 24, "swapped_atoms": ["C16", "C17", "C23", "C25", "C29", "C31", "F24", "F26", "F30", "F32", "O1", "O2", "O3"]}, + "YQ9": {"n_atoms": 38, "n_automorphisms": 24, "swapped_atoms": ["C10", "C2", "C3", "C4", "C41", "C42", "C5", "C6", "C7", "C8", "F37", "F38", "F39"]}, + "Z20": {"n_atoms": 25, "n_automorphisms": 2, "swapped_atoms": ["O23", "O24"]}, + "ZEG": {"n_atoms": 27, "n_automorphisms": 16, "swapped_atoms": ["C04", "C05", "C07", "C08", "C10", "C11", "C18", "C19", "C22", "C23", "C25", "C26", "O01", "O27"]}, + "ZP2": {"n_atoms": 22, "n_automorphisms": 2, "swapped_atoms": ["O21", "O22"]}, + "ZW7": {"n_atoms": 40, "n_automorphisms": 144, "swapped_atoms": ["C14", "C16", "C34", "C35", "C37", "C38", "C8", "C9", "F24", "F25", "F26", "F40", "F41", "F42"]} +} diff --git a/tests/data/experimental/protonation/6dmz_mod_d.cif.zst b/tests/data/experimental/protonation/6dmz_mod_d.cif.zst new file mode 100644 index 00000000..3f8347b2 Binary files /dev/null and b/tests/data/experimental/protonation/6dmz_mod_d.cif.zst differ diff --git a/tests/data/experimental/protonation/6dmz_mod_l.cif.zst b/tests/data/experimental/protonation/6dmz_mod_l.cif.zst new file mode 100644 index 00000000..f0ad4fb9 Binary files /dev/null and b/tests/data/experimental/protonation/6dmz_mod_l.cif.zst differ diff --git a/tests/data/experimental/protonation/amine_oxide_3t2a.cif.zst b/tests/data/experimental/protonation/amine_oxide_3t2a.cif.zst new file mode 100644 index 00000000..48266caf Binary files /dev/null and b/tests/data/experimental/protonation/amine_oxide_3t2a.cif.zst differ diff --git a/tests/data/experimental/protonation/ammonium_5k8h.cif.zst b/tests/data/experimental/protonation/ammonium_5k8h.cif.zst new file mode 100644 index 00000000..8a10ef32 Binary files /dev/null and b/tests/data/experimental/protonation/ammonium_5k8h.cif.zst differ diff --git a/tests/data/experimental/protonation/arginine_on_zinc_7pyk.cif.zst b/tests/data/experimental/protonation/arginine_on_zinc_7pyk.cif.zst new file mode 100644 index 00000000..00c0aba6 Binary files /dev/null and b/tests/data/experimental/protonation/arginine_on_zinc_7pyk.cif.zst differ diff --git a/tests/data/experimental/protonation/arsenate_3we3.cif.zst b/tests/data/experimental/protonation/arsenate_3we3.cif.zst new file mode 100644 index 00000000..fcd8eac9 Binary files /dev/null and b/tests/data/experimental/protonation/arsenate_3we3.cif.zst differ diff --git a/tests/data/experimental/protonation/assembly_copies_1awd.cif.zst b/tests/data/experimental/protonation/assembly_copies_1awd.cif.zst new file mode 100644 index 00000000..f6c7dc07 Binary files /dev/null and b/tests/data/experimental/protonation/assembly_copies_1awd.cif.zst differ diff --git a/tests/data/experimental/protonation/atomized_nucleotide_chain_1qd7.cif.zst b/tests/data/experimental/protonation/atomized_nucleotide_chain_1qd7.cif.zst new file mode 100644 index 00000000..0292ea84 Binary files /dev/null and b/tests/data/experimental/protonation/atomized_nucleotide_chain_1qd7.cif.zst differ diff --git a/tests/data/experimental/protonation/attachment_contexts_8trb.cif.zst b/tests/data/experimental/protonation/attachment_contexts_8trb.cif.zst new file mode 100644 index 00000000..db4d26cf Binary files /dev/null and b/tests/data/experimental/protonation/attachment_contexts_8trb.cif.zst differ diff --git a/tests/data/experimental/protonation/azide_ligand_3wd2.cif.zst b/tests/data/experimental/protonation/azide_ligand_3wd2.cif.zst new file mode 100644 index 00000000..42ea15a4 Binary files /dev/null and b/tests/data/experimental/protonation/azide_ligand_3wd2.cif.zst differ diff --git a/tests/data/experimental/protonation/beta_peptide_3c3g.cif.zst b/tests/data/experimental/protonation/beta_peptide_3c3g.cif.zst new file mode 100644 index 00000000..ea239bbc Binary files /dev/null and b/tests/data/experimental/protonation/beta_peptide_3c3g.cif.zst differ diff --git a/tests/data/experimental/protonation/bicarbonate_5zas.cif.zst b/tests/data/experimental/protonation/bicarbonate_5zas.cif.zst new file mode 100644 index 00000000..981f51b9 Binary files /dev/null and b/tests/data/experimental/protonation/bicarbonate_5zas.cif.zst differ diff --git a/tests/data/experimental/protonation/bridging_imidazolate_1spd.cif.zst b/tests/data/experimental/protonation/bridging_imidazolate_1spd.cif.zst new file mode 100644 index 00000000..9f6e6b5a Binary files /dev/null and b/tests/data/experimental/protonation/bridging_imidazolate_1spd.cif.zst differ diff --git a/tests/data/experimental/protonation/c_terminus_without_oxt_5ark.cif.zst b/tests/data/experimental/protonation/c_terminus_without_oxt_5ark.cif.zst new file mode 100644 index 00000000..4fbd87a5 Binary files /dev/null and b/tests/data/experimental/protonation/c_terminus_without_oxt_5ark.cif.zst differ diff --git a/tests/data/experimental/protonation/ca_irregular_2fvy.cif.zst b/tests/data/experimental/protonation/ca_irregular_2fvy.cif.zst new file mode 100644 index 00000000..f49e4391 Binary files /dev/null and b/tests/data/experimental/protonation/ca_irregular_2fvy.cif.zst differ diff --git a/tests/data/experimental/protonation/cacodylate_8his.cif.zst b/tests/data/experimental/protonation/cacodylate_8his.cif.zst new file mode 100644 index 00000000..92674b23 Binary files /dev/null and b/tests/data/experimental/protonation/cacodylate_8his.cif.zst differ diff --git a/tests/data/experimental/protonation/carboxamide_on_iron_7urh.cif.zst b/tests/data/experimental/protonation/carboxamide_on_iron_7urh.cif.zst new file mode 100644 index 00000000..41f43b8a Binary files /dev/null and b/tests/data/experimental/protonation/carboxamide_on_iron_7urh.cif.zst differ diff --git a/tests/data/experimental/protonation/ccd_model_coords_3uq.cif.zst b/tests/data/experimental/protonation/ccd_model_coords_3uq.cif.zst new file mode 100644 index 00000000..feb159ec Binary files /dev/null and b/tests/data/experimental/protonation/ccd_model_coords_3uq.cif.zst differ diff --git a/tests/data/experimental/protonation/chloride_complex_4hbt.cif.zst b/tests/data/experimental/protonation/chloride_complex_4hbt.cif.zst new file mode 100644 index 00000000..b49a45bd Binary files /dev/null and b/tests/data/experimental/protonation/chloride_complex_4hbt.cif.zst differ diff --git a/tests/data/experimental/protonation/chromophore_3nez.cif.zst b/tests/data/experimental/protonation/chromophore_3nez.cif.zst new file mode 100644 index 00000000..520d9e2b Binary files /dev/null and b/tests/data/experimental/protonation/chromophore_3nez.cif.zst differ diff --git a/tests/data/experimental/protonation/chromophore_nrq_3svu.cif.zst b/tests/data/experimental/protonation/chromophore_nrq_3svu.cif.zst new file mode 100644 index 00000000..03fd9add Binary files /dev/null and b/tests/data/experimental/protonation/chromophore_nrq_3svu.cif.zst differ diff --git a/tests/data/experimental/protonation/clf_nitrogenase_7adr.cif.zst b/tests/data/experimental/protonation/clf_nitrogenase_7adr.cif.zst new file mode 100644 index 00000000..edfc5b54 Binary files /dev/null and b/tests/data/experimental/protonation/clf_nitrogenase_7adr.cif.zst differ diff --git a/tests/data/experimental/protonation/collagen_hyp_1bkv.cif.zst b/tests/data/experimental/protonation/collagen_hyp_1bkv.cif.zst new file mode 100644 index 00000000..e2c8d802 Binary files /dev/null and b/tests/data/experimental/protonation/collagen_hyp_1bkv.cif.zst differ diff --git a/tests/data/experimental/protonation/conflicting_myristate_1aym.cif.zst b/tests/data/experimental/protonation/conflicting_myristate_1aym.cif.zst new file mode 100644 index 00000000..fc6b9771 Binary files /dev/null and b/tests/data/experimental/protonation/conflicting_myristate_1aym.cif.zst differ diff --git a/tests/data/experimental/protonation/cu_blue_copper_2ov0.cif.zst b/tests/data/experimental/protonation/cu_blue_copper_2ov0.cif.zst new file mode 100644 index 00000000..cce8f109 Binary files /dev/null and b/tests/data/experimental/protonation/cu_blue_copper_2ov0.cif.zst differ diff --git a/tests/data/experimental/protonation/cu_zn_sod_3f7l.cif.zst b/tests/data/experimental/protonation/cu_zn_sod_3f7l.cif.zst new file mode 100644 index 00000000..4a5b1f25 Binary files /dev/null and b/tests/data/experimental/protonation/cu_zn_sod_3f7l.cif.zst differ diff --git a/tests/data/experimental/protonation/cua_ba3_2cua.cif.zst b/tests/data/experimental/protonation/cua_ba3_2cua.cif.zst new file mode 100644 index 00000000..c20f4038 Binary files /dev/null and b/tests/data/experimental/protonation/cua_ba3_2cua.cif.zst differ diff --git a/tests/data/experimental/protonation/decreasing_water_author_ids_5xnl.cif.zst b/tests/data/experimental/protonation/decreasing_water_author_ids_5xnl.cif.zst new file mode 100644 index 00000000..60c1b8d0 Binary files /dev/null and b/tests/data/experimental/protonation/decreasing_water_author_ids_5xnl.cif.zst differ diff --git a/tests/data/experimental/protonation/dictionary_valence_2yak.cif.zst b/tests/data/experimental/protonation/dictionary_valence_2yak.cif.zst new file mode 100644 index 00000000..60c0820d Binary files /dev/null and b/tests/data/experimental/protonation/dictionary_valence_2yak.cif.zst differ diff --git a/tests/data/experimental/protonation/enamine_5cbb.cif.zst b/tests/data/experimental/protonation/enamine_5cbb.cif.zst new file mode 100644 index 00000000..31e8760b Binary files /dev/null and b/tests/data/experimental/protonation/enamine_5cbb.cif.zst differ diff --git a/tests/data/experimental/protonation/fe_rubredoxin_30oh.cif.zst b/tests/data/experimental/protonation/fe_rubredoxin_30oh.cif.zst new file mode 100644 index 00000000..c4895686 Binary files /dev/null and b/tests/data/experimental/protonation/fe_rubredoxin_30oh.cif.zst differ diff --git a/tests/data/experimental/protonation/feo_myohemerythrin_2mhr.cif.zst b/tests/data/experimental/protonation/feo_myohemerythrin_2mhr.cif.zst new file mode 100644 index 00000000..c406ccdc Binary files /dev/null and b/tests/data/experimental/protonation/feo_myohemerythrin_2mhr.cif.zst differ diff --git a/tests/data/experimental/protonation/fes_ferredoxin_6e6r.cif.zst b/tests/data/experimental/protonation/fes_ferredoxin_6e6r.cif.zst new file mode 100644 index 00000000..20b5c648 Binary files /dev/null and b/tests/data/experimental/protonation/fes_ferredoxin_6e6r.cif.zst differ diff --git a/tests/data/experimental/protonation/free_and_attached_solutes_5xag.cif.zst b/tests/data/experimental/protonation/free_and_attached_solutes_5xag.cif.zst new file mode 100644 index 00000000..489f8b1f Binary files /dev/null and b/tests/data/experimental/protonation/free_and_attached_solutes_5xag.cif.zst differ diff --git a/tests/data/experimental/protonation/full_conjugate_9ewf.cif.zst b/tests/data/experimental/protonation/full_conjugate_9ewf.cif.zst new file mode 100644 index 00000000..ceebeba6 Binary files /dev/null and b/tests/data/experimental/protonation/full_conjugate_9ewf.cif.zst differ diff --git a/tests/data/experimental/protonation/gamma_peptide_1gac.cif.zst b/tests/data/experimental/protonation/gamma_peptide_1gac.cif.zst new file mode 100644 index 00000000..36f55d4e Binary files /dev/null and b/tests/data/experimental/protonation/gamma_peptide_1gac.cif.zst differ diff --git a/tests/data/experimental/protonation/heme_myoglobin_5yce.cif.zst b/tests/data/experimental/protonation/heme_myoglobin_5yce.cif.zst new file mode 100644 index 00000000..d66856e7 Binary files /dev/null and b/tests/data/experimental/protonation/heme_myoglobin_5yce.cif.zst differ diff --git a/tests/data/experimental/protonation/hydrolase_intermediate_1tqh.cif.zst b/tests/data/experimental/protonation/hydrolase_intermediate_1tqh.cif.zst new file mode 100644 index 00000000..b434f5b8 Binary files /dev/null and b/tests/data/experimental/protonation/hydrolase_intermediate_1tqh.cif.zst differ diff --git a/tests/data/experimental/protonation/hypophosphite_5me4.cif.zst b/tests/data/experimental/protonation/hypophosphite_5me4.cif.zst new file mode 100644 index 00000000..946b0d69 Binary files /dev/null and b/tests/data/experimental/protonation/hypophosphite_5me4.cif.zst differ diff --git a/tests/data/experimental/protonation/lactam_cyclic_7ag5.cif.zst b/tests/data/experimental/protonation/lactam_cyclic_7ag5.cif.zst new file mode 100644 index 00000000..962996d5 Binary files /dev/null and b/tests/data/experimental/protonation/lactam_cyclic_7ag5.cif.zst differ diff --git a/tests/data/experimental/protonation/lys_biotin_1bdo.cif.zst b/tests/data/experimental/protonation/lys_biotin_1bdo.cif.zst new file mode 100644 index 00000000..8f517269 Binary files /dev/null and b/tests/data/experimental/protonation/lys_biotin_1bdo.cif.zst differ diff --git a/tests/data/experimental/protonation/macrocycle_1xvk.cif.zst b/tests/data/experimental/protonation/macrocycle_1xvk.cif.zst new file mode 100644 index 00000000..0ec0303a Binary files /dev/null and b/tests/data/experimental/protonation/macrocycle_1xvk.cif.zst differ diff --git a/tests/data/experimental/protonation/metal_contact_5cqo.cif.zst b/tests/data/experimental/protonation/metal_contact_5cqo.cif.zst new file mode 100644 index 00000000..9a6687c7 Binary files /dev/null and b/tests/data/experimental/protonation/metal_contact_5cqo.cif.zst differ diff --git a/tests/data/experimental/protonation/mg_one_donor_4e3y.cif.zst b/tests/data/experimental/protonation/mg_one_donor_4e3y.cif.zst new file mode 100644 index 00000000..2b0e44ce Binary files /dev/null and b/tests/data/experimental/protonation/mg_one_donor_4e3y.cif.zst differ diff --git a/tests/data/experimental/protonation/mg_rna_aptamer_7eoh.cif.zst b/tests/data/experimental/protonation/mg_rna_aptamer_7eoh.cif.zst new file mode 100644 index 00000000..c58b0d5a Binary files /dev/null and b/tests/data/experimental/protonation/mg_rna_aptamer_7eoh.cif.zst differ diff --git a/tests/data/experimental/protonation/mgf_phosphoglucomutase_6h8z.cif.zst b/tests/data/experimental/protonation/mgf_phosphoglucomutase_6h8z.cif.zst new file mode 100644 index 00000000..e20fe611 Binary files /dev/null and b/tests/data/experimental/protonation/mgf_phosphoglucomutase_6h8z.cif.zst differ diff --git a/tests/data/experimental/protonation/missing_ligand_carbon_5hs6.cif.zst b/tests/data/experimental/protonation/missing_ligand_carbon_5hs6.cif.zst new file mode 100644 index 00000000..16967123 Binary files /dev/null and b/tests/data/experimental/protonation/missing_ligand_carbon_5hs6.cif.zst differ diff --git a/tests/data/experimental/protonation/missing_phosphate_rna_5w1i.cif.zst b/tests/data/experimental/protonation/missing_phosphate_rna_5w1i.cif.zst new file mode 100644 index 00000000..5895a6b5 Binary files /dev/null and b/tests/data/experimental/protonation/missing_phosphate_rna_5w1i.cif.zst differ diff --git a/tests/data/experimental/protonation/missing_proline_ring_7no8.cif.zst b/tests/data/experimental/protonation/missing_proline_ring_7no8.cif.zst new file mode 100644 index 00000000..a2dc0662 Binary files /dev/null and b/tests/data/experimental/protonation/missing_proline_ring_7no8.cif.zst differ diff --git a/tests/data/experimental/protonation/missing_ribose_oxygen_6dp5.cif.zst b/tests/data/experimental/protonation/missing_ribose_oxygen_6dp5.cif.zst new file mode 100644 index 00000000..b8608f2b Binary files /dev/null and b/tests/data/experimental/protonation/missing_ribose_oxygen_6dp5.cif.zst differ diff --git a/tests/data/experimental/protonation/missing_sugar_carbon_7n5v.cif.zst b/tests/data/experimental/protonation/missing_sugar_carbon_7n5v.cif.zst new file mode 100644 index 00000000..34221608 Binary files /dev/null and b/tests/data/experimental/protonation/missing_sugar_carbon_7n5v.cif.zst differ diff --git a/tests/data/experimental/protonation/modified_components_6q9t.cif.zst b/tests/data/experimental/protonation/modified_components_6q9t.cif.zst new file mode 100644 index 00000000..7286757e Binary files /dev/null and b/tests/data/experimental/protonation/modified_components_6q9t.cif.zst differ diff --git a/tests/data/experimental/protonation/modified_nucleotide_aliases_1d9d.cif.zst b/tests/data/experimental/protonation/modified_nucleotide_aliases_1d9d.cif.zst new file mode 100644 index 00000000..787075e1 Binary files /dev/null and b/tests/data/experimental/protonation/modified_nucleotide_aliases_1d9d.cif.zst differ diff --git a/tests/data/experimental/protonation/na_dna_5mc_1d17.cif.zst b/tests/data/experimental/protonation/na_dna_5mc_1d17.cif.zst new file mode 100644 index 00000000..b31f171f Binary files /dev/null and b/tests/data/experimental/protonation/na_dna_5mc_1d17.cif.zst differ diff --git a/tests/data/experimental/protonation/na_dna_8og_183d.cif.zst b/tests/data/experimental/protonation/na_dna_8og_183d.cif.zst new file mode 100644 index 00000000..62ca1a8f Binary files /dev/null and b/tests/data/experimental/protonation/na_dna_8og_183d.cif.zst differ diff --git a/tests/data/experimental/protonation/na_dna_ttd_1ttd.cif.zst b/tests/data/experimental/protonation/na_dna_ttd_1ttd.cif.zst new file mode 100644 index 00000000..cec6a0fa Binary files /dev/null and b/tests/data/experimental/protonation/na_dna_ttd_1ttd.cif.zst differ diff --git a/tests/data/experimental/protonation/na_rna_2ome_310d.cif.zst b/tests/data/experimental/protonation/na_rna_2ome_310d.cif.zst new file mode 100644 index 00000000..fa13416c Binary files /dev/null and b/tests/data/experimental/protonation/na_rna_2ome_310d.cif.zst differ diff --git a/tests/data/experimental/protonation/nco_zdna_1dn8.cif.zst b/tests/data/experimental/protonation/nco_zdna_1dn8.cif.zst new file mode 100644 index 00000000..5bfa47e9 Binary files /dev/null and b/tests/data/experimental/protonation/nco_zdna_1dn8.cif.zst differ diff --git a/tests/data/experimental/protonation/nglycan_tree_1ax2.cif.zst b/tests/data/experimental/protonation/nglycan_tree_1ax2.cif.zst new file mode 100644 index 00000000..e7a76262 Binary files /dev/null and b/tests/data/experimental/protonation/nglycan_tree_1ax2.cif.zst differ diff --git a/tests/data/experimental/protonation/nife_hydride_6fpi.cif.zst b/tests/data/experimental/protonation/nife_hydride_6fpi.cif.zst new file mode 100644 index 00000000..c2552aef Binary files /dev/null and b/tests/data/experimental/protonation/nife_hydride_6fpi.cif.zst differ diff --git a/tests/data/experimental/protonation/nitric_oxide_on_heme_8hbf.cif.zst b/tests/data/experimental/protonation/nitric_oxide_on_heme_8hbf.cif.zst new file mode 100644 index 00000000..c598bc4d Binary files /dev/null and b/tests/data/experimental/protonation/nitric_oxide_on_heme_8hbf.cif.zst differ diff --git a/tests/data/experimental/protonation/nitrophenylalanine_peptide_1ytj.cif.zst b/tests/data/experimental/protonation/nitrophenylalanine_peptide_1ytj.cif.zst new file mode 100644 index 00000000..436c6ccb Binary files /dev/null and b/tests/data/experimental/protonation/nitrophenylalanine_peptide_1ytj.cif.zst differ diff --git a/tests/data/experimental/protonation/nitrosamine_4eji.cif.zst b/tests/data/experimental/protonation/nitrosamine_4eji.cif.zst new file mode 100644 index 00000000..9a25f3b1 Binary files /dev/null and b/tests/data/experimental/protonation/nitrosamine_4eji.cif.zst differ diff --git a/tests/data/experimental/protonation/nmethyl_peptide_6mvz.cif.zst b/tests/data/experimental/protonation/nmethyl_peptide_6mvz.cif.zst new file mode 100644 index 00000000..4a5f4cb7 Binary files /dev/null and b/tests/data/experimental/protonation/nmethyl_peptide_6mvz.cif.zst differ diff --git a/tests/data/experimental/protonation/nmr_his_cation_1r21.cif.zst b/tests/data/experimental/protonation/nmr_his_cation_1r21.cif.zst new file mode 100644 index 00000000..344afa1f Binary files /dev/null and b/tests/data/experimental/protonation/nmr_his_cation_1r21.cif.zst differ diff --git a/tests/data/experimental/protonation/oglycan_sia_1g1s.cif.zst b/tests/data/experimental/protonation/oglycan_sia_1g1s.cif.zst new file mode 100644 index 00000000..822805c3 Binary files /dev/null and b/tests/data/experimental/protonation/oglycan_sia_1g1s.cif.zst differ diff --git a/tests/data/experimental/protonation/orphan_hydrogen_9ewf.cif.zst b/tests/data/experimental/protonation/orphan_hydrogen_9ewf.cif.zst new file mode 100644 index 00000000..4a41f5e4 Binary files /dev/null and b/tests/data/experimental/protonation/orphan_hydrogen_9ewf.cif.zst differ diff --git a/tests/data/experimental/protonation/palmitoyl_ester_thioester_8trb.cif.zst b/tests/data/experimental/protonation/palmitoyl_ester_thioester_8trb.cif.zst new file mode 100644 index 00000000..6acc6897 Binary files /dev/null and b/tests/data/experimental/protonation/palmitoyl_ester_thioester_8trb.cif.zst differ diff --git a/tests/data/experimental/protonation/partial_sugar_rings_2msb.cif.zst b/tests/data/experimental/protonation/partial_sugar_rings_2msb.cif.zst new file mode 100644 index 00000000..c52e1045 Binary files /dev/null and b/tests/data/experimental/protonation/partial_sugar_rings_2msb.cif.zst differ diff --git a/tests/data/experimental/protonation/pepstatin_after_gap_4ejk.cif.zst b/tests/data/experimental/protonation/pepstatin_after_gap_4ejk.cif.zst new file mode 100644 index 00000000..0a76f76b Binary files /dev/null and b/tests/data/experimental/protonation/pepstatin_after_gap_4ejk.cif.zst differ diff --git a/tests/data/experimental/protonation/peptide_inhibitor_7zv5.cif.zst b/tests/data/experimental/protonation/peptide_inhibitor_7zv5.cif.zst new file mode 100644 index 00000000..3c15b745 Binary files /dev/null and b/tests/data/experimental/protonation/peptide_inhibitor_7zv5.cif.zst differ diff --git a/tests/data/experimental/protonation/perchlorate_1jfv.cif.zst b/tests/data/experimental/protonation/perchlorate_1jfv.cif.zst new file mode 100644 index 00000000..d43ecc8d Binary files /dev/null and b/tests/data/experimental/protonation/perchlorate_1jfv.cif.zst differ diff --git a/tests/data/experimental/protonation/peroxide_on_heme_copper_7ypy.cif.zst b/tests/data/experimental/protonation/peroxide_on_heme_copper_7ypy.cif.zst new file mode 100644 index 00000000..47ff5ff0 Binary files /dev/null and b/tests/data/experimental/protonation/peroxide_on_heme_copper_7ypy.cif.zst differ diff --git a/tests/data/experimental/protonation/phosphate_5txd.cif.zst b/tests/data/experimental/protonation/phosphate_5txd.cif.zst new file mode 100644 index 00000000..45902172 Binary files /dev/null and b/tests/data/experimental/protonation/phosphate_5txd.cif.zst differ diff --git a/tests/data/experimental/protonation/phosphate_attachment_8ch1.cif.zst b/tests/data/experimental/protonation/phosphate_attachment_8ch1.cif.zst new file mode 100644 index 00000000..5a03d6b8 Binary files /dev/null and b/tests/data/experimental/protonation/phosphate_attachment_8ch1.cif.zst differ diff --git a/tests/data/experimental/protonation/phosphate_charge_4js1.cif.zst b/tests/data/experimental/protonation/phosphate_charge_4js1.cif.zst new file mode 100644 index 00000000..46ee455e Binary files /dev/null and b/tests/data/experimental/protonation/phosphate_charge_4js1.cif.zst differ diff --git a/tests/data/experimental/protonation/phosphopeptide_5ema.cif.zst b/tests/data/experimental/protonation/phosphopeptide_5ema.cif.zst new file mode 100644 index 00000000..35d8211d Binary files /dev/null and b/tests/data/experimental/protonation/phosphopeptide_5ema.cif.zst differ diff --git a/tests/data/experimental/protonation/phosphoramide_4p45.cif.zst b/tests/data/experimental/protonation/phosphoramide_4p45.cif.zst new file mode 100644 index 00000000..6546dd64 Binary files /dev/null and b/tests/data/experimental/protonation/phosphoramide_4p45.cif.zst differ diff --git a/tests/data/experimental/protonation/plp_cap_5t4j.cif.zst b/tests/data/experimental/protonation/plp_cap_5t4j.cif.zst new file mode 100644 index 00000000..5223f4a6 Binary files /dev/null and b/tests/data/experimental/protonation/plp_cap_5t4j.cif.zst differ diff --git a/tests/data/experimental/protonation/polar_hydrogens_10gs.pdb.zst b/tests/data/experimental/protonation/polar_hydrogens_10gs.pdb.zst new file mode 100644 index 00000000..7e2f52a7 Binary files /dev/null and b/tests/data/experimental/protonation/polar_hydrogens_10gs.pdb.zst differ diff --git a/tests/data/experimental/protonation/protonated_lysine_mg_3bpc.pdb b/tests/data/experimental/protonation/protonated_lysine_mg_3bpc.pdb new file mode 100644 index 00000000..b7edb665 --- /dev/null +++ b/tests/data/experimental/protonation/protonated_lysine_mg_3bpc.pdb @@ -0,0 +1,19 @@ +REMARK PDBbind v2013-core/3bpc/3bpc_protein.pdb +REMARK Source SHA256 f080061075ae86377b297eb46135adef2e1a67db2659c318ba37a0d1962ff32f +REMARK Retained LYS B62, Mg4 and their original CONECT; coordinates unchanged. +ATOM 2699 N LYS B 62 41.679 1.110 -12.338 1.00 21.46 N +ATOM 2700 H LYS B 62 41.445 0.097 -12.304 1.00 0.00 H +ATOM 2701 CA LYS B 62 42.256 1.753 -11.172 1.00 20.77 C +ATOM 2702 C LYS B 62 43.615 2.328 -11.502 1.00 22.45 C +ATOM 2703 O LYS B 62 44.473 1.642 -12.089 1.00 23.22 O +ATOM 2704 CB LYS B 62 42.338 0.806 -9.964 1.00 21.54 C +ATOM 2705 CG LYS B 62 42.407 1.639 -8.680 1.00 20.32 C +ATOM 2706 CD LYS B 62 42.618 0.825 -7.459 1.00 23.58 C +ATOM 2707 CE LYS B 62 43.009 1.839 -6.388 1.00 25.75 C +ATOM 2708 NZ LYS B 62 43.142 1.203 -5.091 1.00 22.71 N +ATOM 2709 HZ1 LYS B 62 42.235 0.769 -4.824 1.00 0.00 H +ATOM 2710 HZ2 LYS B 62 43.877 0.469 -5.140 1.00 0.00 H +ATOM 2711 HZ3 LYS B 62 43.408 1.916 -4.382 1.00 0.00 H +HETATM 4085 MG MG 4 43.770 2.354 -3.306 1.00 22.33 MG +CONECT 4085 2708 +END diff --git a/tests/data/experimental/protonation/repeated_partner_glycans_1hge.cif.zst b/tests/data/experimental/protonation/repeated_partner_glycans_1hge.cif.zst new file mode 100644 index 00000000..c0250a58 Binary files /dev/null and b/tests/data/experimental/protonation/repeated_partner_glycans_1hge.cif.zst differ diff --git a/tests/data/experimental/protonation/repeated_partner_glycans_1ivo.cif.zst b/tests/data/experimental/protonation/repeated_partner_glycans_1ivo.cif.zst new file mode 100644 index 00000000..a831dee7 Binary files /dev/null and b/tests/data/experimental/protonation/repeated_partner_glycans_1ivo.cif.zst differ diff --git a/tests/data/experimental/protonation/retinyl_lysine_4xxj.cif.zst b/tests/data/experimental/protonation/retinyl_lysine_4xxj.cif.zst new file mode 100644 index 00000000..13430e09 Binary files /dev/null and b/tests/data/experimental/protonation/retinyl_lysine_4xxj.cif.zst differ diff --git a/tests/data/experimental/protonation/selenocysteine_5azz.cif.zst b/tests/data/experimental/protonation/selenocysteine_5azz.cif.zst new file mode 100644 index 00000000..6999f7f1 Binary files /dev/null and b/tests/data/experimental/protonation/selenocysteine_5azz.cif.zst differ diff --git a/tests/data/experimental/protonation/sf4_ferredoxin_1fdn.cif.zst b/tests/data/experimental/protonation/sf4_ferredoxin_1fdn.cif.zst new file mode 100644 index 00000000..137a877d Binary files /dev/null and b/tests/data/experimental/protonation/sf4_ferredoxin_1fdn.cif.zst differ diff --git a/tests/data/experimental/protonation/sf4_ferredoxin_2fdn.cif.zst b/tests/data/experimental/protonation/sf4_ferredoxin_2fdn.cif.zst new file mode 100644 index 00000000..8a800b2a Binary files /dev/null and b/tests/data/experimental/protonation/sf4_ferredoxin_2fdn.cif.zst differ diff --git a/tests/data/experimental/protonation/sodium_histidine_1vmj.cif.zst b/tests/data/experimental/protonation/sodium_histidine_1vmj.cif.zst new file mode 100644 index 00000000..abfb1883 Binary files /dev/null and b/tests/data/experimental/protonation/sodium_histidine_1vmj.cif.zst differ diff --git a/tests/data/experimental/protonation/sulfate_1al1.cif.zst b/tests/data/experimental/protonation/sulfate_1al1.cif.zst new file mode 100644 index 00000000..022e88a7 Binary files /dev/null and b/tests/data/experimental/protonation/sulfate_1al1.cif.zst differ diff --git a/tests/data/experimental/protonation/sulfonamide_4e4l.cif.zst b/tests/data/experimental/protonation/sulfonamide_4e4l.cif.zst new file mode 100644 index 00000000..20499fc6 Binary files /dev/null and b/tests/data/experimental/protonation/sulfonamide_4e4l.cif.zst differ diff --git a/tests/data/experimental/protonation/sulfur_attachments_3t14.cif.zst b/tests/data/experimental/protonation/sulfur_attachments_3t14.cif.zst new file mode 100644 index 00000000..0a3fa4b3 Binary files /dev/null and b/tests/data/experimental/protonation/sulfur_attachments_3t14.cif.zst differ diff --git a/tests/data/experimental/protonation/terminal_and_linked_glycans_1en2.cif.zst b/tests/data/experimental/protonation/terminal_and_linked_glycans_1en2.cif.zst new file mode 100644 index 00000000..ddf696ef Binary files /dev/null and b/tests/data/experimental/protonation/terminal_and_linked_glycans_1en2.cif.zst differ diff --git a/tests/data/experimental/protonation/terminal_and_linked_glycans_4ndz.cif.zst b/tests/data/experimental/protonation/terminal_and_linked_glycans_4ndz.cif.zst new file mode 100644 index 00000000..2cf9f1b4 Binary files /dev/null and b/tests/data/experimental/protonation/terminal_and_linked_glycans_4ndz.cif.zst differ diff --git a/tests/data/experimental/protonation/terminal_asj_glycans_1iau.cif.zst b/tests/data/experimental/protonation/terminal_asj_glycans_1iau.cif.zst new file mode 100644 index 00000000..e89d1997 Binary files /dev/null and b/tests/data/experimental/protonation/terminal_asj_glycans_1iau.cif.zst differ diff --git a/tests/data/experimental/protonation/terminal_nucleotide_145d.cif.zst b/tests/data/experimental/protonation/terminal_nucleotide_145d.cif.zst new file mode 100644 index 00000000..a8db69a2 Binary files /dev/null and b/tests/data/experimental/protonation/terminal_nucleotide_145d.cif.zst differ diff --git a/tests/data/experimental/protonation/tertiary_amide_4elb.cif.zst b/tests/data/experimental/protonation/tertiary_amide_4elb.cif.zst new file mode 100644 index 00000000..1515baa0 Binary files /dev/null and b/tests/data/experimental/protonation/tertiary_amide_4elb.cif.zst differ diff --git a/tests/data/experimental/protonation/triphosphate_rna_4gxy.cif.zst b/tests/data/experimental/protonation/triphosphate_rna_4gxy.cif.zst new file mode 100644 index 00000000..d40f9f2e Binary files /dev/null and b/tests/data/experimental/protonation/triphosphate_rna_4gxy.cif.zst differ diff --git a/tests/data/experimental/protonation/unbonded_ligand_2aj6.cif.zst b/tests/data/experimental/protonation/unbonded_ligand_2aj6.cif.zst new file mode 100644 index 00000000..7749492a Binary files /dev/null and b/tests/data/experimental/protonation/unbonded_ligand_2aj6.cif.zst differ diff --git a/tests/data/experimental/protonation/unknown_atom_on_nickel_1jqk.cif.zst b/tests/data/experimental/protonation/unknown_atom_on_nickel_1jqk.cif.zst new file mode 100644 index 00000000..f938d186 Binary files /dev/null and b/tests/data/experimental/protonation/unknown_atom_on_nickel_1jqk.cif.zst differ diff --git a/tests/data/experimental/protonation/unresolved_modified_polymer_1xj9.cif.zst b/tests/data/experimental/protonation/unresolved_modified_polymer_1xj9.cif.zst new file mode 100644 index 00000000..1010f20b Binary files /dev/null and b/tests/data/experimental/protonation/unresolved_modified_polymer_1xj9.cif.zst differ diff --git a/tests/data/experimental/protonation/vinamidine_5oxd.cif.zst b/tests/data/experimental/protonation/vinamidine_5oxd.cif.zst new file mode 100644 index 00000000..fe79e348 Binary files /dev/null and b/tests/data/experimental/protonation/vinamidine_5oxd.cif.zst differ diff --git a/tests/data/experimental/protonation/zn_hydroxide_bridge_1hzy.cif.zst b/tests/data/experimental/protonation/zn_hydroxide_bridge_1hzy.cif.zst new file mode 100644 index 00000000..b359e2f3 Binary files /dev/null and b/tests/data/experimental/protonation/zn_hydroxide_bridge_1hzy.cif.zst differ diff --git a/tests/data/experimental/protonation/zn_tetrahedral_3ks3.cif.zst b/tests/data/experimental/protonation/zn_tetrahedral_3ks3.cif.zst new file mode 100644 index 00000000..a8f3bd91 Binary files /dev/null and b/tests/data/experimental/protonation/zn_tetrahedral_3ks3.cif.zst differ diff --git a/tests/data/io/1a1e_cif_data_dict.pkl b/tests/data/io/1a1e_cif_data_dict.pkl index 153c3c2e..86b8f0a3 100644 Binary files a/tests/data/io/1a1e_cif_data_dict.pkl and b/tests/data/io/1a1e_cif_data_dict.pkl differ diff --git a/tests/data/io/1qfe_pdb_data_dict.pkl b/tests/data/io/1qfe_pdb_data_dict.pkl index 26926d14..5a292564 100644 Binary files a/tests/data/io/1qfe_pdb_data_dict.pkl and b/tests/data/io/1qfe_pdb_data_dict.pkl differ diff --git a/tests/data/io/1rcq.cif.zst b/tests/data/io/1rcq.cif.zst new file mode 100644 index 00000000..8efe4ebb Binary files /dev/null and b/tests/data/io/1rcq.cif.zst differ diff --git a/tests/data/io/2hhb.json.gz b/tests/data/io/2hhb.mmjson.gz similarity index 100% rename from tests/data/io/2hhb.json.gz rename to tests/data/io/2hhb.mmjson.gz diff --git a/tests/data/io/2odp.cif.zst b/tests/data/io/2odp.cif.zst new file mode 100644 index 00000000..3be237aa Binary files /dev/null and b/tests/data/io/2odp.cif.zst differ diff --git a/tests/data/io/5a7x.cif.zst b/tests/data/io/5a7x.cif.zst new file mode 100644 index 00000000..661fb6df Binary files /dev/null and b/tests/data/io/5a7x.cif.zst differ diff --git a/tests/data/io/H_A.cif b/tests/data/io/H_A.cif new file mode 100644 index 00000000..a4ba4917 --- /dev/null +++ b/tests/data/io/H_A.cif @@ -0,0 +1,70 @@ +data_H:A +# +_chem_comp.id H:A +_chem_comp.name "3-HYDROXY-3-CARBOXY-ADIPIC ACID" +_chem_comp.type NON-POLYMER +_chem_comp.pdbx_type HETAIN +_chem_comp.formula "C7 H10 O7" +_chem_comp.pdbx_formal_charge 0 +_chem_comp.pdbx_model_coordinates_missing_flag N +_chem_comp.pdbx_ideal_coordinates_missing_flag N +# + +loop_ +_chem_comp_atom.comp_id +_chem_comp_atom.atom_id +_chem_comp_atom.alt_atom_id +_chem_comp_atom.type_symbol +_chem_comp_atom.charge +_chem_comp_atom.pdbx_align +_chem_comp_atom.pdbx_aromatic_flag +_chem_comp_atom.pdbx_leaving_atom_flag +_chem_comp_atom.pdbx_stereo_config +_chem_comp_atom.model_Cartn_x +_chem_comp_atom.model_Cartn_y +_chem_comp_atom.model_Cartn_z +_chem_comp_atom.pdbx_model_Cartn_x_ideal +_chem_comp_atom.pdbx_model_Cartn_y_ideal +_chem_comp_atom.pdbx_model_Cartn_z_ideal +_chem_comp_atom.pdbx_component_atom_id +_chem_comp_atom.pdbx_component_comp_id +_chem_comp_atom.pdbx_ordinal + +H:A C1 C1 C 0 1 N N N 4.163972 6.5130500 2.6702920 4.163972 6.5130500 2.6702920 C1 H:A 1 +H:A C2 C2 C 0 1 N N N 3.8209715 6.0020504 1.2922935 3.8209715 6.0020504 1.2922935 C2 H:A 2 +H:A C3 C3 C 0 1 N N R 4.441972 4.6250505 0.97029114 4.441972 4.6250505 0.97029114 C3 H:A 3 +H:A C4 C4 C 0 1 N N N 5.9819727 4.7100500 0.7652931 5.9819727 4.7100500 0.7652931 C4 H:A 4 +H:A C5 C5 C 0 1 N N N 6.470973 3.4140500 0.091293335 6.470973 3.4140500 0.091293335 C5 H:A 5 +H:A C6 C6 C 0 1 N N N 7.8399725 3.2250500 0.16329193 7.8399725 3.2250500 0.16329193 C6 H:A 6 +H:A C7 C7 C 0 1 N N N 3.7949724 4.0380500 -0.27370834 3.7949724 4.0380500 -0.27370834 C7 H:A 7 +H:A O1 O1 O 0 1 N N N 4.265973 5.6620502 3.5812912 4.265973 5.6620502 3.5812912 O1 H:A 8 +H:A O2 O2 O 0 1 N N N 4.3429728 7.7300500 2.8142930 4.3429728 7.7300500 2.8142930 O2 H:A 9 +H:A O3 O3 O 0 1 N N N 8.301972 2.3420500 -0.61470795 8.301972 2.3420500 -0.61470795 O3 H:A 10 +H:A O4 O4 O 0 1 N N N 8.489972 3.7710502 1.1002922 8.489972 3.7710502 1.1002922 O4 H:A 11 +H:A O5 O5 O 0 1 N N N 3.3119726 2.8790503 -0.2087059 3.3119726 2.8790503 -0.2087059 O5 H:A 12 +H:A O6 O6 O 0 1 N N N 3.8499727 4.7000504 -1.3497086 3.8499727 4.7000504 -1.3497086 O6 H:A 13 +H:A O7 O7 O 0 1 N N N 4.173972 3.7100500 2.0542908 4.173972 3.7100500 2.0542908 O7 H:A 14 +# + +loop_ +_chem_comp_bond.comp_id +_chem_comp_bond.atom_id_1 +_chem_comp_bond.atom_id_2 +_chem_comp_bond.value_order +_chem_comp_bond.pdbx_aromatic_flag +_chem_comp_bond.pdbx_stereo_config +_chem_comp_bond.pdbx_ordinal +H:A C1 C2 SING N N 1 +H:A C1 O1 DOUB N N 2 +H:A C1 O2 SING N N 3 +H:A C2 C3 SING N N 4 +H:A C3 C4 SING N N 7 +H:A C3 C7 SING N N 8 +H:A C3 O7 SING N N 9 +H:A C4 C5 SING N N 10 +H:A C5 C6 SING N N 13 +H:A C6 O3 DOUB N N 16 +H:A C6 O4 SING N N 17 +H:A C7 O5 DOUB N N 18 +H:A C7 O6 SING N N 19 +# diff --git a/tests/data/io/interactions/1IZC_macrophomate_synthase_dimer.cif b/tests/data/io/interactions/1IZC_macrophomate_synthase_dimer.cif new file mode 100644 index 00000000..d8cd7bcd --- /dev/null +++ b/tests/data/io/interactions/1IZC_macrophomate_synthase_dimer.cif @@ -0,0 +1,4726 @@ +data_structure +# +loop_ +_chem_comp_bond.pdbx_ordinal +_chem_comp_bond.comp_id +_chem_comp_bond.atom_id_1 +_chem_comp_bond.atom_id_2 +_chem_comp_bond.value_order +_chem_comp_bond.pdbx_aromatic_flag +_chem_comp_bond.pdbx_stereo_config +1 ALA N CA SING N ? +2 ALA CA C SING N ? +3 ALA CA CB SING N ? +4 ALA C O DOUB N ? +5 LYS N CA SING N ? +6 LYS CA C SING N ? +7 LYS CA CB SING N ? +8 LYS C O DOUB N ? +9 LYS CB CG SING N ? +10 LYS CG CD SING N ? +11 LYS CD CE SING N ? +12 LYS CE NZ SING N ? +13 SER N CA SING N ? +14 SER CA C SING N ? +15 SER CA CB SING N ? +16 SER C O DOUB N ? +17 SER CB OG SING N ? +18 TYR N CA SING N ? +19 TYR CA C SING N ? +20 TYR CA CB SING N ? +21 TYR C O DOUB N ? +22 TYR CB CG SING N ? +23 TYR CG CD1 DOUB Y ? +24 TYR CG CD2 SING Y ? +25 TYR CD1 CE1 SING Y ? +26 TYR CD2 CE2 DOUB Y ? +27 TYR CE1 CZ DOUB Y ? +28 TYR CE2 CZ SING Y ? +29 TYR CZ OH SING N ? +30 GLU N CA SING N ? +31 GLU CA C SING N ? +32 GLU CA CB SING N ? +33 GLU C O DOUB N ? +34 GLU CB CG SING N ? +35 GLU CG CD SING N ? +36 GLU CD OE1 DOUB N ? +37 GLU CD OE2 SING N ? +38 GLN N CA SING N ? +39 GLN CA C SING N ? +40 GLN CA CB SING N ? +41 GLN C O DOUB N ? +42 GLN CB CG SING N ? +43 GLN CG CD SING N ? +44 GLN CD OE1 DOUB N ? +45 GLN CD NE2 SING N ? +46 PRO N CA SING N ? +47 PRO N CD SING N ? +48 PRO CA C SING N ? +49 PRO CA CB SING N ? +50 PRO C O DOUB N ? +51 PRO CB CG SING N ? +52 PRO CG CD SING N ? +53 LEU N CA SING N ? +54 LEU CA C SING N ? +55 LEU CA CB SING N ? +56 LEU C O DOUB N ? +57 LEU CB CG SING N ? +58 LEU CG CD1 SING N ? +59 LEU CG CD2 SING N ? +60 HIS N CA SING N ? +61 HIS CA C SING N ? +62 HIS CA CB SING N ? +63 HIS C O DOUB N ? +64 HIS CB CG SING N ? +65 HIS CG ND1 SING Y ? +66 HIS CG CD2 DOUB Y ? +67 HIS ND1 CE1 DOUB Y ? +68 HIS CD2 NE2 SING Y ? +69 HIS CE1 NE2 SING Y ? +70 ARG N CA SING N ? +71 ARG CA C SING N ? +72 ARG CA CB SING N ? +73 ARG C O DOUB N ? +74 ARG CB CG SING N ? +75 ARG CG CD SING N ? +76 ARG CD NE SING N ? +77 ARG NE CZ SING N ? +78 ARG CZ NH1 SING N ? +79 ARG CZ NH2 DOUB N ? +80 MET N CA SING N ? +81 MET CA C SING N ? +82 MET CA CB SING N ? +83 MET C O DOUB N ? +84 MET CB CG SING N ? +85 MET CG SD SING N ? +86 MET SD CE SING N ? +87 THR N CA SING N ? +88 THR CA C SING N ? +89 THR CA CB SING N ? +90 THR C O DOUB N ? +91 THR CB OG1 SING N ? +92 THR CB CG2 SING N ? +93 GLY N CA SING N ? +94 GLY CA C SING N ? +95 GLY C O DOUB N ? +96 ASN N CA SING N ? +97 ASN CA C SING N ? +98 ASN CA CB SING N ? +99 ASN C O DOUB N ? +100 ASN CB CG SING N ? +101 ASN CG OD1 DOUB N ? +102 ASN CG ND2 SING N ? +103 ASP N CA SING N ? +104 ASP CA C SING N ? +105 ASP CA CB SING N ? +106 ASP C O DOUB N ? +107 ASP CB CG SING N ? +108 ASP CG OD1 DOUB N ? +109 ASP CG OD2 SING N ? +110 VAL N CA SING N ? +111 VAL CA C SING N ? +112 VAL CA CB SING N ? +113 VAL C O DOUB N ? +114 VAL CB CG1 SING N ? +115 VAL CB CG2 SING N ? +116 ILE N CA SING N ? +117 ILE CA C SING N ? +118 ILE CA CB SING N ? +119 ILE C O DOUB N ? +120 ILE CB CG1 SING N ? +121 ILE CB CG2 SING N ? +122 ILE CG1 CD1 SING N ? +123 PHE N CA SING N ? +124 PHE CA C SING N ? +125 PHE CA CB SING N ? +126 PHE C O DOUB N ? +127 PHE CB CG SING N ? +128 PHE CG CD1 DOUB Y ? +129 PHE CG CD2 SING Y ? +130 PHE CD1 CE1 SING Y ? +131 PHE CD2 CE2 DOUB Y ? +132 PHE CE1 CZ DOUB Y ? +133 PHE CE2 CZ SING Y ? +134 TRP N CA SING N ? +135 TRP CA C SING N ? +136 TRP CA CB SING N ? +137 TRP C O DOUB N ? +138 TRP CB CG SING N ? +139 TRP CG CD1 DOUB Y ? +140 TRP CG CD2 SING Y ? +141 TRP CD1 NE1 SING Y ? +142 TRP CD2 CE2 DOUB Y ? +143 TRP CD2 CE3 SING Y ? +144 TRP CE2 NE1 SING Y ? +145 TRP CE2 CZ2 SING Y ? +146 TRP CE3 CZ3 DOUB Y ? +147 TRP CZ2 CH2 DOUB Y ? +148 TRP CZ3 CH2 SING Y ? +149 CYS N CA SING N ? +150 CYS CA C SING N ? +151 CYS CA CB SING N ? +152 CYS C O DOUB N ? +153 CYS CB SG SING N ? +154 PYR C O DOUB N ? +155 PYR C OXT SING N ? +156 PYR CA C SING N ? +157 PYR CA O3 DOUB N ? +158 PYR CA CB SING N ? +# +loop_ +_atom_site.group_PDB +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.pdbx_PDB_model_num +_atom_site.id +ATOM N N . ALA A 1 2 . 2 ALA A N 88.786 50.325 14.951 1 1 +ATOM C CA . ALA A 1 2 . 2 ALA A CA 88.762 51.789 15.263 1 2 +ATOM C C . ALA A 1 2 . 2 ALA A C 87.349 52.257 15.624 1 3 +ATOM O O . ALA A 1 2 . 2 ALA A O 87.144 53.413 16.015 1 4 +ATOM C CB . ALA A 1 2 . 2 ALA A CB 89.288 52.583 14.058 1 5 +ATOM H H01 . ALA A 1 2 . 2 ALA A H01 88.29 50.156 14.088 1 6 +ATOM H H02 . ALA A 1 2 . 2 ALA A H02 89.404 51.966 16.126 1 7 +ATOM H H03 . ALA A 1 2 . 2 ALA A H03 90.305 52.265 13.83 1 8 +ATOM H H04 . ALA A 1 2 . 2 ALA A H04 88.649 52.4 13.194 1 9 +ATOM H H05 . ALA A 1 2 . 2 ALA A H05 88.343 49.813 15.7 1 10 +ATOM H H06 . ALA A 1 2 . 2 ALA A H06 89.285 53.647 14.294 1 11 +ATOM N N . LYS A 1 3 . 3 LYS A N 86.38 51.349 15.505 1 12 +ATOM C CA . LYS A 1 3 . 3 LYS A CA 84.993 51.68 15.794 1 13 +ATOM C C . LYS A 1 3 . 3 LYS A C 84.218 50.572 16.509 1 14 +ATOM O O . LYS A 1 3 . 3 LYS A O 82.992 50.561 16.473 1 15 +ATOM C CB . LYS A 1 3 . 3 LYS A CB 84.271 52.034 14.499 1 16 +ATOM C CG . LYS A 1 3 . 3 LYS A CG 84.232 50.907 13.493 1 17 +ATOM C CD . LYS A 1 3 . 3 LYS A CD 83.474 51.342 12.25 1 18 +ATOM C CE . LYS A 1 3 . 3 LYS A CE 83.461 50.248 11.208 1 19 +ATOM N NZ . LYS A 1 3 . 3 LYS A NZ 82.667 50.64 10.015 1 20 +ATOM H H01 . LYS A 1 3 . 3 LYS A H01 85.025 52.528 16.478 1 21 +ATOM H H02 . LYS A 1 3 . 3 LYS A H02 84.828 52.85 14.039 1 22 +ATOM H H03 . LYS A 1 3 . 3 LYS A H03 83.246 52.314 14.742 1 23 +ATOM H H04 . LYS A 1 3 . 3 LYS A H04 83.726 50.05 13.937 1 24 +ATOM H H05 . LYS A 1 3 . 3 LYS A H05 85.251 50.633 13.218 1 25 +ATOM H H06 . LYS A 1 3 . 3 LYS A H06 83.974 52.214 11.829 1 26 +ATOM H H07 . LYS A 1 3 . 3 LYS A H07 86.614 50.412 15.208 1 27 +ATOM H H08 . LYS A 1 3 . 3 LYS A H08 82.448 51.584 12.525 1 28 +ATOM H H09 . LYS A 1 3 . 3 LYS A H09 83.001 49.363 11.648 1 29 +ATOM H H10 . LYS A 1 3 . 3 LYS A H10 84.485 50.04 10.899 1 30 +ATOM H H11 . LYS A 1 3 . 3 LYS A H11 83.222 51.241 9.423 1 31 +ATOM H H12 . LYS A 1 3 . 3 LYS A H12 82.401 49.813 9.5 1 32 +ATOM N N . SER A 1 4 . 4 SER A N 84.921 49.641 17.141 1 33 +ATOM C CA . SER A 1 4 . 4 SER A CA 84.244 48.572 17.875 1 34 +ATOM C C . SER A 1 4 . 4 SER A C 83.692 49.193 19.161 1 35 +ATOM O O . SER A 1 4 . 4 SER A O 84.036 50.321 19.512 1 36 +ATOM C CB . SER A 1 4 . 4 SER A CB 85.23 47.462 18.239 1 37 +ATOM O OG . SER A 1 4 . 4 SER A OG 86.182 47.938 19.17 1 38 +ATOM H H01 . SER A 1 4 . 4 SER A H01 83.452 48.137 17.265 1 39 +ATOM H H02 . SER A 1 4 . 4 SER A H02 85.748 47.138 17.336 1 40 +ATOM H H03 . SER A 1 4 . 4 SER A H03 84.685 46.625 18.676 1 41 +ATOM H H04 . SER A 1 4 . 4 SER A H04 86.459 47.218 19.741 1 42 +ATOM H H13 . SER A 1 4 . 4 SER A H13 85.93 49.671 17.115 1 43 +ATOM N N . TYR A 1 5 . 5 TYR A N 82.842 48.456 19.868 1 44 +ATOM C CA . TYR A 1 5 . 5 TYR A CA 82.272 48.958 21.111 1 45 +ATOM C C . TYR A 1 5 . 5 TYR A C 83.367 49.34 22.11 1 46 +ATOM O O . TYR A 1 5 . 5 TYR A O 83.313 50.403 22.735 1 47 +ATOM C CB . TYR A 1 5 . 5 TYR A CB 81.379 47.887 21.742 1 48 +ATOM C CG . TYR A 1 5 . 5 TYR A CG 80.74 48.305 23.046 1 49 +ATOM C CD1 . TYR A 1 5 . 5 TYR A CD1 79.623 49.147 23.061 1 50 +ATOM C CD2 . TYR A 1 5 . 5 TYR A CD2 81.223 47.822 24.266 1 51 +ATOM C CE1 . TYR A 1 5 . 5 TYR A CE1 78.994 49.49 24.259 1 52 +ATOM C CE2 . TYR A 1 5 . 5 TYR A CE2 80.599 48.16 25.472 1 53 +ATOM C CZ . TYR A 1 5 . 5 TYR A CZ 79.487 48.988 25.459 1 54 +ATOM O OH . TYR A 1 5 . 5 TYR A OH 78.846 49.288 26.642 1 55 +ATOM H H01 . TYR A 1 5 . 5 TYR A H01 81.687 49.847 20.874 1 56 +ATOM H H02 . TYR A 1 5 . 5 TYR A H02 82.021 47.035 21.966 1 57 +ATOM H H03 . TYR A 1 5 . 5 TYR A H03 80.586 47.641 21.035 1 58 +ATOM H H04 . TYR A 1 5 . 5 TYR A H04 79.237 49.542 22.121 1 59 +ATOM H H05 . TYR A 1 5 . 5 TYR A H05 82.588 47.535 19.539 1 60 +ATOM H H06 . TYR A 1 5 . 5 TYR A H06 82.098 47.173 24.278 1 61 +ATOM H H07 . TYR A 1 5 . 5 TYR A H07 78.123 50.146 24.254 1 62 +ATOM H H08 . TYR A 1 5 . 5 TYR A H08 80.985 47.775 26.416 1 63 +ATOM H H09 . TYR A 1 5 . 5 TYR A H09 78.792 50.241 26.745 1 64 +ATOM N N . SER A 1 6 . 6 SER A N 84.358 48.467 22.262 1 65 +ATOM C CA . SER A 1 6 . 6 SER A CA 85.438 48.72 23.205 1 66 +ATOM C C . SER A 1 6 . 6 SER A C 86.208 49.991 22.87 1 67 +ATOM O O . SER A 1 6 . 6 SER A O 86.779 50.617 23.76 1 68 +ATOM C CB . SER A 1 6 . 6 SER A CB 86.4 47.526 23.26 1 69 +ATOM O OG . SER A 1 6 . 6 SER A OG 87.03 47.3 22.009 1 70 +ATOM H H01 . SER A 1 6 . 6 SER A H01 84.978 48.858 24.184 1 71 +ATOM H H02 . SER A 1 6 . 6 SER A H02 85.823 46.637 23.514 1 72 +ATOM H H03 . SER A 1 6 . 6 SER A H03 87.165 47.723 24.011 1 73 +ATOM H H04 . SER A 1 6 . 6 SER A H04 87.074 48.124 21.519 1 74 +ATOM H H10 . SER A 1 6 . 6 SER A H10 84.362 47.617 21.716 1 75 +ATOM N N . GLU A 1 7 . 7 GLU A N 86.206 50.374 21.595 1 76 +ATOM C CA . GLU A 1 7 . 7 GLU A CA 86.91 51.575 21.146 1 77 +ATOM C C . GLU A 1 7 . 7 GLU A C 86.028 52.818 21.183 1 78 +ATOM O O . GLU A 1 7 . 7 GLU A O 86.434 53.883 20.718 1 79 +ATOM C CB . GLU A 1 7 . 7 GLU A CB 87.433 51.395 19.717 1 80 +ATOM C CG . GLU A 1 7 . 7 GLU A CG 88.333 50.192 19.504 1 81 +ATOM C CD . GLU A 1 7 . 7 GLU A CD 88.792 50.077 18.058 1 82 +ATOM O OE1 . GLU A 1 7 . 7 GLU A OE1 89.825 50.691 17.705 1 83 +ATOM O OE2 . GLU A 1 7 . 7 GLU A OE2 88.112 49.386 17.265 1 84 +ATOM H H01 . GLU A 1 7 . 7 GLU A H01 87.739 51.717 21.84 1 85 +ATOM H H02 . GLU A 1 7 . 7 GLU A H02 88.046 52.271 19.508 1 86 +ATOM H H03 . GLU A 1 7 . 7 GLU A H03 86.574 51.296 19.053 1 87 +ATOM H H04 . GLU A 1 7 . 7 GLU A H04 87.763 49.296 19.75 1 88 +ATOM H H05 . GLU A 1 7 . 7 GLU A H05 85.703 49.819 20.917 1 89 +ATOM H H06 . GLU A 1 7 . 7 GLU A H06 89.209 50.288 20.146 1 90 +ATOM H H07 . GLU A 1 7 . 7 GLU A H07 87.878 48.555 17.684 1 91 +ATOM N N . GLN A 1 8 . 8 GLN A N 84.818 52.687 21.72 1 92 +ATOM C CA . GLN A 1 8 . 8 GLN A CA 83.903 53.819 21.81 1 93 +ATOM C C . GLN A 1 8 . 8 GLN A C 83.422 53.989 23.252 1 94 +ATOM O O . GLN A 1 8 . 8 GLN A O 82.266 53.716 23.572 1 95 +ATOM C CB . GLN A 1 8 . 8 GLN A CB 82.704 53.623 20.869 1 96 +ATOM C CG . GLN A 1 8 . 8 GLN A CG 83.063 53.651 19.385 1 97 +ATOM C CD . GLN A 1 8 . 8 GLN A CD 81.842 53.795 18.491 1 98 +ATOM N NE2 . GLN A 1 8 . 8 GLN A NE2 81.682 52.872 17.551 1 99 +ATOM O OE1 . GLN A 1 8 . 8 GLN A OE1 81.054 54.731 18.641 1 100 +ATOM H H01 . GLN A 1 8 . 8 GLN A H01 84.434 54.72 21.504 1 101 +ATOM H H02 . GLN A 1 8 . 8 GLN A H02 82.028 54.46 21.044 1 102 +ATOM H H03 . GLN A 1 8 . 8 GLN A H03 82.247 52.659 21.093 1 103 +ATOM H H04 . GLN A 1 8 . 8 GLN A H04 83.539 52.701 19.143 1 104 +ATOM H H05 . GLN A 1 8 . 8 GLN A H05 83.731 54.493 19.203 1 105 +ATOM H H06 . GLN A 1 8 . 8 GLN A H06 80.89 52.919 16.927 1 106 +ATOM H H07 . GLN A 1 8 . 8 GLN A H07 82.353 52.122 17.46 1 107 +ATOM H H08 . GLN A 1 8 . 8 GLN A H08 84.527 51.786 22.072 1 108 +ATOM N N . PRO A 1 9 . 9 PRO A N 84.313 54.466 24.14 1 109 +ATOM C CA . PRO A 1 9 . 9 PRO A CA 84.036 54.688 25.569 1 110 +ATOM C C . PRO A 1 9 . 9 PRO A C 82.757 55.458 25.88 1 111 +ATOM O O . PRO A 1 9 . 9 PRO A O 82.123 55.243 26.917 1 112 +ATOM C CB . PRO A 1 9 . 9 PRO A CB 85.278 55.442 26.053 1 113 +ATOM C CG . PRO A 1 9 . 9 PRO A CG 86.359 54.964 25.121 1 114 +ATOM C CD . PRO A 1 9 . 9 PRO A CD 85.663 54.94 23.783 1 115 +ATOM H H01 . PRO A 1 9 . 9 PRO A H01 85.507 55.256 27.102 1 116 +ATOM H H02 . PRO A 1 9 . 9 PRO A H02 85.138 56.52 25.97 1 117 +ATOM H H03 . PRO A 1 9 . 9 PRO A H03 86.777 53.999 25.406 1 118 +ATOM H H04 . PRO A 1 9 . 9 PRO A H04 87.204 55.653 25.112 1 119 +ATOM H H05 . PRO A 1 9 . 9 PRO A H05 85.667 55.903 23.272 1 120 +ATOM H H06 . PRO A 1 9 . 9 PRO A H06 86.154 54.253 23.094 1 121 +ATOM H H08 . PRO A 1 9 . 9 PRO A H08 83.856 53.739 26.074 1 122 +ATOM N N . GLU A 1 10 . 10 GLU A N 82.381 56.366 24.992 1 123 +ATOM C CA . GLU A 1 10 . 10 GLU A CA 81.183 57.16 25.204 1 124 +ATOM C C . GLU A 1 10 . 10 GLU A C 79.928 56.286 25.243 1 125 +ATOM O O . GLU A 1 10 . 10 GLU A O 78.91 56.68 25.806 1 126 +ATOM C CB . GLU A 1 10 . 10 GLU A CB 81.049 58.208 24.099 1 127 +ATOM C CG . GLU A 1 10 . 10 GLU A CG 82.384 58.773 23.614 1 128 +ATOM C CD . GLU A 1 10 . 10 GLU A CD 82.939 58.024 22.397 1 129 +ATOM O OE1 . GLU A 1 10 . 10 GLU A OE1 83.281 56.827 22.506 1 130 +ATOM O OE2 . GLU A 1 10 . 10 GLU A OE2 83.03 58.645 21.318 1 131 +ATOM H H01 . GLU A 1 10 . 10 GLU A H01 81.279 57.655 26.17 1 132 +ATOM H H02 . GLU A 1 10 . 10 GLU A H02 80.488 59.042 24.521 1 133 +ATOM H H03 . GLU A 1 10 . 10 GLU A H03 80.541 57.75 23.25 1 134 +ATOM H H04 . GLU A 1 10 . 10 GLU A H04 83.103 58.658 24.425 1 135 +ATOM H H05 . GLU A 1 10 . 10 GLU A H05 82.243 59.82 23.345 1 136 +ATOM H H06 . GLU A 1 10 . 10 GLU A H06 82.583 59.491 21.396 1 137 +ATOM H H07 . GLU A 1 10 . 10 GLU A H07 82.933 56.508 24.158 1 138 +ATOM N N . LEU A 1 11 . 11 LEU A N 80.011 55.097 24.653 1 139 +ATOM C CA . LEU A 1 11 . 11 LEU A CA 78.873 54.179 24.62 1 140 +ATOM C C . LEU A 1 11 . 11 LEU A C 78.841 53.234 25.816 1 141 +ATOM O O . LEU A 1 11 . 11 LEU A O 77.847 52.539 26.038 1 142 +ATOM C CB . LEU A 1 11 . 11 LEU A CB 78.921 53.327 23.351 1 143 +ATOM C CG . LEU A 1 11 . 11 LEU A CG 78.85 54.077 22.022 1 144 +ATOM C CD1 . LEU A 1 11 . 11 LEU A CD1 78.992 53.093 20.868 1 145 +ATOM C CD2 . LEU A 1 11 . 11 LEU A CD2 77.531 54.815 21.935 1 146 +ATOM H H01 . LEU A 1 11 . 11 LEU A H01 77.979 54.802 24.646 1 147 +ATOM H H02 . LEU A 1 11 . 11 LEU A H02 78.021 52.712 23.383 1 148 +ATOM H H03 . LEU A 1 11 . 11 LEU A H03 79.854 52.764 23.365 1 149 +ATOM H H04 . LEU A 1 11 . 11 LEU A H04 79.664 54.8 21.961 1 150 +ATOM H H05 . LEU A 1 11 . 11 LEU A H05 80.038 52.807 20.762 1 151 +ATOM H H06 . LEU A 1 11 . 11 LEU A H06 78.392 52.206 21.071 1 152 +ATOM H H07 . LEU A 1 11 . 11 LEU A H07 80.88 54.822 24.218 1 153 +ATOM H H08 . LEU A 1 11 . 11 LEU A H08 78.648 53.562 19.946 1 154 +ATOM H H09 . LEU A 1 11 . 11 LEU A H09 76.782 54.29 22.527 1 155 +ATOM H H10 . LEU A 1 11 . 11 LEU A H10 77.654 55.827 22.321 1 156 +ATOM H H11 . LEU A 1 11 . 11 LEU A H11 77.206 54.859 20.895 1 157 +ATOM N N . HIS A 1 12 . 12 HIS A N 79.922 53.202 26.588 1 158 +ATOM C CA . HIS A 1 12 . 12 HIS A CA 79.986 52.281 27.713 1 159 +ATOM C C . HIS A 1 12 . 12 HIS A C 79.03 52.609 28.841 1 160 +ATOM O O . HIS A 1 12 . 12 HIS A O 78.749 53.772 29.122 1 161 +ATOM C CB . HIS A 1 12 . 12 HIS A CB 81.422 52.195 28.23 1 162 +ATOM C CG . HIS A 1 12 . 12 HIS A CG 82.391 51.714 27.199 1 163 +ATOM C CD2 . HIS A 1 12 . 12 HIS A CD2 82.197 51.292 25.927 1 164 +ATOM N ND1 . HIS A 1 12 . 12 HIS A ND1 83.746 51.62 27.43 1 165 +ATOM C CE1 . HIS A 1 12 . 12 HIS A CE1 84.345 51.161 26.345 1 166 +ATOM N NE2 . HIS A 1 12 . 12 HIS A NE2 83.427 50.954 25.418 1 167 +ATOM H H01 . HIS A 1 12 . 12 HIS A H01 79.663 51.311 27.334 1 168 +ATOM H H02 . HIS A 1 12 . 12 HIS A H02 81.43 51.471 29.045 1 169 +ATOM H H03 . HIS A 1 12 . 12 HIS A H03 81.731 53.186 28.562 1 170 +ATOM H H04 . HIS A 1 12 . 12 HIS A H04 81.242 51.232 25.404 1 171 +ATOM H H05 . HIS A 1 12 . 12 HIS A H05 85.415 50.983 26.234 1 172 +ATOM H H06 . HIS A 1 12 . 12 HIS A H06 83.601 50.604 24.487 1 173 +ATOM H H12 . HIS A 1 12 . 12 HIS A H12 80.699 53.817 26.394 1 174 +ATOM N N . ALA A 1 13 . 13 ALA A N 78.523 51.568 29.489 1 175 +ATOM C CA . ALA A 1 13 . 13 ALA A CA 77.585 51.761 30.581 1 176 +ATOM C C . ALA A 1 13 . 13 ALA A C 78.248 52.287 31.846 1 177 +ATOM O O . ALA A 1 13 . 13 ALA A O 79.353 51.889 32.202 1 178 +ATOM C CB . ALA A 1 13 . 13 ALA A CB 76.856 50.443 30.887 1 179 +ATOM H H01 . ALA A 1 13 . 13 ALA A H01 76.872 52.518 30.254 1 180 +ATOM H H02 . ALA A 1 13 . 13 ALA A H02 75.889 50.438 30.385 1 181 +ATOM H H03 . ALA A 1 13 . 13 ALA A H03 77.454 49.605 30.53 1 182 +ATOM H H04 . ALA A 1 13 . 13 ALA A H04 76.707 50.351 31.963 1 183 +ATOM H H07 . ALA A 1 13 . 13 ALA A H07 78.792 50.632 29.22 1 184 +ATOM N N . LYS A 1 14 . 14 LYS A N 77.563 53.191 32.525 1 185 +ATOM C CA . LYS A 1 14 . 14 LYS A CA 78.077 53.726 33.773 1 186 +ATOM C C . LYS A 1 14 . 14 LYS A C 77.26 53.008 34.835 1 187 +ATOM O O . LYS A 1 14 . 14 LYS A O 76.097 53.327 35.061 1 188 +ATOM C CB . LYS A 1 14 . 14 LYS A CB 77.863 55.239 33.839 1 189 +ATOM C CG . LYS A 1 14 . 14 LYS A CG 78.662 55.999 32.784 1 190 +ATOM C CD . LYS A 1 14 . 14 LYS A CD 78.501 57.504 32.941 1 191 +ATOM C CE . LYS A 1 14 . 14 LYS A CE 79.333 58.258 31.911 1 192 +ATOM N NZ . LYS A 1 14 . 14 LYS A NZ 79.212 59.73 32.093 1 193 +ATOM H H01 . LYS A 1 14 . 14 LYS A H01 79.149 53.572 33.895 1 194 +ATOM H H02 . LYS A 1 14 . 14 LYS A H02 78.209 55.575 34.816 1 195 +ATOM H H03 . LYS A 1 14 . 14 LYS A H03 76.803 55.448 33.691 1 196 +ATOM H H04 . LYS A 1 14 . 14 LYS A H04 78.287 55.715 31.801 1 197 +ATOM H H05 . LYS A 1 14 . 14 LYS A H05 76.673 53.513 32.172 1 198 +ATOM H H06 . LYS A 1 14 . 14 LYS A H06 79.717 55.742 32.884 1 199 +ATOM H H07 . LYS A 1 14 . 14 LYS A H07 78.846 57.786 33.936 1 200 +ATOM H H08 . LYS A 1 14 . 14 LYS A H08 77.451 57.765 32.811 1 201 +ATOM H H09 . LYS A 1 14 . 14 LYS A H09 78.965 58.002 30.917 1 202 +ATOM H H10 . LYS A 1 14 . 14 LYS A H10 80.379 57.971 32.019 1 203 +ATOM H H11 . LYS A 1 14 . 14 LYS A H11 79.965 60.06 32.679 1 204 +ATOM H H12 . LYS A 1 14 . 14 LYS A H12 79.26 60.186 31.193 1 205 +ATOM N N . ALA A 1 15 . 15 ALA A N 77.869 52.007 35.455 1 206 +ATOM C CA . ALA A 1 15 . 15 ALA A CA 77.184 51.213 36.467 1 207 +ATOM C C . ALA A 1 15 . 15 ALA A C 78.099 51.165 37.686 1 208 +ATOM O O . ALA A 1 15 . 15 ALA A O 78.741 50.159 37.953 1 209 +ATOM C CB . ALA A 1 15 . 15 ALA A CB 76.938 49.812 35.919 1 210 +ATOM H H01 . ALA A 1 15 . 15 ALA A H01 76.22 51.644 36.737 1 211 +ATOM H H02 . ALA A 1 15 . 15 ALA A H02 76.061 49.824 35.272 1 212 +ATOM H H03 . ALA A 1 15 . 15 ALA A H03 77.807 49.488 35.346 1 213 +ATOM H H04 . ALA A 1 15 . 15 ALA A H04 76.769 49.122 36.746 1 214 +ATOM H H13 . ALA A 1 15 . 15 ALA A H13 78.827 51.79 35.221 1 215 +ATOM N N . PRO A 1 16 . 16 PRO A N 78.143 52.263 38.448 1 216 +ATOM C CA . PRO A 1 16 . 16 PRO A CA 78.968 52.426 39.648 1 217 +ATOM C C . PRO A 1 16 . 16 PRO A C 78.842 51.348 40.72 1 218 +ATOM O O . PRO A 1 16 . 16 PRO A O 79.804 51.073 41.443 1 219 +ATOM C CB . PRO A 1 16 . 16 PRO A CB 78.528 53.786 40.193 1 220 +ATOM C CG . PRO A 1 16 . 16 PRO A CG 78.001 54.496 39.02 1 221 +ATOM C CD . PRO A 1 16 . 16 PRO A CD 77.266 53.43 38.259 1 222 +ATOM H H01 . PRO A 1 16 . 16 PRO A H01 79.345 54.325 40.673 1 223 +ATOM H H02 . PRO A 1 16 . 16 PRO A H02 77.755 53.667 40.952 1 224 +ATOM H H03 . PRO A 1 16 . 16 PRO A H03 78.783 54.974 38.429 1 225 +ATOM H H04 . PRO A 1 16 . 16 PRO A H04 77.325 55.296 39.321 1 226 +ATOM H H05 . PRO A 1 16 . 16 PRO A H05 80.021 52.346 39.377 1 227 +ATOM H H06 . PRO A 1 16 . 16 PRO A H06 76.243 53.272 38.6 1 228 +ATOM H H07 . PRO A 1 16 . 16 PRO A H07 77.171 53.689 37.204 1 229 +ATOM N N . TYR A 1 17 . 17 TYR A N 77.666 50.746 40.827 1 230 +ATOM C CA . TYR A 1 17 . 17 TYR A CA 77.426 49.761 41.879 1 231 +ATOM C C . TYR A 1 17 . 17 TYR A C 77.196 48.317 41.452 1 232 +ATOM O O . TYR A 1 17 . 17 TYR A O 76.874 47.474 42.295 1 233 +ATOM C CB . TYR A 1 17 . 17 TYR A CB 76.237 50.24 42.72 1 234 +ATOM C CG . TYR A 1 17 . 17 TYR A CG 76.413 51.658 43.203 1 235 +ATOM C CD1 . TYR A 1 17 . 17 TYR A CD1 77.335 51.954 44.211 1 236 +ATOM C CD2 . TYR A 1 17 . 17 TYR A CD2 75.705 52.712 42.624 1 237 +ATOM C CE1 . TYR A 1 17 . 17 TYR A CE1 77.547 53.263 44.622 1 238 +ATOM C CE2 . TYR A 1 17 . 17 TYR A CE2 75.908 54.03 43.035 1 239 +ATOM C CZ . TYR A 1 17 . 17 TYR A CZ 76.832 54.292 44.035 1 240 +ATOM O OH . TYR A 1 17 . 17 TYR A OH 77.043 55.584 44.462 1 241 +ATOM H H01 . TYR A 1 17 . 17 TYR A H01 78.363 49.713 42.434 1 242 +ATOM H H02 . TYR A 1 17 . 17 TYR A H02 76.167 49.595 43.596 1 243 +ATOM H H03 . TYR A 1 17 . 17 TYR A H03 75.333 50.185 42.114 1 244 +ATOM H H04 . TYR A 1 17 . 17 TYR A H04 77.895 51.146 44.681 1 245 +ATOM H H05 . TYR A 1 17 . 17 TYR A H05 74.981 52.502 41.837 1 246 +ATOM H H06 . TYR A 1 17 . 17 TYR A H06 78.274 53.479 45.405 1 247 +ATOM H H07 . TYR A 1 17 . 17 TYR A H07 75.347 54.844 42.575 1 248 +ATOM H H08 . TYR A 1 17 . 17 TYR A H08 76.928 50.97 40.175 1 249 +ATOM H H09 . TYR A 1 17 . 17 TYR A H09 77.189 55.588 45.411 1 250 +ATOM N N . ARG A 1 18 . 18 ARG A N 77.374 47.998 40.173 1 251 +ATOM C CA . ARG A 1 18 . 18 ARG A CA 77.122 46.616 39.751 1 252 +ATOM C C . ARG A 1 18 . 18 ARG A C 78.102 45.608 40.334 1 253 +ATOM O O . ARG A 1 18 . 18 ARG A O 79.245 45.951 40.666 1 254 +ATOM C CB . ARG A 1 18 . 18 ARG A CB 77.141 46.492 38.225 1 255 +ATOM C CG . ARG A 1 18 . 18 ARG A CG 78.49 46.841 37.583 1 256 +ATOM C CD . ARG A 1 18 . 18 ARG A CD 78.565 46.246 36.178 1 257 +ATOM N NE . ARG A 1 18 . 18 ARG A NE 78.771 44.804 36.236 1 258 +ATOM C CZ . ARG A 1 18 . 18 ARG A CZ 78.761 44.004 35.175 1 259 +ATOM N NH1 . ARG A 1 18 . 18 ARG A NH1 78.546 44.509 33.963 1 260 +ATOM N NH2 . ARG A 1 18 . 18 ARG A NH2 78.98 42.703 35.326 1 261 +ATOM H H01 . ARG A 1 18 . 18 ARG A H01 76.132 46.378 40.141 1 262 +ATOM H H02 . ARG A 1 18 . 18 ARG A H02 76.417 47.212 37.843 1 263 +ATOM H H03 . ARG A 1 18 . 18 ARG A H03 76.892 45.464 37.962 1 264 +ATOM H H04 . ARG A 1 18 . 18 ARG A H04 79.295 46.428 38.191 1 265 +ATOM H H05 . ARG A 1 18 . 18 ARG A H05 78.594 47.924 37.524 1 266 +ATOM H H06 . ARG A 1 18 . 18 ARG A H06 79.406 46.698 35.652 1 267 +ATOM H H07 . ARG A 1 18 . 18 ARG A H07 77.634 46.454 35.651 1 268 +ATOM H H08 . ARG A 1 18 . 18 ARG A H08 78.932 44.386 37.141 1 269 +ATOM H H09 . ARG A 1 18 . 18 ARG A H09 78.544 43.902 33.156 1 270 +ATOM H H10 . ARG A 1 18 . 18 ARG A H10 77.677 48.693 39.506 1 271 +ATOM H H11 . ARG A 1 18 . 18 ARG A H11 78.385 45.5 33.85 1 272 +ATOM H H12 . ARG A 1 18 . 18 ARG A H12 78.978 42.094 34.52 1 273 +ATOM N N . SER A 1 19 . 19 SER A N 77.647 44.368 40.478 1 274 +ATOM C CA . SER A 1 19 . 19 SER A CA 78.506 43.299 40.988 1 275 +ATOM C C . SER A 1 19 . 19 SER A C 79.324 42.74 39.824 1 276 +ATOM O O . SER A 1 19 . 19 SER A O 79.171 43.188 38.687 1 277 +ATOM C CB . SER A 1 19 . 19 SER A CB 77.662 42.182 41.6 1 278 +ATOM O OG . SER A 1 19 . 19 SER A OG 78.486 41.262 42.286 1 279 +ATOM H H01 . SER A 1 19 . 19 SER A H01 79.164 43.698 41.76 1 280 +ATOM H H02 . SER A 1 19 . 19 SER A H02 77.136 41.658 40.802 1 281 +ATOM H H03 . SER A 1 19 . 19 SER A H03 76.945 42.614 42.299 1 282 +ATOM H H04 . SER A 1 19 . 19 SER A H04 78.717 40.539 41.699 1 283 +ATOM H H13 . SER A 1 19 . 19 SER A H13 76.69 44.159 40.231 1 284 +ATOM N N . ALA A 1 20 . 20 ALA A N 80.184 41.763 40.1 1 285 +ATOM C CA . ALA A 1 20 . 20 ALA A CA 81.007 41.17 39.048 1 286 +ATOM C C . ALA A 1 20 . 20 ALA A C 80.115 40.5 38.006 1 287 +ATOM O O . ALA A 1 20 . 20 ALA A O 80.451 40.468 36.829 1 288 +ATOM C CB . ALA A 1 20 . 20 ALA A CB 81.978 40.151 39.639 1 289 +ATOM H H01 . ALA A 1 20 . 20 ALA A H01 81.584 41.961 38.569 1 290 +ATOM H H02 . ALA A 1 20 . 20 ALA A H02 82.876 40.663 39.984 1 291 +ATOM H H03 . ALA A 1 20 . 20 ALA A H03 81.505 39.642 40.479 1 292 +ATOM H H04 . ALA A 1 20 . 20 ALA A H04 82.248 39.421 38.876 1 293 +ATOM H H05 . ALA A 1 20 . 20 ALA A H05 80.269 41.427 41.049 1 294 +ATOM N N . MET A 1 21 . 21 MET A N 78.996 39.939 38.456 1 295 +ATOM C CA . MET A 1 21 . 21 MET A CA 78.043 39.292 37.55 1 296 +ATOM C C . MET A 1 21 . 21 MET A C 76.675 39.909 37.783 1 297 +ATOM O O . MET A 1 21 . 21 MET A O 76.263 40.122 38.928 1 298 +ATOM C CB . MET A 1 21 . 21 MET A CB 77.998 37.786 37.796 1 299 +ATOM C CG . MET A 1 21 . 21 MET A CG 79.367 37.115 37.627 1 300 +ATOM S SD . MET A 1 21 . 21 MET A SD 79.247 35.335 37.5 1 301 +ATOM C CE . MET A 1 21 . 21 MET A CE 78.441 34.944 39.032 1 302 +ATOM H H01 . MET A 1 21 . 21 MET A H01 78.353 39.446 36.516 1 303 +ATOM H H02 . MET A 1 21 . 21 MET A H02 77.327 37.353 37.055 1 304 +ATOM H H03 . MET A 1 21 . 21 MET A H03 77.646 37.609 38.812 1 305 +ATOM H H04 . MET A 1 21 . 21 MET A H04 79.959 37.344 38.513 1 306 +ATOM H H05 . MET A 1 21 . 21 MET A H05 78.798 39.959 39.446 1 307 +ATOM H H06 . MET A 1 21 . 21 MET A H06 79.836 37.5 36.722 1 308 +ATOM H H07 . MET A 1 21 . 21 MET A H07 77.435 34.577 38.829 1 309 +ATOM H H08 . MET A 1 21 . 21 MET A H08 79.009 34.175 39.556 1 310 +ATOM H H09 . MET A 1 21 . 21 MET A H09 78.383 35.839 39.652 1 311 +ATOM N N . LEU A 1 22 . 22 LEU A N 75.954 40.171 36.7 1 312 +ATOM C CA . LEU A 1 22 . 22 LEU A CA 74.658 40.831 36.815 1 313 +ATOM C C . LEU A 1 22 . 22 LEU A C 73.523 39.97 37.354 1 314 +ATOM O O . LEU A 1 22 . 22 LEU A O 72.395 40.442 37.494 1 315 +ATOM C CB . LEU A 1 22 . 22 LEU A CB 74.286 41.465 35.471 1 316 +ATOM C CG . LEU A 1 22 . 22 LEU A CG 75.283 42.538 35.004 1 317 +ATOM C CD1 . LEU A 1 22 . 22 LEU A CD1 74.853 43.047 33.637 1 318 +ATOM C CD2 . LEU A 1 22 . 22 LEU A CD2 75.353 43.692 36.003 1 319 +ATOM H H01 . LEU A 1 22 . 22 LEU A H01 74.785 41.597 37.58 1 320 +ATOM H H02 . LEU A 1 22 . 22 LEU A H02 73.323 41.958 35.604 1 321 +ATOM H H03 . LEU A 1 22 . 22 LEU A H03 74.245 40.679 34.717 1 322 +ATOM H H04 . LEU A 1 22 . 22 LEU A H04 76.278 42.098 34.938 1 323 +ATOM H H05 . LEU A 1 22 . 22 LEU A H05 73.995 43.709 33.75 1 324 +ATOM H H06 . LEU A 1 22 . 22 LEU A H06 75.676 43.595 33.177 1 325 +ATOM H H07 . LEU A 1 22 . 22 LEU A H07 74.579 42.203 33.004 1 326 +ATOM H H08 . LEU A 1 22 . 22 LEU A H08 75.778 44.569 35.515 1 327 +ATOM H H09 . LEU A 1 22 . 22 LEU A H09 74.35 43.926 36.36 1 328 +ATOM H H10 . LEU A 1 22 . 22 LEU A H10 76.305 39.912 35.789 1 329 +ATOM H H11 . LEU A 1 22 . 22 LEU A H11 75.982 43.406 36.846 1 330 +ATOM N N . THR A 1 23 . 23 THR A N 73.84 38.716 37.661 1 331 +ATOM C CA . THR A 1 23 . 23 THR A CA 72.899 37.779 38.265 1 332 +ATOM C C . THR A 1 23 . 23 THR A C 72.97 37.943 39.784 1 333 +ATOM O O . THR A 1 23 . 23 THR A O 72.232 37.29 40.528 1 334 +ATOM C CB . THR A 1 23 . 23 THR A CB 73.259 36.327 37.927 1 335 +ATOM C CG2 . THR A 1 23 . 23 THR A CG2 72.87 35.998 36.49 1 336 +ATOM O OG1 . THR A 1 23 . 23 THR A OG1 74.669 36.142 38.099 1 337 +ATOM H H01 . THR A 1 23 . 23 THR A H01 71.902 37.991 37.879 1 338 +ATOM H H02 . THR A 1 23 . 23 THR A H02 72.712 35.661 38.594 1 339 +ATOM H H03 . THR A 1 23 . 23 THR A H03 72.028 36.621 36.19 1 340 +ATOM H H04 . THR A 1 23 . 23 THR A H04 73.716 36.191 35.831 1 341 +ATOM H H05 . THR A 1 23 . 23 THR A H05 72.587 34.948 36.421 1 342 +ATOM H H06 . THR A 1 23 . 23 THR A H06 75.01 35.597 37.386 1 343 +ATOM H H12 . THR A 1 23 . 23 THR A H12 74.778 38.396 37.467 1 344 +ATOM N N . TYR A 1 24 . 24 TYR A N 73.869 38.816 40.234 1 345 +ATOM C CA . TYR A 1 24 . 24 TYR A CA 74.035 39.095 41.662 1 346 +ATOM C C . TYR A 1 24 . 24 TYR A C 73.663 40.54 41.958 1 347 +ATOM O O . TYR A 1 24 . 24 TYR A O 73.755 41.407 41.086 1 348 +ATOM C CB . TYR A 1 24 . 24 TYR A CB 75.467 38.79 42.115 1 349 +ATOM C CG . TYR A 1 24 . 24 TYR A CG 75.647 37.326 42.393 1 350 +ATOM C CD1 . TYR A 1 24 . 24 TYR A CD1 76.017 36.44 41.386 1 351 +ATOM C CD2 . TYR A 1 24 . 24 TYR A CD2 75.343 36.803 43.649 1 352 +ATOM C CE1 . TYR A 1 24 . 24 TYR A CE1 76.078 35.064 41.624 1 353 +ATOM C CE2 . TYR A 1 24 . 24 TYR A CE2 75.397 35.434 43.895 1 354 +ATOM C CZ . TYR A 1 24 . 24 TYR A CZ 75.767 34.568 42.879 1 355 +ATOM O OH . TYR A 1 24 . 24 TYR A OH 75.843 33.205 43.124 1 356 +ATOM H H01 . TYR A 1 24 . 24 TYR A H01 73.366 38.444 42.225 1 357 +ATOM H H02 . TYR A 1 24 . 24 TYR A H02 75.664 39.343 43.034 1 358 +ATOM H H03 . TYR A 1 24 . 24 TYR A H03 76.161 39.092 41.33 1 359 +ATOM H H04 . TYR A 1 24 . 24 TYR A H04 76.263 36.825 40.396 1 360 +ATOM H H05 . TYR A 1 24 . 24 TYR A H05 75.057 37.48 44.454 1 361 +ATOM H H06 . TYR A 1 24 . 24 TYR A H06 76.37 34.383 40.824 1 362 +ATOM H H07 . TYR A 1 24 . 24 TYR A H07 74.455 39.301 39.57 1 363 +ATOM H H08 . TYR A 1 24 . 24 TYR A H08 75.149 35.045 44.883 1 364 +ATOM H H09 . TYR A 1 24 . 24 TYR A H09 75.998 32.739 42.299 1 365 +ATOM N N . PRO A 1 25 . 25 PRO A N 73.243 40.828 43.202 1 366 +ATOM C CA . PRO A 1 25 . 25 PRO A CA 72.858 42.202 43.529 1 367 +ATOM C C . PRO A 1 25 . 25 PRO A C 73.944 43.252 43.45 1 368 +ATOM O O . PRO A 1 25 . 25 PRO A O 75.084 43.016 43.852 1 369 +ATOM C CB . PRO A 1 25 . 25 PRO A CB 72.313 42.085 44.951 1 370 +ATOM C CG . PRO A 1 25 . 25 PRO A CG 73.215 41.042 45.55 1 371 +ATOM C CD . PRO A 1 25 . 25 PRO A CD 73.271 39.988 44.413 1 372 +ATOM H H01 . PRO A 1 25 . 25 PRO A H01 71.255 41.827 44.995 1 373 +ATOM H H02 . PRO A 1 25 . 25 PRO A H02 72.393 43.032 45.485 1 374 +ATOM H H03 . PRO A 1 25 . 25 PRO A H03 72.864 40.647 46.504 1 375 +ATOM H H04 . PRO A 1 25 . 25 PRO A H04 74.203 41.447 45.767 1 376 +ATOM H H05 . PRO A 1 25 . 25 PRO A H05 74.14 39.333 44.469 1 377 +ATOM H H06 . PRO A 1 25 . 25 PRO A H06 72.411 39.32 44.451 1 378 +ATOM H H10 . PRO A 1 25 . 25 PRO A H10 72.15 42.566 42.785 1 379 +ATOM N N . GLY A 1 26 . 26 GLY A N 73.572 44.411 42.919 1 380 +ATOM C CA . GLY A 1 26 . 26 GLY A CA 74.491 45.528 42.857 1 381 +ATOM C C . GLY A 1 26 . 26 GLY A C 74.586 46.077 44.272 1 382 +ATOM O O . GLY A 1 26 . 26 GLY A O 73.793 45.702 45.147 1 383 +ATOM H H01 . GLY A 1 26 . 26 GLY A H01 74.145 46.289 42.158 1 384 +ATOM H H02 . GLY A 1 26 . 26 GLY A H02 75.47 45.194 42.514 1 385 +ATOM H H07 . GLY A 1 26 . 26 GLY A H07 72.636 44.515 42.554 1 386 +ATOM N N . ASN A 1 27 . 27 ASN A N 75.529 46.981 44.51 1 387 +ATOM C CA . ASN A 1 27 . 27 ASN A CA 75.715 47.496 45.866 1 388 +ATOM C C . ASN A 1 27 . 27 ASN A C 74.793 48.641 46.281 1 389 +ATOM O O . ASN A 1 27 . 27 ASN A O 75.205 49.811 46.315 1 390 +ATOM C CB . ASN A 1 27 . 27 ASN A CB 77.172 47.913 46.071 1 391 +ATOM C CG . ASN A 1 27 . 27 ASN A CG 77.532 48.012 47.541 1 392 +ATOM N ND2 . ASN A 1 27 . 27 ASN A ND2 78.829 48.031 47.845 1 393 +ATOM O OD1 . ASN A 1 27 . 27 ASN A OD1 76.647 48.077 48.393 1 394 +ATOM H H01 . ASN A 1 27 . 27 ASN A H01 75.439 46.664 46.514 1 395 +ATOM H H02 . ASN A 1 27 . 27 ASN A H02 77.307 48.898 45.624 1 396 +ATOM H H03 . ASN A 1 27 . 27 ASN A H03 76.116 47.312 43.757 1 397 +ATOM H H04 . ASN A 1 27 . 27 ASN A H04 77.82 47.175 45.598 1 398 +ATOM H H05 . ASN A 1 27 . 27 ASN A H05 79.121 48.098 48.81 1 399 +ATOM H H06 . ASN A 1 27 . 27 ASN A H06 79.521 47.978 47.111 1 400 +ATOM N N . LEU A 1 28 . 28 LEU A N 73.552 48.302 46.621 1 401 +ATOM C CA . LEU A 1 28 . 28 LEU A CA 72.587 49.307 47.049 1 402 +ATOM C C . LEU A 1 28 . 28 LEU A C 73.031 49.9 48.387 1 403 +ATOM O O . LEU A 1 28 . 28 LEU A O 72.785 51.074 48.668 1 404 +ATOM C CB . LEU A 1 28 . 28 LEU A CB 71.182 48.691 47.163 1 405 +ATOM C CG . LEU A 1 28 . 28 LEU A CG 70.077 49.586 47.739 1 406 +ATOM C CD1 . LEU A 1 28 . 28 LEU A CD1 69.904 50.832 46.835 1 407 +ATOM C CD2 . LEU A 1 28 . 28 LEU A CD2 68.765 48.799 47.845 1 408 +ATOM H H01 . LEU A 1 28 . 28 LEU A H01 72.543 50.103 46.306 1 409 +ATOM H H02 . LEU A 1 28 . 28 LEU A H02 71.287 47.874 47.877 1 410 +ATOM H H03 . LEU A 1 28 . 28 LEU A H03 70.873 48.387 46.163 1 411 +ATOM H H04 . LEU A 1 28 . 28 LEU A H04 70.355 49.915 48.74 1 412 +ATOM H H05 . LEU A 1 28 . 28 LEU A H05 69.146 51.488 47.263 1 413 +ATOM H H06 . LEU A 1 28 . 28 LEU A H06 70.851 51.367 46.768 1 414 +ATOM H H07 . LEU A 1 28 . 28 LEU A H07 73.274 47.332 46.582 1 415 +ATOM H H08 . LEU A 1 28 . 28 LEU A H08 69.592 50.518 45.839 1 416 +ATOM H H09 . LEU A 1 28 . 28 LEU A H09 67.99 49.307 47.271 1 417 +ATOM H H10 . LEU A 1 28 . 28 LEU A H10 68.91 47.794 47.448 1 418 +ATOM H H11 . LEU A 1 28 . 28 LEU A H11 68.461 48.737 48.89 1 419 +ATOM N N . ARG A 1 29 . 29 ARG A N 73.689 49.084 49.21 1 420 +ATOM C CA . ARG A 1 29 . 29 ARG A CA 74.176 49.553 50.508 1 421 +ATOM C C . ARG A 1 29 . 29 ARG A C 75.049 50.804 50.312 1 422 +ATOM O O . ARG A 1 29 . 29 ARG A O 74.856 51.838 50.979 1 423 +ATOM C CB . ARG A 1 29 . 29 ARG A CB 74.994 48.437 51.184 1 424 +ATOM C CG . ARG A 1 29 . 29 ARG A CG 75.412 48.698 52.647 1 425 +ATOM C CD . ARG A 1 29 . 29 ARG A CD 74.318 48.264 53.645 1 426 +ATOM N NE . ARG A 1 29 . 29 ARG A NE 74.001 46.844 53.519 1 427 +ATOM C CZ . ARG A 1 29 . 29 ARG A CZ 74.712 45.837 54.036 1 428 +ATOM N NH1 . ARG A 1 29 . 29 ARG A NH1 75.817 46.05 54.754 1 429 +ATOM N NH2 . ARG A 1 29 . 29 ARG A NH2 74.318 44.593 53.817 1 430 +ATOM H H01 . ARG A 1 29 . 29 ARG A H01 73.329 49.809 51.144 1 431 +ATOM H H02 . ARG A 1 29 . 29 ARG A H02 75.924 48.371 50.62 1 432 +ATOM H H03 . ARG A 1 29 . 29 ARG A H03 74.396 47.526 51.164 1 433 +ATOM H H04 . ARG A 1 29 . 29 ARG A H04 75.571 49.77 52.766 1 434 +ATOM H H05 . ARG A 1 29 . 29 ARG A H05 76.324 48.14 52.858 1 435 +ATOM H H06 . ARG A 1 29 . 29 ARG A H06 73.415 48.833 53.426 1 436 +ATOM H H07 . ARG A 1 29 . 29 ARG A H07 74.667 48.46 54.659 1 437 +ATOM H H08 . ARG A 1 29 . 29 ARG A H08 73.172 46.602 52.995 1 438 +ATOM H H09 . ARG A 1 29 . 29 ARG A H09 76.336 45.268 55.126 1 439 +ATOM H H10 . ARG A 1 29 . 29 ARG A H10 76.134 46.994 54.925 1 440 +ATOM H H11 . ARG A 1 29 . 29 ARG A H11 74.848 43.821 54.196 1 441 +ATOM H H12 . ARG A 1 29 . 29 ARG A H12 73.854 48.127 48.934 1 442 +ATOM N N . GLN A 1 30 . 30 GLN A N 76.004 50.714 49.388 1 443 +ATOM C CA . GLN A 1 30 . 30 GLN A CA 76.902 51.832 49.131 1 444 +ATOM C C . GLN A 1 30 . 30 GLN A C 76.189 52.964 48.405 1 445 +ATOM O O . GLN A 1 30 . 30 GLN A O 76.464 54.14 48.646 1 446 +ATOM C CB . GLN A 1 30 . 30 GLN A CB 78.112 51.381 48.306 1 447 +ATOM C CG . GLN A 1 30 . 30 GLN A CG 79.215 52.432 48.247 1 448 +ATOM C CD . GLN A 1 30 . 30 GLN A CD 79.73 52.769 49.641 1 449 +ATOM N NE2 . GLN A 1 30 . 30 GLN A NE2 79.758 54.055 49.982 1 450 +ATOM O OE1 . GLN A 1 30 . 30 GLN A OE1 80.089 51.872 50.404 1 451 +ATOM H H01 . GLN A 1 30 . 30 GLN A H01 77.243 52.199 50.099 1 452 +ATOM H H02 . GLN A 1 30 . 30 GLN A H02 77.767 51.212 47.286 1 453 +ATOM H H03 . GLN A 1 30 . 30 GLN A H03 78.519 50.473 48.752 1 454 +ATOM H H04 . GLN A 1 30 . 30 GLN A H04 78.804 53.339 47.803 1 455 +ATOM H H05 . GLN A 1 30 . 30 GLN A H05 80.04 52.05 47.645 1 456 +ATOM H H06 . GLN A 1 30 . 30 GLN A H06 80.087 54.328 50.897 1 457 +ATOM H H07 . GLN A 1 30 . 30 GLN A H07 79.45 54.758 49.326 1 458 +ATOM H H12 . GLN A 1 30 . 30 GLN A H12 76.107 49.86 48.859 1 459 +ATOM N N . ALA A 1 31 . 31 ALA A N 75.271 52.62 47.506 1 460 +ATOM C CA . ALA A 1 31 . 31 ALA A CA 74.547 53.661 46.78 1 461 +ATOM C C . ALA A 1 31 . 31 ALA A C 73.801 54.551 47.762 1 462 +ATOM O O . ALA A 1 31 . 31 ALA A O 73.773 55.775 47.609 1 463 +ATOM C CB . ALA A 1 31 . 31 ALA A CB 73.563 53.037 45.784 1 464 +ATOM H H01 . ALA A 1 31 . 31 ALA A H01 75.265 54.264 46.224 1 465 +ATOM H H02 . ALA A 1 31 . 31 ALA A H02 72.682 53.673 45.697 1 466 +ATOM H H03 . ALA A 1 31 . 31 ALA A H03 74.042 52.945 44.809 1 467 +ATOM H H04 . ALA A 1 31 . 31 ALA A H04 73.264 52.05 46.138 1 468 +ATOM H H08 . ALA A 1 31 . 31 ALA A H08 75.076 51.645 47.326 1 469 +ATOM N N . LEU A 1 32 . 32 LEU A N 73.194 53.935 48.775 1 470 +ATOM C CA . LEU A 1 32 . 32 LEU A CA 72.463 54.706 49.774 1 471 +ATOM C C . LEU A 1 32 . 32 LEU A C 73.414 55.561 50.601 1 472 +ATOM O O . LEU A 1 32 . 32 LEU A O 73.111 56.716 50.888 1 473 +ATOM C CB . LEU A 1 32 . 32 LEU A CB 71.657 53.788 50.691 1 474 +ATOM C CG . LEU A 1 32 . 32 LEU A CG 70.472 53.112 49.987 1 475 +ATOM C CD1 . LEU A 1 32 . 32 LEU A CD1 69.868 52.045 50.902 1 476 +ATOM C CD2 . LEU A 1 32 . 32 LEU A CD2 69.433 54.165 49.613 1 477 +ATOM H H01 . LEU A 1 32 . 32 LEU A H01 71.772 55.362 49.245 1 478 +ATOM H H02 . LEU A 1 32 . 32 LEU A H02 71.247 54.409 51.488 1 479 +ATOM H H03 . LEU A 1 32 . 32 LEU A H03 72.32 53.013 51.075 1 480 +ATOM H H04 . LEU A 1 32 . 32 LEU A H04 70.814 52.625 49.074 1 481 +ATOM H H05 . LEU A 1 32 . 32 LEU A H05 73.24 52.929 48.852 1 482 +ATOM H H06 . LEU A 1 32 . 32 LEU A H06 68.869 51.788 50.549 1 483 +ATOM H H07 . LEU A 1 32 . 32 LEU A H07 70.498 51.155 50.889 1 484 +ATOM H H08 . LEU A 1 32 . 32 LEU A H08 69.805 52.431 51.919 1 485 +ATOM H H09 . LEU A 1 32 . 32 LEU A H09 68.548 54.038 50.236 1 486 +ATOM H H10 . LEU A 1 32 . 32 LEU A H10 69.849 55.16 49.772 1 487 +ATOM H H11 . LEU A 1 32 . 32 LEU A H11 69.158 54.049 48.565 1 488 +ATOM N N . LYS A 1 33 . 33 LYS A N 74.554 55.001 50.991 1 489 +ATOM C CA . LYS A 1 33 . 33 LYS A CA 75.516 55.774 51.77 1 490 +ATOM C C . LYS A 1 33 . 33 LYS A C 76.013 56.961 50.949 1 491 +ATOM O O . LYS A 1 33 . 33 LYS A O 76.181 58.067 51.479 1 492 +ATOM C CB . LYS A 1 33 . 33 LYS A CB 76.697 54.895 52.195 1 493 +ATOM C CG . LYS A 1 33 . 33 LYS A CG 76.337 53.898 53.304 1 494 +ATOM C CD . LYS A 1 33 . 33 LYS A CD 77.388 52.806 53.465 1 495 +ATOM C CE . LYS A 1 33 . 33 LYS A CE 76.954 51.788 54.53 1 496 +ATOM N NZ . LYS A 1 33 . 33 LYS A NZ 77.616 50.451 54.409 1 497 +ATOM H H01 . LYS A 1 33 . 33 LYS A H01 75.021 56.142 52.669 1 498 +ATOM H H02 . LYS A 1 33 . 33 LYS A H02 77.468 55.556 52.591 1 499 +ATOM H H03 . LYS A 1 33 . 33 LYS A H03 77.046 54.337 51.326 1 500 +ATOM H H04 . LYS A 1 33 . 33 LYS A H04 75.398 53.418 53.028 1 501 +ATOM H H05 . LYS A 1 33 . 33 LYS A H05 76.245 54.439 54.246 1 502 +ATOM H H06 . LYS A 1 33 . 33 LYS A H06 78.325 53.265 53.781 1 503 +ATOM H H07 . LYS A 1 33 . 33 LYS A H07 77.521 52.293 52.512 1 504 +ATOM H H08 . LYS A 1 33 . 33 LYS A H08 75.886 51.622 54.386 1 505 +ATOM H H09 . LYS A 1 33 . 33 LYS A H09 77.189 52.199 55.512 1 506 +ATOM H H10 . LYS A 1 33 . 33 LYS A H10 77.031 49.745 54.833 1 507 +ATOM H H11 . LYS A 1 33 . 33 LYS A H11 77.756 50.231 53.433 1 508 +ATOM H H12 . LYS A 1 33 . 33 LYS A H12 74.755 54.041 50.749 1 509 +ATOM N N . ASP A 1 34 . 34 ASP A N 76.237 56.741 49.654 1 510 +ATOM C CA . ASP A 1 34 . 34 ASP A CA 76.71 57.818 48.786 1 511 +ATOM C C . ASP A 1 34 . 34 ASP A C 75.66 58.918 48.623 1 512 +ATOM O O . ASP A 1 34 . 34 ASP A O 76.003 60.105 48.551 1 513 +ATOM C CB . ASP A 1 34 . 34 ASP A CB 77.119 57.269 47.412 1 514 +ATOM C CG . ASP A 1 34 . 34 ASP A CG 78.363 56.402 47.472 1 515 +ATOM O OD1 . ASP A 1 34 . 34 ASP A OD1 79.074 56.417 48.503 1 516 +ATOM O OD2 . ASP A 1 34 . 34 ASP A OD2 78.646 55.699 46.48 1 517 +ATOM H H01 . ASP A 1 34 . 34 ASP A H01 77.584 58.259 49.265 1 518 +ATOM H H02 . ASP A 1 34 . 34 ASP A H02 77.344 58.122 46.772 1 519 +ATOM H H03 . ASP A 1 34 . 34 ASP A H03 76.297 56.673 47.016 1 520 +ATOM H H04 . ASP A 1 34 . 34 ASP A H04 78.563 56.224 45.681 1 521 +ATOM H H12 . ASP A 1 34 . 34 ASP A H12 76.078 55.821 49.268 1 522 +ATOM N N . ALA A 1 35 . 35 ALA A N 74.383 58.536 48.577 1 523 +ATOM C CA . ALA A 1 35 . 35 ALA A CA 73.299 59.508 48.45 1 524 +ATOM C C . ALA A 1 35 . 35 ALA A C 73.154 60.302 49.755 1 525 +ATOM O O . ALA A 1 35 . 35 ALA A O 72.8 61.48 49.745 1 526 +ATOM C CB . ALA A 1 35 . 35 ALA A CB 71.979 58.8 48.104 1 527 +ATOM H H01 . ALA A 1 35 . 35 ALA A H01 73.539 60.198 47.641 1 528 +ATOM H H02 . ALA A 1 35 . 35 ALA A H02 71.224 59.544 47.852 1 529 +ATOM H H03 . ALA A 1 35 . 35 ALA A H03 72.134 58.137 47.253 1 530 +ATOM H H04 . ALA A 1 35 . 35 ALA A H04 71.642 58.218 48.962 1 531 +ATOM H H05 . ALA A 1 35 . 35 ALA A H05 74.16 57.552 48.631 1 532 +ATOM N N . MET A 1 36 . 36 MET A N 73.421 59.657 50.886 1 533 +ATOM C CA . MET A 1 36 . 36 MET A CA 73.354 60.363 52.158 1 534 +ATOM C C . MET A 1 36 . 36 MET A C 74.479 61.398 52.225 1 535 +ATOM O O . MET A 1 36 . 36 MET A O 74.27 62.518 52.679 1 536 +ATOM C CB . MET A 1 36 . 36 MET A CB 73.472 59.376 53.314 1 537 +ATOM C CG . MET A 1 36 . 36 MET A CG 72.234 58.526 53.451 1 538 +ATOM S SD . MET A 1 36 . 36 MET A SD 72.391 57.369 54.791 1 539 +ATOM C CE . MET A 1 36 . 36 MET A CE 73.119 55.967 53.987 1 540 +ATOM H H01 . MET A 1 36 . 36 MET A H01 72.394 60.873 52.238 1 541 +ATOM H H02 . MET A 1 36 . 36 MET A H02 73.596 59.946 54.235 1 542 +ATOM H H03 . MET A 1 36 . 36 MET A H03 74.33 58.726 53.139 1 543 +ATOM H H04 . MET A 1 36 . 36 MET A H04 72.098 57.965 52.526 1 544 +ATOM H H05 . MET A 1 36 . 36 MET A H05 73.67 58.678 50.863 1 545 +ATOM H H06 . MET A 1 36 . 36 MET A H06 71.377 59.173 53.638 1 546 +ATOM H H07 . MET A 1 36 . 36 MET A H07 74.108 55.781 54.406 1 547 +ATOM H H08 . MET A 1 36 . 36 MET A H08 72.489 55.091 54.142 1 548 +ATOM H H09 . MET A 1 36 . 36 MET A H09 73.209 56.166 52.919 1 549 +ATOM N N . ALA A 1 37 . 37 ALA A N 75.658 61.024 51.745 1 550 +ATOM C CA . ALA A 1 37 . 37 ALA A CA 76.812 61.923 51.749 1 551 +ATOM C C . ALA A 1 37 . 37 ALA A C 76.628 63.084 50.778 1 552 +ATOM O O . ALA A 1 37 . 37 ALA A O 77.089 64.198 51.035 1 553 +ATOM C CB . ALA A 1 37 . 37 ALA A CB 78.077 61.154 51.384 1 554 +ATOM H H01 . ALA A 1 37 . 37 ALA A H01 76.903 62.332 52.755 1 555 +ATOM H H02 . ALA A 1 37 . 37 ALA A H02 78.887 61.451 52.05 1 556 +ATOM H H03 . ALA A 1 37 . 37 ALA A H03 77.895 60.084 51.488 1 557 +ATOM H H04 . ALA A 1 37 . 37 ALA A H04 78.354 61.378 50.354 1 558 +ATOM H H10 . ALA A 1 37 . 37 ALA A H10 75.762 60.093 51.367 1 559 +ATOM N N . ASP A 1 38 . 38 ASP A N 75.959 62.812 49.656 1 560 +ATOM C CA . ASP A 1 38 . 38 ASP A CA 75.716 63.831 48.638 1 561 +ATOM C C . ASP A 1 38 . 38 ASP A C 74.304 63.617 48.104 1 562 +ATOM O O . ASP A 1 38 . 38 ASP A O 74.101 62.825 47.193 1 563 +ATOM C CB . ASP A 1 38 . 38 ASP A CB 76.737 63.681 47.507 1 564 +ATOM C CG . ASP A 1 38 . 38 ASP A CG 76.526 64.686 46.385 1 565 +ATOM O OD1 . ASP A 1 38 . 38 ASP A OD1 75.524 65.422 46.431 1 566 +ATOM O OD2 . ASP A 1 38 . 38 ASP A OD2 77.37 64.727 45.464 1 567 +ATOM H H01 . ASP A 1 38 . 38 ASP A H01 75.815 64.832 49.057 1 568 +ATOM H H02 . ASP A 1 38 . 38 ASP A H02 76.612 62.685 47.083 1 569 +ATOM H H03 . ASP A 1 38 . 38 ASP A H03 77.737 63.822 47.918 1 570 +ATOM H H04 . ASP A 1 38 . 38 ASP A H04 77.52 65.64 45.207 1 571 +ATOM H H05 . ASP A 1 38 . 38 ASP A H05 75.611 61.876 49.506 1 572 +ATOM N N . PRO A 1 39 . 39 PRO A N 73.315 64.328 48.671 1 573 +ATOM C CA . PRO A 1 39 . 39 PRO A CA 71.912 64.206 48.259 1 574 +ATOM C C . PRO A 1 39 . 39 PRO A C 71.592 64.474 46.794 1 575 +ATOM O O . PRO A 1 39 . 39 PRO A O 70.482 64.176 46.354 1 576 +ATOM C CB . PRO A 1 39 . 39 PRO A CB 71.185 65.157 49.207 1 577 +ATOM C CG . PRO A 1 39 . 39 PRO A CG 72.055 65.095 50.45 1 578 +ATOM C CD . PRO A 1 39 . 39 PRO A CD 73.434 65.193 49.858 1 579 +ATOM H H01 . PRO A 1 39 . 39 PRO A H01 70.145 64.886 49.387 1 580 +ATOM H H02 . PRO A 1 39 . 39 PRO A H02 71.145 66.168 48.801 1 581 +ATOM H H03 . PRO A 1 39 . 39 PRO A H03 71.896 64.205 51.059 1 582 +ATOM H H04 . PRO A 1 39 . 39 PRO A H04 71.85 65.928 51.122 1 583 +ATOM H H05 . PRO A 1 39 . 39 PRO A H05 71.594 63.166 48.33 1 584 +ATOM H H06 . PRO A 1 39 . 39 PRO A H06 73.745 66.213 49.631 1 585 +ATOM H H07 . PRO A 1 39 . 39 PRO A H07 74.19 64.819 50.548 1 586 +ATOM N N . SER A 1 40 . 40 SER A N 72.536 65.039 46.042 1 587 +ATOM C CA . SER A 1 40 . 40 SER A CA 72.282 65.261 44.621 1 588 +ATOM C C . SER A 1 40 . 40 SER A C 72.312 63.882 43.964 1 589 +ATOM O O . SER A 1 40 . 40 SER A O 71.891 63.719 42.811 1 590 +ATOM C CB . SER A 1 40 . 40 SER A CB 73.349 66.171 43.991 1 591 +ATOM O OG . SER A 1 40 . 40 SER A OG 74.597 65.514 43.836 1 592 +ATOM H H01 . SER A 1 40 . 40 SER A H01 71.324 65.76 44.477 1 593 +ATOM H H02 . SER A 1 40 . 40 SER A H02 73.5 67.018 44.66 1 594 +ATOM H H03 . SER A 1 40 . 40 SER A H03 72.999 66.495 43.011 1 595 +ATOM H H04 . SER A 1 40 . 40 SER A H04 74.868 65.554 42.916 1 596 +ATOM H H08 . SER A 1 40 . 40 SER A H08 73.419 65.311 46.451 1 597 +ATOM N N . LYS A 1 41 . 41 LYS A N 72.803 62.886 44.704 1 598 +ATOM C CA . LYS A 1 41 . 41 LYS A CA 72.873 61.52 44.192 1 599 +ATOM C C . LYS A 1 41 . 41 LYS A C 71.688 60.659 44.654 1 600 +ATOM O O . LYS A 1 41 . 41 LYS A O 71.71 59.433 44.521 1 601 +ATOM C CB . LYS A 1 41 . 41 LYS A CB 74.185 60.856 44.607 1 602 +ATOM C CG . LYS A 1 41 . 41 LYS A CG 75.414 61.439 43.935 1 603 +ATOM C CD . LYS A 1 41 . 41 LYS A CD 76.666 60.741 44.452 1 604 +ATOM C CE . LYS A 1 41 . 41 LYS A CE 77.904 61.184 43.695 1 605 +ATOM N NZ . LYS A 1 41 . 41 LYS A NZ 79.106 60.455 44.202 1 606 +ATOM H H01 . LYS A 1 41 . 41 LYS A H01 72.827 61.59 43.105 1 607 +ATOM H H02 . LYS A 1 41 . 41 LYS A H02 74.122 59.813 44.295 1 608 +ATOM H H03 . LYS A 1 41 . 41 LYS A H03 74.299 60.966 45.685 1 609 +ATOM H H04 . LYS A 1 41 . 41 LYS A H04 75.477 62.503 44.162 1 610 +ATOM H H05 . LYS A 1 41 . 41 LYS A H05 73.133 63.081 45.638 1 611 +ATOM H H06 . LYS A 1 41 . 41 LYS A H06 75.339 61.297 42.857 1 612 +ATOM H H07 . LYS A 1 41 . 41 LYS A H07 76.543 59.668 44.306 1 613 +ATOM H H08 . LYS A 1 41 . 41 LYS A H08 76.793 60.977 45.509 1 614 +ATOM H H09 . LYS A 1 41 . 41 LYS A H09 78.048 62.254 43.843 1 615 +ATOM H H10 . LYS A 1 41 . 41 LYS A H10 77.774 60.971 42.634 1 616 +ATOM H H11 . LYS A 1 41 . 41 LYS A H11 78.873 59.976 45.06 1 617 +ATOM H H12 . LYS A 1 41 . 41 LYS A H12 79.409 59.783 43.512 1 618 +ATOM N N . THR A 1 42 . 42 THR A N 70.67 61.306 45.208 1 619 +ATOM C CA . THR A 1 42 . 42 THR A CA 69.458 60.619 45.641 1 620 +ATOM C C . THR A 1 42 . 42 THR A C 69.034 59.71 44.485 1 621 +ATOM O O . THR A 1 42 . 42 THR A O 68.997 60.148 43.339 1 622 +ATOM C CB . THR A 1 42 . 42 THR A CB 68.34 61.643 45.942 1 623 +ATOM C CG2 . THR A 1 42 . 42 THR A CG2 66.972 60.967 46.026 1 624 +ATOM O OG1 . THR A 1 42 . 42 THR A OG1 68.629 62.304 47.184 1 625 +ATOM H H01 . THR A 1 42 . 42 THR A H01 69.638 60.046 46.551 1 626 +ATOM H H02 . THR A 1 42 . 42 THR A H02 68.307 62.367 45.128 1 627 +ATOM H H03 . THR A 1 42 . 42 THR A H03 66.247 61.542 45.45 1 628 +ATOM H H04 . THR A 1 42 . 42 THR A H04 67.039 59.958 45.62 1 629 +ATOM H H05 . THR A 1 42 . 42 THR A H05 66.653 60.92 47.067 1 630 +ATOM H H06 . THR A 1 42 . 42 THR A H06 68.892 61.654 47.84 1 631 +ATOM H H13 . THR A 1 42 . 42 THR A H13 70.737 62.306 45.334 1 632 +ATOM N N . LEU A 1 43 . 43 LEU A N 68.738 58.448 44.79 1 633 +ATOM C CA . LEU A 1 43 . 43 LEU A CA 68.347 57.487 43.756 1 634 +ATOM C C . LEU A 1 43 . 43 LEU A C 66.965 57.763 43.196 1 635 +ATOM O O . LEU A 1 43 . 43 LEU A O 65.988 57.839 43.941 1 636 +ATOM C CB . LEU A 1 43 . 43 LEU A CB 68.38 56.063 44.313 1 637 +ATOM C CG . LEU A 1 43 . 43 LEU A CG 69.762 55.484 44.611 1 638 +ATOM C CD1 . LEU A 1 43 . 43 LEU A CD1 70.425 56.291 45.703 1 639 +ATOM C CD2 . LEU A 1 43 . 43 LEU A CD2 69.629 54.023 45.044 1 640 +ATOM H H01 . LEU A 1 43 . 43 LEU A H01 69.068 57.595 42.945 1 641 +ATOM H H02 . LEU A 1 43 . 43 LEU A H02 67.966 55.434 43.525 1 642 +ATOM H H03 . LEU A 1 43 . 43 LEU A H03 67.809 56.056 45.241 1 643 +ATOM H H04 . LEU A 1 43 . 43 LEU A H04 70.376 55.531 43.711 1 644 +ATOM H H05 . LEU A 1 43 . 43 LEU A H05 71.332 56.754 45.313 1 645 +ATOM H H06 . LEU A 1 43 . 43 LEU A H06 69.742 57.067 46.048 1 646 +ATOM H H07 . LEU A 1 43 . 43 LEU A H07 68.784 58.148 45.753 1 647 +ATOM H H08 . LEU A 1 43 . 43 LEU A H08 70.682 55.636 46.535 1 648 +ATOM H H09 . LEU A 1 43 . 43 LEU A H09 68.642 53.651 44.769 1 649 +ATOM H H10 . LEU A 1 43 . 43 LEU A H10 70.393 53.425 44.547 1 650 +ATOM H H11 . LEU A 1 43 . 43 LEU A H11 69.756 53.951 46.124 1 651 +ATOM N N . MET A 1 44 . 44 MET A N 66.89 57.915 41.878 1 652 +ATOM C CA . MET A 1 44 . 44 MET A CA 65.61 58.161 41.223 1 653 +ATOM C C . MET A 1 44 . 44 MET A C 65.248 56.872 40.477 1 654 +ATOM O O . MET A 1 44 . 44 MET A O 65.989 56.432 39.594 1 655 +ATOM C CB . MET A 1 44 . 44 MET A CB 65.74 59.333 40.249 1 656 +ATOM C CG . MET A 1 44 . 44 MET A CG 66.313 60.602 40.885 1 657 +ATOM S SD . MET A 1 44 . 44 MET A SD 65.337 61.168 42.283 1 658 +ATOM C CE . MET A 1 44 . 44 MET A CE 66.336 62.629 42.781 1 659 +ATOM H H01 . MET A 1 44 . 44 MET A H01 64.834 58.421 41.943 1 660 +ATOM H H02 . MET A 1 44 . 44 MET A H02 64.734 59.575 39.907 1 661 +ATOM H H03 . MET A 1 44 . 44 MET A H03 66.395 59.031 39.432 1 662 +ATOM H H04 . MET A 1 44 . 44 MET A H04 66.298 61.388 40.13 1 663 +ATOM H H05 . MET A 1 44 . 44 MET A H05 67.328 60.396 41.226 1 664 +ATOM H H06 . MET A 1 44 . 44 MET A H06 67.264 62.646 42.21 1 665 +ATOM H H07 . MET A 1 44 . 44 MET A H07 65.772 63.54 42.583 1 666 +ATOM H H08 . MET A 1 44 . 44 MET A H08 66.566 62.567 43.845 1 667 +ATOM H H12 . MET A 1 44 . 44 MET A H12 67.731 57.859 41.322 1 668 +ATOM N N . GLY A 1 45 . 45 GLY A N 64.123 56.266 40.859 1 669 +ATOM C CA . GLY A 1 45 . 45 GLY A CA 63.727 55.018 40.234 1 670 +ATOM C C . GLY A 1 45 . 45 GLY A C 62.289 54.968 39.757 1 671 +ATOM O O . GLY A 1 45 . 45 GLY A O 61.588 55.981 39.72 1 672 +ATOM H H01 . GLY A 1 45 . 45 GLY A H01 63.82 54.25 41.002 1 673 +ATOM H H02 . GLY A 1 45 . 45 GLY A H02 64.375 54.849 39.374 1 674 +ATOM H H09 . GLY A 1 45 . 45 GLY A H09 63.548 56.675 41.582 1 675 +ATOM N N . VAL A 1 46 . 46 VAL A N 61.849 53.762 39.415 1 676 +ATOM C CA . VAL A 1 46 . 46 VAL A CA 60.507 53.549 38.897 1 677 +ATOM C C . VAL A 1 46 . 46 VAL A C 59.864 52.353 39.584 1 678 +ATOM O O . VAL A 1 46 . 46 VAL A O 60.53 51.335 39.843 1 679 +ATOM C CB . VAL A 1 46 . 46 VAL A CB 60.564 53.34 37.344 1 680 +ATOM C CG1 . VAL A 1 46 . 46 VAL A CG1 61.4 52.106 36.995 1 681 +ATOM C CG2 . VAL A 1 46 . 46 VAL A CG2 59.156 53.224 36.765 1 682 +ATOM H H01 . VAL A 1 46 . 46 VAL A H01 59.897 54.428 39.105 1 683 +ATOM H H02 . VAL A 1 46 . 46 VAL A H02 61.044 54.211 36.898 1 684 +ATOM H H03 . VAL A 1 46 . 46 VAL A H03 62.466 52.969 39.518 1 685 +ATOM H H04 . VAL A 1 46 . 46 VAL A H04 62.356 52.156 37.516 1 686 +ATOM H H05 . VAL A 1 46 . 46 VAL A H05 60.866 51.206 37.301 1 687 +ATOM H H06 . VAL A 1 46 . 46 VAL A H06 61.575 52.077 35.919 1 688 +ATOM H H07 . VAL A 1 46 . 46 VAL A H07 58.506 53.959 37.241 1 689 +ATOM H H08 . VAL A 1 46 . 46 VAL A H08 59.188 53.409 35.691 1 690 +ATOM H H09 . VAL A 1 46 . 46 VAL A H09 58.768 52.223 36.95 1 691 +ATOM N N . ALA A 1 47 . 47 ALA A N 58.578 52.493 39.907 1 692 +ATOM C CA . ALA A 1 47 . 47 ALA A CA 57.821 51.426 40.567 1 693 +ATOM C C . ALA A 1 47 . 47 ALA A C 57.261 50.55 39.452 1 694 +ATOM O O . ALA A 1 47 . 47 ALA A O 56.741 51.057 38.463 1 695 +ATOM C CB . ALA A 1 47 . 47 ALA A CB 56.704 52.02 41.411 1 696 +ATOM H H01 . ALA A 1 47 . 47 ALA A H01 58.449 50.842 41.24 1 697 +ATOM H H02 . ALA A 1 47 . 47 ALA A H02 55.806 51.412 41.304 1 698 +ATOM H H03 . ALA A 1 47 . 47 ALA A H03 57.008 52.036 42.458 1 699 +ATOM H H04 . ALA A 1 47 . 47 ALA A H04 56.496 53.036 41.076 1 700 +ATOM H H10 . ALA A 1 47 . 47 ALA A H10 58.111 53.362 39.69 1 701 +ATOM N N . HIS A 1 48 . 48 HIS A N 57.373 49.239 39.635 1 702 +ATOM C CA . HIS A 1 48 . 48 HIS A CA 56.99 48.248 38.622 1 703 +ATOM C C . HIS A 1 48 . 48 HIS A C 55.981 47.239 39.188 1 704 +ATOM O O . HIS A 1 48 . 48 HIS A O 56.319 46.37 40.009 1 705 +ATOM C CB . HIS A 1 48 . 48 HIS A CB 58.317 47.609 38.153 1 706 +ATOM C CG . HIS A 1 48 . 48 HIS A CG 58.198 46.513 37.136 1 707 +ATOM C CD2 . HIS A 1 48 . 48 HIS A CD2 59.028 45.476 36.867 1 708 +ATOM N ND1 . HIS A 1 48 . 48 HIS A ND1 57.231 46.491 36.15 1 709 +ATOM C CE1 . HIS A 1 48 . 48 HIS A CE1 57.476 45.493 35.318 1 710 +ATOM N NE2 . HIS A 1 48 . 48 HIS A NE2 58.562 44.86 35.729 1 711 +ATOM H H01 . HIS A 1 48 . 48 HIS A H01 56.471 48.693 37.773 1 712 +ATOM H H02 . HIS A 1 48 . 48 HIS A H02 58.746 47.141 39.039 1 713 +ATOM H H03 . HIS A 1 48 . 48 HIS A H03 58.934 48.398 37.723 1 714 +ATOM H H04 . HIS A 1 48 . 48 HIS A H04 59.904 45.184 37.446 1 715 +ATOM H H05 . HIS A 1 48 . 48 HIS A H05 57.74 48.907 40.516 1 716 +ATOM H H06 . HIS A 1 48 . 48 HIS A H06 56.883 45.235 34.441 1 717 +ATOM H H07 . HIS A 1 48 . 48 HIS A H07 58.98 44.057 35.281 1 718 +ATOM N N . GLY A 1 49 . 49 GLY A N 54.725 47.384 38.751 1 719 +ATOM C CA . GLY A 1 49 . 49 GLY A CA 53.647 46.52 39.205 1 720 +ATOM C C . GLY A 1 49 . 49 GLY A C 53.008 45.712 38.081 1 721 +ATOM O O . GLY A 1 49 . 49 GLY A O 52.156 44.867 38.338 1 722 +ATOM H H01 . GLY A 1 49 . 49 GLY A H01 52.872 47.163 39.621 1 723 +ATOM H H02 . GLY A 1 49 . 49 GLY A H02 54.046 45.828 39.946 1 724 +ATOM H H08 . GLY A 1 49 . 49 GLY A H08 54.519 48.116 38.086 1 725 +ATOM N N . ILE A 1 50 . 50 ILE A N 53.403 45.978 36.839 1 726 +ATOM C CA . ILE A 1 50 . 50 ILE A CA 52.868 45.229 35.693 1 727 +ATOM C C . ILE A 1 50 . 50 ILE A C 53.688 43.947 35.596 1 728 +ATOM O O . ILE A 1 50 . 50 ILE A O 54.9 44.002 35.372 1 729 +ATOM C CB . ILE A 1 50 . 50 ILE A CB 53.026 46.022 34.381 1 730 +ATOM C CG1 . ILE A 1 50 . 50 ILE A CG1 52.181 47.301 34.443 1 731 +ATOM C CG2 . ILE A 1 50 . 50 ILE A CG2 52.587 45.16 33.199 1 732 +ATOM C CD1 . ILE A 1 50 . 50 ILE A CD1 52.452 48.283 33.297 1 733 +ATOM H H01 . ILE A 1 50 . 50 ILE A H01 51.805 45.035 35.836 1 734 +ATOM H H02 . ILE A 1 50 . 50 ILE A H02 54.073 46.295 34.25 1 735 +ATOM H H03 . ILE A 1 50 . 50 ILE A H03 54.083 46.708 36.681 1 736 +ATOM H H04 . ILE A 1 50 . 50 ILE A H04 52.451 47.813 35.367 1 737 +ATOM H H05 . ILE A 1 50 . 50 ILE A H05 51.129 47.019 34.412 1 738 +ATOM H H06 . ILE A 1 50 . 50 ILE A H06 51.708 45.604 32.733 1 739 +ATOM H H07 . ILE A 1 50 . 50 ILE A H07 53.395 45.104 32.47 1 740 +ATOM H H08 . ILE A 1 50 . 50 ILE A H08 52.344 44.157 33.55 1 741 +ATOM H H09 . ILE A 1 50 . 50 ILE A H09 53.482 48.171 32.959 1 742 +ATOM H H10 . ILE A 1 50 . 50 ILE A H10 51.774 48.073 32.47 1 743 +ATOM H H11 . ILE A 1 50 . 50 ILE A H11 52.294 49.303 33.647 1 744 +ATOM N N . PRO A 1 51 . 51 PRO A N 53.038 42.774 35.742 1 745 +ATOM C CA . PRO A 1 51 . 51 PRO A CA 53.735 41.486 35.685 1 746 +ATOM C C . PRO A 1 51 . 51 PRO A C 54.12 41.06 34.283 1 747 +ATOM O O . PRO A 1 51 . 51 PRO A O 53.538 40.134 33.712 1 748 +ATOM C CB . PRO A 1 51 . 51 PRO A CB 52.735 40.539 36.343 1 749 +ATOM C CG . PRO A 1 51 . 51 PRO A CG 51.425 41.066 35.822 1 750 +ATOM C CD . PRO A 1 51 . 51 PRO A CD 51.586 42.58 35.946 1 751 +ATOM H H01 . PRO A 1 51 . 51 PRO A H01 52.8 40.514 37.431 1 752 +ATOM H H02 . PRO A 1 51 . 51 PRO A H02 52.901 39.507 36.033 1 753 +ATOM H H03 . PRO A 1 51 . 51 PRO A H03 50.562 40.681 36.366 1 754 +ATOM H H04 . PRO A 1 51 . 51 PRO A H04 51.269 40.772 34.784 1 755 +ATOM H H05 . PRO A 1 51 . 51 PRO A H05 50.973 43.138 35.238 1 756 +ATOM H H06 . PRO A 1 51 . 51 PRO A H06 51.277 42.929 36.931 1 757 +ATOM H H12 . PRO A 1 51 . 51 PRO A H12 54.702 41.511 36.187 1 758 +ATOM N N . SER A 1 52 . 52 SER A N 55.117 41.747 33.734 1 759 +ATOM C CA . SER A 1 52 . 52 SER A CA 55.583 41.462 32.384 1 760 +ATOM C C . SER A 1 52 . 52 SER A C 57.072 41.767 32.311 1 761 +ATOM O O . SER A 1 52 . 52 SER A O 57.486 42.904 32.567 1 762 +ATOM C CB . SER A 1 52 . 52 SER A CB 54.833 42.353 31.382 1 763 +ATOM O OG . SER A 1 52 . 52 SER A OG 55.496 42.351 30.134 1 764 +ATOM H H01 . SER A 1 52 . 52 SER A H01 55.4 40.415 32.141 1 765 +ATOM H H02 . SER A 1 52 . 52 SER A H02 54.803 43.372 31.767 1 766 +ATOM H H03 . SER A 1 52 . 52 SER A H03 53.819 41.975 31.252 1 767 +ATOM H H04 . SER A 1 52 . 52 SER A H04 55.551 43.249 29.799 1 768 +ATOM H H07 . SER A 1 52 . 52 SER A H07 55.561 42.483 34.265 1 769 +ATOM N N . THR A 1 53 . 53 THR A N 57.877 40.762 31.977 1 770 +ATOM C CA . THR A 1 53 . 53 THR A CA 59.31 41.004 31.872 1 771 +ATOM C C . THR A 1 53 . 53 THR A C 59.594 41.922 30.687 1 772 +ATOM O O . THR A 1 53 . 53 THR A O 60.605 42.615 30.681 1 773 +ATOM C CB . THR A 1 53 . 53 THR A CB 60.11 39.702 31.723 1 774 +ATOM C CG2 . THR A 1 53 . 53 THR A CG2 59.894 38.809 32.941 1 775 +ATOM O OG1 . THR A 1 53 . 53 THR A OG1 59.681 39.014 30.547 1 776 +ATOM H H01 . THR A 1 53 . 53 THR A H01 59.629 41.481 32.799 1 777 +ATOM H H02 . THR A 1 53 . 53 THR A H02 61.17 39.943 31.644 1 778 +ATOM H H03 . THR A 1 53 . 53 THR A H03 58.857 38.475 32.966 1 779 +ATOM H H04 . THR A 1 53 . 53 THR A H04 60.553 37.943 32.879 1 780 +ATOM H H05 . THR A 1 53 . 53 THR A H05 57.499 39.842 31.798 1 781 +ATOM H H06 . THR A 1 53 . 53 THR A H06 60.116 39.371 33.848 1 782 +ATOM H H07 . THR A 1 53 . 53 THR A H07 59.459 38.107 30.769 1 783 +ATOM N N . PHE A 1 54 . 54 PHE A N 58.709 41.943 29.688 1 784 +ATOM C CA . PHE A 1 54 . 54 PHE A CA 58.915 42.834 28.541 1 785 +ATOM C C . PHE A 1 54 . 54 PHE A C 58.868 44.267 29.078 1 786 +ATOM O O . PHE A 1 54 . 54 PHE A O 59.683 45.11 28.707 1 787 +ATOM C CB . PHE A 1 54 . 54 PHE A CB 57.825 42.606 27.475 1 788 +ATOM C CG . PHE A 1 54 . 54 PHE A CG 58.068 43.338 26.189 1 789 +ATOM C CD1 . PHE A 1 54 . 54 PHE A CD1 57.819 44.699 26.086 1 790 +ATOM C CD2 . PHE A 1 54 . 54 PHE A CD2 58.573 42.657 25.073 1 791 +ATOM C CE1 . PHE A 1 54 . 54 PHE A CE1 58.072 45.385 24.883 1 792 +ATOM C CE2 . PHE A 1 54 . 54 PHE A CE2 58.828 43.318 23.885 1 793 +ATOM C CZ . PHE A 1 54 . 54 PHE A CZ 58.581 44.686 23.78 1 794 +ATOM H H01 . PHE A 1 54 . 54 PHE A H01 59.873 42.637 28.06 1 795 +ATOM H H02 . PHE A 1 54 . 54 PHE A H02 56.894 42.994 27.888 1 796 +ATOM H H03 . PHE A 1 54 . 54 PHE A H03 57.774 41.539 27.259 1 797 +ATOM H H04 . PHE A 1 54 . 54 PHE A H04 57.424 45.242 26.944 1 798 +ATOM H H05 . PHE A 1 54 . 54 PHE A H05 58.768 41.587 25.142 1 799 +ATOM H H06 . PHE A 1 54 . 54 PHE A H06 57.873 46.454 24.81 1 800 +ATOM H H07 . PHE A 1 54 . 54 PHE A H07 59.222 42.769 23.03 1 801 +ATOM H H08 . PHE A 1 54 . 54 PHE A H08 57.896 41.344 29.722 1 802 +ATOM H H09 . PHE A 1 54 . 54 PHE A H09 58.783 45.209 22.845 1 803 +ATOM N N . VAL A 1 55 . 55 VAL A N 57.915 44.543 29.966 1 804 +ATOM C CA . VAL A 1 55 . 55 VAL A CA 57.83 45.872 30.569 1 805 +ATOM C C . VAL A 1 55 . 55 VAL A C 59.101 46.16 31.379 1 806 +ATOM O O . VAL A 1 55 . 55 VAL A O 59.637 47.264 31.327 1 807 +ATOM C CB . VAL A 1 55 . 55 VAL A CB 56.589 45.987 31.481 1 808 +ATOM C CG1 . VAL A 1 55 . 55 VAL A CG1 56.623 47.31 32.271 1 809 +ATOM C CG2 . VAL A 1 55 . 55 VAL A CG2 55.328 45.954 30.617 1 810 +ATOM H H01 . VAL A 1 55 . 55 VAL A H01 57.736 46.606 29.768 1 811 +ATOM H H02 . VAL A 1 55 . 55 VAL A H02 56.588 45.154 32.184 1 812 +ATOM H H03 . VAL A 1 55 . 55 VAL A H03 56.801 48.138 31.585 1 813 +ATOM H H04 . VAL A 1 55 . 55 VAL A H04 55.669 47.457 32.777 1 814 +ATOM H H05 . VAL A 1 55 . 55 VAL A H05 57.424 47.273 33.009 1 815 +ATOM H H06 . VAL A 1 55 . 55 VAL A H06 54.559 46.577 31.074 1 816 +ATOM H H07 . VAL A 1 55 . 55 VAL A H07 55.559 46.334 29.622 1 817 +ATOM H H08 . VAL A 1 55 . 55 VAL A H08 54.966 44.929 30.54 1 818 +ATOM H H10 . VAL A 1 55 . 55 VAL A H10 57.247 43.83 30.224 1 819 +ATOM N N . THR A 1 56 . 56 THR A N 59.594 45.174 32.126 1 820 +ATOM C CA . THR A 1 56 . 56 THR A CA 60.808 45.404 32.902 1 821 +ATOM C C . THR A 1 56 . 56 THR A C 61.928 45.84 31.963 1 822 +ATOM O O . THR A 1 56 . 56 THR A O 62.724 46.719 32.285 1 823 +ATOM C CB . THR A 1 56 . 56 THR A CB 61.3 44.129 33.611 1 824 +ATOM C CG2 . THR A 1 56 . 56 THR A CG2 62.232 44.505 34.762 1 825 +ATOM O OG1 . THR A 1 56 . 56 THR A OG1 60.178 43.401 34.124 1 826 +ATOM H H01 . THR A 1 56 . 56 THR A H01 60.567 46.163 33.647 1 827 +ATOM H H02 . THR A 1 56 . 56 THR A H02 61.841 43.508 32.897 1 828 +ATOM H H03 . THR A 1 56 . 56 THR A H03 63.115 45.006 34.365 1 829 +ATOM H H04 . THR A 1 56 . 56 THR A H04 61.712 45.175 35.447 1 830 +ATOM H H05 . THR A 1 56 . 56 THR A H05 62.535 43.603 35.294 1 831 +ATOM H H06 . THR A 1 56 . 56 THR A H06 59.958 43.726 35.0 1 832 +ATOM H H09 . THR A 1 56 . 56 THR A H09 59.132 44.276 32.155 1 833 +ATOM N N . LYS A 1 57 . 57 LYS A N 62.006 45.192 30.808 1 834 +ATOM C CA . LYS A 1 57 . 57 LYS A CA 63.044 45.54 29.841 1 835 +ATOM C C . LYS A 1 57 . 57 LYS A C 62.902 46.977 29.327 1 836 +ATOM O O . LYS A 1 57 . 57 LYS A O 63.898 47.679 29.137 1 837 +ATOM C CB . LYS A 1 57 . 57 LYS A CB 63.003 44.555 28.674 1 838 +ATOM C CG . LYS A 1 57 . 57 LYS A CG 63.452 43.148 29.065 1 839 +ATOM C CD . LYS A 1 57 . 57 LYS A CD 63.255 42.172 27.898 1 840 +ATOM C CE . LYS A 1 57 . 57 LYS A CE 63.792 40.787 28.241 1 841 +ATOM N NZ . LYS A 1 57 . 57 LYS A NZ 63.553 39.834 27.103 1 842 +ATOM H H01 . LYS A 1 57 . 57 LYS A H01 64.007 45.477 30.348 1 843 +ATOM H H02 . LYS A 1 57 . 57 LYS A H02 63.696 44.919 27.915 1 844 +ATOM H H03 . LYS A 1 57 . 57 LYS A H03 61.981 44.503 28.298 1 845 +ATOM H H04 . LYS A 1 57 . 57 LYS A H04 62.851 42.81 29.909 1 846 +ATOM H H05 . LYS A 1 57 . 57 LYS A H05 64.507 43.171 29.339 1 847 +ATOM H H06 . LYS A 1 57 . 57 LYS A H06 63.803 42.551 27.035 1 848 +ATOM H H07 . LYS A 1 57 . 57 LYS A H07 61.346 44.457 30.597 1 849 +ATOM H H08 . LYS A 1 57 . 57 LYS A H08 62.191 42.096 27.673 1 850 +ATOM H H09 . LYS A 1 57 . 57 LYS A H09 63.274 40.417 29.126 1 851 +ATOM H H10 . LYS A 1 57 . 57 LYS A H10 64.862 40.854 28.435 1 852 +ATOM H H11 . LYS A 1 57 . 57 LYS A H11 64.399 39.738 26.56 1 853 +ATOM H H12 . LYS A 1 57 . 57 LYS A H12 63.283 38.932 27.469 1 854 +ATOM N N . VAL A 1 58 . 58 VAL A N 61.669 47.406 29.078 1 855 +ATOM C CA . VAL A 1 58 . 58 VAL A CA 61.433 48.775 28.606 1 856 +ATOM C C . VAL A 1 58 . 58 VAL A C 61.843 49.749 29.715 1 857 +ATOM O O . VAL A 1 58 . 58 VAL A O 62.497 50.755 29.465 1 858 +ATOM C CB . VAL A 1 58 . 58 VAL A CB 59.95 48.992 28.26 1 859 +ATOM C CG1 . VAL A 1 58 . 58 VAL A CG1 59.689 50.467 27.927 1 860 +ATOM C CG2 . VAL A 1 58 . 58 VAL A CG2 59.567 48.103 27.068 1 861 +ATOM H H01 . VAL A 1 58 . 58 VAL A H01 62.021 48.947 27.705 1 862 +ATOM H H02 . VAL A 1 58 . 58 VAL A H02 59.339 48.722 29.121 1 863 +ATOM H H03 . VAL A 1 58 . 58 VAL A H03 58.787 50.8 28.44 1 864 +ATOM H H04 . VAL A 1 58 . 58 VAL A H04 60.536 51.069 28.255 1 865 +ATOM H H05 . VAL A 1 58 . 58 VAL A H05 59.557 50.579 26.851 1 866 +ATOM H H06 . VAL A 1 58 . 58 VAL A H06 58.704 48.532 26.558 1 867 +ATOM H H07 . VAL A 1 58 . 58 VAL A H07 60.406 48.043 26.374 1 868 +ATOM H H08 . VAL A 1 58 . 58 VAL A H08 59.318 47.104 27.425 1 869 +ATOM H H13 . VAL A 1 58 . 58 VAL A H13 60.886 46.783 29.215 1 870 +ATOM N N . LEU A 1 59 . 59 LEU A N 61.463 49.439 30.952 1 871 +ATOM C CA . LEU A 1 59 . 59 LEU A CA 61.828 50.299 32.08 1 872 +ATOM C C . LEU A 1 59 . 59 LEU A C 63.352 50.324 32.273 1 873 +ATOM O O . LEU A 1 59 . 59 LEU A O 63.944 51.385 32.499 1 874 +ATOM C CB . LEU A 1 59 . 59 LEU A CB 61.168 49.791 33.367 1 875 +ATOM C CG . LEU A 1 59 . 59 LEU A CG 59.638 49.783 33.378 1 876 +ATOM C CD1 . LEU A 1 59 . 59 LEU A CD1 59.155 49.231 34.722 1 877 +ATOM C CD2 . LEU A 1 59 . 59 LEU A CD2 59.111 51.206 33.157 1 878 +ATOM H H01 . LEU A 1 59 . 59 LEU A H01 61.479 51.308 31.862 1 879 +ATOM H H02 . LEU A 1 59 . 59 LEU A H02 61.464 50.494 34.146 1 880 +ATOM H H03 . LEU A 1 59 . 59 LEU A H03 61.509 48.77 33.537 1 881 +ATOM H H04 . LEU A 1 59 . 59 LEU A H04 59.261 49.15 32.575 1 882 +ATOM H H05 . LEU A 1 59 . 59 LEU A H05 58.132 48.869 34.619 1 883 +ATOM H H06 . LEU A 1 59 . 59 LEU A H06 59.801 48.41 35.031 1 884 +ATOM H H07 . LEU A 1 59 . 59 LEU A H07 59.186 50.021 35.472 1 885 +ATOM H H08 . LEU A 1 59 . 59 LEU A H08 58.162 51.327 33.68 1 886 +ATOM H H09 . LEU A 1 59 . 59 LEU A H09 60.919 48.604 31.114 1 887 +ATOM H H10 . LEU A 1 59 . 59 LEU A H10 59.833 51.926 33.544 1 888 +ATOM H H11 . LEU A 1 59 . 59 LEU A H11 58.962 51.377 32.091 1 889 +ATOM N N . ALA A 1 60 . 60 ALA A N 63.987 49.16 32.193 1 890 +ATOM C CA . ALA A 1 60 . 60 ALA A CA 65.434 49.092 32.388 1 891 +ATOM C C . ALA A 1 60 . 60 ALA A C 66.193 49.907 31.337 1 892 +ATOM O O . ALA A 1 60 . 60 ALA A O 67.231 50.498 31.637 1 893 +ATOM C CB . ALA A 1 60 . 60 ALA A CB 65.911 47.634 32.359 1 894 +ATOM H H01 . ALA A 1 60 . 60 ALA A H01 65.648 49.525 33.365 1 895 +ATOM H H02 . ALA A 1 60 . 60 ALA A H02 66.872 47.556 32.868 1 896 +ATOM H H03 . ALA A 1 60 . 60 ALA A H03 65.181 47.002 32.864 1 897 +ATOM H H04 . ALA A 1 60 . 60 ALA A H04 66.021 47.308 31.325 1 898 +ATOM H H12 . ALA A 1 60 . 60 ALA A H12 63.467 48.317 31.996 1 899 +ATOM N N . ALA A 1 61 . 61 ALA A N 65.666 49.953 30.119 1 900 +ATOM C CA . ALA A 1 61 . 61 ALA A CA 66.335 50.694 29.046 1 901 +ATOM C C . ALA A 1 61 . 61 ALA A C 66.348 52.208 29.277 1 902 +ATOM O O . ALA A 1 61 . 61 ALA A O 67.136 52.917 28.644 1 903 +ATOM C CB . ALA A 1 61 . 61 ALA A CB 65.694 50.361 27.685 1 904 +ATOM H H01 . ALA A 1 61 . 61 ALA A H01 67.376 50.372 29.046 1 905 +ATOM H H02 . ALA A 1 61 . 61 ALA A H02 64.798 50.967 27.548 1 906 +ATOM H H03 . ALA A 1 61 . 61 ALA A H03 66.404 50.576 26.886 1 907 +ATOM H H04 . ALA A 1 61 . 61 ALA A H04 65.425 49.305 27.658 1 908 +ATOM H H05 . ALA A 1 61 . 61 ALA A H05 64.797 49.474 29.931 1 909 +ATOM N N . THR A 1 62 . 62 THR A N 65.49 52.706 30.168 1 910 +ATOM C CA . THR A 1 62 . 62 THR A CA 65.468 54.145 30.465 1 911 +ATOM C C . THR A 1 62 . 62 THR A C 66.52 54.479 31.525 1 912 +ATOM O O . THR A 1 62 . 62 THR A O 66.671 55.636 31.919 1 913 +ATOM C CB . THR A 1 62 . 62 THR A CB 64.092 54.64 30.976 1 914 +ATOM C CG2 . THR A 1 62 . 62 THR A CG2 62.975 54.247 29.981 1 915 +ATOM O OG1 . THR A 1 62 . 62 THR A OG1 63.821 54.084 32.269 1 916 +ATOM H H01 . THR A 1 62 . 62 THR A H01 65.681 54.651 29.524 1 917 +ATOM H H02 . THR A 1 62 . 62 THR A H02 64.118 55.727 31.056 1 918 +ATOM H H03 . THR A 1 62 . 62 THR A H03 62.132 54.928 30.095 1 919 +ATOM H H04 . THR A 1 62 . 62 THR A H04 63.357 54.31 28.962 1 920 +ATOM H H05 . THR A 1 62 . 62 THR A H05 64.849 52.088 30.644 1 921 +ATOM H H06 . THR A 1 62 . 62 THR A H06 62.648 53.227 30.185 1 922 +ATOM H H07 . THR A 1 62 . 62 THR A H07 63.97 54.752 32.943 1 923 +ATOM N N . LYS A 1 63 . 63 LYS A N 67.211 53.442 31.995 1 924 +ATOM C CA . LYS A 1 63 . 63 LYS A CA 68.285 53.576 32.972 1 925 +ATOM C C . LYS A 1 63 . 63 LYS A C 67.965 54.244 34.309 1 926 +ATOM O O . LYS A 1 63 . 63 LYS A O 68.642 55.183 34.728 1 927 +ATOM C CB . LYS A 1 63 . 63 LYS A CB 69.487 54.262 32.298 1 928 +ATOM C CG . LYS A 1 63 . 63 LYS A CG 70.09 53.388 31.181 1 929 +ATOM C CD . LYS A 1 63 . 63 LYS A CD 71.324 53.99 30.508 1 930 +ATOM C CE . LYS A 1 63 . 63 LYS A CE 70.959 54.893 29.341 1 931 +ATOM N NZ . LYS A 1 63 . 63 LYS A NZ 70.247 56.135 29.771 1 932 +ATOM H H01 . LYS A 1 63 . 63 LYS A H01 68.499 52.551 33.274 1 933 +ATOM H H02 . LYS A 1 63 . 63 LYS A H02 70.255 54.422 33.055 1 934 +ATOM H H03 . LYS A 1 63 . 63 LYS A H03 69.159 55.209 31.869 1 935 +ATOM H H04 . LYS A 1 63 . 63 LYS A H04 69.326 53.298 30.409 1 936 +ATOM H H05 . LYS A 1 63 . 63 LYS A H05 70.37 52.427 31.612 1 937 +ATOM H H06 . LYS A 1 63 . 63 LYS A H06 71.927 53.169 30.12 1 938 +ATOM H H07 . LYS A 1 63 . 63 LYS A H07 71.877 54.573 31.244 1 939 +ATOM H H08 . LYS A 1 63 . 63 LYS A H08 66.98 52.518 31.658 1 940 +ATOM H H09 . LYS A 1 63 . 63 LYS A H09 70.283 54.335 28.693 1 941 +ATOM H H10 . LYS A 1 63 . 63 LYS A H10 71.873 55.177 28.82 1 942 +ATOM H H11 . LYS A 1 63 . 63 LYS A H11 70.793 56.61 30.476 1 943 +ATOM H H12 . LYS A 1 63 . 63 LYS A H12 70.121 56.744 28.975 1 944 +ATOM N N . PRO A 1 64 . 64 PRO A N 66.931 53.751 35.014 1 945 +ATOM C CA . PRO A 1 64 . 64 PRO A CA 66.584 54.333 36.312 1 946 +ATOM C C . PRO A 1 64 . 64 PRO A C 67.651 53.878 37.311 1 947 +ATOM O O . PRO A 1 64 . 64 PRO A O 68.355 52.894 37.062 1 948 +ATOM C CB . PRO A 1 64 . 64 PRO A CB 65.233 53.698 36.617 1 949 +ATOM C CG . PRO A 1 64 . 64 PRO A CG 65.421 52.314 36.072 1 950 +ATOM C CD . PRO A 1 64 . 64 PRO A CD 66.053 52.61 34.701 1 951 +ATOM H H01 . PRO A 1 64 . 64 PRO A H01 64.385 54.232 36.188 1 952 +ATOM H H02 . PRO A 1 64 . 64 PRO A H02 65.033 53.681 37.688 1 953 +ATOM H H03 . PRO A 1 64 . 64 PRO A H03 64.504 51.727 36.022 1 954 +ATOM H H04 . PRO A 1 64 . 64 PRO A H04 66.089 51.725 36.701 1 955 +ATOM H H05 . PRO A 1 64 . 64 PRO A H05 66.575 51.759 34.263 1 956 +ATOM H H06 . PRO A 1 64 . 64 PRO A H06 65.296 52.881 33.964 1 957 +ATOM H H13 . PRO A 1 64 . 64 PRO A H13 66.538 55.422 36.345 1 958 +ATOM N N . ASP A 1 65 . 65 ASP A N 67.782 54.592 38.426 1 959 +ATOM C CA . ASP A 1 65 . 65 ASP A CA 68.759 54.201 39.435 1 960 +ATOM C C . ASP A 1 65 . 65 ASP A C 68.308 52.9 40.098 1 961 +ATOM O O . ASP A 1 65 . 65 ASP A O 69.112 52.18 40.675 1 962 +ATOM C CB . ASP A 1 65 . 65 ASP A CB 68.926 55.308 40.484 1 963 +ATOM C CG . ASP A 1 65 . 65 ASP A CG 69.534 56.576 39.902 1 964 +ATOM O OD1 . ASP A 1 65 . 65 ASP A OD1 70.322 56.462 38.934 1 965 +ATOM O OD2 . ASP A 1 65 . 65 ASP A OD2 69.237 57.677 40.421 1 966 +ATOM H H01 . ASP A 1 65 . 65 ASP A H01 69.725 54.045 38.955 1 967 +ATOM H H02 . ASP A 1 65 . 65 ASP A H02 69.606 54.937 41.251 1 968 +ATOM H H03 . ASP A 1 65 . 65 ASP A H03 67.947 55.548 40.899 1 969 +ATOM H H04 . ASP A 1 65 . 65 ASP A H04 69.246 58.361 39.748 1 970 +ATOM H H07 . ASP A 1 65 . 65 ASP A H07 67.203 55.407 38.574 1 971 +ATOM N N . PHE A 1 66 . 66 PHE A N 67.009 52.618 40.032 1 972 +ATOM C CA . PHE A 1 66 . 66 PHE A CA 66.479 51.369 40.562 1 973 +ATOM C C . PHE A 1 66 . 66 PHE A C 65.109 51.063 39.981 1 974 +ATOM O O . PHE A 1 66 . 66 PHE A O 64.414 51.959 39.508 1 975 +ATOM C CB . PHE A 1 66 . 66 PHE A CB 66.368 51.396 42.106 1 976 +ATOM C CG . PHE A 1 66 . 66 PHE A CG 65.332 52.364 42.65 1 977 +ATOM C CD1 . PHE A 1 66 . 66 PHE A CD1 63.982 52.016 42.714 1 978 +ATOM C CD2 . PHE A 1 66 . 66 PHE A CD2 65.719 53.616 43.12 1 979 +ATOM C CE1 . PHE A 1 66 . 66 PHE A CE1 63.033 52.899 43.244 1 980 +ATOM C CE2 . PHE A 1 66 . 66 PHE A CE2 64.782 54.514 43.655 1 981 +ATOM C CZ . PHE A 1 66 . 66 PHE A CZ 63.439 54.157 43.72 1 982 +ATOM H H01 . PHE A 1 66 . 66 PHE A H01 67.184 50.59 40.272 1 983 +ATOM H H02 . PHE A 1 66 . 66 PHE A H02 67.334 51.737 42.478 1 984 +ATOM H H03 . PHE A 1 66 . 66 PHE A H03 66.109 50.393 42.446 1 985 +ATOM H H04 . PHE A 1 66 . 66 PHE A H04 63.661 51.042 42.346 1 986 +ATOM H H05 . PHE A 1 66 . 66 PHE A H05 66.379 53.283 39.606 1 987 +ATOM H H06 . PHE A 1 66 . 66 PHE A H06 66.769 53.904 43.072 1 988 +ATOM H H07 . PHE A 1 66 . 66 PHE A H07 61.983 52.611 43.287 1 989 +ATOM H H08 . PHE A 1 66 . 66 PHE A H08 65.106 55.489 44.019 1 990 +ATOM H H09 . PHE A 1 66 . 66 PHE A H09 62.707 54.848 44.137 1 991 +ATOM N N . VAL A 1 67 . 67 VAL A N 64.744 49.784 40.002 1 992 +ATOM C CA . VAL A 1 67 . 67 VAL A CA 63.415 49.353 39.577 1 993 +ATOM C C . VAL A 1 67 . 67 VAL A C 62.886 48.742 40.868 1 994 +ATOM O O . VAL A 1 67 . 67 VAL A O 63.529 47.872 41.456 1 995 +ATOM C CB . VAL A 1 67 . 67 VAL A CB 63.458 48.263 38.481 1 996 +ATOM C CG1 . VAL A 1 67 . 67 VAL A CG1 62.026 47.781 38.184 1 997 +ATOM C CG2 . VAL A 1 67 . 67 VAL A CG2 64.088 48.834 37.205 1 998 +ATOM H H01 . VAL A 1 67 . 67 VAL A H01 62.825 50.163 39.148 1 999 +ATOM H H02 . VAL A 1 67 . 67 VAL A H02 64.059 47.423 38.829 1 1000 +ATOM H H03 . VAL A 1 67 . 67 VAL A H03 61.375 48.644 38.042 1 1001 +ATOM H H04 . VAL A 1 67 . 67 VAL A H04 62.027 47.174 37.278 1 1002 +ATOM H H05 . VAL A 1 67 . 67 VAL A H05 61.662 47.185 39.021 1 1003 +ATOM H H06 . VAL A 1 67 . 67 VAL A H06 65.101 48.446 37.095 1 1004 +ATOM H H07 . VAL A 1 67 . 67 VAL A H07 63.491 48.54 36.342 1 1005 +ATOM H H08 . VAL A 1 67 . 67 VAL A H08 64.122 49.921 37.271 1 1006 +ATOM H H10 . VAL A 1 67 . 67 VAL A H10 65.405 49.091 40.321 1 1007 +ATOM N N . TRP A 1 68 . 68 TRP A N 61.741 49.237 41.321 1 1008 +ATOM C CA . TRP A 1 68 . 68 TRP A CA 61.101 48.796 42.553 1 1009 +ATOM C C . TRP A 1 68 . 68 TRP A C 59.979 47.846 42.143 1 1010 +ATOM O O . TRP A 1 68 . 68 TRP A O 58.951 48.28 41.632 1 1011 +ATOM C CB . TRP A 1 68 . 68 TRP A CB 60.54 50.038 43.251 1 1012 +ATOM C CG . TRP A 1 68 . 68 TRP A CG 60.07 49.882 44.645 1 1013 +ATOM C CD1 . TRP A 1 68 . 68 TRP A CD1 60.809 50.046 45.785 1 1014 +ATOM C CD2 . TRP A 1 68 . 68 TRP A CD2 58.728 49.626 45.068 1 1015 +ATOM C CE2 . TRP A 1 68 . 68 TRP A CE2 58.721 49.651 46.479 1 1016 +ATOM C CE3 . TRP A 1 68 . 68 TRP A CE3 57.528 49.383 44.389 1 1017 +ATOM N NE1 . TRP A 1 68 . 68 TRP A NE1 60.002 49.913 46.893 1 1018 +ATOM C CZ2 . TRP A 1 68 . 68 TRP A CZ2 57.554 49.438 47.229 1 1019 +ATOM C CZ3 . TRP A 1 68 . 68 TRP A CZ3 56.363 49.172 45.137 1 1020 +ATOM C CH2 . TRP A 1 68 . 68 TRP A CH2 56.39 49.2 46.539 1 1021 +ATOM H H01 . TRP A 1 68 . 68 TRP A H01 61.785 48.288 43.233 1 1022 +ATOM H H02 . TRP A 1 68 . 68 TRP A H02 59.646 50.296 42.683 1 1023 +ATOM H H03 . TRP A 1 68 . 68 TRP A H03 61.324 50.795 43.253 1 1024 +ATOM H H04 . TRP A 1 68 . 68 TRP A H04 61.879 50.252 45.812 1 1025 +ATOM H H05 . TRP A 1 68 . 68 TRP A H05 57.501 49.359 43.3 1 1026 +ATOM H H06 . TRP A 1 68 . 68 TRP A H06 60.304 49.995 47.853 1 1027 +ATOM H H07 . TRP A 1 68 . 68 TRP A H07 57.571 49.461 48.319 1 1028 +ATOM H H08 . TRP A 1 68 . 68 TRP A H08 55.421 48.983 44.622 1 1029 +ATOM H H09 . TRP A 1 68 . 68 TRP A H09 61.288 49.96 40.781 1 1030 +ATOM H H10 . TRP A 1 68 . 68 TRP A H10 55.467 49.029 47.093 1 1031 +ATOM N N . ILE A 1 69 . 69 ILE A N 60.179 46.551 42.369 1 1032 +ATOM C CA . ILE A 1 69 . 69 ILE A CA 59.179 45.558 41.982 1 1033 +ATOM C C . ILE A 1 69 . 69 ILE A C 58.295 45.207 43.169 1 1034 +ATOM O O . ILE A 1 69 . 69 ILE A O 58.79 44.825 44.226 1 1035 +ATOM C CB . ILE A 1 69 . 69 ILE A CB 59.862 44.288 41.443 1 1036 +ATOM C CG1 . ILE A 1 69 . 69 ILE A CG1 60.802 44.668 40.293 1 1037 +ATOM C CG2 . ILE A 1 69 . 69 ILE A CG2 58.808 43.265 40.993 1 1038 +ATOM C CD1 . ILE A 1 69 . 69 ILE A CD1 61.589 43.493 39.716 1 1039 +ATOM H H01 . ILE A 1 69 . 69 ILE A H01 58.559 45.985 41.194 1 1040 +ATOM H H02 . ILE A 1 69 . 69 ILE A H02 60.451 43.824 42.234 1 1041 +ATOM H H03 . ILE A 1 69 . 69 ILE A H03 61.533 45.365 40.703 1 1042 +ATOM H H04 . ILE A 1 69 . 69 ILE A H04 60.205 45.107 39.493 1 1043 +ATOM H H05 . ILE A 1 69 . 69 ILE A H05 57.926 43.79 40.627 1 1044 +ATOM H H06 . ILE A 1 69 . 69 ILE A H06 59.219 42.646 40.195 1 1045 +ATOM H H07 . ILE A 1 69 . 69 ILE A H07 58.53 42.634 41.837 1 1046 +ATOM H H08 . ILE A 1 69 . 69 ILE A H08 62.559 43.429 40.209 1 1047 +ATOM H H09 . ILE A 1 69 . 69 ILE A H09 61.036 42.568 39.882 1 1048 +ATOM H H10 . ILE A 1 69 . 69 ILE A H10 61.735 43.643 38.646 1 1049 +ATOM H H11 . ILE A 1 69 . 69 ILE A H11 61.034 46.249 42.814 1 1050 +ATOM N N . ASP A 1 70 . 70 ASP A N 56.983 45.336 42.989 1 1051 +ATOM C CA . ASP A 1 70 . 70 ASP A CA 56.045 45.049 44.064 1 1052 +ATOM C C . ASP A 1 70 . 70 ASP A C 55.473 43.641 43.961 1 1053 +ATOM O O . ASP A 1 70 . 70 ASP A O 54.644 43.352 43.094 1 1054 +ATOM C CB . ASP A 1 70 . 70 ASP A CB 54.914 46.082 44.052 1 1055 +ATOM C CG . ASP A 1 70 . 70 ASP A CG 53.93 45.883 45.187 1 1056 +ATOM O OD1 . ASP A 1 70 . 70 ASP A OD1 54.235 45.115 46.118 1 1057 +ATOM O OD2 . ASP A 1 70 . 70 ASP A OD2 52.857 46.517 45.152 1 1058 +ATOM H H01 . ASP A 1 70 . 70 ASP A H01 56.589 45.11 45.007 1 1059 +ATOM H H02 . ASP A 1 70 . 70 ASP A H02 54.367 45.961 43.117 1 1060 +ATOM H H03 . ASP A 1 70 . 70 ASP A H03 55.349 47.078 44.137 1 1061 +ATOM H H04 . ASP A 1 70 . 70 ASP A H04 52.578 46.625 44.24 1 1062 +ATOM H H11 . ASP A 1 70 . 70 ASP A H11 56.632 45.638 42.091 1 1063 +ATOM N N . VAL A 1 71 . 71 VAL A N 55.92 42.762 44.85 1 1064 +ATOM C CA . VAL A 1 71 . 71 VAL A CA 55.434 41.401 44.854 1 1065 +ATOM C C . VAL A 1 71 . 71 VAL A C 54.328 41.267 45.89 1 1066 +ATOM O O . VAL A 1 71 . 71 VAL A O 53.596 40.284 45.888 1 1067 +ATOM C CB . VAL A 1 71 . 71 VAL A CB 56.581 40.398 45.147 1 1068 +ATOM C CG1 . VAL A 1 71 . 71 VAL A CG1 56.066 38.982 45.123 1 1069 +ATOM C CG2 . VAL A 1 71 . 71 VAL A CG2 57.663 40.558 44.112 1 1070 +ATOM H H01 . VAL A 1 71 . 71 VAL A H01 55.037 41.165 43.867 1 1071 +ATOM H H02 . VAL A 1 71 . 71 VAL A H02 56.984 40.606 46.138 1 1072 +ATOM H H03 . VAL A 1 71 . 71 VAL A H03 55.106 38.954 44.607 1 1073 +ATOM H H04 . VAL A 1 71 . 71 VAL A H04 56.778 38.344 44.599 1 1074 +ATOM H H05 . VAL A 1 71 . 71 VAL A H05 56.608 43.048 45.532 1 1075 +ATOM H H06 . VAL A 1 71 . 71 VAL A H06 55.939 38.624 46.145 1 1076 +ATOM H H07 . VAL A 1 71 . 71 VAL A H07 58.575 40.076 44.464 1 1077 +ATOM H H08 . VAL A 1 71 . 71 VAL A H08 57.345 40.094 43.178 1 1078 +ATOM H H09 . VAL A 1 71 . 71 VAL A H09 57.853 41.618 43.945 1 1079 +ATOM N N . GLU A 1 72 . 72 GLU A N 54.192 42.245 46.785 1 1080 +ATOM C CA . GLU A 1 72 . 72 GLU A CA 53.106 42.126 47.753 1 1081 +ATOM C C . GLU A 1 72 . 72 GLU A C 51.788 42.204 47.0 1 1082 +ATOM O O . GLU A 1 72 . 72 GLU A O 50.875 41.413 47.251 1 1083 +ATOM C CB . GLU A 1 72 . 72 GLU A CB 53.121 43.228 48.817 1 1084 +ATOM C CG . GLU A 1 72 . 72 GLU A CG 51.916 43.078 49.775 1 1085 +ATOM C CD . GLU A 1 72 . 72 GLU A CD 51.778 44.201 50.802 1 1086 +ATOM O OE1 . GLU A 1 72 . 72 GLU A OE1 52.247 45.332 50.543 1 1087 +ATOM O OE2 . GLU A 1 72 . 72 GLU A OE2 51.169 43.942 51.862 1 1088 +ATOM H H01 . GLU A 1 72 . 72 GLU A H01 53.232 41.175 48.271 1 1089 +ATOM H H02 . GLU A 1 72 . 72 GLU A H02 53.056 44.196 48.32 1 1090 +ATOM H H03 . GLU A 1 72 . 72 GLU A H03 54.046 43.161 49.39 1 1091 +ATOM H H04 . GLU A 1 72 . 72 GLU A H04 52.082 42.161 50.34 1 1092 +ATOM H H05 . GLU A 1 72 . 72 GLU A H05 51.006 43.05 49.176 1 1093 +ATOM H H06 . GLU A 1 72 . 72 GLU A H06 51.101 44.737 52.395 1 1094 +ATOM H H10 . GLU A 1 72 . 72 GLU A H10 54.817 43.039 46.793 1 1095 +ATOM N N . HIS A 1 73 . 73 HIS A N 51.705 43.149 46.066 1 1096 +ATOM C CA . HIS A 1 73 . 73 HIS A CA 50.476 43.341 45.3 1 1097 +ATOM C C . HIS A 1 73 . 73 HIS A C 50.604 43.176 43.79 1 1098 +ATOM O O . HIS A 1 73 . 73 HIS A O 49.596 43.242 43.078 1 1099 +ATOM C CB . HIS A 1 73 . 73 HIS A CB 49.888 44.711 45.62 1 1100 +ATOM C CG . HIS A 1 73 . 73 HIS A CG 49.373 44.824 47.022 1 1101 +ATOM C CD2 . HIS A 1 73 . 73 HIS A CD2 49.77 45.596 48.059 1 1102 +ATOM N ND1 . HIS A 1 73 . 73 HIS A ND1 48.307 44.083 47.48 1 1103 +ATOM C CE1 . HIS A 1 73 . 73 HIS A CE1 48.068 44.397 48.742 1 1104 +ATOM N NE2 . HIS A 1 73 . 73 HIS A NE2 48.941 45.312 49.117 1 1105 +ATOM H H01 . HIS A 1 73 . 73 HIS A H01 49.815 42.534 45.614 1 1106 +ATOM H H02 . HIS A 1 73 . 73 HIS A H02 49.039 44.861 44.952 1 1107 +ATOM H H03 . HIS A 1 73 . 73 HIS A H03 50.663 45.463 45.474 1 1108 +ATOM H H04 . HIS A 1 73 . 73 HIS A H04 50.594 46.31 48.056 1 1109 +ATOM H H05 . HIS A 1 73 . 73 HIS A H05 47.283 43.972 49.367 1 1110 +ATOM H H06 . HIS A 1 73 . 73 HIS A H06 48.992 45.735 50.033 1 1111 +ATOM H H07 . HIS A 1 73 . 73 HIS A H07 52.503 43.741 45.885 1 1112 +ATOM N N . GLY A 1 74 . 74 GLY A N 51.827 43.0 43.3 1 1113 +ATOM C CA . GLY A 1 74 . 74 GLY A CA 52.041 42.79 41.874 1 1114 +ATOM C C . GLY A 1 74 . 74 GLY A C 51.846 41.313 41.583 1 1115 +ATOM O O . GLY A 1 74 . 74 GLY A O 52.329 40.449 42.315 1 1116 +ATOM H H01 . GLY A 1 74 . 74 GLY A H01 53.047 43.102 41.592 1 1117 +ATOM H H02 . GLY A 1 74 . 74 GLY A H02 51.324 43.378 41.301 1 1118 +ATOM H H07 . GLY A 1 74 . 74 GLY A H07 52.62 43.012 43.925 1 1119 +ATOM N N . MET A 1 75 . 75 MET A N 51.15 41.009 40.495 1 1120 +ATOM C CA . MET A 1 75 . 75 MET A CA 50.844 39.63 40.144 1 1121 +ATOM C C . MET A 1 75 . 75 MET A C 51.977 38.841 39.481 1 1122 +ATOM O O . MET A 1 75 . 75 MET A O 51.769 38.164 38.48 1 1123 +ATOM C CB . MET A 1 75 . 75 MET A CB 49.604 39.613 39.245 1 1124 +ATOM C CG . MET A 1 75 . 75 MET A CG 48.363 40.227 39.872 1 1125 +ATOM S SD . MET A 1 75 . 75 MET A SD 47.816 39.298 41.336 1 1126 +ATOM C CE . MET A 1 75 . 75 MET A CE 48.336 40.398 42.622 1 1127 +ATOM H H01 . MET A 1 75 . 75 MET A H01 50.673 39.118 41.091 1 1128 +ATOM H H02 . MET A 1 75 . 75 MET A H02 49.371 38.565 39.059 1 1129 +ATOM H H03 . MET A 1 75 . 75 MET A H03 50.824 41.753 39.895 1 1130 +ATOM H H04 . MET A 1 75 . 75 MET A H04 49.835 40.166 38.334 1 1131 +ATOM H H05 . MET A 1 75 . 75 MET A H05 47.561 40.198 39.134 1 1132 +ATOM H H06 . MET A 1 75 . 75 MET A H06 48.586 41.252 40.167 1 1133 +ATOM H H07 . MET A 1 75 . 75 MET A H07 49.217 40.949 42.294 1 1134 +ATOM H H08 . MET A 1 75 . 75 MET A H08 47.533 41.1 42.848 1 1135 +ATOM H H09 . MET A 1 75 . 75 MET A H09 48.579 39.824 43.516 1 1136 +ATOM N N . PHE A 1 76 . 76 PHE A N 53.176 38.911 40.046 1 1137 +ATOM C CA . PHE A 1 76 . 76 PHE A CA 54.301 38.181 39.482 1 1138 +ATOM C C . PHE A 1 76 . 76 PHE A C 54.337 36.725 39.946 1 1139 +ATOM O O . PHE A 1 76 . 76 PHE A O 54.269 36.461 41.148 1 1140 +ATOM C CB . PHE A 1 76 . 76 PHE A CB 55.641 38.804 39.913 1 1141 +ATOM C CG . PHE A 1 76 . 76 PHE A CG 55.87 40.199 39.414 1 1142 +ATOM C CD1 . PHE A 1 76 . 76 PHE A CD1 55.354 41.298 40.098 1 1143 +ATOM C CD2 . PHE A 1 76 . 76 PHE A CD2 56.614 40.412 38.257 1 1144 +ATOM C CE1 . PHE A 1 76 . 76 PHE A CE1 55.577 42.6 39.632 1 1145 +ATOM C CE2 . PHE A 1 76 . 76 PHE A CE2 56.845 41.698 37.78 1 1146 +ATOM C CZ . PHE A 1 76 . 76 PHE A CZ 56.326 42.799 38.465 1 1147 +ATOM H H01 . PHE A 1 76 . 76 PHE A H01 54.167 38.232 38.402 1 1148 +ATOM H H02 . PHE A 1 76 . 76 PHE A H02 56.424 38.186 39.473 1 1149 +ATOM H H03 . PHE A 1 76 . 76 PHE A H03 55.673 38.822 41.002 1 1150 +ATOM H H04 . PHE A 1 76 . 76 PHE A H04 54.771 41.143 41.006 1 1151 +ATOM H H05 . PHE A 1 76 . 76 PHE A H05 57.022 39.558 37.716 1 1152 +ATOM H H06 . PHE A 1 76 . 76 PHE A H06 55.17 43.453 40.174 1 1153 +ATOM H H07 . PHE A 1 76 . 76 PHE A H07 57.43 41.846 36.873 1 1154 +ATOM H H08 . PHE A 1 76 . 76 PHE A H08 56.503 43.808 38.093 1 1155 +ATOM H H10 . PHE A 1 76 . 76 PHE A H10 53.308 39.475 40.873 1 1156 +ATOM N N . ASN A 1 77 . 77 ASN A N 54.441 35.793 39.0 1 1157 +ATOM C CA . ASN A 1 77 . 77 ASN A CA 54.609 34.382 39.351 1 1158 +ATOM C C . ASN A 1 77 . 77 ASN A C 56.074 34.269 39.775 1 1159 +ATOM O O . ASN A 1 77 . 77 ASN A O 56.869 35.179 39.539 1 1160 +ATOM C CB . ASN A 1 77 . 77 ASN A CB 54.387 33.46 38.151 1 1161 +ATOM C CG . ASN A 1 77 . 77 ASN A CG 52.932 33.348 37.758 1 1162 +ATOM N ND2 . ASN A 1 77 . 77 ASN A ND2 52.684 32.999 36.492 1 1163 +ATOM O OD1 . ASN A 1 77 . 77 ASN A OD1 52.04 33.547 38.583 1 1164 +ATOM H H01 . ASN A 1 77 . 77 ASN A H01 53.893 34.087 40.118 1 1165 +ATOM H H02 . ASN A 1 77 . 77 ASN A H02 54.727 32.464 38.436 1 1166 +ATOM H H03 . ASN A 1 77 . 77 ASN A H03 54.948 33.851 37.302 1 1167 +ATOM H H04 . ASN A 1 77 . 77 ASN A H04 51.731 32.899 36.173 1 1168 +ATOM H H05 . ASN A 1 77 . 77 ASN A H05 53.45 32.836 35.854 1 1169 +ATOM H H09 . ASN A 1 77 . 77 ASN A H09 54.403 36.064 38.028 1 1170 +ATOM N N . ARG A 1 78 . 78 ARG A N 56.44 33.137 40.358 1 1171 +ATOM C CA . ARG A 1 78 . 78 ARG A CA 57.797 32.942 40.824 1 1172 +ATOM C C . ARG A 1 78 . 78 ARG A C 58.868 33.077 39.743 1 1173 +ATOM O O . ARG A 1 78 . 78 ARG A O 59.806 33.861 39.89 1 1174 +ATOM C CB . ARG A 1 78 . 78 ARG A CB 57.922 31.576 41.491 1 1175 +ATOM C CG . ARG A 1 78 . 78 ARG A CG 59.278 31.384 42.174 1 1176 +ATOM C CD . ARG A 1 78 . 78 ARG A CD 59.353 30.039 42.865 1 1177 +ATOM N NE . ARG A 1 78 . 78 ARG A NE 60.585 29.911 43.641 1 1178 +ATOM C CZ . ARG A 1 78 . 78 ARG A CZ 60.89 28.841 44.369 1 1179 +ATOM N NH1 . ARG A 1 78 . 78 ARG A NH1 60.054 27.811 44.405 1 1180 +ATOM N NH2 . ARG A 1 78 . 78 ARG A NH2 62.012 28.817 45.088 1 1181 +ATOM H H01 . ARG A 1 78 . 78 ARG A H01 57.982 33.748 41.534 1 1182 +ATOM H H02 . ARG A 1 78 . 78 ARG A H02 57.83 30.817 40.714 1 1183 +ATOM H H03 . ARG A 1 78 . 78 ARG A H03 57.135 31.477 42.239 1 1184 +ATOM H H04 . ARG A 1 78 . 78 ARG A H04 59.403 32.166 42.923 1 1185 +ATOM H H05 . ARG A 1 78 . 78 ARG A H05 60.067 31.445 41.424 1 1186 +ATOM H H06 . ARG A 1 78 . 78 ARG A H06 55.762 32.398 40.479 1 1187 +ATOM H H07 . ARG A 1 78 . 78 ARG A H07 59.343 29.259 42.104 1 1188 +ATOM H H08 . ARG A 1 78 . 78 ARG A H08 58.498 29.934 43.533 1 1189 +ATOM H H09 . ARG A 1 78 . 78 ARG A H09 61.241 30.678 43.623 1 1190 +ATOM H H10 . ARG A 1 78 . 78 ARG A H10 60.274 27.001 44.967 1 1191 +ATOM H H11 . ARG A 1 78 . 78 ARG A H11 59.198 27.837 43.87 1 1192 +ATOM H H12 . ARG A 1 78 . 78 ARG A H12 62.234 28.008 45.651 1 1193 +ATOM N N . LEU A 1 79 . 79 LEU A N 58.733 32.314 38.663 1 1194 +ATOM C CA . LEU A 1 79 . 79 LEU A CA 59.731 32.363 37.606 1 1195 +ATOM C C . LEU A 1 79 . 79 LEU A C 59.757 33.735 36.947 1 1196 +ATOM O O . LEU A 1 79 . 79 LEU A O 60.83 34.25 36.623 1 1197 +ATOM C CB . LEU A 1 79 . 79 LEU A CB 59.465 31.266 36.566 1 1198 +ATOM C CG . LEU A 1 79 . 79 LEU A CG 60.459 31.217 35.392 1 1199 +ATOM C CD1 . LEU A 1 79 . 79 LEU A CD1 61.88 31.122 35.911 1 1200 +ATOM C CD2 . LEU A 1 79 . 79 LEU A CD2 60.13 30.012 34.506 1 1201 +ATOM H H01 . LEU A 1 79 . 79 LEU A H01 60.71 32.185 38.052 1 1202 +ATOM H H02 . LEU A 1 79 . 79 LEU A H02 58.493 31.496 36.128 1 1203 +ATOM H H03 . LEU A 1 79 . 79 LEU A H03 59.497 30.303 37.076 1 1204 +ATOM H H04 . LEU A 1 79 . 79 LEU A H04 60.373 32.131 34.804 1 1205 +ATOM H H05 . LEU A 1 79 . 79 LEU A H05 62.501 31.862 35.406 1 1206 +ATOM H H06 . LEU A 1 79 . 79 LEU A H06 61.889 31.312 36.984 1 1207 +ATOM H H07 . LEU A 1 79 . 79 LEU A H07 62.273 30.124 35.715 1 1208 +ATOM H H08 . LEU A 1 79 . 79 LEU A H08 59.258 30.24 33.893 1 1209 +ATOM H H09 . LEU A 1 79 . 79 LEU A H09 60.98 29.793 33.86 1 1210 +ATOM H H10 . LEU A 1 79 . 79 LEU A H10 59.917 29.146 35.133 1 1211 +ATOM H H13 . LEU A 1 79 . 79 LEU A H13 57.936 31.699 38.576 1 1212 +ATOM N N . GLU A 1 80 . 80 GLU A N 58.582 34.327 36.759 1 1213 +ATOM C CA . GLU A 1 80 . 80 GLU A CA 58.494 35.648 36.142 1 1214 +ATOM C C . GLU A 1 80 . 80 GLU A C 59.146 36.735 37.009 1 1215 +ATOM O O . GLU A 1 80 . 80 GLU A O 59.721 37.683 36.478 1 1216 +ATOM C CB . GLU A 1 80 . 80 GLU A CB 57.032 35.981 35.835 1 1217 +ATOM C CG . GLU A 1 80 . 80 GLU A CG 56.456 35.265 34.576 1 1218 +ATOM C CD . GLU A 1 80 . 80 GLU A CD 56.408 33.727 34.652 1 1219 +ATOM O OE1 . GLU A 1 80 . 80 GLU A OE1 56.306 33.153 35.753 1 1220 +ATOM O OE2 . GLU A 1 80 . 80 GLU A OE2 56.448 33.079 33.58 1 1221 +ATOM H H01 . GLU A 1 80 . 80 GLU A H01 59.053 35.623 35.207 1 1222 +ATOM H H02 . GLU A 1 80 . 80 GLU A H02 56.995 37.05 35.627 1 1223 +ATOM H H03 . GLU A 1 80 . 80 GLU A H03 56.429 35.694 36.696 1 1224 +ATOM H H04 . GLU A 1 80 . 80 GLU A H04 57.137 35.503 33.759 1 1225 +ATOM H H05 . GLU A 1 80 . 80 GLU A H05 55.44 35.626 34.419 1 1226 +ATOM H H06 . GLU A 1 80 . 80 GLU A H06 55.74 33.372 33.002 1 1227 +ATOM H H11 . GLU A 1 80 . 80 GLU A H11 57.736 33.856 37.047 1 1228 +ATOM N N . LEU A 1 81 . 81 LEU A N 59.056 36.596 38.334 1 1229 +ATOM C CA . LEU A 1 81 . 81 LEU A CA 59.695 37.542 39.259 1 1230 +ATOM C C . LEU A 1 81 . 81 LEU A C 61.211 37.424 39.062 1 1231 +ATOM O O . LEU A 1 81 . 81 LEU A O 61.932 38.419 38.946 1 1232 +ATOM C CB . LEU A 1 81 . 81 LEU A CB 59.35 37.197 40.712 1 1233 +ATOM C CG . LEU A 1 81 . 81 LEU A CG 60.151 37.955 41.784 1 1234 +ATOM C CD1 . LEU A 1 81 . 81 LEU A CD1 59.976 39.482 41.609 1 1235 +ATOM C CD2 . LEU A 1 81 . 81 LEU A CD2 59.659 37.524 43.167 1 1236 +ATOM H H01 . LEU A 1 81 . 81 LEU A H01 59.343 38.554 39.056 1 1237 +ATOM H H02 . LEU A 1 81 . 81 LEU A H02 59.607 36.145 40.837 1 1238 +ATOM H H03 . LEU A 1 81 . 81 LEU A H03 58.293 37.414 40.867 1 1239 +ATOM H H04 . LEU A 1 81 . 81 LEU A H04 61.21 37.719 41.68 1 1240 +ATOM H H05 . LEU A 1 81 . 81 LEU A H05 59.079 39.807 42.135 1 1241 +ATOM H H06 . LEU A 1 81 . 81 LEU A H06 60.844 39.997 42.02 1 1242 +ATOM H H07 . LEU A 1 81 . 81 LEU A H07 58.534 35.818 38.711 1 1243 +ATOM H H08 . LEU A 1 81 . 81 LEU A H08 59.88 39.718 40.549 1 1244 +ATOM H H09 . LEU A 1 81 . 81 LEU A H09 58.635 37.159 43.09 1 1245 +ATOM H H10 . LEU A 1 81 . 81 LEU A H10 60.3 36.73 43.55 1 1246 +ATOM H H11 . LEU A 1 81 . 81 LEU A H11 59.69 38.376 43.846 1 1247 +ATOM N N . HIS A 1 82 . 82 HIS A N 61.687 36.187 39.062 1 1248 +ATOM C CA . HIS A 1 82 . 82 HIS A CA 63.105 35.893 38.846 1 1249 +ATOM C C . HIS A 1 82 . 82 HIS A C 63.561 36.514 37.522 1 1250 +ATOM O O . HIS A 1 82 . 82 HIS A O 64.6 37.173 37.464 1 1251 +ATOM C CB . HIS A 1 82 . 82 HIS A CB 63.294 34.363 38.847 1 1252 +ATOM C CG . HIS A 1 82 . 82 HIS A CG 64.594 33.877 38.267 1 1253 +ATOM C CD2 . HIS A 1 82 . 82 HIS A CD2 65.757 33.508 38.862 1 1254 +ATOM N ND1 . HIS A 1 82 . 82 HIS A ND1 64.755 33.618 36.923 1 1255 +ATOM C CE1 . HIS A 1 82 . 82 HIS A CE1 65.956 33.105 36.713 1 1256 +ATOM N NE2 . HIS A 1 82 . 82 HIS A NE2 66.584 33.026 37.873 1 1257 +ATOM H H01 . HIS A 1 82 . 82 HIS A H01 63.714 36.322 39.642 1 1258 +ATOM H H02 . HIS A 1 82 . 82 HIS A H02 62.509 33.962 38.206 1 1259 +ATOM H H03 . HIS A 1 82 . 82 HIS A H03 63.234 34.016 39.879 1 1260 +ATOM H H04 . HIS A 1 82 . 82 HIS A H04 65.992 33.58 39.924 1 1261 +ATOM H H05 . HIS A 1 82 . 82 HIS A H05 66.359 32.799 35.747 1 1262 +ATOM H H06 . HIS A 1 82 . 82 HIS A H06 67.519 32.67 38.011 1 1263 +ATOM H H12 . HIS A 1 82 . 82 HIS A H12 61.049 35.42 39.217 1 1264 +ATOM N N . ASP A 1 83 . 83 ASP A N 62.776 36.327 36.466 1 1265 +ATOM C CA . ASP A 1 83 . 83 ASP A CA 63.154 36.867 35.166 1 1266 +ATOM C C . ASP A 1 83 . 83 ASP A C 63.105 38.386 35.122 1 1267 +ATOM O O . ASP A 1 83 . 83 ASP A O 63.914 39.007 34.428 1 1268 +ATOM C CB . ASP A 1 83 . 83 ASP A CB 62.253 36.299 34.055 1 1269 +ATOM C CG . ASP A 1 83 . 83 ASP A CG 62.53 34.82 33.762 1 1270 +ATOM O OD1 . ASP A 1 83 . 83 ASP A OD1 63.531 34.274 34.254 1 1271 +ATOM O OD2 . ASP A 1 83 . 83 ASP A OD2 61.745 34.206 33.011 1 1272 +ATOM H H01 . ASP A 1 83 . 83 ASP A H01 64.187 36.561 35.0 1 1273 +ATOM H H02 . ASP A 1 83 . 83 ASP A H02 62.465 36.859 33.144 1 1274 +ATOM H H03 . ASP A 1 83 . 83 ASP A H03 61.212 36.405 34.362 1 1275 +ATOM H H04 . ASP A 1 83 . 83 ASP A H04 61.49 33.377 33.423 1 1276 +ATOM H H07 . ASP A 1 83 . 83 ASP A H07 61.915 35.809 36.565 1 1277 +ATOM N N . ALA A 1 84 . 84 ALA A N 62.172 38.991 35.857 1 1278 +ATOM C CA . ALA A 1 84 . 84 ALA A CA 62.054 40.444 35.86 1 1279 +ATOM C C . ALA A 1 84 . 84 ALA A C 63.27 41.046 36.568 1 1280 +ATOM O O . ALA A 1 84 . 84 ALA A O 63.84 42.045 36.124 1 1281 +ATOM C CB . ALA A 1 84 . 84 ALA A CB 60.753 40.875 36.578 1 1282 +ATOM H H01 . ALA A 1 84 . 84 ALA A H01 62.016 40.805 34.832 1 1283 +ATOM H H02 . ALA A 1 84 . 84 ALA A H02 59.941 40.208 36.289 1 1284 +ATOM H H03 . ALA A 1 84 . 84 ALA A H03 60.898 40.823 37.657 1 1285 +ATOM H H04 . ALA A 1 84 . 84 ALA A H04 60.502 41.897 36.294 1 1286 +ATOM H H05 . ALA A 1 84 . 84 ALA A H05 61.54 38.437 36.418 1 1287 +ATOM N N . ILE A 1 85 . 85 ILE A N 63.688 40.421 37.66 1 1288 +ATOM C CA . ILE A 1 85 . 85 ILE A CA 64.84 40.938 38.394 1 1289 +ATOM C C . ILE A 1 85 . 85 ILE A C 66.086 40.844 37.502 1 1290 +ATOM O O . ILE A 1 85 . 85 ILE A O 66.856 41.807 37.394 1 1291 +ATOM C CB . ILE A 1 85 . 85 ILE A CB 65.03 40.173 39.715 1 1292 +ATOM C CG1 . ILE A 1 85 . 85 ILE A CG1 63.832 40.474 40.634 1 1293 +ATOM C CG2 . ILE A 1 85 . 85 ILE A CG2 66.343 40.616 40.403 1 1294 +ATOM C CD1 . ILE A 1 85 . 85 ILE A CD1 63.776 39.606 41.909 1 1295 +ATOM H H01 . ILE A 1 85 . 85 ILE A H01 64.672 41.984 38.652 1 1296 +ATOM H H02 . ILE A 1 85 . 85 ILE A H02 65.088 39.103 39.515 1 1297 +ATOM H H03 . ILE A 1 85 . 85 ILE A H03 62.934 40.245 40.061 1 1298 +ATOM H H04 . ILE A 1 85 . 85 ILE A H04 63.886 41.52 40.935 1 1299 +ATOM H H05 . ILE A 1 85 . 85 ILE A H05 63.212 39.591 37.983 1 1300 +ATOM H H06 . ILE A 1 85 . 85 ILE A H06 67.093 40.839 39.644 1 1301 +ATOM H H07 . ILE A 1 85 . 85 ILE A H07 66.157 41.507 41.002 1 1302 +ATOM H H08 . ILE A 1 85 . 85 ILE A H08 66.705 39.814 41.046 1 1303 +ATOM H H09 . ILE A 1 85 . 85 ILE A H09 62.786 39.684 42.358 1 1304 +ATOM H H10 . ILE A 1 85 . 85 ILE A H10 63.976 38.566 41.65 1 1305 +ATOM H H11 . ILE A 1 85 . 85 ILE A H11 64.525 39.955 42.62 1 1306 +ATOM N N . HIS A 1 86 . 86 HIS A N 66.271 39.698 36.851 1 1307 +ATOM C CA . HIS A 1 86 . 86 HIS A CA 67.401 39.524 35.935 1 1308 +ATOM C C . HIS A 1 86 . 86 HIS A C 67.309 40.573 34.824 1 1309 +ATOM O O . HIS A 1 86 . 86 HIS A O 68.292 41.216 34.489 1 1310 +ATOM C CB . HIS A 1 86 . 86 HIS A CB 67.376 38.157 35.253 1 1311 +ATOM C CG . HIS A 1 86 . 86 HIS A CG 67.978 37.052 36.063 1 1312 +ATOM C CD2 . HIS A 1 86 . 86 HIS A CD2 68.54 37.049 37.296 1 1313 +ATOM N ND1 . HIS A 1 86 . 86 HIS A ND1 68.06 35.759 35.597 1 1314 +ATOM C CE1 . HIS A 1 86 . 86 HIS A CE1 68.651 35.004 36.508 1 1315 +ATOM N NE2 . HIS A 1 86 . 86 HIS A NE2 68.953 35.764 37.548 1 1316 +ATOM H H01 . HIS A 1 86 . 86 HIS A H01 68.313 39.622 36.524 1 1317 +ATOM H H02 . HIS A 1 86 . 86 HIS A H02 67.982 38.248 34.352 1 1318 +ATOM H H03 . HIS A 1 86 . 86 HIS A H03 66.338 37.899 35.042 1 1319 +ATOM H H04 . HIS A 1 86 . 86 HIS A H04 68.645 37.905 37.962 1 1320 +ATOM H H05 . HIS A 1 86 . 86 HIS A H05 68.855 33.937 36.417 1 1321 +ATOM H H06 . HIS A 1 86 . 86 HIS A H06 69.413 35.45 38.391 1 1322 +ATOM H H12 . HIS A 1 86 . 86 HIS A H12 65.622 38.937 36.991 1 1323 +ATOM N N . ALA A 1 87 . 87 ALA A N 66.13 40.727 34.233 1 1324 +ATOM C CA . ALA A 1 87 . 87 ALA A CA 66.0 41.701 33.139 1 1325 +ATOM C C . ALA A 1 87 . 87 ALA A C 66.327 43.135 33.565 1 1326 +ATOM O O . ALA A 1 87 . 87 ALA A O 66.981 43.879 32.817 1 1327 +ATOM C CB . ALA A 1 87 . 87 ALA A CB 64.596 41.639 32.538 1 1328 +ATOM H H01 . ALA A 1 87 . 87 ALA A H01 66.738 41.422 32.387 1 1329 +ATOM H H02 . ALA A 1 87 . 87 ALA A H02 63.941 42.326 33.074 1 1330 +ATOM H H03 . ALA A 1 87 . 87 ALA A H03 64.637 41.924 31.487 1 1331 +ATOM H H04 . ALA A 1 87 . 87 ALA A H04 64.207 40.624 32.625 1 1332 +ATOM H H07 . ALA A 1 87 . 87 ALA A H07 65.333 40.182 34.531 1 1333 +ATOM N N . ALA A 1 88 . 88 ALA A N 65.876 43.539 34.749 1 1334 +ATOM C CA . ALA A 1 88 . 88 ALA A CA 66.166 44.9 35.218 1 1335 +ATOM C C . ALA A 1 88 . 88 ALA A C 67.668 45.159 35.275 1 1336 +ATOM O O . ALA A 1 88 . 88 ALA A O 68.153 46.2 34.824 1 1337 +ATOM C CB . ALA A 1 88 . 88 ALA A CB 65.556 45.131 36.607 1 1338 +ATOM H H01 . ALA A 1 88 . 88 ALA A H01 65.72 45.594 34.505 1 1339 +ATOM H H02 . ALA A 1 88 . 88 ALA A H02 64.538 44.741 36.626 1 1340 +ATOM H H03 . ALA A 1 88 . 88 ALA A H03 66.156 44.617 37.358 1 1341 +ATOM H H04 . ALA A 1 88 . 88 ALA A H04 65.539 46.199 36.824 1 1342 +ATOM H H05 . ALA A 1 88 . 88 ALA A H05 65.335 42.91 35.325 1 1343 +ATOM N N . GLN A 1 89 . 89 GLN A N 68.412 44.223 35.847 1 1344 +ATOM C CA . GLN A 1 89 . 89 GLN A CA 69.839 44.418 35.954 1 1345 +ATOM C C . GLN A 1 89 . 89 GLN A C 70.597 44.217 34.65 1 1346 +ATOM O O . GLN A 1 89 . 89 GLN A O 71.527 44.955 34.368 1 1347 +ATOM C CB . GLN A 1 89 . 89 GLN A CB 70.418 43.519 37.059 1 1348 +ATOM C CG . GLN A 1 89 . 89 GLN A CG 69.987 43.97 38.453 1 1349 +ATOM C CD . GLN A 1 89 . 89 GLN A CD 71.011 43.645 39.54 1 1350 +ATOM N NE2 . GLN A 1 89 . 89 GLN A NE2 71.26 42.363 39.751 1 1351 +ATOM O OE1 . GLN A 1 89 . 89 GLN A OE1 71.56 44.546 40.189 1 1352 +ATOM H H01 . GLN A 1 89 . 89 GLN A H01 69.979 45.467 36.216 1 1353 +ATOM H H02 . GLN A 1 89 . 89 GLN A H02 71.505 43.583 37.008 1 1354 +ATOM H H03 . GLN A 1 89 . 89 GLN A H03 70.074 42.497 36.898 1 1355 +ATOM H H04 . GLN A 1 89 . 89 GLN A H04 69.074 43.427 38.699 1 1356 +ATOM H H05 . GLN A 1 89 . 89 GLN A H05 67.985 43.381 36.205 1 1357 +ATOM H H06 . GLN A 1 89 . 89 GLN A H06 69.832 45.049 38.435 1 1358 +ATOM H H07 . GLN A 1 89 . 89 GLN A H07 71.924 42.088 40.46 1 1359 +ATOM H H08 . GLN A 1 89 . 89 GLN A H08 70.786 41.66 39.203 1 1360 +ATOM N N . HIS A 1 90 . 90 HIS A N 70.19 43.248 33.835 1 1361 +ATOM C CA . HIS A 1 90 . 90 HIS A CA 70.914 43.001 32.584 1 1362 +ATOM C C . HIS A 1 90 . 90 HIS A C 70.612 44.022 31.491 1 1363 +ATOM O O . HIS A 1 90 . 90 HIS A O 71.528 44.472 30.81 1 1364 +ATOM C CB . HIS A 1 90 . 90 HIS A CB 70.618 41.578 32.068 1 1365 +ATOM C CG . HIS A 1 90 . 90 HIS A CG 71.203 40.486 32.919 1 1366 +ATOM C CD2 . HIS A 1 90 . 90 HIS A CD2 70.783 39.943 34.09 1 1367 +ATOM N ND1 . HIS A 1 90 . 90 HIS A ND1 72.39 39.852 32.609 1 1368 +ATOM C CE1 . HIS A 1 90 . 90 HIS A CE1 72.675 38.97 33.554 1 1369 +ATOM N NE2 . HIS A 1 90 . 90 HIS A NE2 71.717 39.008 34.46 1 1370 +ATOM H H01 . HIS A 1 90 . 90 HIS A H01 71.973 43.103 32.821 1 1371 +ATOM H H02 . HIS A 1 90 . 90 HIS A H02 71.083 41.496 31.086 1 1372 +ATOM H H03 . HIS A 1 90 . 90 HIS A H03 69.537 41.444 32.032 1 1373 +ATOM H H04 . HIS A 1 90 . 90 HIS A H04 69.875 40.201 34.634 1 1374 +ATOM H H05 . HIS A 1 90 . 90 HIS A H05 73.552 38.323 33.579 1 1375 +ATOM H H06 . HIS A 1 90 . 90 HIS A H06 71.677 38.439 35.294 1 1376 +ATOM H H09 . HIS A 1 90 . 90 HIS A H09 69.387 42.685 34.076 1 1377 +ATOM N N . HIS A 1 91 . 91 HIS A N 69.344 44.402 31.325 1 1378 +ATOM C CA . HIS A 1 91 . 91 HIS A CA 68.988 45.371 30.292 1 1379 +ATOM C C . HIS A 1 91 . 91 HIS A C 69.367 46.827 30.612 1 1380 +ATOM O O . HIS A 1 91 . 91 HIS A O 69.258 47.714 29.754 1 1381 +ATOM C CB . HIS A 1 91 . 91 HIS A CB 67.504 45.23 29.941 1 1382 +ATOM C CG . HIS A 1 91 . 91 HIS A CG 67.213 44.055 29.054 1 1383 +ATOM C CD2 . HIS A 1 91 . 91 HIS A CD2 66.722 43.997 27.793 1 1384 +ATOM N ND1 . HIS A 1 91 . 91 HIS A ND1 67.475 42.749 29.421 1 1385 +ATOM C CE1 . HIS A 1 91 . 91 HIS A CE1 67.158 41.939 28.422 1 1386 +ATOM N NE2 . HIS A 1 91 . 91 HIS A NE2 66.701 42.673 27.422 1 1387 +ATOM H H01 . HIS A 1 91 . 91 HIS A H01 69.596 45.128 29.421 1 1388 +ATOM H H02 . HIS A 1 91 . 91 HIS A H02 67.215 46.127 29.393 1 1389 +ATOM H H03 . HIS A 1 91 . 91 HIS A H03 66.938 45.115 30.866 1 1390 +ATOM H H04 . HIS A 1 91 . 91 HIS A H04 66.403 44.843 27.185 1 1391 +ATOM H H05 . HIS A 1 91 . 91 HIS A H05 67.257 40.854 28.424 1 1392 +ATOM H H06 . HIS A 1 91 . 91 HIS A H06 66.387 42.319 26.53 1 1393 +ATOM H H07 . HIS A 1 91 . 91 HIS A H07 68.624 44.016 31.919 1 1394 +ATOM N N . SER A 1 92 . 92 SER A N 69.814 47.075 31.842 1 1395 +ATOM C CA . SER A 1 92 . 92 SER A CA 70.301 48.405 32.227 1 1396 +ATOM C C . SER A 1 92 . 92 SER A C 71.828 48.269 32.361 1 1397 +ATOM O O . SER A 1 92 . 92 SER A O 72.521 49.223 32.735 1 1398 +ATOM C CB . SER A 1 92 . 92 SER A CB 69.743 48.838 33.585 1 1399 +ATOM O OG . SER A 1 92 . 92 SER A OG 70.228 47.984 34.608 1 1400 +ATOM H H01 . SER A 1 92 . 92 SER A H01 69.994 49.144 31.487 1 1401 +ATOM H H02 . SER A 1 92 . 92 SER A H02 68.655 48.778 33.558 1 1402 +ATOM H H03 . SER A 1 92 . 92 SER A H03 70.053 49.862 33.793 1 1403 +ATOM H H04 . SER A 1 92 . 92 SER A H04 70.435 47.124 34.236 1 1404 +ATOM H H07 . SER A 1 92 . 92 SER A H07 69.818 46.331 32.525 1 1405 +ATOM N N . GLU A 1 93 . 93 GLU A N 72.326 47.075 32.046 1 1406 +ATOM C CA . GLU A 1 93 . 93 GLU A CA 73.747 46.707 32.151 1 1407 +ATOM C C . GLU A 1 93 . 93 GLU A C 74.322 47.069 33.511 1 1408 +ATOM O O . GLU A 1 93 . 93 GLU A O 75.433 47.583 33.623 1 1409 +ATOM C CB . GLU A 1 93 . 93 GLU A CB 74.586 47.325 31.018 1 1410 +ATOM C CG . GLU A 1 93 . 93 GLU A CG 74.161 46.783 29.65 1 1411 +ATOM C CD . GLU A 1 93 . 93 GLU A CD 75.212 46.96 28.566 1 1412 +ATOM O OE1 . GLU A 1 93 . 93 GLU A OE1 76.335 46.451 28.751 1 1413 +ATOM O OE2 . GLU A 1 93 . 93 GLU A OE2 74.906 47.595 27.534 1 1414 +ATOM H H01 . GLU A 1 93 . 93 GLU A H01 73.799 45.623 32.045 1 1415 +ATOM H H02 . GLU A 1 93 . 93 GLU A H02 75.631 47.062 31.182 1 1416 +ATOM H H03 . GLU A 1 93 . 93 GLU A H03 74.455 48.407 31.028 1 1417 +ATOM H H04 . GLU A 1 93 . 93 GLU A H04 73.286 47.355 29.34 1 1418 +ATOM H H05 . GLU A 1 93 . 93 GLU A H05 71.683 46.372 31.711 1 1419 +ATOM H H06 . GLU A 1 93 . 93 GLU A H06 73.948 45.719 29.752 1 1420 +ATOM H H07 . GLU A 1 93 . 93 GLU A H07 74.668 46.979 26.837 1 1421 +ATOM N N . GLY A 1 94 . 94 GLY A N 73.53 46.79 34.542 1 1422 +ATOM C CA . GLY A 1 94 . 94 GLY A CA 73.957 47.026 35.909 1 1423 +ATOM C C . GLY A 1 94 . 94 GLY A C 73.674 48.388 36.509 1 1424 +ATOM O O . GLY A 1 94 . 94 GLY A O 73.92 48.595 37.702 1 1425 +ATOM H H01 . GLY A 1 94 . 94 GLY A H01 75.044 46.961 35.873 1 1426 +ATOM H H02 . GLY A 1 94 . 94 GLY A H02 73.465 46.284 36.537 1 1427 +ATOM H H08 . GLY A 1 94 . 94 GLY A H08 72.612 46.406 34.37 1 1428 +ATOM N N . ARG A 1 95 . 95 ARG A N 73.165 49.318 35.714 1 1429 +ATOM C CA . ARG A 1 95 . 95 ARG A CA 72.885 50.648 36.246 1 1430 +ATOM C C . ARG A 1 95 . 95 ARG A C 71.658 50.699 37.154 1 1431 +ATOM O O . ARG A 1 95 . 95 ARG A O 71.594 51.53 38.072 1 1432 +ATOM C CB . ARG A 1 95 . 95 ARG A CB 72.729 51.651 35.101 1 1433 +ATOM C CG . ARG A 1 95 . 95 ARG A CG 72.379 53.056 35.571 1 1434 +ATOM C CD . ARG A 1 95 . 95 ARG A CD 72.641 54.057 34.464 1 1435 +ATOM N NE . ARG A 1 95 . 95 ARG A NE 71.982 55.34 34.693 1 1436 +ATOM C CZ . ARG A 1 95 . 95 ARG A CZ 72.042 56.35 33.834 1 1437 +ATOM N NH1 . ARG A 1 95 . 95 ARG A NH1 72.734 56.214 32.705 1 1438 +ATOM N NH2 . ARG A 1 95 . 95 ARG A NH2 71.402 57.481 34.088 1 1439 +ATOM H H01 . ARG A 1 95 . 95 ARG A H01 73.74 50.914 36.867 1 1440 +ATOM H H02 . ARG A 1 95 . 95 ARG A H02 71.902 51.304 34.481 1 1441 +ATOM H H03 . ARG A 1 95 . 95 ARG A H03 72.972 49.108 34.745 1 1442 +ATOM H H04 . ARG A 1 95 . 95 ARG A H04 73.668 51.695 34.549 1 1443 +ATOM H H05 . ARG A 1 95 . 95 ARG A H05 72.998 53.308 36.432 1 1444 +ATOM H H06 . ARG A 1 95 . 95 ARG A H06 71.325 53.092 35.848 1 1445 +ATOM H H07 . ARG A 1 95 . 95 ARG A H07 72.235 53.64 33.542 1 1446 +ATOM H H08 . ARG A 1 95 . 95 ARG A H08 73.716 54.225 34.392 1 1447 +ATOM H H09 . ARG A 1 95 . 95 ARG A H09 71.455 55.465 35.546 1 1448 +ATOM H H10 . ARG A 1 95 . 95 ARG A H10 72.777 56.977 32.044 1 1449 +ATOM H H11 . ARG A 1 95 . 95 ARG A H11 73.216 55.348 32.509 1 1450 +ATOM H H12 . ARG A 1 95 . 95 ARG A H12 71.444 58.244 33.428 1 1451 +ATOM N N . SER A 1 96 . 96 SER A N 70.691 49.811 36.918 1 1452 +ATOM C CA . SER A 1 96 . 96 SER A CA 69.467 49.788 37.714 1 1453 +ATOM C C . SER A 1 96 . 96 SER A C 69.46 48.733 38.804 1 1454 +ATOM O O . SER A 1 96 . 96 SER A O 69.343 47.542 38.526 1 1455 +ATOM C CB . SER A 1 96 . 96 SER A CB 68.251 49.554 36.815 1 1456 +ATOM O OG . SER A 1 96 . 96 SER A OG 68.242 50.484 35.741 1 1457 +ATOM H H01 . SER A 1 96 . 96 SER A H01 69.421 50.763 38.199 1 1458 +ATOM H H02 . SER A 1 96 . 96 SER A H02 67.345 49.688 37.405 1 1459 +ATOM H H03 . SER A 1 96 . 96 SER A H03 68.29 48.541 36.414 1 1460 +ATOM H H04 . SER A 1 96 . 96 SER A H04 67.347 50.581 35.408 1 1461 +ATOM H H13 . SER A 1 96 . 96 SER A H13 70.808 49.14 36.172 1 1462 +ATOM N N . LEU A 1 97 . 97 LEU A N 69.567 49.17 40.051 1 1463 +ATOM C CA . LEU A 1 97 . 97 LEU A CA 69.522 48.226 41.161 1 1464 +ATOM C C . LEU A 1 97 . 97 LEU A C 68.07 47.774 41.318 1 1465 +ATOM O O . LEU A 1 97 . 97 LEU A O 67.151 48.474 40.89 1 1466 +ATOM C CB . LEU A 1 97 . 97 LEU A CB 70.002 48.91 42.435 1 1467 +ATOM C CG . LEU A 1 97 . 97 LEU A CG 71.437 49.414 42.287 1 1468 +ATOM C CD1 . LEU A 1 97 . 97 LEU A CD1 71.849 50.127 43.558 1 1469 +ATOM C CD2 . LEU A 1 97 . 97 LEU A CD2 72.368 48.234 41.981 1 1470 +ATOM H H01 . LEU A 1 97 . 97 LEU A H01 70.168 47.369 40.971 1 1471 +ATOM H H02 . LEU A 1 97 . 97 LEU A H02 69.975 48.183 43.247 1 1472 +ATOM H H03 . LEU A 1 97 . 97 LEU A H03 69.349 49.755 42.653 1 1473 +ATOM H H04 . LEU A 1 97 . 97 LEU A H04 71.504 50.12 41.459 1 1474 +ATOM H H05 . LEU A 1 97 . 97 LEU A H05 69.68 50.157 40.232 1 1475 +ATOM H H06 . LEU A 1 97 . 97 LEU A H06 72.709 50.765 43.354 1 1476 +ATOM H H07 . LEU A 1 97 . 97 LEU A H07 71.021 50.739 43.917 1 1477 +ATOM H H08 . LEU A 1 97 . 97 LEU A H08 72.115 49.392 44.318 1 1478 +ATOM H H09 . LEU A 1 97 . 97 LEU A H09 73.306 48.362 42.522 1 1479 +ATOM H H10 . LEU A 1 97 . 97 LEU A H10 71.893 47.305 42.294 1 1480 +ATOM H H11 . LEU A 1 97 . 97 LEU A H11 72.569 48.197 40.91 1 1481 +ATOM N N . VAL A 1 98 . 98 VAL A N 67.854 46.614 41.931 1 1482 +ATOM C CA . VAL A 1 98 . 98 VAL A CA 66.494 46.128 42.094 1 1483 +ATOM C C . VAL A 1 98 . 98 VAL A C 66.079 46.048 43.549 1 1484 +ATOM O O . VAL A 1 98 . 98 VAL A O 66.789 45.476 44.378 1 1485 +ATOM C CB . VAL A 1 98 . 98 VAL A CB 66.31 44.722 41.461 1 1486 +ATOM C CG1 . VAL A 1 98 . 98 VAL A CG1 64.89 44.225 41.71 1 1487 +ATOM C CG2 . VAL A 1 98 . 98 VAL A CG2 66.609 44.773 39.978 1 1488 +ATOM H H01 . VAL A 1 98 . 98 VAL A H01 65.862 46.854 41.582 1 1489 +ATOM H H02 . VAL A 1 98 . 98 VAL A H02 67.008 44.026 41.925 1 1490 +ATOM H H03 . VAL A 1 98 . 98 VAL A H03 64.217 44.666 40.974 1 1491 +ATOM H H04 . VAL A 1 98 . 98 VAL A H04 64.864 43.139 41.621 1 1492 +ATOM H H05 . VAL A 1 98 . 98 VAL A H05 64.573 44.516 42.712 1 1493 +ATOM H H06 . VAL A 1 98 . 98 VAL A H06 67.652 44.505 39.809 1 1494 +ATOM H H07 . VAL A 1 98 . 98 VAL A H07 65.963 44.07 39.452 1 1495 +ATOM H H08 . VAL A 1 98 . 98 VAL A H08 66.429 45.781 39.605 1 1496 +ATOM H H12 . VAL A 1 98 . 98 VAL A H12 68.631 46.073 42.282 1 1497 +ATOM N N . ILE A 1 99 . 99 ILE A N 64.935 46.653 43.852 1 1498 +ATOM C CA . ILE A 1 99 . 99 ILE A CA 64.368 46.637 45.202 1 1499 +ATOM C C . ILE A 1 99 . 99 ILE A C 63.043 45.907 45.058 1 1500 +ATOM O O . ILE A 1 99 . 99 ILE A O 62.281 46.16 44.106 1 1501 +ATOM C CB . ILE A 1 99 . 99 ILE A CB 64.123 48.065 45.72 1 1502 +ATOM C CG1 . ILE A 1 99 . 99 ILE A CG1 65.467 48.791 45.844 1 1503 +ATOM C CG2 . ILE A 1 99 . 99 ILE A CG2 63.403 48.023 47.074 1 1504 +ATOM C CD1 . ILE A 1 99 . 99 ILE A CD1 65.335 50.313 45.981 1 1505 +ATOM H H01 . ILE A 1 99 . 99 ILE A H01 65.041 46.158 45.913 1 1506 +ATOM H H02 . ILE A 1 99 . 99 ILE A H02 63.488 48.605 45.018 1 1507 +ATOM H H03 . ILE A 1 99 . 99 ILE A H03 66.018 48.601 44.923 1 1508 +ATOM H H04 . ILE A 1 99 . 99 ILE A H04 65.985 48.409 46.724 1 1509 +ATOM H H05 . ILE A 1 99 . 99 ILE A H05 62.398 47.624 46.938 1 1510 +ATOM H H06 . ILE A 1 99 . 99 ILE A H06 63.957 47.384 47.761 1 1511 +ATOM H H07 . ILE A 1 99 . 99 ILE A H07 63.341 49.031 47.484 1 1512 +ATOM H H08 . ILE A 1 99 . 99 ILE A H08 64.39 50.553 46.468 1 1513 +ATOM H H09 . ILE A 1 99 . 99 ILE A H09 64.437 47.143 43.122 1 1514 +ATOM H H10 . ILE A 1 99 . 99 ILE A H10 66.16 50.697 46.581 1 1515 +ATOM H H11 . ILE A 1 99 . 99 ILE A H11 65.36 50.771 44.992 1 1516 +ATOM N N . VAL A 1 100 . 100 VAL A N 62.758 45.001 45.986 1 1517 +ATOM C CA . VAL A 1 100 . 100 VAL A CA 61.532 44.24 45.894 1 1518 +ATOM C C . VAL A 1 100 . 100 VAL A C 60.737 44.344 47.171 1 1519 +ATOM O O . VAL A 1 100 . 100 VAL A O 61.281 44.14 48.258 1 1520 +ATOM C CB . VAL A 1 100 . 100 VAL A CB 61.813 42.729 45.639 1 1521 +ATOM C CG1 . VAL A 1 100 . 100 VAL A CG1 60.489 41.957 45.585 1 1522 +ATOM C CG2 . VAL A 1 100 . 100 VAL A CG2 62.572 42.552 44.338 1 1523 +ATOM H H01 . VAL A 1 100 . 100 VAL A H01 60.971 44.659 45.058 1 1524 +ATOM H H02 . VAL A 1 100 . 100 VAL A H02 62.421 42.338 46.455 1 1525 +ATOM H H03 . VAL A 1 100 . 100 VAL A H03 59.728 42.574 45.107 1 1526 +ATOM H H04 . VAL A 1 100 . 100 VAL A H04 60.625 41.04 45.011 1 1527 +ATOM H H05 . VAL A 1 100 . 100 VAL A H05 60.171 41.709 46.598 1 1528 +ATOM H H06 . VAL A 1 100 . 100 VAL A H06 63.57 42.167 44.55 1 1529 +ATOM H H07 . VAL A 1 100 . 100 VAL A H07 62.04 41.848 43.699 1 1530 +ATOM H H08 . VAL A 1 100 . 100 VAL A H08 62.654 43.513 43.831 1 1531 +ATOM H H12 . VAL A 1 100 . 100 VAL A H12 63.395 44.844 46.754 1 1532 +ATOM N N . ARG A 1 101 . 101 ARG A N 59.469 44.72 47.049 1 1533 +ATOM C CA . ARG A 1 101 . 101 ARG A CA 58.621 44.731 48.234 1 1534 +ATOM C C . ARG A 1 101 . 101 ARG A C 57.947 43.372 48.211 1 1535 +ATOM O O . ARG A 1 101 . 101 ARG A O 57.221 43.032 47.272 1 1536 +ATOM C CB . ARG A 1 101 . 101 ARG A CB 57.543 45.805 48.217 1 1537 +ATOM C CG . ARG A 1 101 . 101 ARG A CG 56.561 45.479 49.334 1 1538 +ATOM C CD . ARG A 1 101 . 101 ARG A CD 55.622 46.551 49.632 1 1539 +ATOM N NE . ARG A 1 101 . 101 ARG A NE 54.624 46.746 48.59 1 1540 +ATOM C CZ . ARG A 1 101 . 101 ARG A CZ 53.742 47.728 48.655 1 1541 +ATOM N NH1 . ARG A 1 101 . 101 ARG A NH1 53.777 48.549 49.697 1 1542 +ATOM N NH2 . ARG A 1 101 . 101 ARG A NH2 52.849 47.907 47.69 1 1543 +ATOM H H01 . ARG A 1 101 . 101 ARG A H01 59.222 44.939 49.119 1 1544 +ATOM H H02 . ARG A 1 101 . 101 ARG A H02 57.036 45.827 47.252 1 1545 +ATOM H H03 . ARG A 1 101 . 101 ARG A H03 57.989 46.785 48.386 1 1546 +ATOM H H04 . ARG A 1 101 . 101 ARG A H04 57.153 45.32 50.236 1 1547 +ATOM H H05 . ARG A 1 101 . 101 ARG A H05 55.992 44.596 49.044 1 1548 +ATOM H H06 . ARG A 1 101 . 101 ARG A H06 56.197 47.474 49.706 1 1549 +ATOM H H07 . ARG A 1 101 . 101 ARG A H07 55.109 46.314 50.564 1 1550 +ATOM H H08 . ARG A 1 101 . 101 ARG A H08 54.605 46.117 47.8 1 1551 +ATOM H H09 . ARG A 1 101 . 101 ARG A H09 59.099 44.994 46.15 1 1552 +ATOM H H10 . ARG A 1 101 . 101 ARG A H10 53.117 49.31 49.762 1 1553 +ATOM H H11 . ARG A 1 101 . 101 ARG A H11 54.464 48.412 50.425 1 1554 +ATOM H H12 . ARG A 1 101 . 101 ARG A H12 52.186 48.667 47.75 1 1555 +ATOM N N . VAL A 1 102 . 102 VAL A N 58.184 42.595 49.254 1 1556 +ATOM C CA . VAL A 1 102 . 102 VAL A CA 57.663 41.252 49.332 1 1557 +ATOM C C . VAL A 1 102 . 102 VAL A C 56.45 41.12 50.227 1 1558 +ATOM O O . VAL A 1 102 . 102 VAL A O 56.16 42.006 51.016 1 1559 +ATOM C CB . VAL A 1 102 . 102 VAL A CB 58.745 40.295 49.89 1 1560 +ATOM C CG1 . VAL A 1 102 . 102 VAL A CG1 59.972 40.306 48.986 1 1561 +ATOM C CG2 . VAL A 1 102 . 102 VAL A CG2 59.14 40.728 51.31 1 1562 +ATOM H H01 . VAL A 1 102 . 102 VAL A H01 57.37 40.996 48.314 1 1563 +ATOM H H02 . VAL A 1 102 . 102 VAL A H02 58.34 39.284 49.922 1 1564 +ATOM H H03 . VAL A 1 102 . 102 VAL A H03 60.821 39.882 49.522 1 1565 +ATOM H H04 . VAL A 1 102 . 102 VAL A H04 59.772 39.712 48.094 1 1566 +ATOM H H05 . VAL A 1 102 . 102 VAL A H05 60.201 41.331 48.696 1 1567 +ATOM H H06 . VAL A 1 102 . 102 VAL A H06 59.983 41.417 51.258 1 1568 +ATOM H H07 . VAL A 1 102 . 102 VAL A H07 58.295 41.224 51.788 1 1569 +ATOM H H08 . VAL A 1 102 . 102 VAL A H08 59.424 39.851 51.892 1 1570 +ATOM H H13 . VAL A 1 102 . 102 VAL A H13 58.744 42.951 50.016 1 1571 +ATOM N N . PRO A 1 103 . 103 PRO A N 55.699 40.02 50.072 1 1572 +ATOM C CA . PRO A 1 103 . 103 PRO A CA 54.525 39.809 50.927 1 1573 +ATOM C C . PRO A 1 103 . 103 PRO A C 55.155 39.625 52.317 1 1574 +ATOM O O . PRO A 1 103 . 103 PRO A O 56.203 38.997 52.433 1 1575 +ATOM C CB . PRO A 1 103 . 103 PRO A CB 53.938 38.499 50.402 1 1576 +ATOM C CG . PRO A 1 103 . 103 PRO A CG 54.409 38.438 48.97 1 1577 +ATOM C CD . PRO A 1 103 . 103 PRO A CD 55.813 38.978 49.037 1 1578 +ATOM H H01 . PRO A 1 103 . 103 PRO A H01 52.852 38.461 50.489 1 1579 +ATOM H H02 . PRO A 1 103 . 103 PRO A H02 54.314 37.649 50.971 1 1580 +ATOM H H03 . PRO A 1 103 . 103 PRO A H03 53.77 39.001 48.289 1 1581 +ATOM H H04 . PRO A 1 103 . 103 PRO A H04 54.403 37.413 48.601 1 1582 +ATOM H H05 . PRO A 1 103 . 103 PRO A H05 56.562 38.219 49.262 1 1583 +ATOM H H06 . PRO A 1 103 . 103 PRO A H06 56.12 39.402 48.081 1 1584 +ATOM H H09 . PRO A 1 103 . 103 PRO A H09 53.758 40.583 50.947 1 1585 +ATOM N N . LYS A 1 104 . 104 LYS A N 54.522 40.133 53.37 1 1586 +ATOM C CA . LYS A 1 104 . 104 LYS A CA 55.116 40.037 54.699 1 1587 +ATOM C C . LYS A 1 104 . 104 LYS A C 55.52 38.658 55.246 1 1588 +ATOM O O . LYS A 1 104 . 104 LYS A O 56.496 38.555 56.007 1 1589 +ATOM C CB . LYS A 1 104 . 104 LYS A CB 54.21 40.729 55.718 1 1590 +ATOM C CG . LYS A 1 104 . 104 LYS A CG 54.928 40.975 57.039 1 1591 +ATOM C CD . LYS A 1 104 . 104 LYS A CD 54.007 41.627 58.042 1 1592 +ATOM C CE . LYS A 1 104 . 104 LYS A CE 54.791 42.283 59.165 1 1593 +ATOM N NZ . LYS A 1 104 . 104 LYS A NZ 53.803 42.73 60.197 1 1594 +ATOM H H01 . LYS A 1 104 . 104 LYS A H01 56.077 40.531 54.554 1 1595 +ATOM H H02 . LYS A 1 104 . 104 LYS A H02 53.359 40.076 55.912 1 1596 +ATOM H H03 . LYS A 1 104 . 104 LYS A H03 53.884 41.686 55.309 1 1597 +ATOM H H04 . LYS A 1 104 . 104 LYS A H04 55.771 41.642 56.857 1 1598 +ATOM H H05 . LYS A 1 104 . 104 LYS A H05 55.275 40.023 57.439 1 1599 +ATOM H H06 . LYS A 1 104 . 104 LYS A H06 53.367 40.857 58.473 1 1600 +ATOM H H07 . LYS A 1 104 . 104 LYS A H07 53.628 40.587 53.25 1 1601 +ATOM H H08 . LYS A 1 104 . 104 LYS A H08 53.409 42.385 57.536 1 1602 +ATOM H H09 . LYS A 1 104 . 104 LYS A H09 55.366 43.13 58.79 1 1603 +ATOM H H10 . LYS A 1 104 . 104 LYS A H10 55.489 41.568 59.601 1 1604 +ATOM H H11 . LYS A 1 104 . 104 LYS A H11 54.163 42.527 61.119 1 1605 +ATOM H H12 . LYS A 1 104 . 104 LYS A H12 53.648 43.724 60.107 1 1606 +ATOM N N . HIS A 1 105 . 105 HIS A N 54.804 37.599 54.882 1 1607 +ATOM C CA . HIS A 1 105 . 105 HIS A CA 55.139 36.274 55.41 1 1608 +ATOM C C . HIS A 1 105 . 105 HIS A C 55.486 35.216 54.369 1 1609 +ATOM O O . HIS A 1 105 . 105 HIS A O 55.506 34.028 54.68 1 1610 +ATOM C CB . HIS A 1 105 . 105 HIS A CB 53.98 35.742 56.266 1 1611 +ATOM C CG . HIS A 1 105 . 105 HIS A CG 53.655 36.606 57.443 1 1612 +ATOM C CD2 . HIS A 1 105 . 105 HIS A CD2 52.516 37.248 57.791 1 1613 +ATOM N ND1 . HIS A 1 105 . 105 HIS A ND1 54.584 36.922 58.41 1 1614 +ATOM C CE1 . HIS A 1 105 . 105 HIS A CE1 54.031 37.725 59.303 1 1615 +ATOM N NE2 . HIS A 1 105 . 105 HIS A NE2 52.777 37.939 58.95 1 1616 +ATOM H H01 . HIS A 1 105 . 105 HIS A H01 56.045 36.437 55.993 1 1617 +ATOM H H02 . HIS A 1 105 . 105 HIS A H02 54.293 34.774 56.659 1 1618 +ATOM H H03 . HIS A 1 105 . 105 HIS A H03 53.093 35.668 55.637 1 1619 +ATOM H H04 . HIS A 1 105 . 105 HIS A H04 51.569 37.222 57.253 1 1620 +ATOM H H05 . HIS A 1 105 . 105 HIS A H05 54.526 38.14 60.181 1 1621 +ATOM H H06 . HIS A 1 105 . 105 HIS A H06 52.117 38.517 59.45 1 1622 +ATOM H H13 . HIS A 1 105 . 105 HIS A H13 54.03 37.708 54.242 1 1623 +ATOM N N . ASP A 1 106 . 106 ASP A N 55.798 35.631 53.153 1 1624 +ATOM C CA . ASP A 1 106 . 106 ASP A CA 56.08 34.646 52.116 1 1625 +ATOM C C . ASP A 1 106 . 106 ASP A C 57.567 34.382 51.913 1 1626 +ATOM O O . ASP A 1 106 . 106 ASP A O 58.255 35.158 51.25 1 1627 +ATOM C CB . ASP A 1 106 . 106 ASP A CB 55.453 35.099 50.799 1 1628 +ATOM C CG . ASP A 1 106 . 106 ASP A CG 55.37 33.975 49.796 1 1629 +ATOM O OD1 . ASP A 1 106 . 106 ASP A OD1 54.291 33.357 49.662 1 1630 +ATOM O OD2 . ASP A 1 106 . 106 ASP A OD2 56.395 33.687 49.163 1 1631 +ATOM H H01 . ASP A 1 106 . 106 ASP A H01 55.642 33.705 52.45 1 1632 +ATOM H H02 . ASP A 1 106 . 106 ASP A H02 56.085 35.88 50.377 1 1633 +ATOM H H03 . ASP A 1 106 . 106 ASP A H03 54.447 35.47 50.997 1 1634 +ATOM H H04 . ASP A 1 106 . 106 ASP A H04 56.612 34.403 48.562 1 1635 +ATOM H H07 . ASP A 1 106 . 106 ASP A H07 55.84 36.618 52.945 1 1636 +ATOM N N . GLU A 1 107 . 107 GLU A N 58.056 33.276 52.474 1 1637 +ATOM C CA . GLU A 1 107 . 107 GLU A CA 59.468 32.934 52.37 1 1638 +ATOM C C . GLU A 1 107 . 107 GLU A C 59.931 32.671 50.941 1 1639 +ATOM O O . GLU A 1 107 . 107 GLU A O 61.069 32.993 50.592 1 1640 +ATOM C CB . GLU A 1 107 . 107 GLU A CB 59.788 31.716 53.258 1 1641 +ATOM C CG . GLU A 1 107 . 107 GLU A CG 59.52 31.983 54.743 1 1642 +ATOM C CD . GLU A 1 107 . 107 GLU A CD 60.083 30.91 55.672 1 1643 +ATOM O OE1 . GLU A 1 107 . 107 GLU A OE1 60.428 29.811 55.199 1 1644 +ATOM O OE2 . GLU A 1 107 . 107 GLU A OE2 60.166 31.171 56.889 1 1645 +ATOM H H01 . GLU A 1 107 . 107 GLU A H01 60.018 33.808 52.718 1 1646 +ATOM H H02 . GLU A 1 107 . 107 GLU A H02 60.85 31.498 53.148 1 1647 +ATOM H H03 . GLU A 1 107 . 107 GLU A H03 59.171 30.877 52.937 1 1648 +ATOM H H04 . GLU A 1 107 . 107 GLU A H04 58.438 31.983 54.876 1 1649 +ATOM H H05 . GLU A 1 107 . 107 GLU A H05 57.435 32.663 52.982 1 1650 +ATOM H H06 . GLU A 1 107 . 107 GLU A H06 59.971 32.939 55.008 1 1651 +ATOM H H07 . GLU A 1 107 . 107 GLU A H07 60.726 30.521 57.319 1 1652 +ATOM N N . VAL A 1 108 . 108 VAL A N 59.056 32.111 50.113 1 1653 +ATOM C CA . VAL A 1 108 . 108 VAL A CA 59.428 31.799 48.735 1 1654 +ATOM C C . VAL A 1 108 . 108 VAL A C 59.6 33.073 47.916 1 1655 +ATOM O O . VAL A 1 108 . 108 VAL A O 60.527 33.181 47.111 1 1656 +ATOM C CB . VAL A 1 108 . 108 VAL A CB 58.381 30.873 48.068 1 1657 +ATOM C CG1 . VAL A 1 108 . 108 VAL A CG1 58.732 30.658 46.596 1 1658 +ATOM C CG2 . VAL A 1 108 . 108 VAL A CG2 58.358 29.519 48.789 1 1659 +ATOM H H01 . VAL A 1 108 . 108 VAL A H01 60.382 31.272 48.764 1 1660 +ATOM H H02 . VAL A 1 108 . 108 VAL A H02 57.399 31.342 48.137 1 1661 +ATOM H H03 . VAL A 1 108 . 108 VAL A H03 59.769 30.333 46.514 1 1662 +ATOM H H04 . VAL A 1 108 . 108 VAL A H04 58.078 29.895 46.174 1 1663 +ATOM H H05 . VAL A 1 108 . 108 VAL A H05 58.601 31.592 46.05 1 1664 +ATOM H H06 . VAL A 1 108 . 108 VAL A H06 57.432 29.426 49.356 1 1665 +ATOM H H07 . VAL A 1 108 . 108 VAL A H07 58.417 28.715 48.055 1 1666 +ATOM H H08 . VAL A 1 108 . 108 VAL A H08 58.124 31.899 50.439 1 1667 +ATOM H H09 . VAL A 1 108 . 108 VAL A H09 59.208 29.454 49.469 1 1668 +ATOM N N . SER A 1 109 . 109 SER A N 58.727 34.054 48.12 1 1669 +ATOM C CA . SER A 1 109 . 109 SER A CA 58.883 35.308 47.373 1 1670 +ATOM C C . SER A 1 109 . 109 SER A C 60.23 35.929 47.754 1 1671 +ATOM O O . SER A 1 109 . 109 SER A O 60.962 36.439 46.91 1 1672 +ATOM C CB . SER A 1 109 . 109 SER A CB 57.764 36.3 47.72 1 1673 +ATOM O OG . SER A 1 109 . 109 SER A OG 56.494 35.846 47.276 1 1674 +ATOM H H01 . SER A 1 109 . 109 SER A H01 58.835 35.094 46.305 1 1675 +ATOM H H02 . SER A 1 109 . 109 SER A H02 57.98 37.241 47.215 1 1676 +ATOM H H03 . SER A 1 109 . 109 SER A H03 57.733 36.432 48.802 1 1677 +ATOM H H04 . SER A 1 109 . 109 SER A H04 56.175 35.16 47.866 1 1678 +ATOM H H10 . SER A 1 109 . 109 SER A H10 57.969 33.939 48.778 1 1679 +ATOM N N . LEU A 1 110 . 110 LEU A N 60.553 35.877 49.043 1 1680 +ATOM C CA . LEU A 1 110 . 110 LEU A CA 61.797 36.455 49.533 1 1681 +ATOM C C . LEU A 1 110 . 110 LEU A C 63.025 35.752 48.976 1 1682 +ATOM O O . LEU A 1 110 . 110 LEU A O 63.93 36.405 48.474 1 1683 +ATOM C CB . LEU A 1 110 . 110 LEU A CB 61.841 36.405 51.061 1 1684 +ATOM C CG . LEU A 1 110 . 110 LEU A CG 63.137 36.927 51.692 1 1685 +ATOM C CD1 . LEU A 1 110 . 110 LEU A CD1 63.32 38.42 51.368 1 1686 +ATOM C CD2 . LEU A 1 110 . 110 LEU A CD2 63.062 36.711 53.204 1 1687 +ATOM H H01 . LEU A 1 110 . 110 LEU A H01 61.818 37.489 49.189 1 1688 +ATOM H H02 . LEU A 1 110 . 110 LEU A H02 61.775 35.351 51.332 1 1689 +ATOM H H03 . LEU A 1 110 . 110 LEU A H03 61.014 37.003 51.443 1 1690 +ATOM H H04 . LEU A 1 110 . 110 LEU A H04 63.993 36.388 51.287 1 1691 +ATOM H H05 . LEU A 1 110 . 110 LEU A H05 59.924 35.429 49.694 1 1692 +ATOM H H06 . LEU A 1 110 . 110 LEU A H06 64.291 38.573 50.898 1 1693 +ATOM H H07 . LEU A 1 110 . 110 LEU A H07 62.532 38.744 50.688 1 1694 +ATOM H H08 . LEU A 1 110 . 110 LEU A H08 63.267 39.001 52.289 1 1695 +ATOM H H09 . LEU A 1 110 . 110 LEU A H09 62.053 36.932 53.552 1 1696 +ATOM H H10 . LEU A 1 110 . 110 LEU A H10 63.307 35.675 53.436 1 1697 +ATOM H H11 . LEU A 1 110 . 110 LEU A H11 63.771 37.373 53.702 1 1698 +ATOM N N . SER A 1 111 . 111 SER A N 63.062 34.422 49.056 1 1699 +ATOM C CA . SER A 1 111 . 111 SER A CA 64.235 33.715 48.55 1 1700 +ATOM C C . SER A 1 111 . 111 SER A C 64.351 33.874 47.041 1 1701 +ATOM O O . SER A 1 111 . 111 SER A O 65.453 33.966 46.509 1 1702 +ATOM C CB . SER A 1 111 . 111 SER A CB 64.196 32.232 48.935 1 1703 +ATOM O OG . SER A 1 111 . 111 SER A OG 63.144 31.564 48.284 1 1704 +ATOM H H01 . SER A 1 111 . 111 SER A H01 65.117 34.159 49.012 1 1705 +ATOM H H02 . SER A 1 111 . 111 SER A H02 64.036 32.16 50.011 1 1706 +ATOM H H03 . SER A 1 111 . 111 SER A H03 65.141 31.766 48.655 1 1707 +ATOM H H04 . SER A 1 111 . 111 SER A H04 62.864 30.814 48.814 1 1708 +ATOM H H12 . SER A 1 111 . 111 SER A H12 62.29 33.911 49.46 1 1709 +ATOM N N . THR A 1 112 . 112 THR A N 63.217 33.937 46.351 1 1710 +ATOM C CA . THR A 1 112 . 112 THR A CA 63.246 34.11 44.902 1 1711 +ATOM C C . THR A 1 112 . 112 THR A C 63.845 35.474 44.561 1 1712 +ATOM O O . THR A 1 112 . 112 THR A O 64.711 35.592 43.684 1 1713 +ATOM C CB . THR A 1 112 . 112 THR A CB 61.822 34.031 44.313 1 1714 +ATOM C CG2 . THR A 1 112 . 112 THR A CG2 61.843 34.338 42.806 1 1715 +ATOM O OG1 . THR A 1 112 . 112 THR A OG1 61.286 32.717 44.541 1 1716 +ATOM H H01 . THR A 1 112 . 112 THR A H01 63.854 33.313 44.474 1 1717 +ATOM H H02 . THR A 1 112 . 112 THR A H02 61.192 34.773 44.804 1 1718 +ATOM H H03 . THR A 1 112 . 112 THR A H03 62.58 35.116 42.604 1 1719 +ATOM H H04 . THR A 1 112 . 112 THR A H04 62.108 33.436 42.255 1 1720 +ATOM H H05 . THR A 1 112 . 112 THR A H05 62.331 33.865 46.831 1 1721 +ATOM H H06 . THR A 1 112 . 112 THR A H06 60.858 34.681 42.491 1 1722 +ATOM H H07 . THR A 1 112 . 112 THR A H07 61.186 32.571 45.485 1 1723 +ATOM N N . ALA A 1 113 . 113 ALA A N 63.388 36.511 45.261 1 1724 +ATOM C CA . ALA A 1 113 . 113 ALA A CA 63.884 37.855 45.007 1 1725 +ATOM C C . ALA A 1 113 . 113 ALA A C 65.38 37.925 45.271 1 1726 +ATOM O O . ALA A 1 113 . 113 ALA A O 66.132 38.471 44.472 1 1727 +ATOM C CB . ALA A 1 113 . 113 ALA A CB 63.149 38.871 45.884 1 1728 +ATOM H H01 . ALA A 1 113 . 113 ALA A H01 63.699 38.098 43.961 1 1729 +ATOM H H02 . ALA A 1 113 . 113 ALA A H02 62.16 38.487 46.133 1 1730 +ATOM H H03 . ALA A 1 113 . 113 ALA A H03 63.715 39.037 46.801 1 1731 +ATOM H H04 . ALA A 1 113 . 113 ALA A H04 63.047 39.812 45.343 1 1732 +ATOM H H08 . ALA A 1 113 . 113 ALA A H08 62.69 36.363 45.976 1 1733 +ATOM N N . LEU A 1 114 . 114 LEU A N 65.83 37.352 46.384 1 1734 +ATOM C CA . LEU A 1 114 . 114 LEU A CA 67.259 37.403 46.691 1 1735 +ATOM C C . LEU A 1 114 . 114 LEU A C 68.098 36.565 45.719 1 1736 +ATOM O O . LEU A 1 114 . 114 LEU A O 69.109 37.047 45.181 1 1737 +ATOM C CB . LEU A 1 114 . 114 LEU A CB 67.504 36.919 48.123 1 1738 +ATOM C CG . LEU A 1 114 . 114 LEU A CG 66.9 37.776 49.246 1 1739 +ATOM C CD1 . LEU A 1 114 . 114 LEU A CD1 66.997 36.991 50.555 1 1740 +ATOM C CD2 . LEU A 1 114 . 114 LEU A CD2 67.612 39.12 49.351 1 1741 +ATOM H H01 . LEU A 1 114 . 114 LEU A H01 67.571 38.442 46.585 1 1742 +ATOM H H02 . LEU A 1 114 . 114 LEU A H02 68.583 36.973 48.266 1 1743 +ATOM H H03 . LEU A 1 114 . 114 LEU A H03 67.088 35.915 48.21 1 1744 +ATOM H H04 . LEU A 1 114 . 114 LEU A H04 65.854 37.991 49.027 1 1745 +ATOM H H05 . LEU A 1 114 . 114 LEU A H05 65.19 36.886 47.011 1 1746 +ATOM H H06 . LEU A 1 114 . 114 LEU A H06 67.479 37.608 51.313 1 1747 +ATOM H H07 . LEU A 1 114 . 114 LEU A H07 65.996 36.719 50.891 1 1748 +ATOM H H08 . LEU A 1 114 . 114 LEU A H08 67.585 36.087 50.395 1 1749 +ATOM H H09 . LEU A 1 114 . 114 LEU A H09 68.564 39.068 48.823 1 1750 +ATOM H H10 . LEU A 1 114 . 114 LEU A H10 66.991 39.897 48.904 1 1751 +ATOM H H11 . LEU A 1 114 . 114 LEU A H11 67.791 39.356 50.4 1 1752 +ATOM N N . ASP A 1 115 . 115 ASP A N 67.679 35.326 45.491 1 1753 +ATOM C CA . ASP A 1 115 . 115 ASP A CA 68.412 34.426 44.587 1 1754 +ATOM C C . ASP A 1 115 . 115 ASP A C 68.532 35.037 43.181 1 1755 +ATOM O O . ASP A 1 115 . 115 ASP A O 69.522 34.796 42.487 1 1756 +ATOM C CB . ASP A 1 115 . 115 ASP A CB 67.73 33.044 44.474 1 1757 +ATOM C CG . ASP A 1 115 . 115 ASP A CG 67.927 32.153 45.716 1 1758 +ATOM O OD1 . ASP A 1 115 . 115 ASP A OD1 68.782 32.473 46.568 1 1759 +ATOM O OD2 . ASP A 1 115 . 115 ASP A OD2 67.22 31.112 45.825 1 1760 +ATOM H H01 . ASP A 1 115 . 115 ASP A H01 69.405 34.294 45.017 1 1761 +ATOM H H02 . ASP A 1 115 . 115 ASP A H02 68.195 32.527 43.634 1 1762 +ATOM H H03 . ASP A 1 115 . 115 ASP A H03 66.661 33.198 44.326 1 1763 +ATOM H H04 . ASP A 1 115 . 115 ASP A H04 67.049 30.937 46.753 1 1764 +ATOM H H12 . ASP A 1 115 . 115 ASP A H12 66.841 34.995 45.947 1 1765 +ATOM N N . ALA A 1 116 . 116 ALA A N 67.536 35.824 42.761 1 1766 +ATOM C CA . ALA A 1 116 . 116 ALA A CA 67.58 36.451 41.438 1 1767 +ATOM C C . ALA A 1 116 . 116 ALA A C 68.442 37.709 41.375 1 1768 +ATOM O O . ALA A 1 116 . 116 ALA A O 68.715 38.213 40.289 1 1769 +ATOM C CB . ALA A 1 116 . 116 ALA A CB 66.149 36.779 40.94 1 1770 +ATOM H H01 . ALA A 1 116 . 116 ALA A H01 68.05 35.716 40.784 1 1771 +ATOM H H02 . ALA A 1 116 . 116 ALA A H02 65.499 36.959 41.796 1 1772 +ATOM H H03 . ALA A 1 116 . 116 ALA A H03 66.177 37.67 40.313 1 1773 +ATOM H H04 . ALA A 1 116 . 116 ALA A H04 65.763 35.939 40.362 1 1774 +ATOM H H05 . ALA A 1 116 . 116 ALA A H05 66.743 35.988 43.364 1 1775 +ATOM N N . GLY A 1 117 . 117 GLY A N 68.85 38.245 42.523 1 1776 +ATOM C CA . GLY A 1 117 . 117 GLY A CA 69.701 39.422 42.49 1 1777 +ATOM C C . GLY A 1 117 . 117 GLY A C 69.148 40.719 43.058 1 1778 +ATOM O O . GLY A 1 117 . 117 GLY A O 69.711 41.78 42.818 1 1779 +ATOM H H01 . GLY A 1 117 . 117 GLY A H01 69.84 39.627 41.429 1 1780 +ATOM H H02 . GLY A 1 117 . 117 GLY A H02 70.607 39.184 43.047 1 1781 +ATOM H H05 . GLY A 1 117 . 117 GLY A H05 68.572 37.839 43.405 1 1782 +ATOM N N . ALA A 1 118 . 118 ALA A N 68.053 40.661 43.808 1 1783 +ATOM C CA . ALA A 1 118 . 118 ALA A CA 67.507 41.894 44.379 1 1784 +ATOM C C . ALA A 1 118 . 118 ALA A C 68.494 42.455 45.395 1 1785 +ATOM O O . ALA A 1 118 . 118 ALA A O 69.033 41.706 46.21 1 1786 +ATOM C CB . ALA A 1 118 . 118 ALA A CB 66.161 41.622 45.056 1 1787 +ATOM H H01 . ALA A 1 118 . 118 ALA A H01 67.35 42.618 43.58 1 1788 +ATOM H H02 . ALA A 1 118 . 118 ALA A H02 65.394 41.48 44.295 1 1789 +ATOM H H03 . ALA A 1 118 . 118 ALA A H03 67.601 39.775 43.983 1 1790 +ATOM H H04 . ALA A 1 118 . 118 ALA A H04 66.236 40.723 45.668 1 1791 +ATOM H H05 . ALA A 1 118 . 118 ALA A H05 65.893 42.47 45.687 1 1792 +ATOM N N . ALA A 1 119 . 119 ALA A N 68.712 43.768 45.358 1 1793 +ATOM C CA . ALA A 1 119 . 119 ALA A CA 69.652 44.416 46.286 1 1794 +ATOM C C . ALA A 1 119 . 119 ALA A C 68.961 45.016 47.505 1 1795 +ATOM O O . ALA A 1 119 . 119 ALA A O 69.614 45.375 48.479 1 1796 +ATOM C CB . ALA A 1 119 . 119 ALA A CB 70.441 45.506 45.548 1 1797 +ATOM H H01 . ALA A 1 119 . 119 ALA A H01 70.327 43.641 46.65 1 1798 +ATOM H H02 . ALA A 1 119 . 119 ALA A H02 71.419 45.117 45.265 1 1799 +ATOM H H03 . ALA A 1 119 . 119 ALA A H03 69.897 45.806 44.653 1 1800 +ATOM H H04 . ALA A 1 119 . 119 ALA A H04 70.569 46.368 46.202 1 1801 +ATOM H H06 . ALA A 1 119 . 119 ALA A H06 68.221 44.331 44.678 1 1802 +ATOM N N . GLY A 1 120 . 120 GLY A N 67.643 45.127 47.446 1 1803 +ATOM C CA . GLY A 1 120 . 120 GLY A CA 66.892 45.686 48.558 1 1804 +ATOM C C . GLY A 1 120 . 120 GLY A C 65.555 44.984 48.731 1 1805 +ATOM O O . GLY A 1 120 . 120 GLY A O 64.895 44.609 47.754 1 1806 +ATOM H H01 . GLY A 1 120 . 120 GLY A H01 66.703 46.739 48.35 1 1807 +ATOM H H02 . GLY A 1 120 . 120 GLY A H02 67.474 45.573 49.473 1 1808 +ATOM H H05 . GLY A 1 120 . 120 GLY A H05 67.153 44.819 46.618 1 1809 +ATOM N N . ILE A 1 121 . 121 ILE A N 65.158 44.793 49.982 1 1810 +ATOM C CA . ILE A 1 121 . 121 ILE A CA 63.887 44.163 50.295 1 1811 +ATOM C C . ILE A 1 121 . 121 ILE A C 63.096 45.124 51.18 1 1812 +ATOM O O . ILE A 1 121 . 121 ILE A O 63.595 45.584 52.21 1 1813 +ATOM C CB . ILE A 1 121 . 121 ILE A CB 64.088 42.833 51.063 1 1814 +ATOM C CG1 . ILE A 1 121 . 121 ILE A CG1 64.875 41.833 50.207 1 1815 +ATOM C CG2 . ILE A 1 121 . 121 ILE A CG2 62.75 42.26 51.49 1 1816 +ATOM C CD1 . ILE A 1 121 . 121 ILE A CD1 64.136 41.311 48.954 1 1817 +ATOM H H01 . ILE A 1 121 . 121 ILE A H01 63.361 43.942 49.366 1 1818 +ATOM H H02 . ILE A 1 121 . 121 ILE A H02 64.669 43.032 51.963 1 1819 +ATOM H H03 . ILE A 1 121 . 121 ILE A H03 65.756 45.094 50.738 1 1820 +ATOM H H04 . ILE A 1 121 . 121 ILE A H04 65.045 40.963 50.841 1 1821 +ATOM H H05 . ILE A 1 121 . 121 ILE A H05 65.794 42.319 49.879 1 1822 +ATOM H H06 . ILE A 1 121 . 121 ILE A H06 61.995 42.505 50.742 1 1823 +ATOM H H07 . ILE A 1 121 . 121 ILE A H07 62.831 41.177 51.583 1 1824 +ATOM H H08 . ILE A 1 121 . 121 ILE A H08 62.461 42.687 52.45 1 1825 +ATOM H H09 . ILE A 1 121 . 121 ILE A H09 63.233 41.9 48.794 1 1826 +ATOM H H10 . ILE A 1 121 . 121 ILE A H10 64.787 41.4 48.084 1 1827 +ATOM H H11 . ILE A 1 121 . 121 ILE A H11 63.865 40.265 49.1 1 1828 +ATOM N N . VAL A 1 122 . 122 VAL A N 61.874 45.438 50.76 1 1829 +ATOM C CA . VAL A 1 122 . 122 VAL A CA 60.992 46.298 51.539 1 1830 +ATOM C C . VAL A 1 122 . 122 VAL A C 59.981 45.375 52.22 1 1831 +ATOM O O . VAL A 1 122 . 122 VAL A O 59.334 44.541 51.573 1 1832 +ATOM C CB . VAL A 1 122 . 122 VAL A CB 60.256 47.329 50.646 1 1833 +ATOM C CG1 . VAL A 1 122 . 122 VAL A CG1 59.2 48.077 51.481 1 1834 +ATOM C CG2 . VAL A 1 122 . 122 VAL A CG2 61.269 48.308 50.046 1 1835 +ATOM H H01 . VAL A 1 122 . 122 VAL A H01 61.57 46.873 52.263 1 1836 +ATOM H H02 . VAL A 1 122 . 122 VAL A H02 59.75 46.813 49.83 1 1837 +ATOM H H03 . VAL A 1 122 . 122 VAL A H03 58.276 47.499 51.499 1 1838 +ATOM H H04 . VAL A 1 122 . 122 VAL A H04 59.566 48.207 52.499 1 1839 +ATOM H H05 . VAL A 1 122 . 122 VAL A H05 59.009 49.053 51.036 1 1840 +ATOM H H06 . VAL A 1 122 . 122 VAL A H06 62.197 47.78 49.826 1 1841 +ATOM H H07 . VAL A 1 122 . 122 VAL A H07 60.866 48.731 49.126 1 1842 +ATOM H H08 . VAL A 1 122 . 122 VAL A H08 61.467 49.109 50.758 1 1843 +ATOM H H12 . VAL A 1 122 . 122 VAL A H12 61.548 45.071 49.877 1 1844 +ATOM N N . ILE A 1 123 . 123 ILE A N 59.86 45.519 53.536 1 1845 +ATOM C CA . ILE A 1 123 . 123 ILE A CA 58.97 44.677 54.334 1 1846 +ATOM C C . ILE A 1 123 . 123 ILE A C 57.713 45.457 54.674 1 1847 +ATOM O O . ILE A 1 123 . 123 ILE A O 57.773 46.466 55.379 1 1848 +ATOM C CB . ILE A 1 123 . 123 ILE A CB 59.65 44.282 55.664 1 1849 +ATOM C CG1 . ILE A 1 123 . 123 ILE A CG1 60.994 43.608 55.383 1 1850 +ATOM C CG2 . ILE A 1 123 . 123 ILE A CG2 58.72 43.35 56.464 1 1851 +ATOM C CD1 . ILE A 1 123 . 123 ILE A CD1 61.908 43.594 56.605 1 1852 +ATOM H H01 . ILE A 1 123 . 123 ILE A H01 58.734 43.783 53.756 1 1853 +ATOM H H02 . ILE A 1 123 . 123 ILE A H02 59.837 45.176 56.259 1 1854 +ATOM H H03 . ILE A 1 123 . 123 ILE A H03 61.494 44.181 54.602 1 1855 +ATOM H H04 . ILE A 1 123 . 123 ILE A H04 60.812 42.58 55.07 1 1856 +ATOM H H05 . ILE A 1 123 . 123 ILE A H05 57.766 43.257 55.945 1 1857 +ATOM H H06 . ILE A 1 123 . 123 ILE A H06 59.181 42.367 56.555 1 1858 +ATOM H H07 . ILE A 1 123 . 123 ILE A H07 58.553 43.767 57.457 1 1859 +ATOM H H08 . ILE A 1 123 . 123 ILE A H08 62.792 44.199 56.404 1 1860 +ATOM H H09 . ILE A 1 123 . 123 ILE A H09 60.401 46.234 54.0 1 1861 +ATOM H H10 . ILE A 1 123 . 123 ILE A H10 61.375 44.004 57.463 1 1862 +ATOM H H11 . ILE A 1 123 . 123 ILE A H11 62.211 42.57 56.821 1 1863 +ATOM N N . PRO A 1 124 . 124 PRO A N 56.553 45.009 54.173 1 1864 +ATOM C CA . PRO A 1 124 . 124 PRO A CA 55.347 45.767 54.499 1 1865 +ATOM C C . PRO A 1 124 . 124 PRO A C 54.787 45.546 55.903 1 1866 +ATOM O O . PRO A 1 124 . 124 PRO A O 55.078 44.548 56.566 1 1867 +ATOM C CB . PRO A 1 124 . 124 PRO A CB 54.361 45.341 53.409 1 1868 +ATOM C CG . PRO A 1 124 . 124 PRO A CG 54.748 43.919 53.118 1 1869 +ATOM C CD . PRO A 1 124 . 124 PRO A CD 56.278 43.899 53.241 1 1870 +ATOM H H01 . PRO A 1 124 . 124 PRO A H01 54.403 45.98 52.527 1 1871 +ATOM H H02 . PRO A 1 124 . 124 PRO A H02 53.334 45.4 53.768 1 1872 +ATOM H H03 . PRO A 1 124 . 124 PRO A H03 54.409 43.592 52.135 1 1873 +ATOM H H04 . PRO A 1 124 . 124 PRO A H04 54.295 43.241 53.841 1 1874 +ATOM H H05 . PRO A 1 124 . 124 PRO A H05 56.667 42.942 53.588 1 1875 +ATOM H H06 . PRO A 1 124 . 124 PRO A H06 56.751 44.072 52.275 1 1876 +ATOM H H12 . PRO A 1 124 . 124 PRO A H12 55.558 46.836 54.518 1 1877 +ATOM N N . HIS A 1 125 . 125 HIS A N 54.008 46.529 56.347 1 1878 +ATOM C CA . HIS A 1 125 . 125 HIS A CA 53.305 46.483 57.622 1 1879 +ATOM C C . HIS A 1 125 . 125 HIS A C 54.169 46.151 58.829 1 1880 +ATOM O O . HIS A 1 125 . 125 HIS A O 53.751 45.384 59.685 1 1881 +ATOM C CB . HIS A 1 125 . 125 HIS A CB 52.17 45.459 57.52 1 1882 +ATOM C CG . HIS A 1 125 . 125 HIS A CG 51.448 45.501 56.208 1 1883 +ATOM C CD2 . HIS A 1 125 . 125 HIS A CD2 51.519 44.686 55.126 1 1884 +ATOM N ND1 . HIS A 1 125 . 125 HIS A ND1 50.611 46.538 55.852 1 1885 +ATOM C CE1 . HIS A 1 125 . 125 HIS A CE1 50.205 46.364 54.605 1 1886 +ATOM N NE2 . HIS A 1 125 . 125 HIS A NE2 50.742 45.248 54.142 1 1887 +ATOM H H01 . HIS A 1 125 . 125 HIS A H01 52.939 47.494 57.797 1 1888 +ATOM H H02 . HIS A 1 125 . 125 HIS A H02 51.444 45.705 58.295 1 1889 +ATOM H H03 . HIS A 1 125 . 125 HIS A H03 52.59 44.462 57.652 1 1890 +ATOM H H04 . HIS A 1 125 . 125 HIS A H04 52.087 43.758 55.051 1 1891 +ATOM H H05 . HIS A 1 125 . 125 HIS A H05 49.54 47.028 54.053 1 1892 +ATOM H H06 . HIS A 1 125 . 125 HIS A H06 50.604 44.87 53.216 1 1893 +ATOM H H07 . HIS A 1 125 . 125 HIS A H07 53.898 47.35 55.77 1 1894 +ATOM N N . VAL A 1 126 . 126 VAL A N 55.358 46.733 58.908 1 1895 +ATOM C CA . VAL A 1 126 . 126 VAL A CA 56.233 46.449 60.047 1 1896 +ATOM C C . VAL A 1 126 . 126 VAL A C 55.729 47.161 61.295 1 1897 +ATOM O O . VAL A 1 126 . 126 VAL A O 55.431 48.354 61.257 1 1898 +ATOM C CB . VAL A 1 126 . 126 VAL A CB 57.681 46.889 59.752 1 1899 +ATOM C CG1 . VAL A 1 126 . 126 VAL A CG1 58.535 46.788 61.022 1 1900 +ATOM C CG2 . VAL A 1 126 . 126 VAL A CG2 58.279 45.98 58.698 1 1901 +ATOM H H01 . VAL A 1 126 . 126 VAL A H01 56.221 45.372 60.217 1 1902 +ATOM H H02 . VAL A 1 126 . 126 VAL A H02 57.668 47.921 59.401 1 1903 +ATOM H H03 . VAL A 1 126 . 126 VAL A H03 59.427 47.404 60.909 1 1904 +ATOM H H04 . VAL A 1 126 . 126 VAL A H04 57.958 47.138 61.878 1 1905 +ATOM H H05 . VAL A 1 126 . 126 VAL A H05 58.829 45.75 61.182 1 1906 +ATOM H H06 . VAL A 1 126 . 126 VAL A H06 59.284 45.685 59.001 1 1907 +ATOM H H07 . VAL A 1 126 . 126 VAL A H07 55.659 47.37 58.185 1 1908 +ATOM H H08 . VAL A 1 126 . 126 VAL A H08 57.657 45.091 58.589 1 1909 +ATOM H H09 . VAL A 1 126 . 126 VAL A H09 58.328 46.509 57.746 1 1910 +ATOM N N . GLU A 1 127 . 127 GLU A N 55.634 46.422 62.397 1 1911 +ATOM C CA . GLU A 1 127 . 127 GLU A CA 55.169 47.009 63.656 1 1912 +ATOM C C . GLU A 1 127 . 127 GLU A C 56.088 46.702 64.837 1 1913 +ATOM O O . GLU A 1 127 . 127 GLU A O 55.997 47.361 65.881 1 1914 +ATOM C CB . GLU A 1 127 . 127 GLU A CB 53.745 46.527 63.966 1 1915 +ATOM C CG . GLU A 1 127 . 127 GLU A CG 52.744 46.891 62.877 1 1916 +ATOM C CD . GLU A 1 127 . 127 GLU A CD 51.318 46.524 63.22 1 1917 +ATOM O OE1 . GLU A 1 127 . 127 GLU A OE1 51.111 45.533 63.949 1 1918 +ATOM O OE2 . GLU A 1 127 . 127 GLU A OE2 50.406 47.224 62.744 1 1919 +ATOM H H01 . GLU A 1 127 . 127 GLU A H01 55.179 48.09 63.519 1 1920 +ATOM H H02 . GLU A 1 127 . 127 GLU A H02 53.425 47.023 64.882 1 1921 +ATOM H H03 . GLU A 1 127 . 127 GLU A H03 53.761 45.443 64.076 1 1922 +ATOM H H04 . GLU A 1 127 . 127 GLU A H04 53.016 46.321 61.989 1 1923 +ATOM H H05 . GLU A 1 127 . 127 GLU A H05 52.793 47.967 62.708 1 1924 +ATOM H H06 . GLU A 1 127 . 127 GLU A H06 50.134 46.864 61.897 1 1925 +ATOM H H10 . GLU A 1 127 . 127 GLU A H10 55.886 45.444 62.365 1 1926 +ATOM N N . THR A 1 128 . 128 THR A N 56.968 45.714 64.675 1 1927 +ATOM C CA . THR A 1 128 . 128 THR A CA 57.885 45.332 65.75 1 1928 +ATOM C C . THR A 1 128 . 128 THR A C 59.3 45.053 65.263 1 1929 +ATOM O O . THR A 1 128 . 128 THR A O 59.528 44.699 64.102 1 1930 +ATOM C CB . THR A 1 128 . 128 THR A CB 57.408 44.057 66.506 1 1931 +ATOM C CG2 . THR A 1 128 . 128 THR A CG2 55.949 44.172 66.917 1 1932 +ATOM O OG1 . THR A 1 128 . 128 THR A OG1 57.571 42.906 65.666 1 1933 +ATOM H H01 . THR A 1 128 . 128 THR A H01 57.889 46.197 66.414 1 1934 +ATOM H H02 . THR A 1 128 . 128 THR A H02 58.015 43.954 67.406 1 1935 +ATOM H H03 . THR A 1 128 . 128 THR A H03 55.329 43.621 66.21 1 1936 +ATOM H H04 . THR A 1 128 . 128 THR A H04 55.818 43.756 67.916 1 1937 +ATOM H H05 . THR A 1 128 . 128 THR A H05 55.653 45.221 66.918 1 1938 +ATOM H H06 . THR A 1 128 . 128 THR A H06 57.389 43.146 64.755 1 1939 +ATOM H H07 . THR A 1 128 . 128 THR A H07 57.003 45.22 63.795 1 1940 +ATOM N N . VAL A 1 129 . 129 VAL A N 60.258 45.196 66.17 1 1941 +ATOM C CA . VAL A 1 129 . 129 VAL A CA 61.639 44.93 65.824 1 1942 +ATOM C C . VAL A 1 129 . 129 VAL A C 61.781 43.432 65.541 1 1943 +ATOM O O . VAL A 1 129 . 129 VAL A O 62.592 43.025 64.708 1 1944 +ATOM C CB . VAL A 1 129 . 129 VAL A CB 62.58 45.362 66.98 1 1945 +ATOM C CG1 . VAL A 1 129 . 129 VAL A CG1 63.984 44.895 66.702 1 1946 +ATOM C CG2 . VAL A 1 129 . 129 VAL A CG2 62.556 46.877 67.129 1 1947 +ATOM H H01 . VAL A 1 129 . 129 VAL A H01 61.921 45.502 64.94 1 1948 +ATOM H H02 . VAL A 1 129 . 129 VAL A H02 62.233 44.908 67.908 1 1949 +ATOM H H03 . VAL A 1 129 . 129 VAL A H03 64.656 45.291 67.464 1 1950 +ATOM H H04 . VAL A 1 129 . 129 VAL A H04 64.016 43.806 66.722 1 1951 +ATOM H H05 . VAL A 1 129 . 129 VAL A H05 64.298 45.251 65.721 1 1952 +ATOM H H06 . VAL A 1 129 . 129 VAL A H06 61.545 47.201 67.373 1 1953 +ATOM H H07 . VAL A 1 129 . 129 VAL A H07 60.023 45.492 67.107 1 1954 +ATOM H H08 . VAL A 1 129 . 129 VAL A H08 63.235 47.175 67.928 1 1955 +ATOM H H09 . VAL A 1 129 . 129 VAL A H09 62.869 47.34 66.193 1 1956 +ATOM N N . GLU A 1 130 . 130 GLU A N 60.982 42.61 66.22 1 1957 +ATOM C CA . GLU A 1 130 . 130 GLU A CA 61.045 41.163 66.005 1 1958 +ATOM C C . GLU A 1 130 . 130 GLU A C 60.682 40.786 64.57 1 1959 +ATOM O O . GLU A 1 130 . 130 GLU A O 61.282 39.873 63.994 1 1960 +ATOM C CB . GLU A 1 130 . 130 GLU A CB 60.102 40.412 66.958 1 1961 +ATOM C CG . GLU A 1 130 . 130 GLU A CG 60.505 40.429 68.439 1 1962 +ATOM C CD . GLU A 1 130 . 130 GLU A CD 60.306 41.782 69.119 1 1963 +ATOM O OE1 . GLU A 1 130 . 130 GLU A OE1 59.497 42.598 68.637 1 1964 +ATOM O OE2 . GLU A 1 130 . 130 GLU A OE2 60.953 42.025 70.165 1 1965 +ATOM H H01 . GLU A 1 130 . 130 GLU A H01 62.076 40.872 66.204 1 1966 +ATOM H H02 . GLU A 1 130 . 130 GLU A H02 60.139 39.367 66.651 1 1967 +ATOM H H03 . GLU A 1 130 . 130 GLU A H03 59.112 40.861 66.874 1 1968 +ATOM H H04 . GLU A 1 130 . 130 GLU A H04 61.572 40.209 68.479 1 1969 +ATOM H H05 . GLU A 1 130 . 130 GLU A H05 59.906 39.687 68.967 1 1970 +ATOM H H06 . GLU A 1 130 . 130 GLU A H06 61.204 41.199 70.585 1 1971 +ATOM H H10 . GLU A 1 130 . 130 GLU A H10 60.327 42.988 66.889 1 1972 +ATOM N N . GLU A 1 131 . 131 GLU A N 59.699 41.468 63.992 1 1973 +ATOM C CA . GLU A 1 131 . 131 GLU A CA 59.307 41.151 62.613 1 1974 +ATOM C C . GLU A 1 131 . 131 GLU A C 60.467 41.422 61.664 1 1975 +ATOM O O . GLU A 1 131 . 131 GLU A O 60.687 40.681 60.706 1 1976 +ATOM C CB . GLU A 1 131 . 131 GLU A CB 58.1 41.989 62.178 1 1977 +ATOM C CG . GLU A 1 131 . 131 GLU A CG 56.846 41.678 62.973 1 1978 +ATOM C CD . GLU A 1 131 . 131 GLU A CD 55.696 42.601 62.649 1 1979 +ATOM O OE1 . GLU A 1 131 . 131 GLU A OE1 55.933 43.796 62.392 1 1980 +ATOM O OE2 . GLU A 1 131 . 131 GLU A OE2 54.543 42.125 62.666 1 1981 +ATOM H H01 . GLU A 1 131 . 131 GLU A H01 59.037 40.096 62.577 1 1982 +ATOM H H02 . GLU A 1 131 . 131 GLU A H02 57.894 41.747 61.135 1 1983 +ATOM H H03 . GLU A 1 131 . 131 GLU A H03 58.343 43.043 62.309 1 1984 +ATOM H H04 . GLU A 1 131 . 131 GLU A H04 57.087 41.816 64.027 1 1985 +ATOM H H05 . GLU A 1 131 . 131 GLU A H05 56.542 40.654 62.756 1 1986 +ATOM H H06 . GLU A 1 131 . 131 GLU A H06 54.248 42.031 63.575 1 1987 +ATOM H H07 . GLU A 1 131 . 131 GLU A H07 59.224 42.201 64.499 1 1988 +ATOM N N . VAL A 1 132 . 132 VAL A N 61.203 42.496 61.93 1 1989 +ATOM C CA . VAL A 1 132 . 132 VAL A CA 62.334 42.837 61.085 1 1990 +ATOM C C . VAL A 1 132 . 132 VAL A C 63.451 41.81 61.262 1 1991 +ATOM O O . VAL A 1 132 . 132 VAL A O 64.092 41.408 60.295 1 1992 +ATOM C CB . VAL A 1 132 . 132 VAL A CB 62.865 44.251 61.411 1 1993 +ATOM C CG1 . VAL A 1 132 . 132 VAL A CG1 64.068 44.569 60.533 1 1994 +ATOM C CG2 . VAL A 1 132 . 132 VAL A CG2 61.758 45.283 61.192 1 1995 +ATOM H H01 . VAL A 1 132 . 132 VAL A H01 61.996 42.828 60.049 1 1996 +ATOM H H02 . VAL A 1 132 . 132 VAL A H02 63.176 44.286 62.455 1 1997 +ATOM H H03 . VAL A 1 132 . 132 VAL A H03 64.929 43.996 60.876 1 1998 +ATOM H H04 . VAL A 1 132 . 132 VAL A H04 63.844 44.304 59.5 1 1999 +ATOM H H05 . VAL A 1 132 . 132 VAL A H05 64.293 45.634 60.596 1 2000 +ATOM H H06 . VAL A 1 132 . 132 VAL A H06 60.889 45.016 61.794 1 2001 +ATOM H H07 . VAL A 1 132 . 132 VAL A H07 60.975 43.078 62.723 1 2002 +ATOM H H08 . VAL A 1 132 . 132 VAL A H08 62.116 46.269 61.489 1 2003 +ATOM H H09 . VAL A 1 132 . 132 VAL A H09 61.479 45.299 60.139 1 2004 +ATOM N N . ARG A 1 133 . 133 ARG A N 63.687 41.369 62.491 1 2005 +ATOM C CA . ARG A 1 133 . 133 ARG A CA 64.745 40.393 62.704 1 2006 +ATOM C C . ARG A 1 133 . 133 ARG A C 64.456 39.039 62.06 1 2007 +ATOM O O . ARG A 1 133 . 133 ARG A O 65.379 38.283 61.767 1 2008 +ATOM C CB . ARG A 1 133 . 133 ARG A CB 65.064 40.273 64.2 1 2009 +ATOM C CG . ARG A 1 133 . 133 ARG A CG 65.651 41.592 64.69 1 2010 +ATOM C CD . ARG A 1 133 . 133 ARG A CD 66.396 41.546 66.009 1 2011 +ATOM N NE . ARG A 1 133 . 133 ARG A NE 67.153 42.792 66.118 1 2012 +ATOM C CZ . ARG A 1 133 . 133 ARG A CZ 66.947 43.736 67.032 1 2013 +ATOM N NH1 . ARG A 1 133 . 133 ARG A NH1 66.007 43.58 67.957 1 2014 +ATOM N NH2 . ARG A 1 133 . 133 ARG A NH2 67.647 44.867 66.978 1 2015 +ATOM H H01 . ARG A 1 133 . 133 ARG A H01 65.634 40.762 62.193 1 2016 +ATOM H H02 . ARG A 1 133 . 133 ARG A H02 65.782 39.469 64.36 1 2017 +ATOM H H03 . ARG A 1 133 . 133 ARG A H03 64.151 40.051 64.752 1 2018 +ATOM H H04 . ARG A 1 133 . 133 ARG A H04 64.795 42.245 64.862 1 2019 +ATOM H H05 . ARG A 1 133 . 133 ARG A H05 66.344 41.951 63.929 1 2020 +ATOM H H06 . ARG A 1 133 . 133 ARG A H06 67.061 40.683 66.049 1 2021 +ATOM H H07 . ARG A 1 133 . 133 ARG A H07 65.69 41.463 66.835 1 2022 +ATOM H H08 . ARG A 1 133 . 133 ARG A H08 67.891 42.948 65.446 1 2023 +ATOM H H09 . ARG A 1 133 . 133 ARG A H09 65.839 44.31 68.634 1 2024 +ATOM H H10 . ARG A 1 133 . 133 ARG A H10 63.139 41.707 63.269 1 2025 +ATOM H H11 . ARG A 1 133 . 133 ARG A H11 65.46 42.731 67.983 1 2026 +ATOM H H12 . ARG A 1 133 . 133 ARG A H12 67.479 45.597 67.655 1 2027 +ATOM N N . GLU A 1 134 . 134 GLU A N 63.184 38.736 61.818 1 2028 +ATOM C CA . GLU A 1 134 . 134 GLU A CA 62.857 37.484 61.147 1 2029 +ATOM C C . GLU A 1 134 . 134 GLU A C 63.343 37.601 59.707 1 2030 +ATOM O O . GLU A 1 134 . 134 GLU A O 63.795 36.618 59.111 1 2031 +ATOM C CB . GLU A 1 134 . 134 GLU A CB 61.353 37.21 61.19 1 2032 +ATOM C CG . GLU A 1 134 . 134 GLU A CG 60.886 36.706 62.545 1 2033 +ATOM C CD . GLU A 1 134 . 134 GLU A CD 61.676 35.484 63.015 1 2034 +ATOM O OE1 . GLU A 1 134 . 134 GLU A OE1 61.665 34.446 62.312 1 2035 +ATOM O OE2 . GLU A 1 134 . 134 GLU A OE2 62.314 35.566 64.086 1 2036 +ATOM H H01 . GLU A 1 134 . 134 GLU A H01 63.343 36.649 61.651 1 2037 +ATOM H H02 . GLU A 1 134 . 134 GLU A H02 61.14 36.43 60.459 1 2038 +ATOM H H03 . GLU A 1 134 . 134 GLU A H03 60.823 38.134 60.96 1 2039 +ATOM H H04 . GLU A 1 134 . 134 GLU A H04 59.842 36.41 62.448 1 2040 +ATOM H H05 . GLU A 1 134 . 134 GLU A H05 61.006 37.505 63.277 1 2041 +ATOM H H06 . GLU A 1 134 . 134 GLU A H06 62.566 34.687 64.377 1 2042 +ATOM H H13 . GLU A 1 134 . 134 GLU A H13 62.449 39.37 62.097 1 2043 +ATOM N N . PHE A 1 135 . 135 PHE A N 63.265 38.807 59.143 1 2044 +ATOM C CA . PHE A 1 135 . 135 PHE A CA 63.749 38.999 57.779 1 2045 +ATOM C C . PHE A 1 135 . 135 PHE A C 65.269 38.983 57.759 1 2046 +ATOM O O . PHE A 1 135 . 135 PHE A O 65.882 38.48 56.823 1 2047 +ATOM C CB . PHE A 1 135 . 135 PHE A CB 63.234 40.312 57.162 1 2048 +ATOM C CG . PHE A 1 135 . 135 PHE A CG 61.914 40.158 56.457 1 2049 +ATOM C CD1 . PHE A 1 135 . 135 PHE A CD1 60.717 40.18 57.176 1 2050 +ATOM C CD2 . PHE A 1 135 . 135 PHE A CD2 61.873 39.903 55.091 1 2051 +ATOM C CE1 . PHE A 1 135 . 135 PHE A CE1 59.497 39.939 56.541 1 2052 +ATOM C CE2 . PHE A 1 135 . 135 PHE A CE2 60.652 39.661 54.443 1 2053 +ATOM C CZ . PHE A 1 135 . 135 PHE A CZ 59.471 39.676 55.164 1 2054 +ATOM H H01 . PHE A 1 135 . 135 PHE A H01 63.363 38.176 57.177 1 2055 +ATOM H H02 . PHE A 1 135 . 135 PHE A H02 63.964 40.627 56.417 1 2056 +ATOM H H03 . PHE A 1 135 . 135 PHE A H03 63.119 41.048 57.958 1 2057 +ATOM H H04 . PHE A 1 135 . 135 PHE A H04 60.735 40.388 58.246 1 2058 +ATOM H H05 . PHE A 1 135 . 135 PHE A H05 62.8 39.891 54.517 1 2059 +ATOM H H06 . PHE A 1 135 . 135 PHE A H06 58.569 39.955 57.113 1 2060 +ATOM H H07 . PHE A 1 135 . 135 PHE A H07 62.873 39.583 59.657 1 2061 +ATOM H H08 . PHE A 1 135 . 135 PHE A H08 60.634 39.461 53.372 1 2062 +ATOM H H09 . PHE A 1 135 . 135 PHE A H09 58.522 39.484 54.663 1 2063 +ATOM N N . VAL A 1 136 . 136 VAL A N 65.896 39.534 58.79 1 2064 +ATOM C CA . VAL A 1 136 . 136 VAL A CA 67.35 39.5 58.812 1 2065 +ATOM C C . VAL A 1 136 . 136 VAL A C 67.807 38.038 58.76 1 2066 +ATOM O O . VAL A 1 136 . 136 VAL A O 68.726 37.691 58.011 1 2067 +ATOM C CB . VAL A 1 136 . 136 VAL A CB 67.894 40.183 60.073 1 2068 +ATOM C CG1 . VAL A 1 136 . 136 VAL A CG1 69.419 40.03 60.153 1 2069 +ATOM C CG2 . VAL A 1 136 . 136 VAL A CG2 67.517 41.649 60.048 1 2070 +ATOM H H01 . VAL A 1 136 . 136 VAL A H01 67.737 40.042 57.949 1 2071 +ATOM H H02 . VAL A 1 136 . 136 VAL A H02 67.458 39.71 60.953 1 2072 +ATOM H H03 . VAL A 1 136 . 136 VAL A H03 69.87 40.999 60.367 1 2073 +ATOM H H04 . VAL A 1 136 . 136 VAL A H04 69.673 39.328 60.947 1 2074 +ATOM H H05 . VAL A 1 136 . 136 VAL A H05 69.798 39.656 59.202 1 2075 +ATOM H H06 . VAL A 1 136 . 136 VAL A H06 66.566 41.788 60.562 1 2076 +ATOM H H07 . VAL A 1 136 . 136 VAL A H07 68.289 42.232 60.55 1 2077 +ATOM H H08 . VAL A 1 136 . 136 VAL A H08 67.423 41.983 59.015 1 2078 +ATOM H H10 . VAL A 1 136 . 136 VAL A H10 65.377 39.966 59.541 1 2079 +ATOM N N . LYS A 1 137 . 137 LYS A N 67.151 37.171 59.528 1 2080 +ATOM C CA . LYS A 1 137 . 137 LYS A CA 67.529 35.756 59.542 1 2081 +ATOM C C . LYS A 1 137 . 137 LYS A C 67.455 35.108 58.164 1 2082 +ATOM O O . LYS A 1 137 . 137 LYS A O 68.33 34.325 57.793 1 2083 +ATOM C CB . LYS A 1 137 . 137 LYS A CB 66.634 34.954 60.502 1 2084 +ATOM C CG . LYS A 1 137 . 137 LYS A CG 66.775 35.316 61.974 1 2085 +ATOM C CD . LYS A 1 137 . 137 LYS A CD 65.761 34.527 62.803 1 2086 +ATOM C CE . LYS A 1 137 . 137 LYS A CE 65.751 34.972 64.265 1 2087 +ATOM N NZ . LYS A 1 137 . 137 LYS A NZ 64.601 34.358 65.003 1 2088 +ATOM H H01 . LYS A 1 137 . 137 LYS A H01 68.565 35.733 59.879 1 2089 +ATOM H H02 . LYS A 1 137 . 137 LYS A H02 66.952 33.915 60.414 1 2090 +ATOM H H03 . LYS A 1 137 . 137 LYS A H03 65.596 35.117 60.212 1 2091 +ATOM H H04 . LYS A 1 137 . 137 LYS A H04 66.589 36.383 62.101 1 2092 +ATOM H H05 . LYS A 1 137 . 137 LYS A H05 67.783 35.076 62.311 1 2093 +ATOM H H06 . LYS A 1 137 . 137 LYS A H06 66.045 33.475 62.772 1 2094 +ATOM H H07 . LYS A 1 137 . 137 LYS A H07 64.767 34.677 62.381 1 2095 +ATOM H H08 . LYS A 1 137 . 137 LYS A H08 65.646 36.056 64.298 1 2096 +ATOM H H09 . LYS A 1 137 . 137 LYS A H09 66.387 37.491 60.105 1 2097 +ATOM H H10 . LYS A 1 137 . 137 LYS A H10 66.684 34.666 64.738 1 2098 +ATOM H H11 . LYS A 1 137 . 137 LYS A H11 64.846 33.421 65.29 1 2099 +ATOM H H12 . LYS A 1 137 . 137 LYS A H12 64.387 34.915 65.818 1 2100 +ATOM N N . GLU A 1 138 . 138 GLU A N 66.415 35.437 57.402 1 2101 +ATOM C CA . GLU A 1 138 . 138 GLU A CA 66.237 34.838 56.082 1 2102 +ATOM C C . GLU A 1 138 . 138 GLU A C 66.989 35.522 54.945 1 2103 +ATOM O O . GLU A 1 138 . 138 GLU A O 67.209 34.918 53.89 1 2104 +ATOM C CB . GLU A 1 138 . 138 GLU A CB 64.747 34.802 55.726 1 2105 +ATOM C CG . GLU A 1 138 . 138 GLU A CG 63.894 33.932 56.642 1 2106 +ATOM C CD . GLU A 1 138 . 138 GLU A CD 64.375 32.493 56.679 1 2107 +ATOM O OE1 . GLU A 1 138 . 138 GLU A OE1 64.644 31.926 55.595 1 2108 +ATOM O OE2 . GLU A 1 138 . 138 GLU A OE2 64.482 31.927 57.788 1 2109 +ATOM H H01 . GLU A 1 138 . 138 GLU A H01 66.664 33.839 56.17 1 2110 +ATOM H H02 . GLU A 1 138 . 138 GLU A H02 64.679 34.361 54.732 1 2111 +ATOM H H03 . GLU A 1 138 . 138 GLU A H03 64.362 35.821 55.769 1 2112 +ATOM H H04 . GLU A 1 138 . 138 GLU A H04 62.876 33.934 56.252 1 2113 +ATOM H H05 . GLU A 1 138 . 138 GLU A H05 63.932 34.341 57.651 1 2114 +ATOM H H06 . GLU A 1 138 . 138 GLU A H06 64.919 31.08 57.675 1 2115 +ATOM H H13 . GLU A 1 138 . 138 GLU A H13 65.741 36.109 57.74 1 2116 +ATOM N N . MET A 1 139 . 139 MET A N 67.385 36.77 55.158 1 2117 +ATOM C CA . MET A 1 139 . 139 MET A CA 68.053 37.553 54.121 1 2118 +ATOM C C . MET A 1 139 . 139 MET A C 69.562 37.482 54.052 1 2119 +ATOM O O . MET A 1 139 . 139 MET A O 70.165 37.75 53.009 1 2120 +ATOM C CB . MET A 1 139 . 139 MET A CB 67.665 39.019 54.265 1 2121 +ATOM C CG . MET A 1 139 . 139 MET A CG 66.262 39.343 53.795 1 2122 +ATOM S SD . MET A 1 139 . 139 MET A SD 65.815 41.067 54.062 1 2123 +ATOM C CE . MET A 1 139 . 139 MET A CE 67.029 41.903 52.962 1 2124 +ATOM H H01 . MET A 1 139 . 139 MET A H01 67.705 37.091 53.197 1 2125 +ATOM H H02 . MET A 1 139 . 139 MET A H02 68.345 39.585 53.628 1 2126 +ATOM H H03 . MET A 1 139 . 139 MET A H03 67.744 39.292 55.317 1 2127 +ATOM H H04 . MET A 1 139 . 139 MET A H04 65.573 38.732 54.378 1 2128 +ATOM H H05 . MET A 1 139 . 139 MET A H05 66.193 39.126 52.729 1 2129 +ATOM H H06 . MET A 1 139 . 139 MET A H06 67.967 41.348 52.969 1 2130 +ATOM H H07 . MET A 1 139 . 139 MET A H07 67.22 37.19 56.061 1 2131 +ATOM H H08 . MET A 1 139 . 139 MET A H08 66.636 41.938 51.946 1 2132 +ATOM H H09 . MET A 1 139 . 139 MET A H09 67.205 42.918 53.319 1 2133 +ATOM N N . TYR A 1 140 . 140 TYR A N 70.186 37.135 55.164 1 2134 +ATOM C CA . TYR A 1 140 . 140 TYR A CA 71.632 37.112 55.191 1 2135 +ATOM C C . TYR A 1 140 . 140 TYR A C 72.185 35.778 55.625 1 2136 +ATOM O O . TYR A 1 140 . 140 TYR A O 71.581 35.077 56.439 1 2137 +ATOM C CB . TYR A 1 140 . 140 TYR A CB 72.148 38.218 56.125 1 2138 +ATOM C CG . TYR A 1 140 . 140 TYR A CG 71.694 39.61 55.726 1 2139 +ATOM C CD1 . TYR A 1 140 . 140 TYR A CD1 70.524 40.164 56.249 1 2140 +ATOM C CD2 . TYR A 1 140 . 140 TYR A CD2 72.42 40.357 54.797 1 2141 +ATOM C CE1 . TYR A 1 140 . 140 TYR A CE1 70.084 41.434 55.855 1 2142 +ATOM C CE2 . TYR A 1 140 . 140 TYR A CE2 71.989 41.621 54.391 1 2143 +ATOM C CZ . TYR A 1 140 . 140 TYR A CZ 70.822 42.155 54.924 1 2144 +ATOM O OH . TYR A 1 140 . 140 TYR A OH 70.406 43.405 54.506 1 2145 +ATOM H H01 . TYR A 1 140 . 140 TYR A H01 71.977 37.285 54.172 1 2146 +ATOM H H02 . TYR A 1 140 . 140 TYR A H02 73.236 38.207 56.063 1 2147 +ATOM H H03 . TYR A 1 140 . 140 TYR A H03 71.789 38.014 57.134 1 2148 +ATOM H H04 . TYR A 1 140 . 140 TYR A H04 69.943 39.598 56.977 1 2149 +ATOM H H05 . TYR A 1 140 . 140 TYR A H05 73.34 39.946 54.381 1 2150 +ATOM H H06 . TYR A 1 140 . 140 TYR A H06 69.17 41.854 56.275 1 2151 +ATOM H H07 . TYR A 1 140 . 140 TYR A H07 72.565 42.187 53.659 1 2152 +ATOM H H08 . TYR A 1 140 . 140 TYR A H08 70.259 43.393 53.557 1 2153 +ATOM H H10 . TYR A 1 140 . 140 TYR A H10 69.657 36.888 55.988 1 2154 +ATOM N N . TYR A 1 141 . 141 TYR A N 73.337 35.431 55.064 1 2155 +ATOM C CA . TYR A 1 141 . 141 TYR A CA 74.009 34.186 55.411 1 2156 +ATOM C C . TYR A 1 141 . 141 TYR A C 74.669 34.319 56.775 1 2157 +ATOM O O . TYR A 1 141 . 141 TYR A O 74.905 35.425 57.255 1 2158 +ATOM C CB . TYR A 1 141 . 141 TYR A CB 75.105 33.881 54.39 1 2159 +ATOM C CG . TYR A 1 141 . 141 TYR A CG 74.592 33.683 52.991 1 2160 +ATOM C CD1 . TYR A 1 141 . 141 TYR A CD1 73.846 32.558 52.663 1 2161 +ATOM C CD2 . TYR A 1 141 . 141 TYR A CD2 74.865 34.614 51.992 1 2162 +ATOM C CE1 . TYR A 1 141 . 141 TYR A CE1 73.383 32.356 51.37 1 2163 +ATOM C CE2 . TYR A 1 141 . 141 TYR A CE2 74.406 34.422 50.696 1 2164 +ATOM C CZ . TYR A 1 141 . 141 TYR A CZ 73.672 33.296 50.395 1 2165 +ATOM O OH . TYR A 1 141 . 141 TYR A OH 73.247 33.097 49.109 1 2166 +ATOM H H01 . TYR A 1 141 . 141 TYR A H01 73.267 33.387 55.421 1 2167 +ATOM H H02 . TYR A 1 141 . 141 TYR A H02 75.569 32.943 54.694 1 2168 +ATOM H H03 . TYR A 1 141 . 141 TYR A H03 75.811 34.712 54.384 1 2169 +ATOM H H04 . TYR A 1 141 . 141 TYR A H04 73.62 31.821 53.434 1 2170 +ATOM H H05 . TYR A 1 141 . 141 TYR A H05 75.446 35.505 52.231 1 2171 +ATOM H H06 . TYR A 1 141 . 141 TYR A H06 72.799 31.469 51.125 1 2172 +ATOM H H07 . TYR A 1 141 . 141 TYR A H07 74.625 35.157 49.922 1 2173 +ATOM H H08 . TYR A 1 141 . 141 TYR A H08 73.007 32.175 48.99 1 2174 +ATOM H H09 . TYR A 1 141 . 141 TYR A H09 73.757 36.045 54.381 1 2175 +ATOM N N . GLY A 1 142 . 142 GLY A N 74.95 33.185 57.399 1 2176 +ATOM C CA . GLY A 1 142 . 142 GLY A CA 75.641 33.222 58.673 1 2177 +ATOM C C . GLY A 1 142 . 142 GLY A C 76.97 33.909 58.411 1 2178 +ATOM O O . GLY A 1 142 . 142 GLY A O 77.499 33.835 57.298 1 2179 +ATOM H H01 . GLY A 1 142 . 142 GLY A H01 75.784 32.22 59.078 1 2180 +ATOM H H02 . GLY A 1 142 . 142 GLY A H02 75.06 33.786 59.403 1 2181 +ATOM H H09 . GLY A 1 142 . 142 GLY A H09 74.685 32.3 56.99 1 2182 +ATOM N N . PRO A 1 143 . 143 PRO A N 77.566 34.559 59.418 1 2183 +ATOM C CA . PRO A 1 143 . 143 PRO A CA 77.101 34.703 60.799 1 2184 +ATOM C C . PRO A 1 143 . 143 PRO A C 75.973 35.706 61.053 1 2185 +ATOM O O . PRO A 1 143 . 143 PRO A O 75.477 35.802 62.168 1 2186 +ATOM C CB . PRO A 1 143 . 143 PRO A CB 78.378 35.093 61.539 1 2187 +ATOM C CG . PRO A 1 143 . 143 PRO A CG 79.075 35.955 60.524 1 2188 +ATOM C CD . PRO A 1 143 . 143 PRO A CD 78.89 35.188 59.234 1 2189 +ATOM H H01 . PRO A 1 143 . 143 PRO A H01 78.97 34.241 61.873 1 2190 +ATOM H H02 . PRO A 1 143 . 143 PRO A H02 78.155 35.651 62.448 1 2191 +ATOM H H03 . PRO A 1 143 . 143 PRO A H03 76.627 33.78 61.131 1 2192 +ATOM H H04 . PRO A 1 143 . 143 PRO A H04 80.122 36.138 60.766 1 2193 +ATOM H H05 . PRO A 1 143 . 143 PRO A H05 78.613 36.94 60.464 1 2194 +ATOM H H06 . PRO A 1 143 . 143 PRO A H06 78.951 35.816 58.345 1 2195 +ATOM H H07 . PRO A 1 143 . 143 PRO A H07 79.669 34.436 59.107 1 2196 +ATOM N N . ILE A 1 144 . 144 ILE A N 75.557 36.445 60.032 1 2197 +ATOM C CA . ILE A 1 144 . 144 ILE A CA 74.509 37.44 60.227 1 2198 +ATOM C C . ILE A 1 144 . 144 ILE A C 73.125 36.818 60.372 1 2199 +ATOM O O . ILE A 1 144 . 144 ILE A O 72.354 37.182 61.272 1 2200 +ATOM C CB . ILE A 1 144 . 144 ILE A CB 74.504 38.457 59.066 1 2201 +ATOM C CG1 . ILE A 1 144 . 144 ILE A CG1 75.883 39.124 58.974 1 2202 +ATOM C CG2 . ILE A 1 144 . 144 ILE A CG2 73.39 39.487 59.275 1 2203 +ATOM C CD1 . ILE A 1 144 . 144 ILE A CD1 76.017 40.141 57.85 1 2204 +ATOM H H01 . ILE A 1 144 . 144 ILE A H01 74.737 37.951 61.163 1 2205 +ATOM H H02 . ILE A 1 144 . 144 ILE A H02 74.306 37.947 58.123 1 2206 +ATOM H H03 . ILE A 1 144 . 144 ILE A H03 76.598 38.33 58.761 1 2207 +ATOM H H04 . ILE A 1 144 . 144 ILE A H04 76.081 39.631 59.918 1 2208 +ATOM H H05 . ILE A 1 144 . 144 ILE A H05 72.508 38.99 59.678 1 2209 +ATOM H H06 . ILE A 1 144 . 144 ILE A H06 73.728 40.251 59.975 1 2210 +ATOM H H07 . ILE A 1 144 . 144 ILE A H07 73.14 39.952 58.321 1 2211 +ATOM H H08 . ILE A 1 144 . 144 ILE A H08 75.967 36.318 59.118 1 2212 +ATOM H H09 . ILE A 1 144 . 144 ILE A H09 77.067 40.239 57.573 1 2213 +ATOM H H10 . ILE A 1 144 . 144 ILE A H10 75.443 39.806 56.986 1 2214 +ATOM H H11 . ILE A 1 144 . 144 ILE A H11 75.639 41.107 58.186 1 2215 +ATOM N N . GLY A 1 145 . 145 GLY A N 72.811 35.875 59.495 1 2216 +ATOM C CA . GLY A 1 145 . 145 GLY A CA 71.518 35.226 59.552 1 2217 +ATOM C C . GLY A 1 145 . 145 GLY A C 71.645 33.742 59.283 1 2218 +ATOM O O . GLY A 1 145 . 145 GLY A O 72.661 33.13 59.618 1 2219 +ATOM H H01 . GLY A 1 145 . 145 GLY A H01 70.875 35.664 58.788 1 2220 +ATOM H H02 . GLY A 1 145 . 145 GLY A H02 71.087 35.374 60.542 1 2221 +ATOM H H12 . GLY A 1 145 . 145 GLY A H12 73.477 35.608 58.784 1 2222 +ATOM N N . ARG A 1 146 . 146 ARG A N 70.621 33.154 58.674 1 2223 +ATOM C CA . ARG A 1 146 . 146 ARG A CA 70.652 31.736 58.364 1 2224 +ATOM C C . ARG A 1 146 . 146 ARG A C 70.103 31.471 56.976 1 2225 +ATOM O O . ARG A 1 146 . 146 ARG A O 69.588 30.39 56.686 1 2226 +ATOM C CB . ARG A 1 146 . 146 ARG A CB 69.873 30.939 59.413 1 2227 +ATOM C CG . ARG A 1 146 . 146 ARG A CG 68.452 31.391 59.648 1 2228 +ATOM C CD . ARG A 1 146 . 146 ARG A CD 67.933 30.792 60.958 1 2229 +ATOM N NE . ARG A 1 146 . 146 ARG A NE 66.584 31.248 61.277 1 2230 +ATOM C CZ . ARG A 1 146 . 146 ARG A CZ 65.511 30.95 60.554 1 2231 +ATOM N NH1 . ARG A 1 146 . 146 ARG A NH1 65.633 30.19 59.473 1 2232 +ATOM N NH2 . ARG A 1 146 . 146 ARG A NH2 64.32 31.42 60.904 1 2233 +ATOM H H01 . ARG A 1 146 . 146 ARG A H01 71.691 31.409 58.383 1 2234 +ATOM H H02 . ARG A 1 146 . 146 ARG A H02 70.396 31.094 60.356 1 2235 +ATOM H H03 . ARG A 1 146 . 146 ARG A H03 69.81 33.701 58.423 1 2236 +ATOM H H04 . ARG A 1 146 . 146 ARG A H04 69.846 29.897 59.093 1 2237 +ATOM H H05 . ARG A 1 146 . 146 ARG A H05 67.824 31.055 58.823 1 2238 +ATOM H H06 . ARG A 1 146 . 146 ARG A H06 68.422 32.479 59.709 1 2239 +ATOM H H07 . ARG A 1 146 . 146 ARG A H07 68.595 31.119 61.76 1 2240 +ATOM H H08 . ARG A 1 146 . 146 ARG A H08 67.925 29.706 60.869 1 2241 +ATOM H H09 . ARG A 1 146 . 146 ARG A H09 66.459 31.825 62.097 1 2242 +ATOM H H10 . ARG A 1 146 . 146 ARG A H10 64.819 29.966 58.919 1 2243 +ATOM H H11 . ARG A 1 146 . 146 ARG A H11 66.54 29.836 59.204 1 2244 +ATOM H H12 . ARG A 1 146 . 146 ARG A H12 63.506 31.196 60.35 1 2245 +ATOM N N . ARG A 1 147 . 147 ARG A N 70.226 32.473 56.113 1 2246 +ATOM C CA . ARG A 1 147 . 147 ARG A CA 69.754 32.344 54.74 1 2247 +ATOM C C . ARG A 1 147 . 147 ARG A C 70.338 31.086 54.115 1 2248 +ATOM O O . ARG A 1 147 . 147 ARG A O 71.514 30.77 54.297 1 2249 +ATOM C CB . ARG A 1 147 . 147 ARG A CB 70.169 33.564 53.912 1 2250 +ATOM C CG . ARG A 1 147 . 147 ARG A CG 69.822 33.434 52.415 1 2251 +ATOM C CD . ARG A 1 147 . 147 ARG A CD 70.292 34.643 51.62 1 2252 +ATOM N NE . ARG A 1 147 . 147 ARG A NE 70.102 34.441 50.183 1 2253 +ATOM C CZ . ARG A 1 147 . 147 ARG A CZ 70.617 35.233 49.243 1 2254 +ATOM N NH1 . ARG A 1 147 . 147 ARG A NH1 71.341 36.283 49.591 1 2255 +ATOM N NH2 . ARG A 1 147 . 147 ARG A NH2 70.438 34.954 47.959 1 2256 +ATOM H H01 . ARG A 1 147 . 147 ARG A H01 68.666 32.279 54.75 1 2257 +ATOM H H02 . ARG A 1 147 . 147 ARG A H02 71.253 33.649 53.985 1 2258 +ATOM H H03 . ARG A 1 147 . 147 ARG A H03 69.661 34.442 54.31 1 2259 +ATOM H H04 . ARG A 1 147 . 147 ARG A H04 68.738 33.368 52.323 1 2260 +ATOM H H05 . ARG A 1 147 . 147 ARG A H05 70.303 32.54 52.017 1 2261 +ATOM H H06 . ARG A 1 147 . 147 ARG A H06 71.356 34.787 51.809 1 2262 +ATOM H H07 . ARG A 1 147 . 147 ARG A H07 69.724 35.518 51.934 1 2263 +ATOM H H08 . ARG A 1 147 . 147 ARG A H08 69.546 33.652 49.885 1 2264 +ATOM H H09 . ARG A 1 147 . 147 ARG A H09 71.744 36.877 48.88 1 2265 +ATOM H H10 . ARG A 1 147 . 147 ARG A H10 71.491 36.491 50.568 1 2266 +ATOM H H11 . ARG A 1 147 . 147 ARG A H11 70.843 35.55 47.251 1 2267 +ATOM H H13 . ARG A 1 147 . 147 ARG A H13 70.653 33.338 56.413 1 2268 +ATOM N N . SER A 1 148 . 148 SER A N 69.5 30.371 53.371 1 2269 +ATOM C CA . SER A 1 148 . 148 SER A CA 69.919 29.155 52.702 1 2270 +ATOM C C . SER A 1 148 . 148 SER A C 70.626 29.483 51.397 1 2271 +ATOM O O . SER A 1 148 . 148 SER A O 70.167 30.31 50.607 1 2272 +ATOM C CB . SER A 1 148 . 148 SER A CB 68.707 28.267 52.423 1 2273 +ATOM O OG . SER A 1 148 . 148 SER A OG 69.103 27.081 51.767 1 2274 +ATOM H H01 . SER A 1 148 . 148 SER A H01 70.612 28.623 53.353 1 2275 +ATOM H H02 . SER A 1 148 . 148 SER A H02 68.013 28.81 51.781 1 2276 +ATOM H H03 . SER A 1 148 . 148 SER A H03 68.224 28.012 53.366 1 2277 +ATOM H H04 . SER A 1 148 . 148 SER A H04 69.118 27.229 50.819 1 2278 +ATOM H H12 . SER A 1 148 . 148 SER A H12 68.545 30.684 53.269 1 2279 +ATOM N N . PHE A 1 149 . 149 PHE A N 71.737 28.802 51.17 1 2280 +ATOM C CA . PHE A 1 149 . 149 PHE A CA 72.544 28.996 49.982 1 2281 +ATOM C C . PHE A 1 149 . 149 PHE A C 72.003 28.178 48.793 1 2282 +ATOM O O . PHE A 1 149 . 149 PHE A O 71.852 26.96 48.894 1 2283 +ATOM C CB . PHE A 1 149 . 149 PHE A CB 73.972 28.569 50.314 1 2284 +ATOM C CG . PHE A 1 149 . 149 PHE A CG 74.967 28.94 49.275 1 2285 +ATOM C CD1 . PHE A 1 149 . 149 PHE A CD1 75.524 30.216 49.255 1 2286 +ATOM C CD2 . PHE A 1 149 . 149 PHE A CD2 75.333 28.026 48.293 1 2287 +ATOM C CE1 . PHE A 1 149 . 149 PHE A CE1 76.439 30.584 48.262 1 2288 +ATOM C CE2 . PHE A 1 149 . 149 PHE A CE2 76.246 28.384 47.298 1 2289 +ATOM C CZ . PHE A 1 149 . 149 PHE A CZ 76.797 29.666 47.286 1 2290 +ATOM H H01 . PHE A 1 149 . 149 PHE A H01 72.514 30.045 49.687 1 2291 +ATOM H H02 . PHE A 1 149 . 149 PHE A H02 73.971 27.481 50.377 1 2292 +ATOM H H03 . PHE A 1 149 . 149 PHE A H03 74.263 29.041 51.253 1 2293 +ATOM H H04 . PHE A 1 149 . 149 PHE A H04 75.244 30.937 50.023 1 2294 +ATOM H H05 . PHE A 1 149 . 149 PHE A H05 72.035 28.119 51.852 1 2295 +ATOM H H06 . PHE A 1 149 . 149 PHE A H06 74.905 27.024 48.3 1 2296 +ATOM H H07 . PHE A 1 149 . 149 PHE A H07 76.868 31.586 48.256 1 2297 +ATOM H H08 . PHE A 1 149 . 149 PHE A H08 76.528 27.662 46.531 1 2298 +ATOM H H09 . PHE A 1 149 . 149 PHE A H09 77.509 29.946 46.509 1 2299 +ATOM N N . SER A 1 150 . 150 SER A N 71.714 28.839 47.674 1 2300 +ATOM C CA . SER A 1 150 . 150 SER A CA 71.205 28.143 46.49 1 2301 +ATOM C C . SER A 1 150 . 150 SER A C 72.343 27.684 45.598 1 2302 +ATOM O O . SER A 1 150 . 150 SER A O 73.28 28.433 45.348 1 2303 +ATOM C CB . SER A 1 150 . 150 SER A CB 70.293 29.05 45.661 1 2304 +ATOM O OG . SER A 1 150 . 150 SER A OG 69.935 28.432 44.43 1 2305 +ATOM H H01 . SER A 1 150 . 150 SER A H01 70.641 27.283 46.851 1 2306 +ATOM H H02 . SER A 1 150 . 150 SER A H02 70.831 29.972 45.441 1 2307 +ATOM H H03 . SER A 1 150 . 150 SER A H03 69.388 29.261 46.23 1 2308 +ATOM H H04 . SER A 1 150 . 150 SER A H04 69.7 27.515 44.588 1 2309 +ATOM H H10 . SER A 1 150 . 150 SER A H10 71.848 29.839 47.641 1 2310 +ATOM N N . PRO A 1 151 . 151 PRO A N 72.268 26.446 45.098 1 2311 +ATOM C CA . PRO A 1 151 . 151 PRO A CA 73.332 25.951 44.229 1 2312 +ATOM C C . PRO A 1 151 . 151 PRO A C 73.118 26.335 42.77 1 2313 +ATOM O O . PRO A 1 151 . 151 PRO A O 74.081 26.394 42.002 1 2314 +ATOM C CB . PRO A 1 151 . 151 PRO A CB 73.253 24.441 44.431 1 2315 +ATOM C CG . PRO A 1 151 . 151 PRO A CG 71.773 24.219 44.523 1 2316 +ATOM C CD . PRO A 1 151 . 151 PRO A CD 71.338 25.356 45.458 1 2317 +ATOM H H01 . PRO A 1 151 . 151 PRO A H01 73.807 24.083 45.299 1 2318 +ATOM H H02 . PRO A 1 151 . 151 PRO A H02 73.684 23.904 43.586 1 2319 +ATOM H H03 . PRO A 1 151 . 151 PRO A H03 71.499 23.227 44.881 1 2320 +ATOM H H04 . PRO A 1 151 . 151 PRO A H04 71.297 24.305 43.546 1 2321 +ATOM H H05 . PRO A 1 151 . 151 PRO A H05 74.307 26.373 44.472 1 2322 +ATOM H H06 . PRO A 1 151 . 151 PRO A H06 70.288 25.629 45.348 1 2323 +ATOM H H07 . PRO A 1 151 . 151 PRO A H07 71.451 25.071 46.504 1 2324 +ATOM N N . TRP A 1 152 . 152 TRP A N 71.868 26.627 42.397 1 2325 +ATOM C CA . TRP A 1 152 . 152 TRP A CA 71.531 26.921 41.004 1 2326 +ATOM C C . TRP A 1 152 . 152 TRP A C 71.76 28.335 40.471 1 2327 +ATOM O O . TRP A 1 152 . 152 TRP A O 71.551 28.575 39.283 1 2328 +ATOM C CB . TRP A 1 152 . 152 TRP A CB 70.068 26.565 40.721 1 2329 +ATOM C CG . TRP A 1 152 . 152 TRP A CG 69.523 25.388 41.484 1 2330 +ATOM C CD1 . TRP A 1 152 . 152 TRP A CD1 68.715 25.431 42.585 1 2331 +ATOM C CD2 . TRP A 1 152 . 152 TRP A CD2 69.713 24.002 41.181 1 2332 +ATOM C CE2 . TRP A 1 152 . 152 TRP A CE2 68.986 23.26 42.143 1 2333 +ATOM C CE3 . TRP A 1 152 . 152 TRP A CE3 70.421 23.312 40.188 1 2334 +ATOM N NE1 . TRP A 1 152 . 152 TRP A NE1 68.386 24.158 42.985 1 2335 +ATOM C CZ2 . TRP A 1 152 . 152 TRP A CZ2 68.948 21.862 42.141 1 2336 +ATOM C CZ3 . TRP A 1 152 . 152 TRP A CZ3 70.383 21.913 40.185 1 2337 +ATOM C CH2 . TRP A 1 152 . 152 TRP A CH2 69.65 21.207 41.158 1 2338 +ATOM H H01 . TRP A 1 152 . 152 TRP A H01 72.256 26.301 40.476 1 2339 +ATOM H H02 . TRP A 1 152 . 152 TRP A H02 70.03 26.28 39.67 1 2340 +ATOM H H03 . TRP A 1 152 . 152 TRP A H03 69.458 27.435 40.965 1 2341 +ATOM H H04 . TRP A 1 152 . 152 TRP A H04 68.379 26.344 43.076 1 2342 +ATOM H H05 . TRP A 1 152 . 152 TRP A H05 70.99 23.854 39.433 1 2343 +ATOM H H06 . TRP A 1 152 . 152 TRP A H06 67.798 23.923 43.772 1 2344 +ATOM H H07 . TRP A 1 152 . 152 TRP A H07 68.382 21.312 42.892 1 2345 +ATOM H H08 . TRP A 1 152 . 152 TRP A H08 71.138 26.645 43.094 1 2346 +ATOM H H09 . TRP A 1 152 . 152 TRP A H09 70.928 21.362 39.419 1 2347 +ATOM H H10 . TRP A 1 152 . 152 TRP A H10 69.639 20.117 41.131 1 2348 +ATOM N N . THR A 1 153 . 153 THR A N 72.186 29.251 41.33 1 2349 +ATOM C CA . THR A 1 153 . 153 THR A CA 72.393 30.643 40.927 1 2350 +ATOM C C . THR A 1 153 . 153 THR A C 73.72 30.897 40.231 1 2351 +ATOM O O . THR A 1 153 . 153 THR A O 73.896 31.92 39.566 1 2352 +ATOM C CB . THR A 1 153 . 153 THR A CB 72.33 31.58 42.138 1 2353 +ATOM C CG2 . THR A 1 153 . 153 THR A CG2 70.955 31.51 42.796 1 2354 +ATOM O OG1 . THR A 1 153 . 153 THR A OG1 73.333 31.191 43.088 1 2355 +ATOM H H01 . THR A 1 153 . 153 THR A H01 71.589 30.844 40.219 1 2356 +ATOM H H02 . THR A 1 153 . 153 THR A H02 72.507 32.602 41.804 1 2357 +ATOM H H03 . THR A 1 153 . 153 THR A H03 70.217 31.188 42.061 1 2358 +ATOM H H04 . THR A 1 153 . 153 THR A H04 70.982 30.797 43.62 1 2359 +ATOM H H05 . THR A 1 153 . 153 THR A H05 70.683 32.495 43.176 1 2360 +ATOM H H06 . THR A 1 153 . 153 THR A H06 73.615 30.292 42.906 1 2361 +ATOM H H11 . THR A 1 153 . 153 THR A H11 72.372 28.982 42.285 1 2362 +ATOM N N . PHE A 1 154 . 154 PHE A N 74.653 29.969 40.387 1 2363 +ATOM C CA . PHE A 1 154 . 154 PHE A CA 75.972 30.126 39.791 1 2364 +ATOM C C . PHE A 1 154 . 154 PHE A C 76.52 28.782 39.348 1 2365 +ATOM O O . PHE A 1 154 . 154 PHE A O 75.855 27.76 39.469 1 2366 +ATOM C CB . PHE A 1 154 . 154 PHE A CB 76.924 30.751 40.808 1 2367 +ATOM C CG . PHE A 1 154 . 154 PHE A CG 77.215 29.86 41.989 1 2368 +ATOM C CD1 . PHE A 1 154 . 154 PHE A CD1 76.207 29.519 42.886 1 2369 +ATOM C CD2 . PHE A 1 154 . 154 PHE A CD2 78.486 29.326 42.178 1 2370 +ATOM C CE1 . PHE A 1 154 . 154 PHE A CE1 76.46 28.658 43.952 1 2371 +ATOM C CE2 . PHE A 1 154 . 154 PHE A CE2 78.75 28.461 43.245 1 2372 +ATOM C CZ . PHE A 1 154 . 154 PHE A CZ 77.731 28.128 44.133 1 2373 +ATOM H H01 . PHE A 1 154 . 154 PHE A H01 75.885 30.775 38.92 1 2374 +ATOM H H02 . PHE A 1 154 . 154 PHE A H02 76.436 31.645 41.195 1 2375 +ATOM H H03 . PHE A 1 154 . 154 PHE A H03 77.865 30.978 40.306 1 2376 +ATOM H H04 . PHE A 1 154 . 154 PHE A H04 75.207 29.931 42.753 1 2377 +ATOM H H05 . PHE A 1 154 . 154 PHE A H05 79.287 29.585 41.485 1 2378 +ATOM H H06 . PHE A 1 154 . 154 PHE A H06 75.659 28.4 44.645 1 2379 +ATOM H H07 . PHE A 1 154 . 154 PHE A H07 74.446 29.141 40.927 1 2380 +ATOM H H08 . PHE A 1 154 . 154 PHE A H08 79.75 28.049 43.381 1 2381 +ATOM H H09 . PHE A 1 154 . 154 PHE A H09 77.93 27.454 44.967 1 2382 +ATOM N N . SER A 1 155 . 155 SER A N 77.74 28.793 38.83 1 2383 +ATOM C CA . SER A 1 155 . 155 SER A CA 78.381 27.569 38.378 1 2384 +ATOM C C . SER A 1 155 . 155 SER A C 79.833 27.629 38.826 1 2385 +ATOM O O . SER A 1 155 . 155 SER A O 80.615 28.428 38.321 1 2386 +ATOM C CB . SER A 1 155 . 155 SER A CB 78.309 27.457 36.855 1 2387 +ATOM O OG . SER A 1 155 . 155 SER A OG 78.841 26.215 36.437 1 2388 +ATOM H H01 . SER A 1 155 . 155 SER A H01 77.878 26.698 38.799 1 2389 +ATOM H H02 . SER A 1 155 . 155 SER A H02 78.89 28.264 36.409 1 2390 +ATOM H H03 . SER A 1 155 . 155 SER A H03 77.27 27.532 36.535 1 2391 +ATOM H H04 . SER A 1 155 . 155 SER A H04 78.925 26.208 35.481 1 2392 +ATOM H H10 . SER A 1 155 . 155 SER A H10 78.234 29.67 38.748 1 2393 +ATOM N N . PRO A 1 156 . 156 PRO A N 80.207 26.787 39.798 1 2394 +ATOM C CA . PRO A 1 156 . 156 PRO A CA 81.576 26.749 40.323 1 2395 +ATOM C C . PRO A 1 156 . 156 PRO A C 82.656 26.665 39.248 1 2396 +ATOM O O . PRO A 1 156 . 156 PRO A O 82.616 25.771 38.398 1 2397 +ATOM C CB . PRO A 1 156 . 156 PRO A CB 81.56 25.509 41.224 1 2398 +ATOM C CG . PRO A 1 156 . 156 PRO A CG 80.165 25.519 41.747 1 2399 +ATOM C CD . PRO A 1 156 . 156 PRO A CD 79.354 25.809 40.493 1 2400 +ATOM H H01 . PRO A 1 156 . 156 PRO A H01 82.32 25.526 42.005 1 2401 +ATOM H H02 . PRO A 1 156 . 156 PRO A H02 81.763 24.602 40.654 1 2402 +ATOM H H03 . PRO A 1 156 . 156 PRO A H03 80.004 26.24 42.549 1 2403 +ATOM H H04 . PRO A 1 156 . 156 PRO A H04 79.897 24.554 42.178 1 2404 +ATOM H H05 . PRO A 1 156 . 156 PRO A H05 81.836 27.67 40.845 1 2405 +ATOM H H06 . PRO A 1 156 . 156 PRO A H06 79.128 24.923 39.9 1 2406 +ATOM H H07 . PRO A 1 156 . 156 PRO A H07 78.381 26.235 40.739 1 2407 +ATOM N N . GLY A 1 157 . 157 GLY A N 83.6 27.609 39.297 1 2408 +ATOM C CA . GLY A 1 157 . 157 GLY A CA 84.716 27.664 38.362 1 2409 +ATOM C C . GLY A 1 157 . 157 GLY A C 84.399 28.124 36.951 1 2410 +ATOM O O . GLY A 1 157 . 157 GLY A O 85.288 28.192 36.094 1 2411 +ATOM H H01 . GLY A 1 157 . 157 GLY A H01 85.054 26.633 38.258 1 2412 +ATOM H H02 . GLY A 1 157 . 157 GLY A H02 85.46 28.346 38.774 1 2413 +ATOM H H08 . GLY A 1 157 . 157 GLY A H08 83.537 28.317 40.015 1 2414 +ATOM N N . ILE A 1 158 . 158 ILE A N 83.135 28.472 36.725 1 2415 +ATOM C CA . ILE A 1 158 . 158 ILE A CA 82.648 28.873 35.411 1 2416 +ATOM C C . ILE A 1 158 . 158 ILE A C 81.95 30.226 35.44 1 2417 +ATOM O O . ILE A 1 158 . 158 ILE A O 82.257 31.107 34.64 1 2418 +ATOM C CB . ILE A 1 158 . 158 ILE A CB 81.69 27.775 34.886 1 2419 +ATOM C CG1 . ILE A 1 158 . 158 ILE A CG1 82.498 26.504 34.616 1 2420 +ATOM C CG2 . ILE A 1 158 . 158 ILE A CG2 80.923 28.257 33.653 1 2421 +ATOM C CD1 . ILE A 1 158 . 158 ILE A CD1 81.655 25.278 34.365 1 2422 +ATOM H H01 . ILE A 1 158 . 158 ILE A H01 83.502 28.983 34.742 1 2423 +ATOM H H02 . ILE A 1 158 . 158 ILE A H02 80.935 27.549 35.639 1 2424 +ATOM H H03 . ILE A 1 158 . 158 ILE A H03 82.485 28.458 37.498 1 2425 +ATOM H H04 . ILE A 1 158 . 158 ILE A H04 83.08 26.305 35.516 1 2426 +ATOM H H05 . ILE A 1 158 . 158 ILE A H05 83.123 26.675 33.74 1 2427 +ATOM H H06 . ILE A 1 158 . 158 ILE A H06 80.024 28.786 33.969 1 2428 +ATOM H H07 . ILE A 1 158 . 158 ILE A H07 81.554 28.93 33.072 1 2429 +ATOM H H08 . ILE A 1 158 . 158 ILE A H08 80.644 27.4 33.04 1 2430 +ATOM H H09 . ILE A 1 158 . 158 ILE A H09 80.723 25.571 33.883 1 2431 +ATOM H H10 . ILE A 1 158 . 158 ILE A H10 82.198 24.59 33.717 1 2432 +ATOM H H11 . ILE A 1 158 . 158 ILE A H11 81.434 24.788 35.313 1 2433 +ATOM N N . ALA A 1 159 . 159 ALA A N 81.0 30.375 36.355 1 2434 +ATOM C CA . ALA A 1 159 . 159 ALA A CA 80.272 31.627 36.518 1 2435 +ATOM C C . ALA A 1 159 . 159 ALA A C 80.119 31.827 38.017 1 2436 +ATOM O O . ALA A 1 159 . 159 ALA A O 79.112 31.435 38.598 1 2437 +ATOM C CB . ALA A 1 159 . 159 ALA A CB 78.893 31.54 35.841 1 2438 +ATOM H H01 . ALA A 1 159 . 159 ALA A H01 80.801 32.461 36.056 1 2439 +ATOM H H02 . ALA A 1 159 . 159 ALA A H02 78.204 32.226 36.333 1 2440 +ATOM H H03 . ALA A 1 159 . 159 ALA A H03 78.986 31.811 34.789 1 2441 +ATOM H H04 . ALA A 1 159 . 159 ALA A H04 78.511 30.522 35.922 1 2442 +ATOM H H12 . ALA A 1 159 . 159 ALA A H12 80.775 29.596 36.957 1 2443 +ATOM N N . ASP A 1 160 . 160 ASP A N 81.141 32.413 38.638 1 2444 +ATOM C CA . ASP A 1 160 . 160 ASP A CA 81.143 32.646 40.079 1 2445 +ATOM C C . ASP A 1 160 . 160 ASP A C 81.958 33.868 40.482 1 2446 +ATOM O O . ASP A 1 160 . 160 ASP A O 82.413 33.973 41.621 1 2447 +ATOM C CB . ASP A 1 160 . 160 ASP A CB 81.684 31.406 40.818 1 2448 +ATOM C CG . ASP A 1 160 . 160 ASP A CG 83.118 31.029 40.4 1 2449 +ATOM O OD1 . ASP A 1 160 . 160 ASP A OD1 83.766 31.8 39.66 1 2450 +ATOM O OD2 . ASP A 1 160 . 160 ASP A OD2 83.607 29.954 40.841 1 2451 +ATOM H H01 . ASP A 1 160 . 160 ASP A H01 80.108 32.835 40.364 1 2452 +ATOM H H02 . ASP A 1 160 . 160 ASP A H02 81.039 30.567 40.557 1 2453 +ATOM H H03 . ASP A 1 160 . 160 ASP A H03 81.677 31.609 41.889 1 2454 +ATOM H H04 . ASP A 1 160 . 160 ASP A H04 83.773 29.354 40.11 1 2455 +ATOM H H05 . ASP A 1 160 . 160 ASP A H05 81.94 32.706 38.094 1 2456 +ATOM N N . ALA A 1 161 . 161 ALA A N 82.121 34.811 39.563 1 2457 +ATOM C CA . ALA A 1 161 . 161 ALA A CA 82.903 36.007 39.85 1 2458 +ATOM C C . ALA A 1 161 . 161 ALA A C 82.278 36.861 40.94 1 2459 +ATOM O O . ALA A 1 161 . 161 ALA A O 81.065 36.939 41.06 1 2460 +ATOM C CB . ALA A 1 161 . 161 ALA A CB 83.082 36.835 38.578 1 2461 +ATOM H H01 . ALA A 1 161 . 161 ALA A H01 83.875 35.675 40.215 1 2462 +ATOM H H02 . ALA A 1 161 . 161 ALA A H02 84.131 36.823 38.282 1 2463 +ATOM H H03 . ALA A 1 161 . 161 ALA A H03 82.474 36.41 37.779 1 2464 +ATOM H H04 . ALA A 1 161 . 161 ALA A H04 82.77 37.862 38.766 1 2465 +ATOM H H05 . ALA A 1 161 . 161 ALA A H05 81.698 34.699 38.653 1 2466 +ATOM N N . SER A 1 162 . 162 SER A N 83.132 37.511 41.722 1 2467 +ATOM C CA . SER A 1 162 . 162 SER A CA 82.691 38.375 42.817 1 2468 +ATOM C C . SER A 1 162 . 162 SER A C 83.649 39.559 42.904 1 2469 +ATOM O O . SER A 1 162 . 162 SER A O 84.801 39.463 42.482 1 2470 +ATOM C CB . SER A 1 162 . 162 SER A CB 82.721 37.606 44.147 1 2471 +ATOM O OG . SER A 1 162 . 162 SER A OG 82.56 38.493 45.254 1 2472 +ATOM H H01 . SER A 1 162 . 162 SER A H01 81.672 38.713 42.63 1 2473 +ATOM H H02 . SER A 1 162 . 162 SER A H02 83.686 37.108 44.239 1 2474 +ATOM H H03 . SER A 1 162 . 162 SER A H03 81.913 36.874 44.155 1 2475 +ATOM H H04 . SER A 1 162 . 162 SER A H04 82.828 39.378 44.997 1 2476 +ATOM H H05 . SER A 1 162 . 162 SER A H05 84.122 37.405 41.555 1 2477 +ATOM N N . LEU A 1 163 . 163 LEU A N 83.178 40.674 43.447 1 2478 +ATOM C CA . LEU A 1 163 . 163 LEU A CA 84.032 41.849 43.611 1 2479 +ATOM C C . LEU A 1 163 . 163 LEU A C 84.927 41.642 44.82 1 2480 +ATOM O O . LEU A 1 163 . 163 LEU A O 85.935 42.325 44.967 1 2481 +ATOM C CB . LEU A 1 163 . 163 LEU A CB 83.208 43.102 43.875 1 2482 +ATOM C CG . LEU A 1 163 . 163 LEU A CG 82.283 43.606 42.767 1 2483 +ATOM C CD1 . LEU A 1 163 . 163 LEU A CD1 81.518 44.813 43.287 1 2484 +ATOM C CD2 . LEU A 1 163 . 163 LEU A CD2 83.105 43.969 41.532 1 2485 +ATOM H H01 . LEU A 1 163 . 163 LEU A H01 84.606 41.975 42.693 1 2486 +ATOM H H02 . LEU A 1 163 . 163 LEU A H02 83.949 43.893 43.988 1 2487 +ATOM H H03 . LEU A 1 163 . 163 LEU A H03 82.588 42.903 44.749 1 2488 +ATOM H H04 . LEU A 1 163 . 163 LEU A H04 81.574 42.829 42.481 1 2489 +ATOM H H05 . LEU A 1 163 . 163 LEU A H05 82.216 40.712 43.751 1 2490 +ATOM H H06 . LEU A 1 163 . 163 LEU A H06 80.545 44.493 43.661 1 2491 +ATOM H H07 . LEU A 1 163 . 163 LEU A H07 82.081 45.28 44.095 1 2492 +ATOM H H08 . LEU A 1 163 . 163 LEU A H08 81.378 45.531 42.479 1 2493 +ATOM H H09 . LEU A 1 163 . 163 LEU A H09 84.093 43.514 41.607 1 2494 +ATOM H H10 . LEU A 1 163 . 163 LEU A H10 82.602 43.599 40.639 1 2495 +ATOM H H11 . LEU A 1 163 . 163 LEU A H11 83.208 45.052 41.469 1 2496 +ATOM N N . PHE A 1 164 . 164 PHE A N 84.54 40.707 45.681 1 2497 +ATOM C CA . PHE A 1 164 . 164 PHE A CA 85.288 40.44 46.91 1 2498 +ATOM C C . PHE A 1 164 . 164 PHE A C 86.298 39.314 46.772 1 2499 +ATOM O O . PHE A 1 164 . 164 PHE A O 86.111 38.394 45.987 1 2500 +ATOM C CB . PHE A 1 164 . 164 PHE A CB 84.311 40.134 48.045 1 2501 +ATOM C CG . PHE A 1 164 . 164 PHE A CG 83.348 41.249 48.308 1 2502 +ATOM C CD1 . PHE A 1 164 . 164 PHE A CD1 82.197 41.389 47.539 1 2503 +ATOM C CD2 . PHE A 1 164 . 164 PHE A CD2 83.635 42.214 49.271 1 2504 +ATOM C CE1 . PHE A 1 164 . 164 PHE A CE1 81.347 42.481 47.722 1 2505 +ATOM C CE2 . PHE A 1 164 . 164 PHE A CE2 82.793 43.31 49.463 1 2506 +ATOM C CZ . PHE A 1 164 . 164 PHE A CZ 81.647 43.446 48.686 1 2507 +ATOM H H01 . PHE A 1 164 . 164 PHE A H01 85.863 41.339 47.133 1 2508 +ATOM H H02 . PHE A 1 164 . 164 PHE A H02 84.9 39.993 48.951 1 2509 +ATOM H H03 . PHE A 1 164 . 164 PHE A H03 83.745 39.24 47.784 1 2510 +ATOM H H04 . PHE A 1 164 . 164 PHE A H04 81.957 40.638 46.786 1 2511 +ATOM H H05 . PHE A 1 164 . 164 PHE A H05 84.531 42.111 49.884 1 2512 +ATOM H H06 . PHE A 1 164 . 164 PHE A H06 80.449 42.581 47.112 1 2513 +ATOM H H07 . PHE A 1 164 . 164 PHE A H07 83.032 44.057 50.219 1 2514 +ATOM H H08 . PHE A 1 164 . 164 PHE A H08 80.987 44.301 48.829 1 2515 +ATOM H H12 . PHE A 1 164 . 164 PHE A H12 83.71 40.168 45.482 1 2516 +ATOM N N . PRO A 1 165 . 165 PRO A N 87.396 39.38 47.549 1 2517 +ATOM C CA . PRO A 1 165 . 165 PRO A CA 88.465 38.371 47.533 1 2518 +ATOM C C . PRO A 1 165 . 165 PRO A C 87.996 37.015 48.044 1 2519 +ATOM O O . PRO A 1 165 . 165 PRO A O 87.204 36.944 48.985 1 2520 +ATOM C CB . PRO A 1 165 . 165 PRO A CB 89.531 38.962 48.466 1 2521 +ATOM C CG . PRO A 1 165 . 165 PRO A CG 89.214 40.419 48.526 1 2522 +ATOM C CD . PRO A 1 165 . 165 PRO A CD 87.71 40.464 48.495 1 2523 +ATOM H H01 . PRO A 1 165 . 165 PRO A H01 90.543 38.774 48.108 1 2524 +ATOM H H02 . PRO A 1 165 . 165 PRO A H02 89.468 38.513 49.457 1 2525 +ATOM H H03 . PRO A 1 165 . 165 PRO A H03 89.668 40.982 47.71 1 2526 +ATOM H H04 . PRO A 1 165 . 165 PRO A H04 89.597 40.862 49.445 1 2527 +ATOM H H05 . PRO A 1 165 . 165 PRO A H05 87.245 40.345 49.473 1 2528 +ATOM H H06 . PRO A 1 165 . 165 PRO A H06 87.348 41.426 48.132 1 2529 +ATOM H H09 . PRO A 1 165 . 165 PRO A H09 88.826 38.179 46.522 1 2530 +ATOM N N . ASN A 1 166 . 166 ASN A N 88.501 35.948 47.428 1 2531 +ATOM C CA . ASN A 1 166 . 166 ASN A CA 88.168 34.579 47.818 1 2532 +ATOM C C . ASN A 1 166 . 166 ASN A C 86.709 34.453 48.212 1 2533 +ATOM O O . ASN A 1 166 . 166 ASN A O 86.385 33.964 49.296 1 2534 +ATOM C CB . ASN A 1 166 . 166 ASN A CB 89.047 34.143 48.994 1 2535 +ATOM C CG . ASN A 1 166 . 166 ASN A CG 90.516 34.411 48.747 1 2536 +ATOM N ND2 . ASN A 1 166 . 166 ASN A ND2 91.106 35.27 49.572 1 2537 +ATOM O OD1 . ASN A 1 166 . 166 ASN A OD1 91.116 33.856 47.821 1 2538 +ATOM H H01 . ASN A 1 166 . 166 ASN A H01 88.349 33.936 46.957 1 2539 +ATOM H H02 . ASN A 1 166 . 166 ASN A H02 88.922 33.068 49.121 1 2540 +ATOM H H03 . ASN A 1 166 . 166 ASN A H03 88.736 34.688 49.885 1 2541 +ATOM H H04 . ASN A 1 166 . 166 ASN A H04 92.085 35.491 49.456 1 2542 +ATOM H H05 . ASN A 1 166 . 166 ASN A H05 90.576 35.702 50.315 1 2543 +ATOM H H07 . ASN A 1 166 . 166 ASN A H07 89.141 36.09 46.659 1 2544 +ATOM N N . ASP A 1 167 . 167 ASP A N 85.83 34.881 47.313 1 2545 +ATOM C CA . ASP A 1 167 . 167 ASP A CA 84.397 34.844 47.566 1 2546 +ATOM C C . ASP A 1 167 . 167 ASP A C 83.703 34.105 46.419 1 2547 +ATOM O O . ASP A 1 167 . 167 ASP A O 82.807 34.651 45.774 1 2548 +ATOM C CB . ASP A 1 167 . 167 ASP A CB 83.897 36.289 47.678 1 2549 +ATOM C CG . ASP A 1 167 . 167 ASP A CG 82.451 36.386 48.118 1 2550 +ATOM O OD1 . ASP A 1 167 . 167 ASP A OD1 82.06 35.651 49.042 1 2551 +ATOM O OD2 . ASP A 1 167 . 167 ASP A OD2 81.714 37.218 47.539 1 2552 +ATOM H H01 . ASP A 1 167 . 167 ASP A H01 84.174 34.315 48.493 1 2553 +ATOM H H02 . ASP A 1 167 . 167 ASP A H02 83.964 36.733 46.685 1 2554 +ATOM H H03 . ASP A 1 167 . 167 ASP A H03 84.517 36.816 48.403 1 2555 +ATOM H H04 . ASP A 1 167 . 167 ASP A H04 81.479 36.887 46.669 1 2556 +ATOM H H06 . ASP A 1 167 . 167 ASP A H06 86.165 35.242 46.431 1 2557 +ATOM N N . PRO A 1 168 . 168 PRO A N 84.105 32.842 46.165 1 2558 +ATOM C CA . PRO A 1 168 . 168 PRO A CA 83.58 31.962 45.107 1 2559 +ATOM C C . PRO A 1 168 . 168 PRO A C 82.082 31.682 45.184 1 2560 +ATOM O O . PRO A 1 168 . 168 PRO A O 81.463 31.264 44.199 1 2561 +ATOM C CB . PRO A 1 168 . 168 PRO A CB 84.392 30.673 45.288 1 2562 +ATOM C CG . PRO A 1 168 . 168 PRO A CG 85.635 31.124 45.961 1 2563 +ATOM C CD . PRO A 1 168 . 168 PRO A CD 85.113 32.12 46.96 1 2564 +ATOM H H01 . PRO A 1 168 . 168 PRO A H01 84.587 30.164 44.344 1 2565 +ATOM H H02 . PRO A 1 168 . 168 PRO A H02 83.852 29.958 45.909 1 2566 +ATOM H H03 . PRO A 1 168 . 168 PRO A H03 86.376 31.538 45.278 1 2567 +ATOM H H04 . PRO A 1 168 . 168 PRO A H04 86.14 30.295 46.456 1 2568 +ATOM H H05 . PRO A 1 168 . 168 PRO A H05 83.686 32.432 44.129 1 2569 +ATOM H H06 . PRO A 1 168 . 168 PRO A H06 84.717 31.668 47.869 1 2570 +ATOM H H07 . PRO A 1 168 . 168 PRO A H07 85.902 32.789 47.304 1 2571 +ATOM N N . TYR A 1 169 . 169 TYR A N 81.498 31.903 46.352 1 2572 +ATOM C CA . TYR A 1 169 . 169 TYR A CA 80.084 31.652 46.529 1 2573 +ATOM C C . TYR A 1 169 . 169 TYR A C 79.276 32.929 46.67 1 2574 +ATOM O O . TYR A 1 169 . 169 TYR A O 78.064 32.899 46.883 1 2575 +ATOM C CB . TYR A 1 169 . 169 TYR A CB 79.909 30.727 47.721 1 2576 +ATOM C CG . TYR A 1 169 . 169 TYR A CG 80.732 29.48 47.505 1 2577 +ATOM C CD1 . TYR A 1 169 . 169 TYR A CD1 82.066 29.417 47.922 1 2578 +ATOM C CD2 . TYR A 1 169 . 169 TYR A CD2 80.22 28.413 46.767 1 2579 +ATOM C CE1 . TYR A 1 169 . 169 TYR A CE1 82.875 28.321 47.599 1 2580 +ATOM C CE2 . TYR A 1 169 . 169 TYR A CE2 81.016 27.317 46.434 1 2581 +ATOM C CZ . TYR A 1 169 . 169 TYR A CZ 82.342 27.277 46.85 1 2582 +ATOM O OH . TYR A 1 169 . 169 TYR A OH 83.136 26.206 46.492 1 2583 +ATOM H H01 . TYR A 1 169 . 169 TYR A H01 79.693 31.17 45.633 1 2584 +ATOM H H02 . TYR A 1 169 . 169 TYR A H02 78.858 30.459 47.826 1 2585 +ATOM H H03 . TYR A 1 169 . 169 TYR A H03 80.242 31.232 48.628 1 2586 +ATOM H H04 . TYR A 1 169 . 169 TYR A H04 82.483 30.236 48.509 1 2587 +ATOM H H05 . TYR A 1 169 . 169 TYR A H05 79.179 28.436 46.445 1 2588 +ATOM H H06 . TYR A 1 169 . 169 TYR A H06 83.913 28.286 47.931 1 2589 +ATOM H H07 . TYR A 1 169 . 169 TYR A H07 80.6 26.496 45.85 1 2590 +ATOM H H08 . TYR A 1 169 . 169 TYR A H08 82.045 32.25 47.127 1 2591 +ATOM H H09 . TYR A 1 169 . 169 TYR A H09 83.297 26.228 45.546 1 2592 +ATOM N N . ASN A 1 170 . 170 ASN A N 79.971 34.051 46.536 1 2593 +ATOM C CA . ASN A 1 170 . 170 ASN A CA 79.352 35.362 46.615 1 2594 +ATOM C C . ASN A 1 170 . 170 ASN A C 78.603 35.615 47.917 1 2595 +ATOM O O . ASN A 1 170 . 170 ASN A O 77.627 36.357 47.968 1 2596 +ATOM C CB . ASN A 1 170 . 170 ASN A CB 78.444 35.567 45.386 1 2597 +ATOM C CG . ASN A 1 170 . 170 ASN A CG 79.205 36.169 44.206 1 2598 +ATOM N ND2 . ASN A 1 170 . 170 ASN A ND2 79.613 35.329 43.247 1 2599 +ATOM O OD1 . ASN A 1 170 . 170 ASN A OD1 79.439 37.37 44.173 1 2600 +ATOM H H01 . ASN A 1 170 . 170 ASN A H01 80.153 36.101 46.611 1 2601 +ATOM H H02 . ASN A 1 170 . 170 ASN A H02 77.652 36.262 45.665 1 2602 +ATOM H H03 . ASN A 1 170 . 170 ASN A H03 78.034 34.603 45.086 1 2603 +ATOM H H04 . ASN A 1 170 . 170 ASN A H04 80.127 35.681 42.452 1 2604 +ATOM H H05 . ASN A 1 170 . 170 ASN A H05 79.407 34.343 43.318 1 2605 +ATOM H H10 . ASN A 1 170 . 170 ASN A H10 80.966 33.991 46.373 1 2606 +ATOM N N . VAL A 1 171 . 171 VAL A N 79.073 34.987 48.986 1 2607 +ATOM C CA . VAL A 1 171 . 171 VAL A CA 78.471 35.199 50.289 1 2608 +ATOM C C . VAL A 1 171 . 171 VAL A C 78.77 36.63 50.742 1 2609 +ATOM O O . VAL A 1 171 . 171 VAL A O 77.931 37.277 51.359 1 2610 +ATOM C CB . VAL A 1 171 . 171 VAL A CB 79.043 34.2 51.324 1 2611 +ATOM C CG1 . VAL A 1 171 . 171 VAL A CG1 78.77 34.69 52.739 1 2612 +ATOM C CG2 . VAL A 1 171 . 171 VAL A CG2 78.405 32.837 51.113 1 2613 +ATOM H H01 . VAL A 1 171 . 171 VAL A H01 77.395 35.041 50.216 1 2614 +ATOM H H02 . VAL A 1 171 . 171 VAL A H02 80.122 34.122 51.188 1 2615 +ATOM H H03 . VAL A 1 171 . 171 VAL A H03 77.727 34.501 52.993 1 2616 +ATOM H H04 . VAL A 1 171 . 171 VAL A H04 79.416 34.159 53.439 1 2617 +ATOM H H05 . VAL A 1 171 . 171 VAL A H05 78.97 35.76 52.799 1 2618 +ATOM H H06 . VAL A 1 171 . 171 VAL A H06 79.856 34.356 48.893 1 2619 +ATOM H H07 . VAL A 1 171 . 171 VAL A H07 77.453 32.958 50.595 1 2620 +ATOM H H08 . VAL A 1 171 . 171 VAL A H08 79.068 32.214 50.513 1 2621 +ATOM H H09 . VAL A 1 171 . 171 VAL A H09 78.234 32.362 52.079 1 2622 +ATOM N N . ALA A 1 172 . 172 ALA A N 79.965 37.124 50.434 1 2623 +ATOM C CA . ALA A 1 172 . 172 ALA A CA 80.341 38.481 50.824 1 2624 +ATOM C C . ALA A 1 172 . 172 ALA A C 79.511 39.479 50.024 1 2625 +ATOM O O . ALA A 1 172 . 172 ALA A O 79.041 40.492 50.547 1 2626 +ATOM C CB . ALA A 1 172 . 172 ALA A CB 81.841 38.715 50.567 1 2627 +ATOM H H01 . ALA A 1 172 . 172 ALA A H01 80.149 38.617 51.888 1 2628 +ATOM H H02 . ALA A 1 172 . 172 ALA A H02 82.414 37.887 50.985 1 2629 +ATOM H H03 . ALA A 1 172 . 172 ALA A H03 82.021 38.776 49.494 1 2630 +ATOM H H04 . ALA A 1 172 . 172 ALA A H04 82.151 39.646 51.041 1 2631 +ATOM H H10 . ALA A 1 172 . 172 ALA A H10 80.623 36.553 49.923 1 2632 +ATOM N N . THR A 1 173 . 173 THR A N 79.342 39.185 48.741 1 2633 +ATOM C CA . THR A 1 173 . 173 THR A CA 78.559 40.056 47.869 1 2634 +ATOM C C . THR A 1 173 . 173 THR A C 77.178 40.239 48.468 1 2635 +ATOM O O . THR A 1 173 . 173 THR A O 76.693 41.365 48.648 1 2636 +ATOM C CB . THR A 1 173 . 173 THR A CB 78.411 39.431 46.48 1 2637 +ATOM C CG2 . THR A 1 173 . 173 THR A CG2 77.502 40.294 45.595 1 2638 +ATOM O OG1 . THR A 1 173 . 173 THR A OG1 79.702 39.32 45.883 1 2639 +ATOM H H01 . THR A 1 173 . 173 THR A H01 79.07 41.015 47.777 1 2640 +ATOM H H02 . THR A 1 173 . 173 THR A H02 77.959 38.444 46.577 1 2641 +ATOM H H03 . THR A 1 173 . 173 THR A H03 76.604 40.564 46.151 1 2642 +ATOM H H04 . THR A 1 173 . 173 THR A H04 78.033 41.2 45.302 1 2643 +ATOM H H05 . THR A 1 173 . 173 THR A H05 79.761 38.347 48.363 1 2644 +ATOM H H06 . THR A 1 173 . 173 THR A H06 77.222 39.732 44.704 1 2645 +ATOM H H07 . THR A 1 173 . 173 THR A H07 79.944 40.159 45.485 1 2646 +ATOM N N . SER A 1 174 . 174 SER A N 76.543 39.12 48.789 1 2647 +ATOM C CA . SER A 1 174 . 174 SER A CA 75.209 39.16 49.347 1 2648 +ATOM C C . SER A 1 174 . 174 SER A C 75.12 39.865 50.691 1 2649 +ATOM O O . SER A 1 174 . 174 SER A O 74.254 40.714 50.909 1 2650 +ATOM C CB . SER A 1 174 . 174 SER A CB 74.663 37.74 49.469 1 2651 +ATOM O OG . SER A 1 174 . 174 SER A OG 74.508 37.165 48.173 1 2652 +ATOM H H01 . SER A 1 174 . 174 SER A H01 74.605 39.75 48.657 1 2653 +ATOM H H02 . SER A 1 174 . 174 SER A H02 73.693 37.771 49.965 1 2654 +ATOM H H03 . SER A 1 174 . 174 SER A H03 75.356 37.135 50.053 1 2655 +ATOM H H04 . SER A 1 174 . 174 SER A H04 74.129 36.287 48.255 1 2656 +ATOM H H08 . SER A 1 174 . 174 SER A H08 76.996 38.229 48.642 1 2657 +ATOM N N . ASN A 1 175 . 175 ASN A N 76.019 39.535 51.605 1 2658 +ATOM C CA . ASN A 1 175 . 175 ASN A CA 75.926 40.158 52.915 1 2659 +ATOM C C . ASN A 1 175 . 175 ASN A C 76.359 41.628 52.967 1 2660 +ATOM O O . ASN A 1 175 . 175 ASN A O 75.957 42.363 53.876 1 2661 +ATOM C CB . ASN A 1 175 . 175 ASN A CB 76.683 39.302 53.943 1 2662 +ATOM C CG . ASN A 1 175 . 175 ASN A CG 75.872 38.07 54.399 1 2663 +ATOM N ND2 . ASN A 1 175 . 175 ASN A ND2 76.39 37.363 55.403 1 2664 +ATOM O OD1 . ASN A 1 175 . 175 ASN A OD1 74.803 37.759 53.851 1 2665 +ATOM H H01 . ASN A 1 175 . 175 ASN A H01 74.866 40.192 53.166 1 2666 +ATOM H H02 . ASN A 1 175 . 175 ASN A H02 76.864 39.922 54.821 1 2667 +ATOM H H03 . ASN A 1 175 . 175 ASN A H03 77.616 38.961 53.495 1 2668 +ATOM H H04 . ASN A 1 175 . 175 ASN A H04 75.908 36.544 55.745 1 2669 +ATOM H H05 . ASN A 1 175 . 175 ASN A H05 76.749 38.869 51.398 1 2670 +ATOM H H06 . ASN A 1 175 . 175 ASN A H06 77.264 37.646 55.822 1 2671 +ATOM N N . ASN A 1 176 . 176 ASN A N 77.141 42.067 51.983 1 2672 +ATOM C CA . ASN A 1 176 . 176 ASN A CA 77.597 43.449 51.947 1 2673 +ATOM C C . ASN A 1 176 . 176 ASN A C 76.76 44.367 51.055 1 2674 +ATOM O O . ASN A 1 176 . 176 ASN A O 76.754 45.576 51.25 1 2675 +ATOM C CB . ASN A 1 176 . 176 ASN A CB 79.049 43.534 51.46 1 2676 +ATOM C CG . ASN A 1 176 . 176 ASN A CG 80.017 42.806 52.367 1 2677 +ATOM N ND2 . ASN A 1 176 . 176 ASN A ND2 81.083 42.277 51.789 1 2678 +ATOM O OD1 . ASN A 1 176 . 176 ASN A OD1 79.817 42.735 53.573 1 2679 +ATOM H H01 . ASN A 1 176 . 176 ASN A H01 77.496 43.794 52.976 1 2680 +ATOM H H02 . ASN A 1 176 . 176 ASN A H02 79.332 44.587 51.457 1 2681 +ATOM H H03 . ASN A 1 176 . 176 ASN A H03 79.109 43.096 50.463 1 2682 +ATOM H H04 . ASN A 1 176 . 176 ASN A H04 81.767 41.785 52.346 1 2683 +ATOM H H05 . ASN A 1 176 . 176 ASN A H05 81.213 42.365 50.791 1 2684 +ATOM H H07 . ASN A 1 176 . 176 ASN A H07 77.422 41.431 51.25 1 2685 +ATOM N N . HIS A 1 177 . 177 HIS A N 76.06 43.799 50.079 1 2686 +ATOM C CA . HIS A 1 177 . 177 HIS A CA 75.291 44.629 49.144 1 2687 +ATOM C C . HIS A 1 177 . 177 HIS A C 73.807 44.781 49.44 1 2688 +ATOM O O . HIS A 1 177 . 177 HIS A O 73.216 45.823 49.135 1 2689 +ATOM C CB . HIS A 1 177 . 177 HIS A CB 75.399 44.063 47.722 1 2690 +ATOM C CG . HIS A 1 177 . 177 HIS A CG 76.735 44.261 47.075 1 2691 +ATOM C CD2 . HIS A 1 177 . 177 HIS A CD2 77.916 44.71 47.566 1 2692 +ATOM N ND1 . HIS A 1 177 . 177 HIS A ND1 76.959 43.972 45.745 1 2693 +ATOM C CE1 . HIS A 1 177 . 177 HIS A CE1 78.218 44.237 45.443 1 2694 +ATOM N NE2 . HIS A 1 177 . 177 HIS A NE2 78.822 44.686 46.529 1 2695 +ATOM H H01 . HIS A 1 177 . 177 HIS A H01 75.742 45.615 49.256 1 2696 +ATOM H H02 . HIS A 1 177 . 177 HIS A H02 74.677 44.609 47.115 1 2697 +ATOM H H03 . HIS A 1 177 . 177 HIS A H03 75.196 42.993 47.764 1 2698 +ATOM H H04 . HIS A 1 177 . 177 HIS A H04 78.112 45.03 48.589 1 2699 +ATOM H H05 . HIS A 1 177 . 177 HIS A H05 78.68 44.107 44.464 1 2700 +ATOM H H06 . HIS A 1 177 . 177 HIS A H06 76.057 42.794 49.98 1 2701 +ATOM H H07 . HIS A 1 177 . 177 HIS A H07 79.791 44.966 46.589 1 2702 +ATOM N N . VAL A 1 178 . 178 VAL A N 73.215 43.734 50.012 1 2703 +ATOM C CA . VAL A 1 178 . 178 VAL A CA 71.774 43.682 50.265 1 2704 +ATOM C C . VAL A 1 178 . 178 VAL A C 71.266 44.521 51.431 1 2705 +ATOM O O . VAL A 1 178 . 178 VAL A O 71.851 44.511 52.519 1 2706 +ATOM C CB . VAL A 1 178 . 178 VAL A CB 71.329 42.213 50.443 1 2707 +ATOM C CG1 . VAL A 1 178 . 178 VAL A CG1 69.834 42.146 50.759 1 2708 +ATOM C CG2 . VAL A 1 178 . 178 VAL A CG2 71.634 41.425 49.158 1 2709 +ATOM H H01 . VAL A 1 178 . 178 VAL A H01 71.322 44.136 49.383 1 2710 +ATOM H H02 . VAL A 1 178 . 178 VAL A H02 71.878 41.772 51.275 1 2711 +ATOM H H03 . VAL A 1 178 . 178 VAL A H03 69.272 42.031 49.832 1 2712 +ATOM H H04 . VAL A 1 178 . 178 VAL A H04 69.637 41.294 51.41 1 2713 +ATOM H H05 . VAL A 1 178 . 178 VAL A H05 69.526 43.065 51.258 1 2714 +ATOM H H06 . VAL A 1 178 . 178 VAL A H06 72.665 41.071 49.185 1 2715 +ATOM H H07 . VAL A 1 178 . 178 VAL A H07 70.959 40.572 49.085 1 2716 +ATOM H H08 . VAL A 1 178 . 178 VAL A H08 73.783 42.944 50.282 1 2717 +ATOM H H09 . VAL A 1 178 . 178 VAL A H09 71.496 42.073 48.292 1 2718 +ATOM N N . CYS A 1 179 . 179 CYS A N 70.154 45.215 51.191 1 2719 +ATOM C CA . CYS A 1 179 . 179 CYS A CA 69.542 46.111 52.179 1 2720 +ATOM C C . CYS A 1 179 . 179 CYS A C 68.139 45.716 52.625 1 2721 +ATOM O O . CYS A 1 179 . 179 CYS A O 67.372 45.113 51.857 1 2722 +ATOM C CB . CYS A 1 179 . 179 CYS A CB 69.472 47.529 51.612 1 2723 +ATOM S SG . CYS A 1 179 . 179 CYS A SG 71.064 48.22 51.105 1 2724 +ATOM H H01 . CYS A 1 179 . 179 CYS A H01 70.184 46.043 53.057 1 2725 +ATOM H H02 . CYS A 1 179 . 179 CYS A H02 69.104 48.164 52.418 1 2726 +ATOM H H03 . CYS A 1 179 . 179 CYS A H03 68.815 47.516 50.743 1 2727 +ATOM H H04 . CYS A 1 179 . 179 CYS A H04 71.412 49.205 51.944 1 2728 +ATOM H H10 . CYS A 1 179 . 179 CYS A H10 69.712 45.12 50.288 1 2729 +ATOM N N . ILE A 1 180 . 180 ILE A N 67.814 46.075 53.87 1 2730 +ATOM C CA . ILE A 1 180 . 180 ILE A CA 66.503 45.816 54.458 1 2731 +ATOM C C . ILE A 1 180 . 180 ILE A C 65.835 47.164 54.735 1 2732 +ATOM O O . ILE A 1 180 . 180 ILE A O 66.424 48.052 55.37 1 2733 +ATOM C CB . ILE A 1 180 . 180 ILE A CB 66.587 45.026 55.792 1 2734 +ATOM C CG1 . ILE A 1 180 . 180 ILE A CG1 67.245 43.678 55.579 1 2735 +ATOM C CG2 . ILE A 1 180 . 180 ILE A CG2 65.187 44.777 56.328 1 2736 +ATOM C CD1 . ILE A 1 180 . 180 ILE A CD1 67.173 42.771 56.814 1 2737 +ATOM H H01 . ILE A 1 180 . 180 ILE A H01 65.934 45.209 53.754 1 2738 +ATOM H H02 . ILE A 1 180 . 180 ILE A H02 67.174 45.617 56.494 1 2739 +ATOM H H03 . ILE A 1 180 . 180 ILE A H03 68.299 43.861 55.371 1 2740 +ATOM H H04 . ILE A 1 180 . 180 ILE A H04 66.748 43.176 54.749 1 2741 +ATOM H H05 . ILE A 1 180 . 180 ILE A H05 68.508 46.548 54.431 1 2742 +ATOM H H06 . ILE A 1 180 . 180 ILE A H06 64.485 44.719 55.496 1 2743 +ATOM H H07 . ILE A 1 180 . 180 ILE A H07 65.171 43.839 56.883 1 2744 +ATOM H H08 . ILE A 1 180 . 180 ILE A H08 64.899 45.595 56.988 1 2745 +ATOM H H09 . ILE A 1 180 . 180 ILE A H09 66.145 42.727 57.172 1 2746 +ATOM H H10 . ILE A 1 180 . 180 ILE A H10 67.508 41.768 56.549 1 2747 +ATOM H H11 . ILE A 1 180 . 180 ILE A H11 67.814 43.173 57.599 1 2748 +ATOM N N . ILE A 1 181 . 181 ILE A N 64.606 47.308 54.245 1 2749 +ATOM C CA . ILE A 1 181 . 181 ILE A CA 63.83 48.527 54.39 1 2750 +ATOM C C . ILE A 1 181 . 181 ILE A C 62.435 48.254 54.965 1 2751 +ATOM O O . ILE A 1 181 . 181 ILE A O 61.537 47.793 54.252 1 2752 +ATOM C CB . ILE A 1 181 . 181 ILE A CB 63.653 49.234 53.016 1 2753 +ATOM C CG1 . ILE A 1 181 . 181 ILE A CG1 65.022 49.573 52.422 1 2754 +ATOM C CG2 . ILE A 1 181 . 181 ILE A CG2 62.843 50.532 53.164 1 2755 +ATOM C CD1 . ILE A 1 181 . 181 ILE A CD1 65.521 48.539 51.42 1 2756 +ATOM H H01 . ILE A 1 181 . 181 ILE A H01 64.384 49.164 55.079 1 2757 +ATOM H H02 . ILE A 1 181 . 181 ILE A H02 63.116 48.552 52.356 1 2758 +ATOM H H03 . ILE A 1 181 . 181 ILE A H03 65.734 49.591 53.247 1 2759 +ATOM H H04 . ILE A 1 181 . 181 ILE A H04 64.951 50.537 51.918 1 2760 +ATOM H H05 . ILE A 1 181 . 181 ILE A H05 61.889 50.312 53.643 1 2761 +ATOM H H06 . ILE A 1 181 . 181 ILE A H06 63.401 51.241 53.775 1 2762 +ATOM H H07 . ILE A 1 181 . 181 ILE A H07 62.663 50.963 52.179 1 2763 +ATOM H H08 . ILE A 1 181 . 181 ILE A H08 66.529 48.226 51.693 1 2764 +ATOM H H09 . ILE A 1 181 . 181 ILE A H09 64.857 47.674 51.428 1 2765 +ATOM H H10 . ILE A 1 181 . 181 ILE A H10 65.536 48.977 50.422 1 2766 +ATOM H H12 . ILE A 1 181 . 181 ILE A H12 64.193 46.532 53.748 1 2767 +ATOM N N . PRO A 1 182 . 182 PRO A N 62.241 48.524 56.263 1 2768 +ATOM C CA . PRO A 1 182 . 182 PRO A CA 60.919 48.291 56.858 1 2769 +ATOM C C . PRO A 1 182 . 182 PRO A C 59.956 49.342 56.331 1 2770 +ATOM O O . PRO A 1 182 . 182 PRO A O 60.353 50.485 56.115 1 2771 +ATOM C CB . PRO A 1 182 . 182 PRO A CB 61.157 48.512 58.351 1 2772 +ATOM C CG . PRO A 1 182 . 182 PRO A CG 62.619 48.193 58.533 1 2773 +ATOM C CD . PRO A 1 182 . 182 PRO A CD 63.242 48.826 57.303 1 2774 +ATOM H H01 . PRO A 1 182 . 182 PRO A H01 60.51 47.897 58.976 1 2775 +ATOM H H02 . PRO A 1 182 . 182 PRO A H02 60.946 49.544 58.63 1 2776 +ATOM H H03 . PRO A 1 182 . 182 PRO A H03 62.825 47.127 58.628 1 2777 +ATOM H H04 . PRO A 1 182 . 182 PRO A H04 63.01 48.638 59.448 1 2778 +ATOM H H05 . PRO A 1 182 . 182 PRO A H05 63.452 49.89 57.415 1 2779 +ATOM H H06 . PRO A 1 182 . 182 PRO A H06 64.206 48.374 57.069 1 2780 +ATOM H H11 . PRO A 1 182 . 182 PRO A H11 60.502 47.309 56.636 1 2781 +ATOM N N . GLN A 1 183 . 183 GLN A N 58.699 48.961 56.128 1 2782 +ATOM C CA . GLN A 1 183 . 183 GLN A CA 57.699 49.918 55.692 1 2783 +ATOM C C . GLN A 1 183 . 183 GLN A C 56.72 50.1 56.853 1 2784 +ATOM O O . GLN A 1 183 . 183 GLN A O 56.177 49.127 57.387 1 2785 +ATOM C CB . GLN A 1 183 . 183 GLN A CB 56.984 49.42 54.434 1 2786 +ATOM C CG . GLN A 1 183 . 183 GLN A CG 55.867 50.325 54.004 1 2787 +ATOM C CD . GLN A 1 183 . 183 GLN A CD 55.361 50.0 52.62 1 2788 +ATOM N NE2 . GLN A 1 183 . 183 GLN A NE2 54.806 51.0 51.953 1 2789 +ATOM O OE1 . GLN A 1 183 . 183 GLN A OE1 55.455 48.859 52.159 1 2790 +ATOM H H01 . GLN A 1 183 . 183 GLN A H01 58.159 50.871 55.431 1 2791 +ATOM H H02 . GLN A 1 183 . 183 GLN A H02 56.548 48.449 54.667 1 2792 +ATOM H H03 . GLN A 1 183 . 183 GLN A H03 57.71 49.352 53.624 1 2793 +ATOM H H04 . GLN A 1 183 . 183 GLN A H04 56.255 51.343 53.984 1 2794 +ATOM H H05 . GLN A 1 183 . 183 GLN A H05 55.043 50.225 54.711 1 2795 +ATOM H H06 . GLN A 1 183 . 183 GLN A H06 54.443 50.847 51.023 1 2796 +ATOM H H07 . GLN A 1 183 . 183 GLN A H07 58.438 47.997 56.278 1 2797 +ATOM H H08 . GLN A 1 183 . 183 GLN A H08 54.746 51.916 52.374 1 2798 +ATOM N N . ILE A 1 184 . 184 ILE A N 56.535 51.353 57.255 1 2799 +ATOM C CA . ILE A 1 184 . 184 ILE A CA 55.654 51.732 58.363 1 2800 +ATOM C C . ILE A 1 184 . 184 ILE A C 54.339 52.203 57.757 1 2801 +ATOM O O . ILE A 1 184 . 184 ILE A O 54.297 53.208 57.041 1 2802 +ATOM C CB . ILE A 1 184 . 184 ILE A CB 56.289 52.888 59.175 1 2803 +ATOM C CG1 . ILE A 1 184 . 184 ILE A CG1 57.657 52.459 59.707 1 2804 +ATOM C CG2 . ILE A 1 184 . 184 ILE A CG2 55.38 53.287 60.338 1 2805 +ATOM C CD1 . ILE A 1 184 . 184 ILE A CD1 57.602 51.337 60.727 1 2806 +ATOM H H01 . ILE A 1 184 . 184 ILE A H01 55.497 50.886 59.032 1 2807 +ATOM H H02 . ILE A 1 184 . 184 ILE A H02 56.413 53.749 58.518 1 2808 +ATOM H H03 . ILE A 1 184 . 184 ILE A H03 58.081 53.325 60.216 1 2809 +ATOM H H04 . ILE A 1 184 . 184 ILE A H04 58.264 52.127 58.865 1 2810 +ATOM H H05 . ILE A 1 184 . 184 ILE A H05 54.41 52.802 60.223 1 2811 +ATOM H H06 . ILE A 1 184 . 184 ILE A H06 55.833 52.974 61.278 1 2812 +ATOM H H07 . ILE A 1 184 . 184 ILE A H07 55.247 54.369 60.341 1 2813 +ATOM H H08 . ILE A 1 184 . 184 ILE A H08 56.584 51.238 61.103 1 2814 +ATOM H H09 . ILE A 1 184 . 184 ILE A H09 57.03 52.087 56.769 1 2815 +ATOM H H10 . ILE A 1 184 . 184 ILE A H10 57.907 50.402 60.257 1 2816 +ATOM H H11 . ILE A 1 184 . 184 ILE A H11 58.274 51.564 61.555 1 2817 +ATOM N N . GLU A 1 185 . 185 GLU A N 53.256 51.498 58.054 1 2818 +ATOM C CA . GLU A 1 185 . 185 GLU A CA 51.985 51.86 57.443 1 2819 +ATOM C C . GLU A 1 185 . 185 GLU A C 50.766 51.508 58.271 1 2820 +ATOM O O . GLU A 1 185 . 185 GLU A O 49.715 51.135 57.742 1 2821 +ATOM C CB . GLU A 1 185 . 185 GLU A CB 51.894 51.216 56.067 1 2822 +ATOM C CG . GLU A 1 185 . 185 GLU A CG 52.18 49.735 56.044 1 2823 +ATOM C CD . GLU A 1 185 . 185 GLU A CD 52.253 49.216 54.624 1 2824 +ATOM O OE1 . GLU A 1 185 . 185 GLU A OE1 51.676 49.889 53.759 1 2825 +ATOM O OE2 . GLU A 1 185 . 185 GLU A OE2 52.868 48.161 54.37 1 2826 +ATOM H H01 . GLU A 1 185 . 185 GLU A H01 51.975 52.947 57.368 1 2827 +ATOM H H02 . GLU A 1 185 . 185 GLU A H02 52.663 51.687 55.455 1 2828 +ATOM H H03 . GLU A 1 185 . 185 GLU A H03 50.886 51.374 55.684 1 2829 +ATOM H H04 . GLU A 1 185 . 185 GLU A H04 51.371 49.218 56.56 1 2830 +ATOM H H05 . GLU A 1 185 . 185 GLU A H05 53.132 49.548 56.542 1 2831 +ATOM H H06 . GLU A 1 185 . 185 GLU A H06 53.006 48.09 53.423 1 2832 +ATOM H H12 . GLU A 1 185 . 185 GLU A H12 53.312 50.72 58.696 1 2833 +ATOM N N . SER A 1 186 . 186 SER A N 50.909 51.631 59.58 1 2834 +ATOM C CA . SER A 1 186 . 186 SER A CA 49.797 51.36 60.476 1 2835 +ATOM C C . SER A 1 186 . 186 SER A C 49.966 52.222 61.714 1 2836 +ATOM O O . SER A 1 186 . 186 SER A O 51.056 52.73 61.981 1 2837 +ATOM C CB . SER A 1 186 . 186 SER A CB 49.758 49.888 60.882 1 2838 +ATOM O OG . SER A 1 186 . 186 SER A OG 50.895 49.527 61.646 1 2839 +ATOM H H01 . SER A 1 186 . 186 SER A H01 48.862 51.59 59.965 1 2840 +ATOM H H02 . SER A 1 186 . 186 SER A H02 49.752 49.285 59.974 1 2841 +ATOM H H03 . SER A 1 186 . 186 SER A H03 48.861 49.707 61.474 1 2842 +ATOM H H04 . SER A 1 186 . 186 SER A H04 51.201 48.659 61.374 1 2843 +ATOM H H07 . SER A 1 186 . 186 SER A H07 51.8 51.916 59.961 1 2844 +ATOM N N . VAL A 1 187 . 187 VAL A N 48.884 52.38 62.467 1 2845 +ATOM C CA . VAL A 1 187 . 187 VAL A CA 48.936 53.172 63.692 1 2846 +ATOM C C . VAL A 1 187 . 187 VAL A C 49.946 52.569 64.664 1 2847 +ATOM O O . VAL A 1 187 . 187 VAL A O 50.749 53.29 65.266 1 2848 +ATOM C CB . VAL A 1 187 . 187 VAL A CB 47.542 53.247 64.358 1 2849 +ATOM C CG1 . VAL A 1 187 . 187 VAL A CG1 47.651 53.871 65.746 1 2850 +ATOM C CG2 . VAL A 1 187 . 187 VAL A CG2 46.608 54.072 63.486 1 2851 +ATOM H H01 . VAL A 1 187 . 187 VAL A H01 49.249 54.183 63.432 1 2852 +ATOM H H02 . VAL A 1 187 . 187 VAL A H02 47.142 52.239 64.463 1 2853 +ATOM H H03 . VAL A 1 187 . 187 VAL A H03 48.14 54.842 65.67 1 2854 +ATOM H H04 . VAL A 1 187 . 187 VAL A H04 46.654 54.0 66.167 1 2855 +ATOM H H05 . VAL A 1 187 . 187 VAL A H05 48.015 51.947 62.189 1 2856 +ATOM H H06 . VAL A 1 187 . 187 VAL A H06 48.238 53.219 66.393 1 2857 +ATOM H H07 . VAL A 1 187 . 187 VAL A H07 45.716 54.333 64.056 1 2858 +ATOM H H08 . VAL A 1 187 . 187 VAL A H08 47.116 54.983 63.169 1 2859 +ATOM H H09 . VAL A 1 187 . 187 VAL A H09 46.321 53.491 62.609 1 2860 +ATOM N N . LYS A 1 188 . 188 LYS A N 49.92 51.251 64.825 1 2861 +ATOM C CA . LYS A 1 188 . 188 LYS A CA 50.864 50.603 65.732 1 2862 +ATOM C C . LYS A 1 188 . 188 LYS A C 52.298 50.757 65.227 1 2863 +ATOM O O . LYS A 1 188 . 188 LYS A O 53.236 50.858 66.022 1 2864 +ATOM C CB . LYS A 1 188 . 188 LYS A CB 50.526 49.118 65.891 1 2865 +ATOM C CG . LYS A 1 188 . 188 LYS A CG 49.287 48.859 66.736 1 2866 +ATOM C CD . LYS A 1 188 . 188 LYS A CD 49.126 47.371 67.032 1 2867 +ATOM C CE . LYS A 1 188 . 188 LYS A CE 47.982 47.124 68.011 1 2868 +ATOM N NZ . LYS A 1 188 . 188 LYS A NZ 47.786 45.672 68.3 1 2869 +ATOM H H01 . LYS A 1 188 . 188 LYS A H01 50.782 51.091 66.703 1 2870 +ATOM H H02 . LYS A 1 188 . 188 LYS A H02 51.366 48.654 66.408 1 2871 +ATOM H H03 . LYS A 1 188 . 188 LYS A H03 50.362 48.694 64.9 1 2872 +ATOM H H04 . LYS A 1 188 . 188 LYS A H04 48.412 49.198 66.181 1 2873 +ATOM H H05 . LYS A 1 188 . 188 LYS A H05 49.377 49.402 67.677 1 2874 +ATOM H H06 . LYS A 1 188 . 188 LYS A H06 50.05 47.007 67.482 1 2875 +ATOM H H07 . LYS A 1 188 . 188 LYS A H07 48.919 46.843 66.101 1 2876 +ATOM H H08 . LYS A 1 188 . 188 LYS A H08 47.066 47.499 67.554 1 2877 +ATOM H H09 . LYS A 1 188 . 188 LYS A H09 48.201 47.641 68.945 1 2878 +ATOM H H10 . LYS A 1 188 . 188 LYS A H10 49.246 50.695 64.319 1 2879 +ATOM H H11 . LYS A 1 188 . 188 LYS A H11 48.523 45.138 67.861 1 2880 +ATOM H H12 . LYS A 1 188 . 188 LYS A H12 47.809 45.521 69.298 1 2881 +ATOM N N . GLY A 1 189 . 189 GLY A N 52.469 50.767 63.905 1 2882 +ATOM C CA . GLY A 1 189 . 189 GLY A CA 53.803 50.923 63.344 1 2883 +ATOM C C . GLY A 1 189 . 189 GLY A C 54.372 52.294 63.681 1 2884 +ATOM O O . GLY A 1 189 . 189 GLY A O 55.533 52.433 64.073 1 2885 +ATOM H H01 . GLY A 1 189 . 189 GLY A H01 53.74 50.827 62.26 1 2886 +ATOM H H02 . GLY A 1 189 . 189 GLY A H02 54.457 50.153 63.754 1 2887 +ATOM H H13 . GLY A 1 189 . 189 GLY A H13 51.672 50.666 63.292 1 2888 +ATOM N N . VAL A 1 190 . 190 VAL A N 53.543 53.314 63.522 1 2889 +ATOM C CA . VAL A 1 190 . 190 VAL A CA 53.949 54.677 63.822 1 2890 +ATOM C C . VAL A 1 190 . 190 VAL A C 54.229 54.811 65.319 1 2891 +ATOM O O . VAL A 1 190 . 190 VAL A O 55.199 55.451 65.73 1 2892 +ATOM C CB . VAL A 1 190 . 190 VAL A CB 52.846 55.666 63.385 1 2893 +ATOM C CG1 . VAL A 1 190 . 190 VAL A CG1 53.101 57.056 63.983 1 2894 +ATOM C CG2 . VAL A 1 190 . 190 VAL A CG2 52.821 55.752 61.859 1 2895 +ATOM H H01 . VAL A 1 190 . 190 VAL A H01 54.86 54.913 63.271 1 2896 +ATOM H H02 . VAL A 1 190 . 190 VAL A H02 51.883 55.307 63.748 1 2897 +ATOM H H03 . VAL A 1 190 . 190 VAL A H03 52.607 53.141 63.184 1 2898 +ATOM H H04 . VAL A 1 190 . 190 VAL A H04 53.969 57.505 63.5 1 2899 +ATOM H H05 . VAL A 1 190 . 190 VAL A H05 52.228 57.688 63.82 1 2900 +ATOM H H06 . VAL A 1 190 . 190 VAL A H06 53.289 56.963 65.053 1 2901 +ATOM H H07 . VAL A 1 190 . 190 VAL A H07 53.026 56.777 61.55 1 2902 +ATOM H H08 . VAL A 1 190 . 190 VAL A H08 53.581 55.089 61.445 1 2903 +ATOM H H09 . VAL A 1 190 . 190 VAL A H09 51.839 55.453 61.494 1 2904 +ATOM N N . GLU A 1 191 . 191 GLU A N 53.398 54.179 66.137 1 2905 +ATOM C CA . GLU A 1 191 . 191 GLU A CA 53.586 54.26 67.579 1 2906 +ATOM C C . GLU A 1 191 . 191 GLU A C 54.909 53.643 68.034 1 2907 +ATOM O O . GLU A 1 191 . 191 GLU A O 55.465 54.033 69.067 1 2908 +ATOM C CB . GLU A 1 191 . 191 GLU A CB 52.406 53.594 68.3 1 2909 +ATOM C CG . GLU A 1 191 . 191 GLU A CG 51.063 54.223 67.928 1 2910 +ATOM C CD . GLU A 1 191 . 191 GLU A CD 49.87 53.672 68.702 1 2911 +ATOM O OE1 . GLU A 1 191 . 191 GLU A OE1 49.7 52.432 68.763 1 2912 +ATOM O OE2 . GLU A 1 191 . 191 GLU A OE2 49.088 54.494 69.232 1 2913 +ATOM H H01 . GLU A 1 191 . 191 GLU A H01 53.625 55.317 67.843 1 2914 +ATOM H H02 . GLU A 1 191 . 191 GLU A H02 52.551 53.733 69.371 1 2915 +ATOM H H03 . GLU A 1 191 . 191 GLU A H03 52.384 52.537 68.033 1 2916 +ATOM H H04 . GLU A 1 191 . 191 GLU A H04 50.889 53.983 66.879 1 2917 +ATOM H H05 . GLU A 1 191 . 191 GLU A H05 51.125 55.295 68.115 1 2918 +ATOM H H06 . GLU A 1 191 . 191 GLU A H06 48.929 55.222 68.626 1 2919 +ATOM H H10 . GLU A 1 191 . 191 GLU A H10 52.632 53.64 65.759 1 2920 +ATOM N N . ASN A 1 192 . 192 ASN A N 55.436 52.704 67.253 1 2921 +ATOM C CA . ASN A 1 192 . 192 ASN A CA 56.683 52.039 67.617 1 2922 +ATOM C C . ASN A 1 192 . 192 ASN A C 57.827 52.384 66.651 1 2923 +ATOM O O . ASN A 1 192 . 192 ASN A O 58.843 51.691 66.621 1 2924 +ATOM C CB . ASN A 1 192 . 192 ASN A CB 56.436 50.521 67.642 1 2925 +ATOM C CG . ASN A 1 192 . 192 ASN A CG 57.545 49.746 68.332 1 2926 +ATOM N ND2 . ASN A 1 192 . 192 ASN A ND2 57.803 48.532 67.851 1 2927 +ATOM O OD1 . ASN A 1 192 . 192 ASN A OD1 58.153 50.221 69.296 1 2928 +ATOM H H01 . ASN A 1 192 . 192 ASN A H01 56.992 52.389 68.602 1 2929 +ATOM H H02 . ASN A 1 192 . 192 ASN A H02 56.4 50.182 66.607 1 2930 +ATOM H H03 . ASN A 1 192 . 192 ASN A H03 55.5 50.33 68.167 1 2931 +ATOM H H04 . ASN A 1 192 . 192 ASN A H04 58.525 47.965 68.272 1 2932 +ATOM H H05 . ASN A 1 192 . 192 ASN A H05 57.277 48.178 67.065 1 2933 +ATOM H H07 . ASN A 1 192 . 192 ASN A H07 54.967 52.448 66.396 1 2934 +ATOM N N . VAL A 1 193 . 193 VAL A N 57.682 53.465 65.889 1 2935 +ATOM C CA . VAL A 1 193 . 193 VAL A CA 58.711 53.815 64.903 1 2936 +ATOM C C . VAL A 1 193 . 193 VAL A C 60.068 54.19 65.495 1 2937 +ATOM O O . VAL A 1 193 . 193 VAL A O 61.104 53.972 64.87 1 2938 +ATOM C CB . VAL A 1 193 . 193 VAL A CB 58.224 54.944 63.946 1 2939 +ATOM C CG1 . VAL A 1 193 . 193 VAL A CG1 58.167 56.283 64.675 1 2940 +ATOM C CG2 . VAL A 1 193 . 193 VAL A CG2 59.152 55.04 62.742 1 2941 +ATOM H H01 . VAL A 1 193 . 193 VAL A H01 58.869 52.894 64.342 1 2942 +ATOM H H02 . VAL A 1 193 . 193 VAL A H02 57.219 54.698 63.603 1 2943 +ATOM H H03 . VAL A 1 193 . 193 VAL A H03 57.129 56.601 64.769 1 2944 +ATOM H H04 . VAL A 1 193 . 193 VAL A H04 58.605 56.176 65.667 1 2945 +ATOM H H05 . VAL A 1 193 . 193 VAL A H05 58.725 57.029 64.109 1 2946 +ATOM H H06 . VAL A 1 193 . 193 VAL A H06 56.862 54.046 65.989 1 2947 +ATOM H H07 . VAL A 1 193 . 193 VAL A H07 60.117 54.6 62.992 1 2948 +ATOM H H08 . VAL A 1 193 . 193 VAL A H08 58.715 54.501 61.902 1 2949 +ATOM H H09 . VAL A 1 193 . 193 VAL A H09 59.29 56.087 62.471 1 2950 +ATOM N N . ASP A 1 194 . 194 ASP A N 60.087 54.752 66.699 1 2951 +ATOM C CA . ASP A 1 194 . 194 ASP A CA 61.373 55.098 67.282 1 2952 +ATOM C C . ASP A 1 194 . 194 ASP A C 62.241 53.857 67.441 1 2953 +ATOM O O . ASP A 1 194 . 194 ASP A O 63.438 53.893 67.152 1 2954 +ATOM C CB . ASP A 1 194 . 194 ASP A CB 61.194 55.79 68.64 1 2955 +ATOM C CG . ASP A 1 194 . 194 ASP A CG 60.497 57.128 68.515 1 2956 +ATOM O OD1 . ASP A 1 194 . 194 ASP A OD1 60.701 57.804 67.486 1 2957 +ATOM O OD2 . ASP A 1 194 . 194 ASP A OD2 59.753 57.511 69.443 1 2958 +ATOM H H01 . ASP A 1 194 . 194 ASP A H01 61.87 55.792 66.604 1 2959 +ATOM H H02 . ASP A 1 194 . 194 ASP A H02 62.185 55.968 69.057 1 2960 +ATOM H H03 . ASP A 1 194 . 194 ASP A H03 60.601 55.144 69.288 1 2961 +ATOM H H04 . ASP A 1 194 . 194 ASP A H04 59.634 58.462 69.387 1 2962 +ATOM H H10 . ASP A 1 194 . 194 ASP A H10 59.228 54.935 67.198 1 2963 +ATOM N N . ALA A 1 195 . 195 ALA A N 61.643 52.758 67.893 1 2964 +ATOM C CA . ALA A 1 195 . 195 ALA A CA 62.387 51.522 68.088 1 2965 +ATOM C C . ALA A 1 195 . 195 ALA A C 62.801 50.891 66.758 1 2966 +ATOM O O . ALA A 1 195 . 195 ALA A O 63.916 50.396 66.611 1 2967 +ATOM C CB . ALA A 1 195 . 195 ALA A CB 61.551 50.534 68.886 1 2968 +ATOM H H01 . ALA A 1 195 . 195 ALA A H01 63.295 51.767 68.638 1 2969 +ATOM H H02 . ALA A 1 195 . 195 ALA A H02 60.581 50.407 68.405 1 2970 +ATOM H H03 . ALA A 1 195 . 195 ALA A H03 62.064 49.573 68.927 1 2971 +ATOM H H04 . ALA A 1 195 . 195 ALA A H04 61.407 50.913 69.898 1 2972 +ATOM H H05 . ALA A 1 195 . 195 ALA A H05 60.656 52.782 68.105 1 2973 +ATOM N N . ILE A 1 196 . 196 ILE A N 61.9 50.911 65.786 1 2974 +ATOM C CA . ILE A 1 196 . 196 ILE A CA 62.21 50.31 64.5 1 2975 +ATOM C C . ILE A 1 196 . 196 ILE A C 63.33 51.084 63.803 1 2976 +ATOM O O . ILE A 1 196 . 196 ILE A O 64.26 50.487 63.256 1 2977 +ATOM C CB . ILE A 1 196 . 196 ILE A CB 60.946 50.265 63.602 1 2978 +ATOM C CG1 . ILE A 1 196 . 196 ILE A CG1 59.885 49.368 64.258 1 2979 +ATOM C CG2 . ILE A 1 196 . 196 ILE A CG2 61.314 49.72 62.212 1 2980 +ATOM C CD1 . ILE A 1 196 . 196 ILE A CD1 58.492 49.5 63.649 1 2981 +ATOM H H01 . ILE A 1 196 . 196 ILE A H01 62.549 49.288 64.672 1 2982 +ATOM H H02 . ILE A 1 196 . 196 ILE A H02 60.543 51.272 63.489 1 2983 +ATOM H H03 . ILE A 1 196 . 196 ILE A H03 59.804 49.687 65.297 1 2984 +ATOM H H04 . ILE A 1 196 . 196 ILE A H04 60.205 48.331 64.16 1 2985 +ATOM H H05 . ILE A 1 196 . 196 ILE A H05 61.0 51.343 65.939 1 2986 +ATOM H H06 . ILE A 1 196 . 196 ILE A H06 62.345 49.367 62.222 1 2987 +ATOM H H07 . ILE A 1 196 . 196 ILE A H07 60.651 48.893 61.958 1 2988 +ATOM H H08 . ILE A 1 196 . 196 ILE A H08 61.209 50.512 61.471 1 2989 +ATOM H H09 . ILE A 1 196 . 196 ILE A H09 57.828 49.987 64.363 1 2990 +ATOM H H10 . ILE A 1 196 . 196 ILE A H10 58.548 50.098 62.739 1 2991 +ATOM H H11 . ILE A 1 196 . 196 ILE A H11 58.104 48.51 63.41 1 2992 +ATOM N N . ALA A 1 197 . 197 ALA A N 63.252 52.41 63.859 1 2993 +ATOM C CA . ALA A 1 197 . 197 ALA A CA 64.24 53.273 63.214 1 2994 +ATOM C C . ALA A 1 197 . 197 ALA A C 65.635 53.127 63.803 1 2995 +ATOM O O . ALA A 1 197 . 197 ALA A O 66.634 53.364 63.116 1 2996 +ATOM C CB . ALA A 1 197 . 197 ALA A CB 63.792 54.731 63.308 1 2997 +ATOM H H01 . ALA A 1 197 . 197 ALA A H01 64.301 52.959 62.172 1 2998 +ATOM H H02 . ALA A 1 197 . 197 ALA A H02 62.828 54.782 63.815 1 2999 +ATOM H H03 . ALA A 1 197 . 197 ALA A H03 64.529 55.303 63.872 1 3000 +ATOM H H04 . ALA A 1 197 . 197 ALA A H04 63.697 55.148 62.305 1 3001 +ATOM H H12 . ALA A 1 197 . 197 ALA A H12 62.486 52.834 64.363 1 3002 +ATOM N N . ALA A 1 198 . 198 ALA A N 65.691 52.73 65.07 1 3003 +ATOM C CA . ALA A 1 198 . 198 ALA A CA 66.958 52.57 65.782 1 3004 +ATOM C C . ALA A 1 198 . 198 ALA A C 67.652 51.226 65.567 1 3005 +ATOM O O . ALA A 1 198 . 198 ALA A O 68.764 51.011 66.062 1 3006 +ATOM C CB . ALA A 1 198 . 198 ALA A CB 66.735 52.795 67.29 1 3007 +ATOM H H01 . ALA A 1 198 . 198 ALA A H01 67.627 53.32 65.36 1 3008 +ATOM H H02 . ALA A 1 198 . 198 ALA A H02 65.683 52.636 67.529 1 3009 +ATOM H H03 . ALA A 1 198 . 198 ALA A H03 67.346 52.092 67.857 1 3010 +ATOM H H04 . ALA A 1 198 . 198 ALA A H04 67.016 53.815 67.551 1 3011 +ATOM H H05 . ALA A 1 198 . 198 ALA A H05 64.829 52.532 65.558 1 3012 +ATOM N N . MET A 1 199 . 199 MET A N 67.014 50.314 64.843 1 3013 +ATOM C CA . MET A 1 199 . 199 MET A CA 67.627 49.005 64.612 1 3014 +ATOM C C . MET A 1 199 . 199 MET A C 68.901 49.14 63.787 1 3015 +ATOM O O . MET A 1 199 . 199 MET A O 68.893 49.723 62.711 1 3016 +ATOM C CB . MET A 1 199 . 199 MET A CB 66.655 48.079 63.878 1 3017 +ATOM C CG . MET A 1 199 . 199 MET A CG 65.399 47.75 64.669 1 3018 +ATOM S SD . MET A 1 199 . 199 MET A SD 64.368 46.614 63.734 1 3019 +ATOM C CE . MET A 1 199 . 199 MET A CE 65.337 45.094 63.843 1 3020 +ATOM H H01 . MET A 1 199 . 199 MET A H01 67.873 48.579 65.585 1 3021 +ATOM H H02 . MET A 1 199 . 199 MET A H02 67.18 47.138 63.713 1 3022 +ATOM H H03 . MET A 1 199 . 199 MET A H03 66.358 48.561 62.947 1 3023 +ATOM H H04 . MET A 1 199 . 199 MET A H04 64.842 48.668 64.857 1 3024 +ATOM H H05 . MET A 1 199 . 199 MET A H05 66.107 50.523 64.452 1 3025 +ATOM H H06 . MET A 1 199 . 199 MET A H06 65.678 47.29 65.617 1 3026 +ATOM H H07 . MET A 1 199 . 199 MET A H07 66.28 45.3 64.349 1 3027 +ATOM H H08 . MET A 1 199 . 199 MET A H08 64.779 44.346 64.406 1 3028 +ATOM H H09 . MET A 1 199 . 199 MET A H09 65.539 44.718 62.84 1 3029 +ATOM N N . PRO A 1 200 . 200 PRO A N 70.014 48.588 64.284 1 3030 +ATOM C CA . PRO A 1 200 . 200 PRO A CA 71.298 48.653 63.58 1 3031 +ATOM C C . PRO A 1 200 . 200 PRO A C 71.244 47.867 62.269 1 3032 +ATOM O O . PRO A 1 200 . 200 PRO A O 72.022 48.109 61.343 1 3033 +ATOM C CB . PRO A 1 200 . 200 PRO A CB 72.284 48.002 64.563 1 3034 +ATOM C CG . PRO A 1 200 . 200 PRO A CG 71.565 47.946 65.855 1 3035 +ATOM C CD . PRO A 1 200 . 200 PRO A CD 70.122 47.8 65.523 1 3036 +ATOM H H01 . PRO A 1 200 . 200 PRO A H01 73.214 48.565 64.639 1 3037 +ATOM H H02 . PRO A 1 200 . 200 PRO A H02 72.551 46.999 64.23 1 3038 +ATOM H H03 . PRO A 1 200 . 200 PRO A H03 71.746 48.842 66.449 1 3039 +ATOM H H04 . PRO A 1 200 . 200 PRO A H04 71.906 47.093 66.441 1 3040 +ATOM H H05 . PRO A 1 200 . 200 PRO A H05 69.797 46.765 65.416 1 3041 +ATOM H H06 . PRO A 1 200 . 200 PRO A H06 69.49 48.21 66.311 1 3042 +ATOM H H10 . PRO A 1 200 . 200 PRO A H10 71.578 49.671 63.308 1 3043 +ATOM N N . GLU A 1 201 . 201 GLU A N 70.303 46.937 62.182 1 3044 +ATOM C CA . GLU A 1 201 . 201 GLU A CA 70.206 46.097 60.991 1 3045 +ATOM C C . GLU A 1 201 . 201 GLU A C 69.602 46.74 59.748 1 3046 +ATOM O O . GLU A 1 201 . 201 GLU A O 69.828 46.247 58.646 1 3047 +ATOM C CB . GLU A 1 201 . 201 GLU A CB 69.406 44.821 61.286 1 3048 +ATOM C CG . GLU A 1 201 . 201 GLU A CG 69.984 43.948 62.402 1 3049 +ATOM C CD . GLU A 1 201 . 201 GLU A CD 69.372 44.259 63.753 1 3050 +ATOM O OE1 . GLU A 1 201 . 201 GLU A OE1 68.977 45.425 63.986 1 3051 +ATOM O OE2 . GLU A 1 201 . 201 GLU A OE2 69.297 43.336 64.595 1 3052 +ATOM H H01 . GLU A 1 201 . 201 GLU A H01 71.251 45.894 60.755 1 3053 +ATOM H H02 . GLU A 1 201 . 201 GLU A H02 69.439 44.22 60.377 1 3054 +ATOM H H03 . GLU A 1 201 . 201 GLU A H03 68.395 45.112 61.571 1 3055 +ATOM H H04 . GLU A 1 201 . 201 GLU A H04 71.052 44.154 62.466 1 3056 +ATOM H H05 . GLU A 1 201 . 201 GLU A H05 69.794 42.902 62.163 1 3057 +ATOM H H06 . GLU A 1 201 . 201 GLU A H06 69.709 43.616 65.416 1 3058 +ATOM H H07 . GLU A 1 201 . 201 GLU A H07 69.651 46.809 62.942 1 3059 +ATOM N N . ILE A 1 202 . 202 ILE A N 68.86 47.83 59.902 1 3060 +ATOM C CA . ILE A 1 202 . 202 ILE A CA 68.196 48.42 58.741 1 3061 +ATOM C C . ILE A 1 202 . 202 ILE A C 68.974 49.455 57.953 1 3062 +ATOM O O . ILE A 1 202 . 202 ILE A O 69.88 50.102 58.477 1 3063 +ATOM C CB . ILE A 1 202 . 202 ILE A CB 66.814 48.981 59.127 1 3064 +ATOM C CG1 . ILE A 1 202 . 202 ILE A CG1 66.942 50.261 59.964 1 3065 +ATOM C CG2 . ILE A 1 202 . 202 ILE A CG2 66.062 47.923 59.924 1 3066 +ATOM C CD1 . ILE A 1 202 . 202 ILE A CD1 65.601 50.979 60.176 1 3067 +ATOM H H01 . ILE A 1 202 . 202 ILE A H01 68.104 47.578 58.054 1 3068 +ATOM H H02 . ILE A 1 202 . 202 ILE A H02 66.272 49.231 58.215 1 3069 +ATOM H H03 . ILE A 1 202 . 202 ILE A H03 67.593 50.943 59.417 1 3070 +ATOM H H04 . ILE A 1 202 . 202 ILE A H04 67.354 49.999 60.939 1 3071 +ATOM H H05 . ILE A 1 202 . 202 ILE A H05 65.08 47.763 59.478 1 3072 +ATOM H H06 . ILE A 1 202 . 202 ILE A H06 66.624 46.989 59.91 1 3073 +ATOM H H07 . ILE A 1 202 . 202 ILE A H07 68.755 48.249 60.815 1 3074 +ATOM H H08 . ILE A 1 202 . 202 ILE A H08 65.942 48.26 60.954 1 3075 +ATOM H H09 . ILE A 1 202 . 202 ILE A H09 64.841 50.252 60.462 1 3076 +ATOM H H10 . ILE A 1 202 . 202 ILE A H10 65.707 51.722 60.966 1 3077 +ATOM H H11 . ILE A 1 202 . 202 ILE A H11 65.302 51.472 59.251 1 3078 +ATOM N N . HIS A 1 203 . 203 HIS A N 68.602 49.607 56.681 1 3079 +ATOM C CA . HIS A 1 203 . 203 HIS A CA 69.287 50.522 55.769 1 3080 +ATOM C C . HIS A 1 203 . 203 HIS A C 68.399 51.569 55.135 1 3081 +ATOM O O . HIS A 1 203 . 203 HIS A O 68.867 52.405 54.345 1 3082 +ATOM C CB . HIS A 1 203 . 203 HIS A CB 69.969 49.702 54.675 1 3083 +ATOM C CG . HIS A 1 203 . 203 HIS A CG 70.754 48.549 55.215 1 3084 +ATOM C CD2 . HIS A 1 203 . 203 HIS A CD2 71.918 48.517 55.908 1 3085 +ATOM N ND1 . HIS A 1 203 . 203 HIS A ND1 70.297 47.248 55.171 1 3086 +ATOM C CE1 . HIS A 1 203 . 203 HIS A CE1 71.143 46.466 55.818 1 3087 +ATOM N NE2 . HIS A 1 203 . 203 HIS A NE2 72.134 47.211 56.278 1 3088 +ATOM H H01 . HIS A 1 203 . 203 HIS A H01 70.006 51.076 56.372 1 3089 +ATOM H H02 . HIS A 1 203 . 203 HIS A H02 70.669 50.361 54.161 1 3090 +ATOM H H03 . HIS A 1 203 . 203 HIS A H03 69.204 49.317 54.0 1 3091 +ATOM H H04 . HIS A 1 203 . 203 HIS A H04 72.562 49.368 56.13 1 3092 +ATOM H H05 . HIS A 1 203 . 203 HIS A H05 71.041 45.389 55.95 1 3093 +ATOM H H06 . HIS A 1 203 . 203 HIS A H06 72.921 46.875 56.814 1 3094 +ATOM H H12 . HIS A 1 203 . 203 HIS A H12 67.819 49.071 56.335 1 3095 +ATOM N N . GLY A 1 204 . 204 GLY A N 67.118 51.532 55.476 1 3096 +ATOM C CA . GLY A 1 204 . 204 GLY A CA 66.19 52.5 54.924 1 3097 +ATOM C C . GLY A 1 204 . 204 GLY A C 64.847 52.37 55.615 1 3098 +ATOM O O . GLY A 1 204 . 204 GLY A O 64.612 51.395 56.317 1 3099 +ATOM H H01 . GLY A 1 204 . 204 GLY A H01 66.067 52.315 53.857 1 3100 +ATOM H H02 . GLY A 1 204 . 204 GLY A H02 66.581 53.506 55.076 1 3101 +ATOM H H07 . GLY A 1 204 . 204 GLY A H07 66.788 50.828 56.121 1 3102 +ATOM N N . LEU A 1 205 . 205 LEU A N 63.981 53.357 55.429 1 3103 +ATOM C CA . LEU A 1 205 . 205 LEU A CA 62.636 53.33 56.013 1 3104 +ATOM C C . LEU A 1 205 . 205 LEU A C 61.654 53.884 54.991 1 3105 +ATOM O O . LEU A 1 205 . 205 LEU A O 61.948 54.871 54.314 1 3106 +ATOM C CB . LEU A 1 205 . 205 LEU A CB 62.561 54.178 57.286 1 3107 +ATOM C CG . LEU A 1 205 . 205 LEU A CG 63.021 53.481 58.568 1 3108 +ATOM C CD1 . LEU A 1 205 . 205 LEU A CD1 63.104 54.502 59.699 1 3109 +ATOM C CD2 . LEU A 1 205 . 205 LEU A CD2 62.052 52.349 58.917 1 3110 +ATOM H H01 . LEU A 1 205 . 205 LEU A H01 62.391 52.301 56.275 1 3111 +ATOM H H02 . LEU A 1 205 . 205 LEU A H02 61.505 54.407 57.432 1 3112 +ATOM H H03 . LEU A 1 205 . 205 LEU A H03 64.256 54.151 54.868 1 3113 +ATOM H H04 . LEU A 1 205 . 205 LEU A H04 63.184 55.06 57.14 1 3114 +ATOM H H05 . LEU A 1 205 . 205 LEU A H05 64.01 53.048 58.42 1 3115 +ATOM H H06 . LEU A 1 205 . 205 LEU A H06 63.554 55.422 59.325 1 3116 +ATOM H H07 . LEU A 1 205 . 205 LEU A H07 62.102 54.714 60.072 1 3117 +ATOM H H08 . LEU A 1 205 . 205 LEU A H08 63.716 54.101 60.507 1 3118 +ATOM H H09 . LEU A 1 205 . 205 LEU A H09 61.122 52.484 58.365 1 3119 +ATOM H H10 . LEU A 1 205 . 205 LEU A H10 62.498 51.392 58.647 1 3120 +ATOM H H11 . LEU A 1 205 . 205 LEU A H11 61.844 52.365 59.987 1 3121 +ATOM N N . MET A 1 206 . 206 MET A N 60.49 53.255 54.895 1 3122 +ATOM C CA . MET A 1 206 . 206 MET A CA 59.466 53.692 53.949 1 3123 +ATOM C C . MET A 1 206 . 206 MET A C 58.142 53.833 54.677 1 3124 +ATOM O O . MET A 1 206 . 206 MET A O 57.877 53.1 55.618 1 3125 +ATOM C CB . MET A 1 206 . 206 MET A CB 59.311 52.658 52.825 1 3126 +ATOM C CG . MET A 1 206 . 206 MET A CG 58.264 53.011 51.772 1 3127 +ATOM S SD . MET A 1 206 . 206 MET A SD 58.182 51.699 50.532 1 3128 +ATOM C CE . MET A 1 206 . 206 MET A CE 59.585 52.144 49.455 1 3129 +ATOM H H01 . MET A 1 206 . 206 MET A H01 59.762 54.649 53.519 1 3130 +ATOM H H02 . MET A 1 206 . 206 MET A H02 58.971 51.737 53.299 1 3131 +ATOM H H03 . MET A 1 206 . 206 MET A H03 60.274 52.552 52.325 1 3132 +ATOM H H04 . MET A 1 206 . 206 MET A H04 58.54 53.948 51.288 1 3133 +ATOM H H05 . MET A 1 206 . 206 MET A H05 57.291 53.12 52.25 1 3134 +ATOM H H06 . MET A 1 206 . 206 MET A H06 60.462 51.565 49.744 1 3135 +ATOM H H07 . MET A 1 206 . 206 MET A H07 59.331 51.925 48.418 1 3136 +ATOM H H08 . MET A 1 206 . 206 MET A H08 59.802 53.207 49.56 1 3137 +ATOM H H12 . MET A 1 206 . 206 MET A H12 60.308 52.458 55.488 1 3138 +ATOM N N . PHE A 1 207 . 207 PHE A N 57.31 54.77 54.227 1 3139 +ATOM C CA . PHE A 1 207 . 207 PHE A CA 55.999 54.97 54.839 1 3140 +ATOM C C . PHE A 1 207 . 207 PHE A C 54.954 54.514 53.822 1 3141 +ATOM O O . PHE A 1 207 . 207 PHE A O 55.192 54.569 52.614 1 3142 +ATOM C CB . PHE A 1 207 . 207 PHE A CB 55.771 56.45 55.155 1 3143 +ATOM C CG . PHE A 1 207 . 207 PHE A CG 54.689 56.7 56.182 1 3144 +ATOM C CD1 . PHE A 1 207 . 207 PHE A CD1 54.974 56.639 57.54 1 3145 +ATOM C CD2 . PHE A 1 207 . 207 PHE A CD2 53.385 56.993 55.789 1 3146 +ATOM C CE1 . PHE A 1 207 . 207 PHE A CE1 53.975 56.868 58.498 1 3147 +ATOM C CE2 . PHE A 1 207 . 207 PHE A CE2 52.386 57.223 56.743 1 3148 +ATOM C CZ . PHE A 1 207 . 207 PHE A CZ 52.684 57.159 58.094 1 3149 +ATOM H H01 . PHE A 1 207 . 207 PHE A H01 55.931 54.407 55.77 1 3150 +ATOM H H02 . PHE A 1 207 . 207 PHE A H02 55.442 56.923 54.23 1 3151 +ATOM H H03 . PHE A 1 207 . 207 PHE A H03 56.705 56.869 55.531 1 3152 +ATOM H H04 . PHE A 1 207 . 207 PHE A H04 55.989 56.41 57.866 1 3153 +ATOM H H05 . PHE A 1 207 . 207 PHE A H05 53.14 57.044 54.728 1 3154 +ATOM H H06 . PHE A 1 207 . 207 PHE A H06 54.216 56.817 59.56 1 3155 +ATOM H H07 . PHE A 1 207 . 207 PHE A H07 51.37 57.453 56.421 1 3156 +ATOM H H08 . PHE A 1 207 . 207 PHE A H08 51.905 57.337 58.836 1 3157 +ATOM H H09 . PHE A 1 207 . 207 PHE A H09 57.59 55.352 53.45 1 3158 +ATOM N N . GLY A 1 208 . 208 GLY A N 53.818 54.035 54.317 1 3159 +ATOM C CA . GLY A 1 208 . 208 GLY A CA 52.737 53.622 53.437 1 3160 +ATOM C C . GLY A 1 208 . 208 GLY A C 51.565 54.526 53.791 1 3161 +ATOM O O . GLY A 1 208 . 208 GLY A O 50.763 54.17 54.644 1 3162 +ATOM H H01 . GLY A 1 208 . 208 GLY A H01 52.49 52.568 53.563 1 3163 +ATOM H H02 . GLY A 1 208 . 208 GLY A H02 53.021 53.755 52.393 1 3164 +ATOM H H09 . GLY A 1 208 . 208 GLY A H09 53.704 53.957 55.317 1 3165 +ATOM N N . PRO A 1 209 . 209 PRO A N 51.439 55.699 53.148 1 3166 +ATOM C CA . PRO A 1 209 . 209 PRO A CA 50.347 56.641 53.44 1 3167 +ATOM C C . PRO A 1 209 . 209 PRO A C 48.937 56.064 53.328 1 3168 +ATOM O O . PRO A 1 209 . 209 PRO A O 48.089 56.302 54.183 1 3169 +ATOM C CB . PRO A 1 209 . 209 PRO A CB 50.557 57.768 52.428 1 3170 +ATOM C CG . PRO A 1 209 . 209 PRO A CG 51.964 57.598 51.933 1 3171 +ATOM C CD . PRO A 1 209 . 209 PRO A CD 52.222 56.128 51.976 1 3172 +ATOM H H01 . PRO A 1 209 . 209 PRO A H01 50.399 58.75 52.874 1 3173 +ATOM H H02 . PRO A 1 209 . 209 PRO A H02 49.848 57.68 51.605 1 3174 +ATOM H H03 . PRO A 1 209 . 209 PRO A H03 50.396 56.955 54.483 1 3175 +ATOM H H04 . PRO A 1 209 . 209 PRO A H04 52.678 58.154 52.541 1 3176 +ATOM H H05 . PRO A 1 209 . 209 PRO A H05 52.059 57.973 50.914 1 3177 +ATOM H H06 . PRO A 1 209 . 209 PRO A H06 51.945 55.612 51.057 1 3178 +ATOM H H07 . PRO A 1 209 . 209 PRO A H07 53.283 55.918 52.112 1 3179 +ATOM N N . GLY A 1 210 . 210 GLY A N 48.69 55.328 52.251 1 3180 +ATOM C CA . GLY A 1 210 . 210 GLY A CA 47.374 54.753 52.033 1 3181 +ATOM C C . GLY A 1 210 . 210 GLY A C 46.94 53.808 53.131 1 3182 +ATOM O O . GLY A 1 210 . 210 GLY A O 45.888 54.007 53.752 1 3183 +ATOM H H01 . GLY A 1 210 . 210 GLY A H01 47.426 54.172 51.112 1 3184 +ATOM H H02 . GLY A 1 210 . 210 GLY A H02 46.649 55.564 51.968 1 3185 +ATOM H H08 . GLY A 1 210 . 210 GLY A H08 49.424 55.166 51.577 1 3186 +ATOM N N . ASP A 1 211 . 211 ASP A N 47.734 52.767 53.371 1 3187 +ATOM C CA . ASP A 1 211 . 211 ASP A CA 47.38 51.826 54.417 1 3188 +ATOM C C . ASP A 1 211 . 211 ASP A C 47.324 52.527 55.766 1 3189 +ATOM O O . ASP A 1 211 . 211 ASP A O 46.464 52.218 56.581 1 3190 +ATOM C CB . ASP A 1 211 . 211 ASP A CB 48.358 50.651 54.465 1 3191 +ATOM C CG . ASP A 1 211 . 211 ASP A CG 47.996 49.559 53.463 1 3192 +ATOM O OD1 . ASP A 1 211 . 211 ASP A OD1 46.789 49.341 53.229 1 3193 +ATOM O OD2 . ASP A 1 211 . 211 ASP A OD2 48.905 48.909 52.92 1 3194 +ATOM H H01 . ASP A 1 211 . 211 ASP A H01 46.392 51.428 54.187 1 3195 +ATOM H H02 . ASP A 1 211 . 211 ASP A H02 48.311 50.217 55.464 1 3196 +ATOM H H03 . ASP A 1 211 . 211 ASP A H03 48.576 52.633 52.829 1 3197 +ATOM H H04 . ASP A 1 211 . 211 ASP A H04 49.359 51.017 54.239 1 3198 +ATOM H H05 . ASP A 1 211 . 211 ASP A H05 49.102 49.29 52.061 1 3199 +ATOM N N . TYR A 1 212 . 212 TYR A N 48.226 53.477 56.004 1 3200 +ATOM C CA . TYR A 1 212 . 212 TYR A CA 48.202 54.182 57.284 1 3201 +ATOM C C . TYR A 1 212 . 212 TYR A C 46.877 54.919 57.465 1 3202 +ATOM O O . TYR A 1 212 . 212 TYR A O 46.214 54.783 58.498 1 3203 +ATOM C CB . TYR A 1 212 . 212 TYR A CB 49.342 55.197 57.387 1 3204 +ATOM C CG . TYR A 1 212 . 212 TYR A CG 49.187 56.08 58.61 1 3205 +ATOM C CD1 . TYR A 1 212 . 212 TYR A CD1 49.497 55.603 59.887 1 3206 +ATOM C CD2 . TYR A 1 212 . 212 TYR A CD2 48.625 57.349 58.497 1 3207 +ATOM C CE1 . TYR A 1 212 . 212 TYR A CE1 49.241 56.377 61.032 1 3208 +ATOM C CE2 . TYR A 1 212 . 212 TYR A CE2 48.363 58.13 59.628 1 3209 +ATOM C CZ . TYR A 1 212 . 212 TYR A CZ 48.669 57.638 60.885 1 3210 +ATOM O OH . TYR A 1 212 . 212 TYR A OH 48.364 58.4 62.0 1 3211 +ATOM H H01 . TYR A 1 212 . 212 TYR A H01 48.322 53.431 58.065 1 3212 +ATOM H H02 . TYR A 1 212 . 212 TYR A H02 49.318 55.831 56.501 1 3213 +ATOM H H03 . TYR A 1 212 . 212 TYR A H03 50.289 54.662 57.453 1 3214 +ATOM H H04 . TYR A 1 212 . 212 TYR A H04 49.944 54.615 59.996 1 3215 +ATOM H H05 . TYR A 1 212 . 212 TYR A H05 48.385 57.741 57.509 1 3216 +ATOM H H06 . TYR A 1 212 . 212 TYR A H06 48.92 53.708 55.307 1 3217 +ATOM H H07 . TYR A 1 212 . 212 TYR A H07 49.487 55.995 62.023 1 3218 +ATOM H H08 . TYR A 1 212 . 212 TYR A H08 47.92 59.12 59.52 1 3219 +ATOM H H09 . TYR A 1 212 . 212 TYR A H09 48.465 59.331 61.789 1 3220 +ATOM N N . MET A 1 213 . 213 MET A N 46.495 55.701 56.462 1 3221 +ATOM C CA . MET A 1 213 . 213 MET A CA 45.255 56.457 56.545 1 3222 +ATOM C C . MET A 1 213 . 213 MET A C 44.045 55.563 56.788 1 3223 +ATOM O O . MET A 1 213 . 213 MET A O 43.17 55.896 57.582 1 3224 +ATOM C CB . MET A 1 213 . 213 MET A CB 45.06 57.289 55.281 1 3225 +ATOM C CG . MET A 1 213 . 213 MET A CG 46.072 58.42 55.169 1 3226 +ATOM S SD . MET A 1 213 . 213 MET A SD 45.747 59.52 53.795 1 3227 +ATOM C CE . MET A 1 213 . 213 MET A CE 46.66 58.714 52.454 1 3228 +ATOM H H01 . MET A 1 213 . 213 MET A H01 45.337 57.122 57.405 1 3229 +ATOM H H02 . MET A 1 213 . 213 MET A H02 44.067 57.736 55.327 1 3230 +ATOM H H03 . MET A 1 213 . 213 MET A H03 45.164 56.638 54.413 1 3231 +ATOM H H04 . MET A 1 213 . 213 MET A H04 47.048 57.966 54.998 1 3232 +ATOM H H05 . MET A 1 213 . 213 MET A H05 46.049 59.0 56.092 1 3233 +ATOM H H06 . MET A 1 213 . 213 MET A H06 47.651 58.43 52.809 1 3234 +ATOM H H07 . MET A 1 213 . 213 MET A H07 46.121 57.823 52.131 1 3235 +ATOM H H08 . MET A 1 213 . 213 MET A H08 46.759 59.403 51.615 1 3236 +ATOM H H10 . MET A 1 213 . 213 MET A H10 47.07 55.771 55.635 1 3237 +ATOM N N . ILE A 1 214 . 214 ILE A N 43.993 54.416 56.124 1 3238 +ATOM C CA . ILE A 1 214 . 214 ILE A CA 42.866 53.518 56.335 1 3239 +ATOM C C . ILE A 1 214 . 214 ILE A C 42.869 53.021 57.775 1 3240 +ATOM O O . ILE A 1 214 . 214 ILE A O 41.841 53.023 58.452 1 3241 +ATOM C CB . ILE A 1 214 . 214 ILE A CB 42.941 52.31 55.366 1 3242 +ATOM C CG1 . ILE A 1 214 . 214 ILE A CG1 42.78 52.807 53.927 1 3243 +ATOM C CG2 . ILE A 1 214 . 214 ILE A CG2 41.87 51.28 55.714 1 3244 +ATOM C CD1 . ILE A 1 214 . 214 ILE A CD1 42.96 51.721 52.868 1 3245 +ATOM H H01 . ILE A 1 214 . 214 ILE A H01 41.944 54.065 56.139 1 3246 +ATOM H H02 . ILE A 1 214 . 214 ILE A H02 43.911 51.823 55.465 1 3247 +ATOM H H03 . ILE A 1 214 . 214 ILE A H03 43.567 53.543 53.759 1 3248 +ATOM H H04 . ILE A 1 214 . 214 ILE A H04 41.781 53.228 53.82 1 3249 +ATOM H H05 . ILE A 1 214 . 214 ILE A H05 40.97 51.482 55.133 1 3250 +ATOM H H06 . ILE A 1 214 . 214 ILE A H06 42.237 50.281 55.48 1 3251 +ATOM H H07 . ILE A 1 214 . 214 ILE A H07 41.637 51.343 56.777 1 3252 +ATOM H H08 . ILE A 1 214 . 214 ILE A H08 43.994 51.375 52.875 1 3253 +ATOM H H09 . ILE A 1 214 . 214 ILE A H09 44.728 54.168 55.478 1 3254 +ATOM H H10 . ILE A 1 214 . 214 ILE A H10 42.296 50.885 53.088 1 3255 +ATOM H H11 . ILE A 1 214 . 214 ILE A H11 42.721 52.128 51.885 1 3256 +ATOM N N . ASP A 1 215 . 215 ASP A N 44.041 52.623 58.256 1 3257 +ATOM C CA . ASP A 1 215 . 215 ASP A CA 44.163 52.096 59.596 1 3258 +ATOM C C . ASP A 1 215 . 215 ASP A C 43.865 53.154 60.649 1 3259 +ATOM O O . ASP A 1 215 . 215 ASP A O 43.364 52.834 61.72 1 3260 +ATOM C CB . ASP A 1 215 . 215 ASP A CB 45.569 51.528 59.808 1 3261 +ATOM C CG . ASP A 1 215 . 215 ASP A CG 45.684 50.714 61.084 1 3262 +ATOM O OD1 . ASP A 1 215 . 215 ASP A OD1 44.878 49.773 61.279 1 3263 +ATOM O OD2 . ASP A 1 215 . 215 ASP A OD2 46.587 51.006 61.896 1 3264 +ATOM H H01 . ASP A 1 215 . 215 ASP A H01 43.426 51.301 59.708 1 3265 +ATOM H H02 . ASP A 1 215 . 215 ASP A H02 46.256 52.37 59.891 1 3266 +ATOM H H03 . ASP A 1 215 . 215 ASP A H03 45.819 50.888 58.962 1 3267 +ATOM H H04 . ASP A 1 215 . 215 ASP A H04 46.879 50.212 62.35 1 3268 +ATOM H H12 . ASP A 1 215 . 215 ASP A H12 44.864 52.69 57.674 1 3269 +ATOM N N . ALA A 1 216 . 216 ALA A N 44.167 54.406 60.324 1 3270 +ATOM C CA . ALA A 1 216 . 216 ALA A CA 43.953 55.518 61.25 1 3271 +ATOM C C . ALA A 1 216 . 216 ALA A C 42.541 56.091 61.16 1 3272 +ATOM O O . ALA A 1 216 . 216 ALA A O 42.192 57.01 61.904 1 3273 +ATOM C CB . ALA A 1 216 . 216 ALA A CB 44.972 56.622 60.98 1 3274 +ATOM H H01 . ALA A 1 216 . 216 ALA A H01 44.082 55.125 62.259 1 3275 +ATOM H H02 . ALA A 1 216 . 216 ALA A H02 45.887 56.182 60.583 1 3276 +ATOM H H03 . ALA A 1 216 . 216 ALA A H03 44.563 57.325 60.255 1 3277 +ATOM H H04 . ALA A 1 216 . 216 ALA A H04 45.195 57.146 61.909 1 3278 +ATOM H H05 . ALA A 1 216 . 216 ALA A H05 44.556 54.596 59.411 1 3279 +ATOM N N . GLY A 1 217 . 217 GLY A N 41.738 55.557 60.244 1 3280 +ATOM C CA . GLY A 1 217 . 217 GLY A CA 40.377 56.036 60.089 1 3281 +ATOM C C . GLY A 1 217 . 217 GLY A C 40.31 57.424 59.488 1 3282 +ATOM O O . GLY A 1 217 . 217 GLY A O 39.365 58.174 59.742 1 3283 +ATOM H H01 . GLY A 1 217 . 217 GLY A H01 39.931 56.086 61.082 1 3284 +ATOM H H02 . GLY A 1 217 . 217 GLY A H02 39.836 55.348 59.439 1 3285 +ATOM H H05 . GLY A 1 217 . 217 GLY A H05 42.078 54.813 59.652 1 3286 +ATOM N N . LEU A 1 218 . 218 LEU A N 41.311 57.771 58.686 1 3287 +ATOM C CA . LEU A 1 218 . 218 LEU A CA 41.36 59.077 58.052 1 3288 +ATOM C C . LEU A 1 218 . 218 LEU A C 40.839 58.969 56.628 1 3289 +ATOM O O . LEU A 1 218 . 218 LEU A O 41.149 58.018 55.915 1 3290 +ATOM C CB . LEU A 1 218 . 218 LEU A CB 42.796 59.612 58.049 1 3291 +ATOM C CG . LEU A 1 218 . 218 LEU A CG 43.463 59.717 59.424 1 3292 +ATOM C CD1 . LEU A 1 218 . 218 LEU A CD1 44.913 60.15 59.266 1 3293 +ATOM C CD2 . LEU A 1 218 . 218 LEU A CD2 42.7 60.707 60.291 1 3294 +ATOM H H01 . LEU A 1 218 . 218 LEU A H01 40.734 59.771 58.613 1 3295 +ATOM H H02 . LEU A 1 218 . 218 LEU A H02 42.735 60.631 57.668 1 3296 +ATOM H H03 . LEU A 1 218 . 218 LEU A H03 42.056 57.111 58.513 1 3297 +ATOM H H04 . LEU A 1 218 . 218 LEU A H04 43.401 58.948 57.431 1 3298 +ATOM H H05 . LEU A 1 218 . 218 LEU A H05 43.445 58.741 59.91 1 3299 +ATOM H H06 . LEU A 1 218 . 218 LEU A H06 45.539 59.272 59.108 1 3300 +ATOM H H07 . LEU A 1 218 . 218 LEU A H07 45.002 60.818 58.409 1 3301 +ATOM H H08 . LEU A 1 218 . 218 LEU A H08 45.238 60.669 60.168 1 3302 +ATOM H H09 . LEU A 1 218 . 218 LEU A H09 41.715 60.302 60.523 1 3303 +ATOM H H10 . LEU A 1 218 . 218 LEU A H10 43.249 60.878 61.217 1 3304 +ATOM H H11 . LEU A 1 218 . 218 LEU A H11 42.588 61.649 59.755 1 3305 +ATOM N N . ASP A 1 219 . 219 ASP A N 40.042 59.945 56.218 1 3306 +ATOM C CA . ASP A 1 219 . 219 ASP A CA 39.474 59.945 54.881 1 3307 +ATOM C C . ASP A 1 219 . 219 ASP A C 40.543 60.073 53.809 1 3308 +ATOM O O . ASP A 1 219 . 219 ASP A O 40.949 61.178 53.452 1 3309 +ATOM C CB . ASP A 1 219 . 219 ASP A CB 38.455 61.077 54.754 1 3310 +ATOM C CG . ASP A 1 219 . 219 ASP A CG 38.934 62.362 55.401 1 3311 +ATOM O OD1 . ASP A 1 219 . 219 ASP A OD1 39.262 62.339 56.612 1 3312 +ATOM O OD2 . ASP A 1 219 . 219 ASP A OD2 38.979 63.398 54.702 1 3313 +ATOM H H01 . ASP A 1 219 . 219 ASP A H01 38.977 58.987 54.728 1 3314 +ATOM H H02 . ASP A 1 219 . 219 ASP A H02 37.546 60.765 55.269 1 3315 +ATOM H H03 . ASP A 1 219 . 219 ASP A H03 38.27 61.265 53.696 1 3316 +ATOM H H04 . ASP A 1 219 . 219 ASP A H04 38.421 64.074 55.094 1 3317 +ATOM H H12 . ASP A 1 219 . 219 ASP A H12 39.825 60.704 56.848 1 3318 +ATOM N N . LEU A 1 220 . 220 LEU A N 41.01 58.935 53.305 1 3319 +ATOM C CA . LEU A 1 220 . 220 LEU A CA 42.025 58.947 52.262 1 3320 +ATOM C C . LEU A 1 220 . 220 LEU A C 41.473 59.793 51.125 1 3321 +ATOM O O . LEU A 1 220 . 220 LEU A O 40.322 59.628 50.729 1 3322 +ATOM C CB . LEU A 1 220 . 220 LEU A CB 42.335 57.52 51.786 1 3323 +ATOM C CG . LEU A 1 220 . 220 LEU A CG 41.2 56.607 51.316 1 3324 +ATOM C CD1 . LEU A 1 220 . 220 LEU A CD1 41.785 55.301 50.802 1 3325 +ATOM C CD2 . LEU A 1 220 . 220 LEU A CD2 40.238 56.34 52.459 1 3326 +ATOM H H01 . LEU A 1 220 . 220 LEU A H01 42.961 59.363 52.634 1 3327 +ATOM H H02 . LEU A 1 220 . 220 LEU A H02 42.695 57.027 52.689 1 3328 +ATOM H H03 . LEU A 1 220 . 220 LEU A H03 43.033 57.61 50.953 1 3329 +ATOM H H04 . LEU A 1 220 . 220 LEU A H04 40.651 57.096 50.511 1 3330 +ATOM H H05 . LEU A 1 220 . 220 LEU A H05 40.657 58.054 53.649 1 3331 +ATOM H H06 . LEU A 1 220 . 220 LEU A H06 42.769 55.145 51.245 1 3332 +ATOM H H07 . LEU A 1 220 . 220 LEU A H07 41.129 54.475 51.077 1 3333 +ATOM H H08 . LEU A 1 220 . 220 LEU A H08 41.879 55.346 49.717 1 3334 +ATOM H H09 . LEU A 1 220 . 220 LEU A H09 39.284 55.998 52.058 1 3335 +ATOM H H10 . LEU A 1 220 . 220 LEU A H10 40.653 55.572 53.112 1 3336 +ATOM H H11 . LEU A 1 220 . 220 LEU A H11 40.085 57.257 53.027 1 3337 +ATOM N N . ASN A 1 221 . 221 ASN A N 42.287 60.715 50.623 1 3338 +ATOM C CA . ASN A 1 221 . 221 ASN A CA 41.851 61.599 49.553 1 3339 +ATOM C C . ASN A 1 221 . 221 ASN A C 40.622 62.366 50.032 1 3340 +ATOM O O . ASN A 1 221 . 221 ASN A O 39.606 62.433 49.339 1 3341 +ATOM C CB . ASN A 1 221 . 221 ASN A CB 41.504 60.786 48.307 1 3342 +ATOM C CG . ASN A 1 221 . 221 ASN A CG 41.042 61.653 47.153 1 3343 +ATOM N ND2 . ASN A 1 221 . 221 ASN A ND2 39.825 61.405 46.676 1 3344 +ATOM O OD1 . ASN A 1 221 . 221 ASN A OD1 41.769 62.536 46.694 1 3345 +ATOM H H01 . ASN A 1 221 . 221 ASN A H01 42.651 62.294 49.298 1 3346 +ATOM H H02 . ASN A 1 221 . 221 ASN A H02 40.682 60.119 48.567 1 3347 +ATOM H H03 . ASN A 1 221 . 221 ASN A H03 42.388 60.231 47.995 1 3348 +ATOM H H04 . ASN A 1 221 . 221 ASN A H04 39.463 61.951 45.907 1 3349 +ATOM H H05 . ASN A 1 221 . 221 ASN A H05 39.263 60.671 47.083 1 3350 +ATOM H H12 . ASN A 1 221 . 221 ASN A H12 43.224 60.804 50.99 1 3351 +ATOM N N . GLY A 1 222 . 222 GLY A N 40.723 62.94 51.227 1 3352 +ATOM C CA . GLY A 1 222 . 222 GLY A CA 39.609 63.683 51.786 1 3353 +ATOM C C . GLY A 1 222 . 222 GLY A C 39.979 65.052 52.324 1 3354 +ATOM O O . GLY A 1 222 . 222 GLY A O 39.44 65.494 53.34 1 3355 +ATOM H H01 . GLY A 1 222 . 222 GLY A H01 39.242 63.104 52.633 1 3356 +ATOM H H02 . GLY A 1 222 . 222 GLY A H02 38.859 63.813 51.006 1 3357 +ATOM H H06 . GLY A 1 222 . 222 GLY A H06 41.583 62.858 51.75 1 3358 +ATOM N N . ALA A 1 223 . 223 ALA A N 40.902 65.727 51.649 1 3359 +ATOM C CA . ALA A 1 223 . 223 ALA A CA 41.321 67.059 52.067 1 3360 +ATOM C C . ALA A 1 223 . 223 ALA A C 40.333 68.077 51.497 1 3361 +ATOM O O . ALA A 1 223 . 223 ALA A O 40.51 68.566 50.379 1 3362 +ATOM C CB . ALA A 1 223 . 223 ALA A CB 42.736 67.346 51.564 1 3363 +ATOM H H01 . ALA A 1 223 . 223 ALA A H01 41.33 67.125 53.155 1 3364 +ATOM H H02 . ALA A 1 223 . 223 ALA A H02 43.432 66.638 52.014 1 3365 +ATOM H H03 . ALA A 1 223 . 223 ALA A H03 41.322 65.31 50.831 1 3366 +ATOM H H04 . ALA A 1 223 . 223 ALA A H04 42.765 67.243 50.479 1 3367 +ATOM H H05 . ALA A 1 223 . 223 ALA A H05 43.022 68.361 51.841 1 3368 +ATOM N N . LEU A 1 224 . 224 LEU A N 39.289 68.378 52.267 1 3369 +ATOM C CA . LEU A 1 224 . 224 LEU A CA 38.253 69.325 51.853 1 3370 +ATOM C C . LEU A 1 224 . 224 LEU A C 38.852 70.553 51.176 1 3371 +ATOM O O . LEU A 1 224 . 224 LEU A O 38.634 70.786 49.986 1 3372 +ATOM C CB . LEU A 1 224 . 224 LEU A CB 37.416 69.75 53.064 1 3373 +ATOM C CG . LEU A 1 224 . 224 LEU A CG 36.613 68.631 53.738 1 3374 +ATOM C CD1 . LEU A 1 224 . 224 LEU A CD1 35.975 69.146 55.021 1 3375 +ATOM C CD2 . LEU A 1 224 . 224 LEU A CD2 35.551 68.11 52.773 1 3376 +ATOM H H01 . LEU A 1 224 . 224 LEU A H01 37.613 68.823 51.127 1 3377 +ATOM H H02 . LEU A 1 224 . 224 LEU A H02 36.68 70.459 52.684 1 3378 +ATOM H H03 . LEU A 1 224 . 224 LEU A H03 38.091 70.174 53.807 1 3379 +ATOM H H04 . LEU A 1 224 . 224 LEU A H04 37.281 67.81 53.997 1 3380 +ATOM H H05 . LEU A 1 224 . 224 LEU A H05 34.963 68.75 55.107 1 3381 +ATOM H H06 . LEU A 1 224 . 224 LEU A H06 39.209 67.936 53.171 1 3382 +ATOM H H07 . LEU A 1 224 . 224 LEU A H07 36.566 68.821 55.877 1 3383 +ATOM H H08 . LEU A 1 224 . 224 LEU A H08 35.938 70.235 54.997 1 3384 +ATOM H H09 . LEU A 1 224 . 224 LEU A H09 34.693 68.782 52.781 1 3385 +ATOM H H10 . LEU A 1 224 . 224 LEU A H10 35.966 68.064 51.766 1 3386 +ATOM H H11 . LEU A 1 224 . 224 LEU A H11 35.235 67.114 53.083 1 3387 +ATOM N N . SER A 1 225 . 225 SER A N 39.604 71.335 51.944 1 3388 +ATOM C CA . SER A 1 225 . 225 SER A CA 40.259 72.528 51.42 1 3389 +ATOM C C . SER A 1 225 . 225 SER A C 41.76 72.254 51.413 1 3390 +ATOM O O . SER A 1 225 . 225 SER A O 42.56 73.092 51.833 1 3391 +ATOM C CB . SER A 1 225 . 225 SER A CB 39.951 73.738 52.306 1 3392 +ATOM O OG . SER A 1 225 . 225 SER A OG 38.556 73.991 52.358 1 3393 +ATOM H H01 . SER A 1 225 . 225 SER A H01 39.9 72.751 50.415 1 3394 +ATOM H H02 . SER A 1 225 . 225 SER A H02 40.446 74.612 51.882 1 3395 +ATOM H H03 . SER A 1 225 . 225 SER A H03 40.316 73.544 53.314 1 3396 +ATOM H H04 . SER A 1 225 . 225 SER A H04 38.253 74.297 51.5 1 3397 +ATOM H H12 . SER A 1 225 . 225 SER A H12 39.725 71.096 52.918 1 3398 +ATOM N N . GLY A 1 226 . 226 GLY A N 42.13 71.072 50.927 1 3399 +ATOM C CA . GLY A 1 226 . 226 GLY A CA 43.527 70.683 50.896 1 3400 +ATOM C C . GLY A 1 226 . 226 GLY A C 43.966 70.433 52.325 1 3401 +ATOM O O . GLY A 1 226 . 226 GLY A O 43.146 70.07 53.168 1 3402 +ATOM H H01 . GLY A 1 226 . 226 GLY A H01 44.132 71.468 50.442 1 3403 +ATOM H H02 . GLY A 1 226 . 226 GLY A H02 43.648 69.775 50.305 1 3404 +ATOM H H05 . GLY A 1 226 . 226 GLY A H05 41.428 70.437 50.575 1 3405 +ATOM N N . VAL A 1 227 . 227 VAL A N 45.252 70.622 52.604 1 3406 +ATOM C CA . VAL A 1 227 . 227 VAL A CA 45.776 70.436 53.955 1 3407 +ATOM C C . VAL A 1 227 . 227 VAL A C 45.483 69.05 54.533 1 3408 +ATOM O O . VAL A 1 227 . 227 VAL A O 44.368 68.779 54.983 1 3409 +ATOM C CB . VAL A 1 227 . 227 VAL A CB 45.191 71.499 54.919 1 3410 +ATOM C CG1 . VAL A 1 227 . 227 VAL A CG1 45.811 71.352 56.302 1 3411 +ATOM C CG2 . VAL A 1 227 . 227 VAL A CG2 45.435 72.894 54.36 1 3412 +ATOM H H01 . VAL A 1 227 . 227 VAL A H01 46.857 70.544 53.867 1 3413 +ATOM H H02 . VAL A 1 227 . 227 VAL A H02 44.116 71.347 55.011 1 3414 +ATOM H H03 . VAL A 1 227 . 227 VAL A H03 45.881 70.901 51.865 1 3415 +ATOM H H04 . VAL A 1 227 . 227 VAL A H04 46.703 71.975 56.368 1 3416 +ATOM H H05 . VAL A 1 227 . 227 VAL A H05 45.092 71.667 57.058 1 3417 +ATOM H H06 . VAL A 1 227 . 227 VAL A H06 46.083 70.31 56.47 1 3418 +ATOM H H07 . VAL A 1 227 . 227 VAL A H07 46.428 72.937 53.912 1 3419 +ATOM H H08 . VAL A 1 227 . 227 VAL A H08 44.685 73.119 53.602 1 3420 +ATOM H H09 . VAL A 1 227 . 227 VAL A H09 45.369 73.625 55.165 1 3421 +ATOM N N . PRO A 1 228 . 228 PRO A N 46.489 68.157 54.532 1 3422 +ATOM C CA . PRO A 1 228 . 228 PRO A CA 46.354 66.793 55.056 1 3423 +ATOM C C . PRO A 1 228 . 228 PRO A C 45.872 66.798 56.502 1 3424 +ATOM O O . PRO A 1 228 . 228 PRO A O 46.012 67.798 57.205 1 3425 +ATOM C CB . PRO A 1 228 . 228 PRO A CB 47.767 66.238 54.929 1 3426 +ATOM C CG . PRO A 1 228 . 228 PRO A CG 48.288 66.94 53.723 1 3427 +ATOM C CD . PRO A 1 228 . 228 PRO A CD 47.826 68.357 53.947 1 3428 +ATOM H H01 . PRO A 1 228 . 228 PRO A H01 47.799 65.152 54.844 1 3429 +ATOM H H02 . PRO A 1 228 . 228 PRO A H02 48.363 66.474 55.81 1 3430 +ATOM H H03 . PRO A 1 228 . 228 PRO A H03 47.937 66.511 52.785 1 3431 +ATOM H H04 . PRO A 1 228 . 228 PRO A H04 49.375 66.883 53.672 1 3432 +ATOM H H05 . PRO A 1 228 . 228 PRO A H05 48.497 68.942 54.576 1 3433 +ATOM H H06 . PRO A 1 228 . 228 PRO A H06 47.77 68.909 53.009 1 3434 +ATOM H H10 . PRO A 1 228 . 228 PRO A H10 45.615 66.196 54.522 1 3435 +ATOM N N . HIS A 1 229 . 229 HIS A N 45.307 65.68 56.943 1 3436 +ATOM C CA . HIS A 1 229 . 229 HIS A CA 44.812 65.576 58.309 1 3437 +ATOM C C . HIS A 1 229 . 229 HIS A C 45.974 65.747 59.282 1 3438 +ATOM O O . HIS A 1 229 . 229 HIS A O 47.077 65.262 59.034 1 3439 +ATOM C CB . HIS A 1 229 . 229 HIS A CB 44.144 64.218 58.525 1 3440 +ATOM C CG . HIS A 1 229 . 229 HIS A CG 43.291 64.153 59.753 1 3441 +ATOM C CD2 . HIS A 1 229 . 229 HIS A CD2 41.945 64.111 59.9 1 3442 +ATOM N ND1 . HIS A 1 229 . 229 HIS A ND1 43.815 64.142 61.027 1 3443 +ATOM C CE1 . HIS A 1 229 . 229 HIS A CE1 42.829 64.094 61.906 1 3444 +ATOM N NE2 . HIS A 1 229 . 229 HIS A NE2 41.684 64.074 61.248 1 3445 +ATOM H H01 . HIS A 1 229 . 229 HIS A H01 44.074 66.359 58.485 1 3446 +ATOM H H02 . HIS A 1 229 . 229 HIS A H02 44.943 63.488 58.655 1 3447 +ATOM H H03 . HIS A 1 229 . 229 HIS A H03 43.518 64.0 57.659 1 3448 +ATOM H H04 . HIS A 1 229 . 229 HIS A H04 41.207 64.107 59.098 1 3449 +ATOM H H05 . HIS A 1 229 . 229 HIS A H05 42.942 64.074 62.99 1 3450 +ATOM H H06 . HIS A 1 229 . 229 HIS A H06 40.766 64.037 61.668 1 3451 +ATOM H H07 . HIS A 1 229 . 229 HIS A H07 45.219 64.889 56.321 1 3452 +ATOM N N . PRO A 1 230 . 230 PRO A N 45.742 66.457 60.399 1 3453 +ATOM C CA . PRO A 1 230 . 230 PRO A CA 46.783 66.688 61.405 1 3454 +ATOM C C . PRO A 1 230 . 230 PRO A C 47.37 65.39 61.959 1 3455 +ATOM O O . PRO A 1 230 . 230 PRO A O 48.563 65.324 62.26 1 3456 +ATOM C CB . PRO A 1 230 . 230 PRO A CB 46.05 67.495 62.48 1 3457 +ATOM C CG . PRO A 1 230 . 230 PRO A CG 45.04 68.258 61.686 1 3458 +ATOM C CD . PRO A 1 230 . 230 PRO A CD 44.515 67.197 60.743 1 3459 +ATOM H H01 . PRO A 1 230 . 230 PRO A H01 46.71 68.133 63.067 1 3460 +ATOM H H02 . PRO A 1 230 . 230 PRO A H02 45.568 66.839 63.204 1 3461 +ATOM H H03 . PRO A 1 230 . 230 PRO A H03 45.448 69.133 61.18 1 3462 +ATOM H H04 . PRO A 1 230 . 230 PRO A H04 44.247 68.65 62.324 1 3463 +ATOM H H05 . PRO A 1 230 . 230 PRO A H05 43.733 66.573 61.176 1 3464 +ATOM H H06 . PRO A 1 230 . 230 PRO A H06 44.059 67.643 59.859 1 3465 +ATOM H H07 . PRO A 1 230 . 230 PRO A H07 47.651 67.205 60.997 1 3466 +ATOM N N . THR A 1 231 . 231 THR A N 46.53 64.364 62.098 1 3467 +ATOM C CA . THR A 1 231 . 231 THR A CA 46.983 63.077 62.616 1 3468 +ATOM C C . THR A 1 231 . 231 THR A C 48.014 62.478 61.666 1 3469 +ATOM O O . THR A 1 231 . 231 THR A O 48.971 61.848 62.103 1 3470 +ATOM C CB . THR A 1 231 . 231 THR A CB 45.809 62.083 62.793 1 3471 +ATOM C CG2 . THR A 1 231 . 231 THR A CG2 46.321 60.726 63.276 1 3472 +ATOM O OG1 . THR A 1 231 . 231 THR A OG1 44.893 62.596 63.773 1 3473 +ATOM H H01 . THR A 1 231 . 231 THR A H01 47.428 63.251 63.596 1 3474 +ATOM H H02 . THR A 1 231 . 231 THR A H02 45.313 61.962 61.83 1 3475 +ATOM H H03 . THR A 1 231 . 231 THR A H03 47.256 60.863 63.819 1 3476 +ATOM H H04 . THR A 1 231 . 231 THR A H04 45.582 60.272 63.936 1 3477 +ATOM H H05 . THR A 1 231 . 231 THR A H05 46.492 60.075 62.418 1 3478 +ATOM H H06 . THR A 1 231 . 231 THR A H06 44.753 63.534 63.621 1 3479 +ATOM H H07 . THR A 1 231 . 231 THR A H07 45.561 64.48 61.84 1 3480 +ATOM N N . PHE A 1 232 . 232 PHE A N 47.808 62.681 60.37 1 3481 +ATOM C CA . PHE A 1 232 . 232 PHE A CA 48.733 62.183 59.355 1 3482 +ATOM C C . PHE A 1 232 . 232 PHE A C 50.022 63.002 59.415 1 3483 +ATOM O O . PHE A 1 232 . 232 PHE A O 51.123 62.452 59.359 1 3484 +ATOM C CB . PHE A 1 232 . 232 PHE A CB 48.12 62.311 57.957 1 3485 +ATOM C CG . PHE A 1 232 . 232 PHE A CG 49.048 61.901 56.846 1 3486 +ATOM C CD1 . PHE A 1 232 . 232 PHE A CD1 49.278 60.559 56.571 1 3487 +ATOM C CD2 . PHE A 1 232 . 232 PHE A CD2 49.702 62.857 56.081 1 3488 +ATOM C CE1 . PHE A 1 232 . 232 PHE A CE1 50.147 60.179 55.548 1 3489 +ATOM C CE2 . PHE A 1 232 . 232 PHE A CE2 50.571 62.487 55.062 1 3490 +ATOM C CZ . PHE A 1 232 . 232 PHE A CZ 50.793 61.142 54.795 1 3491 +ATOM H H01 . PHE A 1 232 . 232 PHE A H01 48.941 61.131 59.551 1 3492 +ATOM H H02 . PHE A 1 232 . 232 PHE A H02 47.892 63.366 57.806 1 3493 +ATOM H H03 . PHE A 1 232 . 232 PHE A H03 47.231 61.682 57.912 1 3494 +ATOM H H04 . PHE A 1 232 . 232 PHE A H04 48.774 59.794 57.161 1 3495 +ATOM H H05 . PHE A 1 232 . 232 PHE A H05 49.531 63.914 56.283 1 3496 +ATOM H H06 . PHE A 1 232 . 232 PHE A H06 50.317 59.122 55.342 1 3497 +ATOM H H07 . PHE A 1 232 . 232 PHE A H07 46.989 63.194 60.078 1 3498 +ATOM H H08 . PHE A 1 232 . 232 PHE A H08 51.078 63.251 54.473 1 3499 +ATOM H H09 . PHE A 1 232 . 232 PHE A H09 51.473 60.847 53.996 1 3500 +ATOM N N . VAL A 1 233 . 233 VAL A N 49.881 64.322 59.521 1 3501 +ATOM C CA . VAL A 1 233 . 233 VAL A CA 51.05 65.195 59.597 1 3502 +ATOM C C . VAL A 1 233 . 233 VAL A C 51.931 64.778 60.771 1 3503 +ATOM O O . VAL A 1 233 . 233 VAL A O 53.148 64.641 60.625 1 3504 +ATOM C CB . VAL A 1 233 . 233 VAL A CB 50.636 66.681 59.768 1 3505 +ATOM C CG1 . VAL A 1 233 . 233 VAL A CG1 51.874 67.551 59.984 1 3506 +ATOM C CG2 . VAL A 1 233 . 233 VAL A CG2 49.888 67.149 58.53 1 3507 +ATOM H H01 . VAL A 1 233 . 233 VAL A H01 51.603 65.097 58.663 1 3508 +ATOM H H02 . VAL A 1 233 . 233 VAL A H02 49.986 66.77 60.639 1 3509 +ATOM H H03 . VAL A 1 233 . 233 VAL A H03 52.682 67.198 59.342 1 3510 +ATOM H H04 . VAL A 1 233 . 233 VAL A H04 51.639 68.586 59.736 1 3511 +ATOM H H05 . VAL A 1 233 . 233 VAL A H05 52.185 67.488 61.027 1 3512 +ATOM H H06 . VAL A 1 233 . 233 VAL A H06 48.989 67.686 58.831 1 3513 +ATOM H H07 . VAL A 1 233 . 233 VAL A H07 50.528 67.811 57.947 1 3514 +ATOM H H08 . VAL A 1 233 . 233 VAL A H08 49.61 66.286 57.925 1 3515 +ATOM H H10 . VAL A 1 233 . 233 VAL A H10 48.955 64.724 59.549 1 3516 +ATOM N N . GLU A 1 234 . 234 GLU A N 51.316 64.574 61.932 1 3517 +ATOM C CA . GLU A 1 234 . 234 GLU A CA 52.054 64.165 63.121 1 3518 +ATOM C C . GLU A 1 234 . 234 GLU A C 52.753 62.828 62.882 1 3519 +ATOM O O . GLU A 1 234 . 234 GLU A O 53.906 62.641 63.275 1 3520 +ATOM C CB . GLU A 1 234 . 234 GLU A CB 51.113 64.024 64.318 1 3521 +ATOM C CG . GLU A 1 234 . 234 GLU A CG 50.504 65.331 64.79 1 3522 +ATOM C CD . GLU A 1 234 . 234 GLU A CD 49.439 65.12 65.849 1 3523 +ATOM O OE1 . GLU A 1 234 . 234 GLU A OE1 49.725 64.426 66.852 1 3524 +ATOM O OE2 . GLU A 1 234 . 234 GLU A OE2 48.318 65.651 65.681 1 3525 +ATOM H H01 . GLU A 1 234 . 234 GLU A H01 52.797 64.934 63.333 1 3526 +ATOM H H02 . GLU A 1 234 . 234 GLU A H02 51.714 63.643 65.144 1 3527 +ATOM H H03 . GLU A 1 234 . 234 GLU A H03 50.303 63.35 64.039 1 3528 +ATOM H H04 . GLU A 1 234 . 234 GLU A H04 50.031 65.807 63.931 1 3529 +ATOM H H05 . GLU A 1 234 . 234 GLU A H05 51.294 65.957 65.206 1 3530 +ATOM H H06 . GLU A 1 234 . 234 GLU A H06 48.014 65.482 64.786 1 3531 +ATOM H H09 . GLU A 1 234 . 234 GLU A H09 50.316 64.705 61.992 1 3532 +ATOM N N . ALA A 1 235 . 235 ALA A N 52.043 61.903 62.239 1 3533 +ATOM C CA . ALA A 1 235 . 235 ALA A CA 52.594 60.582 61.955 1 3534 +ATOM C C . ALA A 1 235 . 235 ALA A C 53.823 60.695 61.063 1 3535 +ATOM O O . ALA A 1 235 . 235 ALA A O 54.85 60.07 61.327 1 3536 +ATOM C CB . ALA A 1 235 . 235 ALA A CB 51.538 59.703 61.284 1 3537 +ATOM H H01 . ALA A 1 235 . 235 ALA A H01 52.891 60.122 62.898 1 3538 +ATOM H H02 . ALA A 1 235 . 235 ALA A H02 50.666 60.308 61.037 1 3539 +ATOM H H03 . ALA A 1 235 . 235 ALA A H03 51.95 59.271 60.372 1 3540 +ATOM H H04 . ALA A 1 235 . 235 ALA A H04 51.244 58.904 61.964 1 3541 +ATOM H H07 . ALA A 1 235 . 235 ALA A H07 51.103 62.12 61.941 1 3542 +ATOM N N . MET A 1 236 . 236 MET A N 53.725 61.493 60.01 1 3543 +ATOM C CA . MET A 1 236 . 236 MET A CA 54.858 61.662 59.104 1 3544 +ATOM C C . MET A 1 236 . 236 MET A C 56.02 62.372 59.792 1 3545 +ATOM O O . MET A 1 236 . 236 MET A O 57.188 62.103 59.501 1 3546 +ATOM C CB . MET A 1 236 . 236 MET A CB 54.448 62.444 57.86 1 3547 +ATOM C CG . MET A 1 236 . 236 MET A CG 53.736 61.608 56.813 1 3548 +ATOM S SD . MET A 1 236 . 236 MET A SD 54.67 60.104 56.441 1 3549 +ATOM C CE . MET A 1 236 . 236 MET A CE 56.145 60.764 55.788 1 3550 +ATOM H H01 . MET A 1 236 . 236 MET A H01 55.185 60.665 58.808 1 3551 +ATOM H H02 . MET A 1 236 . 236 MET A H02 55.366 62.81 57.401 1 3552 +ATOM H H03 . MET A 1 236 . 236 MET A H03 53.783 63.251 58.166 1 3553 +ATOM H H04 . MET A 1 236 . 236 MET A H04 53.647 62.197 55.9 1 3554 +ATOM H H05 . MET A 1 236 . 236 MET A H05 52.862 61.987 59.833 1 3555 +ATOM H H06 . MET A 1 236 . 236 MET A H06 52.749 61.333 57.186 1 3556 +ATOM H H07 . MET A 1 236 . 236 MET A H07 56.948 60.034 55.895 1 3557 +ATOM H H08 . MET A 1 236 . 236 MET A H08 56.004 60.996 54.732 1 3558 +ATOM H H09 . MET A 1 236 . 236 MET A H09 56.407 61.673 56.329 1 3559 +ATOM N N . THR A 1 237 . 237 THR A N 55.701 63.279 60.707 1 3560 +ATOM C CA . THR A 1 237 . 237 THR A CA 56.745 63.991 61.422 1 3561 +ATOM C C . THR A 1 237 . 237 THR A C 57.492 63.042 62.337 1 3562 +ATOM O O . THR A 1 237 . 237 THR A O 58.723 63.078 62.402 1 3563 +ATOM C CB . THR A 1 237 . 237 THR A CB 56.169 65.147 62.256 1 3564 +ATOM C CG2 . THR A 1 237 . 237 THR A CG2 57.254 65.756 63.138 1 3565 +ATOM O OG1 . THR A 1 237 . 237 THR A OG1 55.659 66.151 61.371 1 3566 +ATOM H H01 . THR A 1 237 . 237 THR A H01 57.427 64.405 60.68 1 3567 +ATOM H H02 . THR A 1 237 . 237 THR A H02 55.371 64.766 62.893 1 3568 +ATOM H H03 . THR A 1 237 . 237 THR A H03 58.105 66.043 62.52 1 3569 +ATOM H H04 . THR A 1 237 . 237 THR A H04 56.859 66.637 63.644 1 3570 +ATOM H H05 . THR A 1 237 . 237 THR A H05 57.575 65.023 63.879 1 3571 +ATOM H H06 . THR A 1 237 . 237 THR A H06 55.458 65.756 60.52 1 3572 +ATOM H H10 . THR A 1 237 . 237 THR A H10 54.73 63.474 60.906 1 3573 +ATOM N N . LYS A 1 238 . 238 LYS A N 56.753 62.195 63.044 1 3574 +ATOM C CA . LYS A 1 238 . 238 LYS A CA 57.375 61.239 63.946 1 3575 +ATOM C C . LYS A 1 238 . 238 LYS A C 58.235 60.263 63.144 1 3576 +ATOM O O . LYS A 1 238 . 238 LYS A O 59.365 59.951 63.524 1 3577 +ATOM C CB . LYS A 1 238 . 238 LYS A CB 56.312 60.48 64.73 1 3578 +ATOM C CG . LYS A 1 238 . 238 LYS A CG 56.886 59.548 65.78 1 3579 +ATOM C CD . LYS A 1 238 . 238 LYS A CD 55.785 58.801 66.516 1 3580 +ATOM C CE . LYS A 1 238 . 238 LYS A CE 56.363 57.894 67.583 1 3581 +ATOM N NZ . LYS A 1 238 . 238 LYS A NZ 55.317 57.045 68.201 1 3582 +ATOM H H01 . LYS A 1 238 . 238 LYS A H01 58.006 61.777 64.653 1 3583 +ATOM H H02 . LYS A 1 238 . 238 LYS A H02 55.767 59.862 64.016 1 3584 +ATOM H H03 . LYS A 1 238 . 238 LYS A H03 55.666 61.204 65.226 1 3585 +ATOM H H04 . LYS A 1 238 . 238 LYS A H04 57.44 60.145 66.504 1 3586 +ATOM H H05 . LYS A 1 238 . 238 LYS A H05 57.543 58.826 65.294 1 3587 +ATOM H H06 . LYS A 1 238 . 238 LYS A H06 55.241 58.188 65.797 1 3588 +ATOM H H07 . LYS A 1 238 . 238 LYS A H07 55.747 62.215 62.954 1 3589 +ATOM H H08 . LYS A 1 238 . 238 LYS A H08 55.116 59.522 66.985 1 3590 +ATOM H H09 . LYS A 1 238 . 238 LYS A H09 56.8 58.519 68.362 1 3591 +ATOM H H10 . LYS A 1 238 . 238 LYS A H10 57.119 57.252 67.131 1 3592 +ATOM H H11 . LYS A 1 238 . 238 LYS A H11 55.643 56.09 68.25 1 3593 +ATOM H H12 . LYS A 1 238 . 238 LYS A H12 55.117 57.381 69.132 1 3594 +ATOM N N . PHE A 1 239 . 239 PHE A N 57.688 59.793 62.028 1 3595 +ATOM C CA . PHE A 1 239 . 239 PHE A CA 58.392 58.868 61.149 1 3596 +ATOM C C . PHE A 1 239 . 239 PHE A C 59.685 59.466 60.586 1 3597 +ATOM O O . PHE A 1 239 . 239 PHE A O 60.759 58.866 60.704 1 3598 +ATOM C CB . PHE A 1 239 . 239 PHE A CB 57.458 58.455 59.993 1 3599 +ATOM C CG . PHE A 1 239 . 239 PHE A CG 58.142 57.708 58.868 1 3600 +ATOM C CD1 . PHE A 1 239 . 239 PHE A CD1 58.302 56.328 58.916 1 3601 +ATOM C CD2 . PHE A 1 239 . 239 PHE A CD2 58.607 58.4 57.746 1 3602 +ATOM C CE1 . PHE A 1 239 . 239 PHE A CE1 58.919 55.635 57.854 1 3603 +ATOM C CE2 . PHE A 1 239 . 239 PHE A CE2 59.227 57.729 56.682 1 3604 +ATOM C CZ . PHE A 1 239 . 239 PHE A CZ 59.381 56.345 56.735 1 3605 +ATOM H H01 . PHE A 1 239 . 239 PHE A H01 58.673 57.997 61.74 1 3606 +ATOM H H02 . PHE A 1 239 . 239 PHE A H02 57.073 59.379 59.561 1 3607 +ATOM H H03 . PHE A 1 239 . 239 PHE A H03 56.674 57.816 60.4 1 3608 +ATOM H H04 . PHE A 1 239 . 239 PHE A H04 57.946 55.775 59.785 1 3609 +ATOM H H05 . PHE A 1 239 . 239 PHE A H05 58.485 59.482 57.697 1 3610 +ATOM H H06 . PHE A 1 239 . 239 PHE A H06 59.037 54.552 57.902 1 3611 +ATOM H H07 . PHE A 1 239 . 239 PHE A H07 59.587 58.287 55.818 1 3612 +ATOM H H08 . PHE A 1 239 . 239 PHE A H08 59.859 55.816 55.91 1 3613 +ATOM H H13 . PHE A 1 239 . 239 PHE A H13 56.754 60.086 61.781 1 3614 +ATOM N N . SER A 1 240 . 240 SER A N 59.59 60.647 59.978 1 3615 +ATOM C CA . SER A 1 240 . 240 SER A CA 60.752 61.276 59.359 1 3616 +ATOM C C . SER A 1 240 . 240 SER A C 61.82 61.673 60.364 1 3617 +ATOM O O . SER A 1 240 . 240 SER A O 63.008 61.54 60.083 1 3618 +ATOM C CB . SER A 1 240 . 240 SER A CB 60.335 62.495 58.532 1 3619 +ATOM O OG . SER A 1 240 . 240 SER A OG 59.671 63.456 59.329 1 3620 +ATOM H H01 . SER A 1 240 . 240 SER A H01 61.191 60.524 58.704 1 3621 +ATOM H H02 . SER A 1 240 . 240 SER A H02 59.649 62.163 57.753 1 3622 +ATOM H H03 . SER A 1 240 . 240 SER A H03 61.224 62.949 58.095 1 3623 +ATOM H H04 . SER A 1 240 . 240 SER A H04 59.585 64.276 58.837 1 3624 +ATOM H H09 . SER A 1 240 . 240 SER A H09 58.696 61.116 59.945 1 3625 +ATOM N N . THR A 1 241 . 241 THR A N 61.398 62.161 61.525 1 3626 +ATOM C CA . THR A 1 241 . 241 THR A CA 62.347 62.55 62.563 1 3627 +ATOM C C . THR A 1 241 . 241 THR A C 63.085 61.315 63.085 1 3628 +ATOM O O . THR A 1 241 . 241 THR A O 64.292 61.368 63.344 1 3629 +ATOM C CB . THR A 1 241 . 241 THR A CB 61.628 63.258 63.732 1 3630 +ATOM C CG2 . THR A 1 241 . 241 THR A CG2 62.627 63.64 64.817 1 3631 +ATOM O OG1 . THR A 1 241 . 241 THR A OG1 61.004 64.451 63.243 1 3632 +ATOM H H01 . THR A 1 241 . 241 THR A H01 63.063 63.245 62.126 1 3633 +ATOM H H02 . THR A 1 241 . 241 THR A H02 60.884 62.58 64.151 1 3634 +ATOM H H03 . THR A 1 241 . 241 THR A H03 63.496 64.112 64.36 1 3635 +ATOM H H04 . THR A 1 241 . 241 THR A H04 62.16 64.337 65.513 1 3636 +ATOM H H05 . THR A 1 241 . 241 THR A H05 60.407 62.264 61.692 1 3637 +ATOM H H06 . THR A 1 241 . 241 THR A H06 62.941 62.745 65.354 1 3638 +ATOM H H07 . THR A 1 241 . 241 THR A H07 60.823 64.357 62.305 1 3639 +ATOM N N . ALA A 1 242 . 242 ALA A N 62.371 60.2 63.233 1 3640 +ATOM C CA . ALA A 1 242 . 242 ALA A CA 63.002 58.973 63.712 1 3641 +ATOM C C . ALA A 1 242 . 242 ALA A C 64.059 58.512 62.709 1 3642 +ATOM O O . ALA A 1 242 . 242 ALA A O 65.169 58.153 63.097 1 3643 +ATOM C CB . ALA A 1 242 . 242 ALA A CB 61.955 57.872 63.932 1 3644 +ATOM H H01 . ALA A 1 242 . 242 ALA A H01 63.483 59.176 64.669 1 3645 +ATOM H H02 . ALA A 1 242 . 242 ALA A H02 61.05 58.116 63.377 1 3646 +ATOM H H03 . ALA A 1 242 . 242 ALA A H03 62.35 56.919 63.581 1 3647 +ATOM H H04 . ALA A 1 242 . 242 ALA A H04 61.721 57.8 64.994 1 3648 +ATOM H H08 . ALA A 1 242 . 242 ALA A H08 61.385 60.203 63.012 1 3649 +ATOM N N . ALA A 1 243 . 243 ALA A N 63.714 58.514 61.421 1 3650 +ATOM C CA . ALA A 1 243 . 243 ALA A CA 64.669 58.116 60.387 1 3651 +ATOM C C . ALA A 1 243 . 243 ALA A C 65.899 59.042 60.41 1 3652 +ATOM O O . ALA A 1 243 . 243 ALA A O 67.039 58.584 60.446 1 3653 +ATOM C CB . ALA A 1 243 . 243 ALA A CB 63.998 58.168 58.996 1 3654 +ATOM H H01 . ALA A 1 243 . 243 ALA A H01 64.993 57.095 60.588 1 3655 +ATOM H H02 . ALA A 1 243 . 243 ALA A H02 63.104 58.789 59.048 1 3656 +ATOM H H03 . ALA A 1 243 . 243 ALA A H03 64.693 58.593 58.272 1 3657 +ATOM H H04 . ALA A 1 243 . 243 ALA A H04 63.722 57.16 58.687 1 3658 +ATOM H H05 . ALA A 1 243 . 243 ALA A H05 62.781 58.794 61.156 1 3659 +ATOM N N . GLN A 1 244 . 244 GLN A N 65.66 60.346 60.4 1 3660 +ATOM C CA . GLN A 1 244 . 244 GLN A CA 66.752 61.327 60.404 1 3661 +ATOM C C . GLN A 1 244 . 244 GLN A C 67.7 61.22 61.603 1 3662 +ATOM O O . GLN A 1 244 . 244 GLN A O 68.92 61.258 61.441 1 3663 +ATOM C CB . GLN A 1 244 . 244 GLN A CB 66.174 62.75 60.354 1 3664 +ATOM C CG . GLN A 1 244 . 244 GLN A CG 65.583 63.146 59.002 1 3665 +ATOM C CD . GLN A 1 244 . 244 GLN A CD 64.489 64.2 59.12 1 3666 +ATOM N NE2 . GLN A 1 244 . 244 GLN A NE2 63.674 64.323 58.077 1 3667 +ATOM O OE1 . GLN A 1 244 . 244 GLN A OE1 64.379 64.898 60.136 1 3668 +ATOM H H01 . GLN A 1 244 . 244 GLN A H01 67.346 61.104 59.518 1 3669 +ATOM H H02 . GLN A 1 244 . 244 GLN A H02 67.006 63.429 60.539 1 3670 +ATOM H H03 . GLN A 1 244 . 244 GLN A H03 65.388 62.826 61.105 1 3671 +ATOM H H04 . GLN A 1 244 . 244 GLN A H04 65.132 62.254 58.568 1 3672 +ATOM H H05 . GLN A 1 244 . 244 GLN A H05 64.704 60.672 60.389 1 3673 +ATOM H H06 . GLN A 1 244 . 244 GLN A H06 66.382 63.541 58.375 1 3674 +ATOM H H07 . GLN A 1 244 . 244 GLN A H07 62.93 65.005 58.095 1 3675 +ATOM H H08 . GLN A 1 244 . 244 GLN A H08 63.799 63.734 57.266 1 3676 +ATOM N N . ARG A 1 245 . 245 ARG A N 67.147 61.082 62.802 1 3677 +ATOM C CA . ARG A 1 245 . 245 ARG A CA 67.987 61.021 63.989 1 3678 +ATOM C C . ARG A 1 245 . 245 ARG A C 68.888 59.789 64.027 1 3679 +ATOM O O . ARG A 1 245 . 245 ARG A O 69.975 59.824 64.614 1 3680 +ATOM C CB . ARG A 1 245 . 245 ARG A CB 67.125 61.076 65.257 1 3681 +ATOM C CG . ARG A 1 245 . 245 ARG A CG 66.603 59.741 65.738 1 3682 +ATOM C CD . ARG A 1 245 . 245 ARG A CD 66.083 59.848 67.18 1 3683 +ATOM N NE . ARG A 1 245 . 245 ARG A NE 64.882 60.665 67.236 1 3684 +ATOM C CZ . ARG A 1 245 . 245 ARG A CZ 63.64 60.184 67.217 1 3685 +ATOM N NH1 . ARG A 1 245 . 245 ARG A NH1 63.413 58.874 67.164 1 3686 +ATOM N NH2 . ARG A 1 245 . 245 ARG A NH2 62.62 61.027 67.215 1 3687 +ATOM H H01 . ARG A 1 245 . 245 ARG A H01 68.642 61.891 63.946 1 3688 +ATOM H H02 . ARG A 1 245 . 245 ARG A H02 66.247 61.669 65.001 1 3689 +ATOM H H03 . ARG A 1 245 . 245 ARG A H03 67.726 61.512 66.055 1 3690 +ATOM H H04 . ARG A 1 245 . 245 ARG A H04 67.417 59.017 65.713 1 3691 +ATOM H H05 . ARG A 1 245 . 245 ARG A H05 65.79 59.418 65.087 1 3692 +ATOM H H06 . ARG A 1 245 . 245 ARG A H06 66.852 60.315 67.795 1 3693 +ATOM H H07 . ARG A 1 245 . 245 ARG A H07 65.855 58.85 67.553 1 3694 +ATOM H H08 . ARG A 1 245 . 245 ARG A H08 64.997 61.667 67.293 1 3695 +ATOM H H09 . ARG A 1 245 . 245 ARG A H09 66.143 61.019 62.891 1 3696 +ATOM H H10 . ARG A 1 245 . 245 ARG A H10 62.465 58.526 67.136 1 3697 +ATOM H H11 . ARG A 1 245 . 245 ARG A H11 64.189 58.227 67.152 1 3698 +ATOM H H12 . ARG A 1 245 . 245 ARG A H12 61.674 60.675 67.187 1 3699 +ATOM N N . ASN A 1 246 . 246 ASN A N 68.444 58.703 63.394 1 3700 +ATOM C CA . ASN A 1 246 . 246 ASN A CA 69.205 57.458 63.378 1 3701 +ATOM C C . ASN A 1 246 . 246 ASN A C 70.076 57.277 62.143 1 3702 +ATOM O O . ASN A 1 246 . 246 ASN A O 70.729 56.243 61.984 1 3703 +ATOM C CB . ASN A 1 246 . 246 ASN A CB 68.245 56.267 63.533 1 3704 +ATOM C CG . ASN A 1 246 . 246 ASN A CG 67.706 56.14 64.952 1 3705 +ATOM N ND2 . ASN A 1 246 . 246 ASN A ND2 66.441 56.498 65.145 1 3706 +ATOM O OD1 . ASN A 1 246 . 246 ASN A OD1 68.428 55.736 65.866 1 3707 +ATOM H H01 . ASN A 1 246 . 246 ASN A H01 69.895 57.507 64.22 1 3708 +ATOM H H02 . ASN A 1 246 . 246 ASN A H02 68.802 55.358 63.306 1 3709 +ATOM H H03 . ASN A 1 246 . 246 ASN A H03 67.407 56.4 62.849 1 3710 +ATOM H H04 . ASN A 1 246 . 246 ASN A H04 66.035 56.439 66.068 1 3711 +ATOM H H05 . ASN A 1 246 . 246 ASN A H05 65.886 56.829 64.369 1 3712 +ATOM H H13 . ASN A 1 246 . 246 ASN A H13 67.557 58.743 62.913 1 3713 +ATOM N N . GLY A 1 247 . 247 GLY A N 70.077 58.277 61.268 1 3714 +ATOM C CA . GLY A 1 247 . 247 GLY A CA 70.881 58.214 60.063 1 3715 +ATOM C C . GLY A 1 247 . 247 GLY A C 70.365 57.185 59.07 1 3716 +ATOM O O . GLY A 1 247 . 247 GLY A O 71.155 56.501 58.408 1 3717 +ATOM H H01 . GLY A 1 247 . 247 GLY A H01 71.889 57.916 60.353 1 3718 +ATOM H H02 . GLY A 1 247 . 247 GLY A H02 70.875 59.194 59.586 1 3719 +ATOM H H06 . GLY A 1 247 . 247 GLY A H06 69.509 59.093 61.445 1 3720 +ATOM N N . VAL A 1 248 . 248 VAL A N 69.039 57.084 58.973 1 3721 +ATOM C CA . VAL A 1 248 . 248 VAL A CA 68.395 56.135 58.06 1 3722 +ATOM C C . VAL A 1 248 . 248 VAL A C 67.738 56.896 56.911 1 3723 +ATOM O O . VAL A 1 248 . 248 VAL A O 66.902 57.779 57.129 1 3724 +ATOM C CB . VAL A 1 248 . 248 VAL A CB 67.331 55.3 58.806 1 3725 +ATOM C CG1 . VAL A 1 248 . 248 VAL A CG1 66.672 54.306 57.855 1 3726 +ATOM C CG2 . VAL A 1 248 . 248 VAL A CG2 67.996 54.544 59.943 1 3727 +ATOM H H01 . VAL A 1 248 . 248 VAL A H01 69.154 55.459 57.666 1 3728 +ATOM H H02 . VAL A 1 248 . 248 VAL A H02 66.567 55.97 59.2 1 3729 +ATOM H H03 . VAL A 1 248 . 248 VAL A H03 68.46 57.68 59.547 1 3730 +ATOM H H04 . VAL A 1 248 . 248 VAL A H04 65.754 54.739 57.457 1 3731 +ATOM H H05 . VAL A 1 248 . 248 VAL A H05 67.353 54.081 57.034 1 3732 +ATOM H H06 . VAL A 1 248 . 248 VAL A H06 66.436 53.388 58.394 1 3733 +ATOM H H07 . VAL A 1 248 . 248 VAL A H07 68.904 55.066 60.244 1 3734 +ATOM H H08 . VAL A 1 248 . 248 VAL A H08 67.313 54.487 60.79 1 3735 +ATOM H H09 . VAL A 1 248 . 248 VAL A H09 68.25 53.537 59.611 1 3736 +ATOM N N . PRO A 1 249 . 249 PRO A N 68.111 56.574 55.665 1 3737 +ATOM C CA . PRO A 1 249 . 249 PRO A CA 67.49 57.302 54.556 1 3738 +ATOM C C . PRO A 1 249 . 249 PRO A C 66.058 56.859 54.339 1 3739 +ATOM O O . PRO A 1 249 . 249 PRO A O 65.718 55.704 54.585 1 3740 +ATOM C CB . PRO A 1 249 . 249 PRO A CB 68.381 56.952 53.375 1 3741 +ATOM C CG . PRO A 1 249 . 249 PRO A CG 68.771 55.531 53.675 1 3742 +ATOM C CD . PRO A 1 249 . 249 PRO A CD 69.077 55.576 55.168 1 3743 +ATOM H H01 . PRO A 1 249 . 249 PRO A H01 69.233 57.622 53.259 1 3744 +ATOM H H02 . PRO A 1 249 . 249 PRO A H02 67.835 57.022 52.434 1 3745 +ATOM H H03 . PRO A 1 249 . 249 PRO A H03 69.607 55.176 53.073 1 3746 +ATOM H H04 . PRO A 1 249 . 249 PRO A H04 67.952 54.844 53.461 1 3747 +ATOM H H05 . PRO A 1 249 . 249 PRO A H05 68.989 54.606 55.658 1 3748 +ATOM H H06 . PRO A 1 249 . 249 PRO A H06 70.103 55.895 55.351 1 3749 +ATOM H H10 . PRO A 1 249 . 249 PRO A H10 67.422 58.376 54.728 1 3750 +ATOM N N . ILE A 1 250 . 250 ILE A N 65.219 57.78 53.881 1 3751 +ATOM C CA . ILE A 1 250 . 250 ILE A CA 63.826 57.447 53.621 1 3752 +ATOM C C . ILE A 1 250 . 250 ILE A C 63.644 57.05 52.158 1 3753 +ATOM O O . ILE A 1 250 . 250 ILE A O 64.275 57.617 51.256 1 3754 +ATOM C CB . ILE A 1 250 . 250 ILE A CB 62.895 58.636 53.95 1 3755 +ATOM C CG1 . ILE A 1 250 . 250 ILE A CG1 63.015 58.984 55.44 1 3756 +ATOM C CG2 . ILE A 1 250 . 250 ILE A CG2 61.442 58.28 53.611 1 3757 +ATOM C CD1 . ILE A 1 250 . 250 ILE A CD1 62.265 60.246 55.837 1 3758 +ATOM H H01 . ILE A 1 250 . 250 ILE A H01 63.559 56.609 54.265 1 3759 +ATOM H H02 . ILE A 1 250 . 250 ILE A H02 63.192 59.498 53.353 1 3760 +ATOM H H03 . ILE A 1 250 . 250 ILE A H03 64.071 59.166 55.64 1 3761 +ATOM H H04 . ILE A 1 250 . 250 ILE A H04 62.62 58.151 56.021 1 3762 +ATOM H H05 . ILE A 1 250 . 250 ILE A H05 60.831 59.182 53.64 1 3763 +ATOM H H06 . ILE A 1 250 . 250 ILE A H06 61.398 57.844 52.613 1 3764 +ATOM H H07 . ILE A 1 250 . 250 ILE A H07 65.55 58.719 53.712 1 3765 +ATOM H H08 . ILE A 1 250 . 250 ILE A H08 61.064 57.562 54.339 1 3766 +ATOM H H09 . ILE A 1 250 . 250 ILE A H09 61.295 59.975 56.255 1 3767 +ATOM H H10 . ILE A 1 250 . 250 ILE A H10 62.842 60.793 56.583 1 3768 +ATOM H H11 . ILE A 1 250 . 250 ILE A H11 62.119 60.874 54.958 1 3769 +ATOM N N . PHE A 1 251 . 251 PHE A N 62.792 56.048 51.95 1 3770 +ATOM C CA . PHE A 1 251 . 251 PHE A CA 62.438 55.518 50.636 1 3771 +ATOM C C . PHE A 1 251 . 251 PHE A C 60.988 55.927 50.423 1 3772 +ATOM O O . PHE A 1 251 . 251 PHE A O 60.166 55.776 51.328 1 3773 +ATOM C CB . PHE A 1 251 . 251 PHE A CB 62.481 53.991 50.639 1 3774 +ATOM C CG . PHE A 1 251 . 251 PHE A CG 63.846 53.408 50.486 1 3775 +ATOM C CD1 . PHE A 1 251 . 251 PHE A CD1 64.878 53.753 51.356 1 3776 +ATOM C CD2 . PHE A 1 251 . 251 PHE A CD2 64.09 52.463 49.496 1 3777 +ATOM C CE1 . PHE A 1 251 . 251 PHE A CE1 66.139 53.157 51.238 1 3778 +ATOM C CE2 . PHE A 1 251 . 251 PHE A CE2 65.34 51.862 49.368 1 3779 +ATOM C CZ . PHE A 1 251 . 251 PHE A CZ 66.369 52.209 50.243 1 3780 +ATOM H H01 . PHE A 1 251 . 251 PHE A H01 63.122 55.887 49.872 1 3781 +ATOM H H02 . PHE A 1 251 . 251 PHE A H02 61.907 53.664 49.772 1 3782 +ATOM H H03 . PHE A 1 251 . 251 PHE A H03 62.065 53.64 51.583 1 3783 +ATOM H H04 . PHE A 1 251 . 251 PHE A H04 64.702 54.494 52.136 1 3784 +ATOM H H05 . PHE A 1 251 . 251 PHE A H05 63.29 52.188 48.809 1 3785 +ATOM H H06 . PHE A 1 251 . 251 PHE A H06 66.94 53.434 51.923 1 3786 +ATOM H H07 . PHE A 1 251 . 251 PHE A H07 65.513 51.123 48.586 1 3787 +ATOM H H08 . PHE A 1 251 . 251 PHE A H08 67.349 51.741 50.149 1 3788 +ATOM H H12 . PHE A 1 251 . 251 PHE A H12 62.36 55.624 52.759 1 3789 +ATOM N N . GLY A 1 252 . 252 GLY A N 60.653 56.433 49.244 1 3790 +ATOM C CA . GLY A 1 252 . 252 GLY A CA 59.27 56.821 49.024 1 3791 +ATOM C C . GLY A 1 252 . 252 GLY A C 58.954 57.089 47.567 1 3792 +ATOM O O . GLY A 1 252 . 252 GLY A O 59.782 56.846 46.698 1 3793 +ATOM H H01 . GLY A 1 252 . 252 GLY A H01 59.102 57.75 49.569 1 3794 +ATOM H H02 . GLY A 1 252 . 252 GLY A H02 58.623 56.019 49.378 1 3795 +ATOM H H09 . GLY A 1 252 . 252 GLY A H09 61.343 56.546 48.515 1 3796 +ATOM N N . GLY A 1 253 . 253 GLY A N 57.758 57.606 47.308 1 3797 +ATOM C CA . GLY A 1 253 . 253 GLY A CA 57.361 57.897 45.942 1 3798 +ATOM C C . GLY A 1 253 . 253 GLY A C 57.053 59.369 45.78 1 3799 +ATOM O O . GLY A 1 253 . 253 GLY A O 56.8 60.062 46.756 1 3800 +ATOM H H01 . GLY A 1 253 . 253 GLY A H01 56.465 57.322 45.706 1 3801 +ATOM H H02 . GLY A 1 253 . 253 GLY A H02 58.171 57.623 45.266 1 3802 +ATOM H H03 . GLY A 1 253 . 253 GLY A H03 57.12 57.798 48.067 1 3803 +ATOM N N . ALA A 1 254 . 254 ALA A N 57.097 59.863 44.55 1 3804 +ATOM C CA . ALA A 1 254 . 254 ALA A CA 56.789 61.261 44.307 1 3805 +ATOM C C . ALA A 1 254 . 254 ALA A C 55.537 61.327 43.441 1 3806 +ATOM O O . ALA A 1 254 . 254 ALA A O 55.581 60.96 42.272 1 3807 +ATOM C CB . ALA A 1 254 . 254 ALA A CB 57.945 61.942 43.606 1 3808 +ATOM H H01 . ALA A 1 254 . 254 ALA A H01 56.62 61.775 45.253 1 3809 +ATOM H H02 . ALA A 1 254 . 254 ALA A H02 58.821 61.294 43.637 1 3810 +ATOM H H03 . ALA A 1 254 . 254 ALA A H03 57.346 59.263 43.777 1 3811 +ATOM H H04 . ALA A 1 254 . 254 ALA A H04 57.676 62.139 42.568 1 3812 +ATOM H H05 . ALA A 1 254 . 254 ALA A H05 58.172 62.882 44.108 1 3813 +ATOM N N . LEU A 1 255 . 255 LEU A N 54.435 61.793 44.027 1 3814 +ATOM C CA . LEU A 1 255 . 255 LEU A CA 53.161 61.919 43.319 1 3815 +ATOM C C . LEU A 1 255 . 255 LEU A C 53.367 62.719 42.037 1 3816 +ATOM O O . LEU A 1 255 . 255 LEU A O 52.769 62.427 41.001 1 3817 +ATOM C CB . LEU A 1 255 . 255 LEU A CB 52.13 62.605 44.222 1 3818 +ATOM C CG . LEU A 1 255 . 255 LEU A CG 51.888 61.924 45.576 1 3819 +ATOM C CD1 . LEU A 1 255 . 255 LEU A CD1 50.807 62.68 46.339 1 3820 +ATOM C CD2 . LEU A 1 255 . 255 LEU A CD2 51.473 60.466 45.362 1 3821 +ATOM H H01 . LEU A 1 255 . 255 LEU A H01 52.788 60.928 43.059 1 3822 +ATOM H H02 . LEU A 1 255 . 255 LEU A H02 51.182 62.557 43.687 1 3823 +ATOM H H03 . LEU A 1 255 . 255 LEU A H03 52.472 63.622 44.412 1 3824 +ATOM H H04 . LEU A 1 255 . 255 LEU A H04 52.809 61.938 46.159 1 3825 +ATOM H H05 . LEU A 1 255 . 255 LEU A H05 49.897 62.081 46.367 1 3826 +ATOM H H06 . LEU A 1 255 . 255 LEU A H06 54.481 62.07 44.997 1 3827 +ATOM H H07 . LEU A 1 255 . 255 LEU A H07 51.147 62.872 47.357 1 3828 +ATOM H H08 . LEU A 1 255 . 255 LEU A H08 50.603 63.627 45.839 1 3829 +ATOM H H09 . LEU A 1 255 . 255 LEU A H09 50.531 60.434 44.815 1 3830 +ATOM H H10 . LEU A 1 255 . 255 LEU A H10 52.243 59.949 44.79 1 3831 +ATOM H H11 . LEU A 1 255 . 255 LEU A H11 51.348 59.978 46.328 1 3832 +ATOM N N . SER A 1 256 . 256 SER A N 54.21 63.739 42.12 1 3833 +ATOM C CA . SER A 1 256 . 256 SER A CA 54.544 64.558 40.963 1 3834 +ATOM C C . SER A 1 256 . 256 SER A C 56.044 64.785 41.062 1 3835 +ATOM O O . SER A 1 256 . 256 SER A O 56.584 64.958 42.159 1 3836 +ATOM C CB . SER A 1 256 . 256 SER A CB 53.783 65.887 40.981 1 3837 +ATOM O OG . SER A 1 256 . 256 SER A OG 54.149 66.691 42.081 1 3838 +ATOM H H01 . SER A 1 256 . 256 SER A H01 54.265 64.071 40.029 1 3839 +ATOM H H02 . SER A 1 256 . 256 SER A H02 52.719 65.664 41.066 1 3840 +ATOM H H03 . SER A 1 256 . 256 SER A H03 53.999 66.429 40.061 1 3841 +ATOM H H04 . SER A 1 256 . 256 SER A H04 54.375 66.13 42.827 1 3842 +ATOM H H12 . SER A 1 256 . 256 SER A H12 54.633 63.954 43.012 1 3843 +ATOM N N . VAL A 1 257 . 257 VAL A N 56.729 64.785 39.928 1 3844 +ATOM C CA . VAL A 1 257 . 257 VAL A CA 58.171 64.943 39.961 1 3845 +ATOM C C . VAL A 1 257 . 257 VAL A C 58.649 66.25 40.594 1 3846 +ATOM O O . VAL A 1 257 . 257 VAL A O 59.756 66.306 41.13 1 3847 +ATOM C CB . VAL A 1 257 . 257 VAL A CB 58.767 64.77 38.551 1 3848 +ATOM C CG1 . VAL A 1 257 . 257 VAL A CG1 60.28 64.647 38.63 1 3849 +ATOM C CG2 . VAL A 1 257 . 257 VAL A CG2 58.186 63.509 37.904 1 3850 +ATOM H H01 . VAL A 1 257 . 257 VAL A H01 58.538 64.152 40.615 1 3851 +ATOM H H02 . VAL A 1 257 . 257 VAL A H02 58.514 65.643 37.95 1 3852 +ATOM H H03 . VAL A 1 257 . 257 VAL A H03 60.716 65.632 38.8 1 3853 +ATOM H H04 . VAL A 1 257 . 257 VAL A H04 60.549 63.984 39.453 1 3854 +ATOM H H05 . VAL A 1 257 . 257 VAL A H05 56.25 64.676 39.045 1 3855 +ATOM H H06 . VAL A 1 257 . 257 VAL A H06 60.661 64.239 37.694 1 3856 +ATOM H H07 . VAL A 1 257 . 257 VAL A H07 57.255 63.759 37.394 1 3857 +ATOM H H08 . VAL A 1 257 . 257 VAL A H08 58.899 63.109 37.183 1 3858 +ATOM H H09 . VAL A 1 257 . 257 VAL A H09 57.989 62.763 38.674 1 3859 +ATOM N N . ASP A 1 258 . 258 ASP A N 57.826 67.295 40.573 1 3860 +ATOM C CA . ASP A 1 258 . 258 ASP A CA 58.264 68.553 41.17 1 3861 +ATOM C C . ASP A 1 258 . 258 ASP A C 58.342 68.503 42.697 1 3862 +ATOM O O . ASP A 1 258 . 258 ASP A O 58.758 69.47 43.328 1 3863 +ATOM C CB . ASP A 1 258 . 258 ASP A CB 57.382 69.736 40.726 1 3864 +ATOM C CG . ASP A 1 258 . 258 ASP A CG 55.908 69.532 41.02 1 3865 +ATOM O OD1 . ASP A 1 258 . 258 ASP A OD1 55.561 68.76 41.938 1 3866 +ATOM O OD2 . ASP A 1 258 . 258 ASP A OD2 55.086 70.17 40.329 1 3867 +ATOM H H01 . ASP A 1 258 . 258 ASP A H01 59.277 68.709 40.799 1 3868 +ATOM H H02 . ASP A 1 258 . 258 ASP A H02 57.485 69.826 39.645 1 3869 +ATOM H H03 . ASP A 1 258 . 258 ASP A H03 57.718 70.633 41.247 1 3870 +ATOM H H04 . ASP A 1 258 . 258 ASP A H04 54.822 69.64 39.574 1 3871 +ATOM H H10 . ASP A 1 258 . 258 ASP A H10 56.912 67.219 40.15 1 3872 +ATOM N N . MET A 1 259 . 259 MET A N 57.961 67.373 43.286 1 3873 +ATOM C CA . MET A 1 259 . 259 MET A CA 58.014 67.214 44.739 1 3874 +ATOM C C . MET A 1 259 . 259 MET A C 59.402 66.763 45.2 1 3875 +ATOM O O . MET A 1 259 . 259 MET A O 59.725 66.824 46.386 1 3876 +ATOM C CB . MET A 1 259 . 259 MET A CB 57.009 66.158 45.198 1 3877 +ATOM C CG . MET A 1 259 . 259 MET A CG 55.549 66.498 44.97 1 3878 +ATOM S SD . MET A 1 259 . 259 MET A SD 54.536 65.062 45.371 1 3879 +ATOM C CE . MET A 1 259 . 259 MET A CE 54.599 65.084 47.206 1 3880 +ATOM H H01 . MET A 1 259 . 259 MET A H01 57.778 68.186 45.173 1 3881 +ATOM H H02 . MET A 1 259 . 259 MET A H02 57.131 66.081 46.278 1 3882 +ATOM H H03 . MET A 1 259 . 259 MET A H03 57.226 65.233 44.663 1 3883 +ATOM H H04 . MET A 1 259 . 259 MET A H04 55.396 66.774 43.927 1 3884 +ATOM H H05 . MET A 1 259 . 259 MET A H05 57.628 66.608 42.717 1 3885 +ATOM H H06 . MET A 1 259 . 259 MET A H06 55.265 67.335 45.608 1 3886 +ATOM H H07 . MET A 1 259 . 259 MET A H07 54.691 66.113 47.553 1 3887 +ATOM H H08 . MET A 1 259 . 259 MET A H08 53.685 64.647 47.607 1 3888 +ATOM H H09 . MET A 1 259 . 259 MET A H09 55.458 64.507 47.548 1 3889 +ATOM N N . VAL A 1 260 . 260 VAL A N 60.217 66.307 44.26 1 3890 +ATOM C CA . VAL A 1 260 . 260 VAL A CA 61.538 65.792 44.582 1 3891 +ATOM C C . VAL A 1 260 . 260 VAL A C 62.402 66.657 45.511 1 3892 +ATOM O O . VAL A 1 260 . 260 VAL A O 62.93 66.154 46.514 1 3893 +ATOM C CB . VAL A 1 260 . 260 VAL A CB 62.301 65.432 43.283 1 3894 +ATOM C CG1 . VAL A 1 260 . 260 VAL A CG1 63.751 65.118 43.583 1 3895 +ATOM C CG2 . VAL A 1 260 . 260 VAL A CG2 61.63 64.211 42.633 1 3896 +ATOM H H01 . VAL A 1 260 . 260 VAL A H01 61.347 64.898 45.176 1 3897 +ATOM H H02 . VAL A 1 260 . 260 VAL A H02 62.269 66.284 42.603 1 3898 +ATOM H H03 . VAL A 1 260 . 260 VAL A H03 64.336 66.037 43.547 1 3899 +ATOM H H04 . VAL A 1 260 . 260 VAL A H04 63.83 64.676 44.576 1 3900 +ATOM H H05 . VAL A 1 260 . 260 VAL A H05 64.132 64.416 42.841 1 3901 +ATOM H H06 . VAL A 1 260 . 260 VAL A H06 60.698 64.519 42.159 1 3902 +ATOM H H07 . VAL A 1 260 . 260 VAL A H07 62.296 63.787 41.882 1 3903 +ATOM H H08 . VAL A 1 260 . 260 VAL A H08 61.419 63.462 43.397 1 3904 +ATOM H H10 . VAL A 1 260 . 260 VAL A H10 59.913 66.317 43.297 1 3905 +ATOM N N . PRO A 1 261 . 261 PRO A N 62.557 67.957 45.212 1 3906 +ATOM C CA . PRO A 1 261 . 261 PRO A CA 63.387 68.767 46.115 1 3907 +ATOM C C . PRO A 1 261 . 261 PRO A C 62.911 68.728 47.559 1 3908 +ATOM O O . PRO A 1 261 . 261 PRO A O 63.709 68.57 48.482 1 3909 +ATOM C CB . PRO A 1 261 . 261 PRO A CB 63.29 70.164 45.509 1 3910 +ATOM C CG . PRO A 1 261 . 261 PRO A CG 63.234 69.863 44.041 1 3911 +ATOM C CD . PRO A 1 261 . 261 PRO A CD 62.221 68.718 43.994 1 3912 +ATOM H H01 . PRO A 1 261 . 261 PRO A H01 64.11 70.824 45.792 1 3913 +ATOM H H02 . PRO A 1 261 . 261 PRO A H02 62.39 70.68 45.843 1 3914 +ATOM H H03 . PRO A 1 261 . 261 PRO A H03 64.201 69.614 43.605 1 3915 +ATOM H H04 . PRO A 1 261 . 261 PRO A H04 62.886 70.725 43.471 1 3916 +ATOM H H05 . PRO A 1 261 . 261 PRO A H05 61.184 69.052 43.958 1 3917 +ATOM H H06 . PRO A 1 261 . 261 PRO A H06 62.351 68.11 43.099 1 3918 +ATOM H H09 . PRO A 1 261 . 261 PRO A H09 64.411 68.399 46.183 1 3919 +ATOM N N . SER A 1 262 . 262 SER A N 61.608 68.861 47.756 1 3920 +ATOM C CA . SER A 1 262 . 262 SER A CA 61.05 68.843 49.098 1 3921 +ATOM C C . SER A 1 262 . 262 SER A C 61.284 67.484 49.757 1 3922 +ATOM O O . SER A 1 262 . 262 SER A O 61.605 67.403 50.947 1 3923 +ATOM C CB . SER A 1 262 . 262 SER A CB 59.552 69.14 49.054 1 3924 +ATOM O OG . SER A 1 262 . 262 SER A OG 59.014 69.156 50.367 1 3925 +ATOM H H01 . SER A 1 262 . 262 SER A H01 61.55 69.613 49.686 1 3926 +ATOM H H02 . SER A 1 262 . 262 SER A H02 59.053 68.362 48.476 1 3927 +ATOM H H03 . SER A 1 262 . 262 SER A H03 59.391 70.112 48.587 1 3928 +ATOM H H04 . SER A 1 262 . 262 SER A H04 59.011 70.056 50.701 1 3929 +ATOM H H07 . SER A 1 262 . 262 SER A H07 60.994 68.977 46.963 1 3930 +ATOM N N . LEU A 1 263 . 263 LEU A N 61.126 66.41 48.986 1 3931 +ATOM C CA . LEU A 1 263 . 263 LEU A CA 61.339 65.081 49.548 1 3932 +ATOM C C . LEU A 1 263 . 263 LEU A C 62.793 64.92 49.97 1 3933 +ATOM O O . LEU A 1 263 . 263 LEU A O 63.074 64.352 51.021 1 3934 +ATOM C CB . LEU A 1 263 . 263 LEU A CB 60.955 63.995 48.53 1 3935 +ATOM C CG . LEU A 1 263 . 263 LEU A CG 59.467 63.993 48.162 1 3936 +ATOM C CD1 . LEU A 1 263 . 263 LEU A CD1 59.209 63.019 47.017 1 3937 +ATOM C CD2 . LEU A 1 263 . 263 LEU A CD2 58.632 63.621 49.389 1 3938 +ATOM H H01 . LEU A 1 263 . 263 LEU A H01 60.702 64.968 50.425 1 3939 +ATOM H H02 . LEU A 1 263 . 263 LEU A H02 61.169 63.033 48.996 1 3940 +ATOM H H03 . LEU A 1 263 . 263 LEU A H03 61.535 64.154 47.621 1 3941 +ATOM H H04 . LEU A 1 263 . 263 LEU A H04 59.177 64.991 47.832 1 3942 +ATOM H H05 . LEU A 1 263 . 263 LEU A H05 60.86 66.515 48.017 1 3943 +ATOM H H06 . LEU A 1 263 . 263 LEU A H06 58.4 63.399 46.393 1 3944 +ATOM H H07 . LEU A 1 263 . 263 LEU A H07 60.113 62.916 46.416 1 3945 +ATOM H H08 . LEU A 1 263 . 263 LEU A H08 58.929 62.047 47.422 1 3946 +ATOM H H09 . LEU A 1 263 . 263 LEU A H09 57.648 63.28 49.068 1 3947 +ATOM H H10 . LEU A 1 263 . 263 LEU A H10 59.13 62.824 49.94 1 3948 +ATOM H H11 . LEU A 1 263 . 263 LEU A H11 58.521 64.494 50.032 1 3949 +ATOM N N . ILE A 1 264 . 264 ILE A N 63.722 65.418 49.159 1 3950 +ATOM C CA . ILE A 1 264 . 264 ILE A CA 65.132 65.303 49.507 1 3951 +ATOM C C . ILE A 1 264 . 264 ILE A C 65.406 66.088 50.788 1 3952 +ATOM O O . ILE A 1 264 . 264 ILE A O 66.15 65.63 51.655 1 3953 +ATOM C CB . ILE A 1 264 . 264 ILE A CB 66.042 65.814 48.366 1 3954 +ATOM C CG1 . ILE A 1 264 . 264 ILE A CG1 65.98 64.821 47.19 1 3955 +ATOM C CG2 . ILE A 1 264 . 264 ILE A CG2 67.492 65.961 48.86 1 3956 +ATOM C CD1 . ILE A 1 264 . 264 ILE A CD1 66.642 65.311 45.927 1 3957 +ATOM H H01 . ILE A 1 264 . 264 ILE A H01 65.36 64.249 49.664 1 3958 +ATOM H H02 . ILE A 1 264 . 264 ILE A H02 65.693 66.793 48.037 1 3959 +ATOM H H03 . ILE A 1 264 . 264 ILE A H03 64.925 64.683 46.955 1 3960 +ATOM H H04 . ILE A 1 264 . 264 ILE A H04 66.47 63.897 47.497 1 3961 +ATOM H H05 . ILE A 1 264 . 264 ILE A H05 68.101 66.401 48.07 1 3962 +ATOM H H06 . ILE A 1 264 . 264 ILE A H06 67.514 66.607 49.737 1 3963 +ATOM H H07 . ILE A 1 264 . 264 ILE A H07 67.889 64.98 49.121 1 3964 +ATOM H H08 . ILE A 1 264 . 264 ILE A H08 67.651 64.903 45.864 1 3965 +ATOM H H09 . ILE A 1 264 . 264 ILE A H09 66.064 64.984 45.063 1 3966 +ATOM H H10 . ILE A 1 264 . 264 ILE A H10 66.691 66.4 45.941 1 3967 +ATOM H H12 . ILE A 1 264 . 264 ILE A H12 63.45 65.876 48.301 1 3968 +ATOM N N . GLU A 1 265 . 265 GLU A N 64.783 67.255 50.917 1 3969 +ATOM C CA . GLU A 1 265 . 265 GLU A CA 64.978 68.085 52.11 1 3970 +ATOM C C . GLU A 1 265 . 265 GLU A C 64.448 67.367 53.342 1 3971 +ATOM O O . GLU A 1 265 . 265 GLU A O 64.943 67.565 54.456 1 3972 +ATOM C CB . GLU A 1 265 . 265 GLU A CB 64.26 69.432 51.952 1 3973 +ATOM C CG . GLU A 1 265 . 265 GLU A CG 64.795 70.272 50.807 1 3974 +ATOM C CD . GLU A 1 265 . 265 GLU A CD 64.022 71.569 50.612 1 3975 +ATOM O OE1 . GLU A 1 265 . 265 GLU A OE1 62.775 71.518 50.5 1 3976 +ATOM O OE2 . GLU A 1 265 . 265 GLU A OE2 64.666 72.64 50.564 1 3977 +ATOM H H01 . GLU A 1 265 . 265 GLU A H01 66.046 68.265 52.23 1 3978 +ATOM H H02 . GLU A 1 265 . 265 GLU A H02 64.43 69.995 52.87 1 3979 +ATOM H H03 . GLU A 1 265 . 265 GLU A H03 63.201 69.241 51.776 1 3980 +ATOM H H04 . GLU A 1 265 . 265 GLU A H04 64.687 69.687 49.894 1 3981 +ATOM H H05 . GLU A 1 265 . 265 GLU A H05 65.838 70.515 51.011 1 3982 +ATOM H H06 . GLU A 1 265 . 265 GLU A H06 64.827 72.957 51.456 1 3983 +ATOM H H11 . GLU A 1 265 . 265 GLU A H11 64.167 67.574 50.183 1 3984 +ATOM N N . GLN A 1 266 . 266 GLN A N 63.441 66.531 53.129 1 3985 +ATOM C CA . GLN A 1 266 . 266 GLN A CA 62.822 65.763 54.2 1 3986 +ATOM C C . GLN A 1 266 . 266 GLN A C 63.595 64.497 54.561 1 3987 +ATOM O O . GLN A 1 266 . 266 GLN A O 63.239 63.8 55.51 1 3988 +ATOM C CB . GLN A 1 266 . 266 GLN A CB 61.401 65.378 53.81 1 3989 +ATOM C CG . GLN A 1 266 . 266 GLN A CG 60.451 66.542 53.709 1 3990 +ATOM C CD . GLN A 1 266 . 266 GLN A CD 59.079 66.109 53.251 1 3991 +ATOM N NE2 . GLN A 1 266 . 266 GLN A NE2 58.603 66.704 52.162 1 3992 +ATOM O OE1 . GLN A 1 266 . 266 GLN A OE1 58.449 65.246 53.869 1 3993 +ATOM H H01 . GLN A 1 266 . 266 GLN A H01 62.824 66.408 55.079 1 3994 +ATOM H H02 . GLN A 1 266 . 266 GLN A H02 61.023 64.734 54.604 1 3995 +ATOM H H03 . GLN A 1 266 . 266 GLN A H03 61.435 64.879 52.842 1 3996 +ATOM H H04 . GLN A 1 266 . 266 GLN A H04 60.849 67.24 52.972 1 3997 +ATOM H H05 . GLN A 1 266 . 266 GLN A H05 60.364 67.013 54.688 1 3998 +ATOM H H06 . GLN A 1 266 . 266 GLN A H06 57.69 66.455 51.808 1 3999 +ATOM H H07 . GLN A 1 266 . 266 GLN A H07 63.09 66.423 52.188 1 4000 +ATOM H H08 . GLN A 1 266 . 266 GLN A H08 59.154 67.405 51.688 1 4001 +ATOM N N . GLY A 1 267 . 267 GLY A N 64.638 64.191 53.797 1 4002 +ATOM C CA . GLY A 1 267 . 267 GLY A CA 65.428 63.012 54.091 1 4003 +ATOM C C . GLY A 1 267 . 267 GLY A C 65.258 61.833 53.147 1 4004 +ATOM O O . GLY A 1 267 . 267 GLY A O 65.829 60.775 53.396 1 4005 +ATOM H H01 . GLY A 1 267 . 267 GLY A H01 65.074 62.66 55.06 1 4006 +ATOM H H02 . GLY A 1 267 . 267 GLY A H02 66.478 63.303 54.074 1 4007 +ATOM H H09 . GLY A 1 267 . 267 GLY A H09 64.88 64.779 53.012 1 4008 +ATOM N N . TYR A 1 268 . 268 TYR A N 64.482 61.988 52.078 1 4009 +ATOM C CA . TYR A 1 268 . 268 TYR A CA 64.32 60.872 51.14 1 4010 +ATOM C C . TYR A 1 268 . 268 TYR A C 65.589 60.734 50.306 1 4011 +ATOM O O . TYR A 1 268 . 268 TYR A O 66.09 61.725 49.782 1 4012 +ATOM C CB . TYR A 1 268 . 268 TYR A CB 63.14 61.105 50.183 1 4013 +ATOM C CG . TYR A 1 268 . 268 TYR A CG 61.764 61.051 50.825 1 4014 +ATOM C CD1 . TYR A 1 268 . 268 TYR A CD1 61.445 61.886 51.891 1 4015 +ATOM C CD2 . TYR A 1 268 . 268 TYR A CD2 60.769 60.204 50.331 1 4016 +ATOM C CE1 . TYR A 1 268 . 268 TYR A CE1 60.18 61.892 52.45 1 4017 +ATOM C CE2 . TYR A 1 268 . 268 TYR A CE2 59.485 60.202 50.888 1 4018 +ATOM C CZ . TYR A 1 268 . 268 TYR A CZ 59.205 61.052 51.947 1 4019 +ATOM O OH . TYR A 1 268 . 268 TYR A OH 57.947 61.084 52.512 1 4020 +ATOM H H01 . TYR A 1 268 . 268 TYR A H01 64.128 59.97 51.722 1 4021 +ATOM H H02 . TYR A 1 268 . 268 TYR A H02 63.169 60.287 49.463 1 4022 +ATOM H H03 . TYR A 1 268 . 268 TYR A H03 64.009 62.865 51.914 1 4023 +ATOM H H04 . TYR A 1 268 . 268 TYR A H04 63.262 62.088 49.729 1 4024 +ATOM H H05 . TYR A 1 268 . 268 TYR A H05 62.209 62.55 52.295 1 4025 +ATOM H H06 . TYR A 1 268 . 268 TYR A H06 60.996 59.536 49.5 1 4026 +ATOM H H07 . TYR A 1 268 . 268 TYR A H07 59.953 62.556 53.284 1 4027 +ATOM H H08 . TYR A 1 268 . 268 TYR A H08 58.714 59.54 50.494 1 4028 +ATOM H H09 . TYR A 1 268 . 268 TYR A H09 57.704 61.993 52.702 1 4029 +ATOM N N . ARG A 1 269 . 269 ARG A N 66.098 59.515 50.169 1 4030 +ATOM C CA . ARG A 1 269 . 269 ARG A CA 67.297 59.29 49.365 1 4031 +ATOM C C . ARG A 1 269 . 269 ARG A C 67.068 58.237 48.283 1 4032 +ATOM O O . ARG A 1 269 . 269 ARG A O 67.983 57.887 47.544 1 4033 +ATOM C CB . ARG A 1 269 . 269 ARG A CB 68.481 58.908 50.259 1 4034 +ATOM C CG . ARG A 1 269 . 269 ARG A CG 68.846 60.001 51.269 1 4035 +ATOM C CD . ARG A 1 269 . 269 ARG A CD 69.473 61.227 50.602 1 4036 +ATOM N NE . ARG A 1 269 . 269 ARG A NE 69.784 62.259 51.598 1 4037 +ATOM C CZ . ARG A 1 269 . 269 ARG A CZ 68.958 63.242 51.945 1 4038 +ATOM N NH1 . ARG A 1 269 . 269 ARG A NH1 67.767 63.346 51.374 1 4039 +ATOM N NH2 . ARG A 1 269 . 269 ARG A NH2 69.316 64.121 52.882 1 4040 +ATOM H H01 . ARG A 1 269 . 269 ARG A H01 67.533 60.226 48.858 1 4041 +ATOM H H02 . ARG A 1 269 . 269 ARG A H02 69.345 58.762 49.611 1 4042 +ATOM H H03 . ARG A 1 269 . 269 ARG A H03 68.226 58.0 50.806 1 4043 +ATOM H H04 . ARG A 1 269 . 269 ARG A H04 69.582 59.585 51.957 1 4044 +ATOM H H05 . ARG A 1 269 . 269 ARG A H05 67.941 60.311 51.792 1 4045 +ATOM H H06 . ARG A 1 269 . 269 ARG A H06 68.762 61.637 49.885 1 4046 +ATOM H H07 . ARG A 1 269 . 269 ARG A H07 70.391 60.929 50.096 1 4047 +ATOM H H08 . ARG A 1 269 . 269 ARG A H08 70.686 62.22 52.05 1 4048 +ATOM H H09 . ARG A 1 269 . 269 ARG A H09 67.14 64.09 51.645 1 4049 +ATOM H H10 . ARG A 1 269 . 269 ARG A H10 65.651 58.734 50.626 1 4050 +ATOM H H11 . ARG A 1 269 . 269 ARG A H11 67.486 62.681 50.668 1 4051 +ATOM H H12 . ARG A 1 269 . 269 ARG A H12 68.683 64.862 53.147 1 4052 +ATOM N N . ALA A 1 270 . 270 ALA A N 65.842 57.732 48.199 1 4053 +ATOM C CA . ALA A 1 270 . 270 ALA A CA 65.475 56.76 47.155 1 4054 +ATOM C C . ALA A 1 270 . 270 ALA A C 64.036 57.116 46.859 1 4055 +ATOM O O . ALA A 1 270 . 270 ALA A O 63.16 56.923 47.706 1 4056 +ATOM C CB . ALA A 1 270 . 270 ALA A CB 65.577 55.334 47.664 1 4057 +ATOM H H01 . ALA A 1 270 . 270 ALA A H01 66.128 56.805 46.283 1 4058 +ATOM H H02 . ALA A 1 270 . 270 ALA A H02 66.346 55.278 48.434 1 4059 +ATOM H H03 . ALA A 1 270 . 270 ALA A H03 64.619 55.029 48.085 1 4060 +ATOM H H04 . ALA A 1 270 . 270 ALA A H04 65.84 54.671 46.84 1 4061 +ATOM H H13 . ALA A 1 270 . 270 ALA A H13 65.144 58.022 48.868 1 4062 +ATOM N N . ILE A 1 271 . 271 ILE A N 63.794 57.649 45.663 1 4063 +ATOM C CA . ILE A 1 271 . 271 ILE A CA 62.455 58.089 45.297 1 4064 +ATOM C C . ILE A 1 271 . 271 ILE A C 61.964 57.466 43.995 1 4065 +ATOM O O . ILE A 1 271 . 271 ILE A O 62.652 57.533 42.983 1 4066 +ATOM C CB . ILE A 1 271 . 271 ILE A CB 62.424 59.621 45.105 1 4067 +ATOM C CG1 . ILE A 1 271 . 271 ILE A CG1 63.028 60.317 46.334 1 4068 +ATOM C CG2 . ILE A 1 271 . 271 ILE A CG2 60.988 60.086 44.86 1 4069 +ATOM C CD1 . ILE A 1 271 . 271 ILE A CD1 63.183 61.827 46.154 1 4070 +ATOM H H01 . ILE A 1 271 . 271 ILE A H01 61.806 57.773 46.114 1 4071 +ATOM H H02 . ILE A 1 271 . 271 ILE A H02 63.023 59.889 44.234 1 4072 +ATOM H H03 . ILE A 1 271 . 271 ILE A H03 64.026 59.904 46.482 1 4073 +ATOM H H04 . ILE A 1 271 . 271 ILE A H04 62.379 60.134 47.191 1 4074 +ATOM H H05 . ILE A 1 271 . 271 ILE A H05 64.55 57.749 45.0 1 4075 +ATOM H H06 . ILE A 1 271 . 271 ILE A H06 60.357 59.767 45.689 1 4076 +ATOM H H07 . ILE A 1 271 . 271 ILE A H07 60.965 61.173 44.784 1 4077 +ATOM H H08 . ILE A 1 271 . 271 ILE A H08 60.617 59.649 43.933 1 4078 +ATOM H H09 . ILE A 1 271 . 271 ILE A H09 64.135 62.039 45.668 1 4079 +ATOM H H10 . ILE A 1 271 . 271 ILE A H10 62.369 62.205 45.536 1 4080 +ATOM H H11 . ILE A 1 271 . 271 ILE A H11 63.157 62.314 47.129 1 4081 +ATOM N N . ALA A 1 272 . 272 ALA A N 60.78 56.861 44.032 1 4082 +ATOM C CA . ALA A 1 272 . 272 ALA A CA 60.196 56.29 42.819 1 4083 +ATOM C C . ALA A 1 272 . 272 ALA A C 59.49 57.491 42.19 1 4084 +ATOM O O . ALA A 1 272 . 272 ALA A O 58.536 58.016 42.766 1 4085 +ATOM C CB . ALA A 1 272 . 272 ALA A CB 59.194 55.212 43.178 1 4086 +ATOM H H01 . ALA A 1 272 . 272 ALA A H01 60.926 55.822 42.159 1 4087 +ATOM H H02 . ALA A 1 272 . 272 ALA A H02 58.275 55.368 42.613 1 4088 +ATOM H H03 . ALA A 1 272 . 272 ALA A H03 59.608 54.234 42.933 1 4089 +ATOM H H04 . ALA A 1 272 . 272 ALA A H04 58.976 55.258 44.245 1 4090 +ATOM H H12 . ALA A 1 272 . 272 ALA A H12 60.279 56.795 44.907 1 4091 +ATOM N N . VAL A 1 273 . 273 VAL A N 59.967 57.927 41.026 1 4092 +ATOM C CA . VAL A 1 273 . 273 VAL A CA 59.399 59.1 40.375 1 4093 +ATOM C C . VAL A 1 273 . 273 VAL A C 58.346 58.824 39.314 1 4094 +ATOM O O . VAL A 1 273 . 273 VAL A O 57.807 59.753 38.716 1 4095 +ATOM C CB . VAL A 1 273 . 273 VAL A CB 60.516 59.988 39.769 1 4096 +ATOM C CG1 . VAL A 1 273 . 273 VAL A CG1 61.46 60.469 40.89 1 4097 +ATOM C CG2 . VAL A 1 273 . 273 VAL A CG2 61.28 59.227 38.708 1 4098 +ATOM H H01 . VAL A 1 273 . 273 VAL A H01 58.876 59.618 41.179 1 4099 +ATOM H H02 . VAL A 1 273 . 273 VAL A H02 60.064 60.859 39.294 1 4100 +ATOM H H03 . VAL A 1 273 . 273 VAL A H03 62.344 60.929 40.448 1 4101 +ATOM H H04 . VAL A 1 273 . 273 VAL A H04 60.943 61.2 41.512 1 4102 +ATOM H H05 . VAL A 1 273 . 273 VAL A H05 60.734 57.437 40.588 1 4103 +ATOM H H06 . VAL A 1 273 . 273 VAL A H06 61.761 59.619 41.502 1 4104 +ATOM H H07 . VAL A 1 273 . 273 VAL A H07 62.268 58.969 39.088 1 4105 +ATOM H H08 . VAL A 1 273 . 273 VAL A H08 60.739 58.315 38.454 1 4106 +ATOM H H09 . VAL A 1 273 . 273 VAL A H09 61.384 59.848 37.818 1 4107 +ATOM N N . GLN A 1 274 . 274 GLN A N 58.058 57.552 39.08 1 4108 +ATOM C CA . GLN A 1 274 . 274 GLN A CA 57.041 57.165 38.105 1 4109 +ATOM C C . GLN A 1 274 . 274 GLN A C 56.596 55.757 38.511 1 4110 +ATOM O O . GLN A 1 274 . 274 GLN A O 57.345 55.026 39.163 1 4111 +ATOM C CB . GLN A 1 274 . 274 GLN A CB 57.656 57.197 36.701 1 4112 +ATOM C CG . GLN A 1 274 . 274 GLN A CG 56.725 57.546 35.543 1 4113 +ATOM C CD . GLN A 1 274 . 274 GLN A CD 55.577 58.469 35.913 1 4114 +ATOM N NE2 . GLN A 1 274 . 274 GLN A NE2 55.767 59.767 35.718 1 4115 +ATOM O OE1 . GLN A 1 274 . 274 GLN A OE1 54.527 58.011 36.368 1 4116 +ATOM H H01 . GLN A 1 274 . 274 GLN A H01 56.184 57.838 38.087 1 4117 +ATOM H H02 . GLN A 1 274 . 274 GLN A H02 57.974 56.172 36.509 1 4118 +ATOM H H03 . GLN A 1 274 . 274 GLN A H03 58.46 57.933 36.71 1 4119 +ATOM H H04 . GLN A 1 274 . 274 GLN A H04 56.275 56.609 35.215 1 4120 +ATOM H H05 . GLN A 1 274 . 274 GLN A H05 57.315 58.031 34.765 1 4121 +ATOM H H06 . GLN A 1 274 . 274 GLN A H06 55.035 60.424 35.946 1 4122 +ATOM H H07 . GLN A 1 274 . 274 GLN A H07 56.644 60.098 35.341 1 4123 +ATOM H H10 . GLN A 1 274 . 274 GLN A H10 58.553 56.833 39.588 1 4124 +ATOM N N . PHE A 1 275 . 275 PHE A N 55.369 55.393 38.153 1 4125 +ATOM C CA . PHE A 1 275 . 275 PHE A CA 54.802 54.093 38.498 1 4126 +ATOM C C . PHE A 1 275 . 275 PHE A C 54.269 53.564 37.176 1 4127 +ATOM O O . PHE A 1 275 . 275 PHE A O 53.519 54.265 36.482 1 4128 +ATOM C CB . PHE A 1 275 . 275 PHE A CB 53.705 54.323 39.537 1 4129 +ATOM C CG . PHE A 1 275 . 275 PHE A CG 54.101 55.349 40.574 1 4130 +ATOM C CD1 . PHE A 1 275 . 275 PHE A CD1 54.063 56.714 40.268 1 4131 +ATOM C CD2 . PHE A 1 275 . 275 PHE A CD2 54.642 54.953 41.792 1 4132 +ATOM C CE1 . PHE A 1 275 . 275 PHE A CE1 54.569 57.669 41.156 1 4133 +ATOM C CE2 . PHE A 1 275 . 275 PHE A CE2 55.153 55.897 42.692 1 4134 +ATOM C CZ . PHE A 1 275 . 275 PHE A CZ 55.119 57.258 42.372 1 4135 +ATOM H H01 . PHE A 1 275 . 275 PHE A H01 55.501 53.38 38.936 1 4136 +ATOM H H02 . PHE A 1 275 . 275 PHE A H02 53.532 53.378 40.053 1 4137 +ATOM H H03 . PHE A 1 275 . 275 PHE A H03 52.806 54.668 39.027 1 4138 +ATOM H H04 . PHE A 1 275 . 275 PHE A H04 53.632 57.039 39.321 1 4139 +ATOM H H05 . PHE A 1 275 . 275 PHE A H05 54.669 53.894 42.05 1 4140 +ATOM H H06 . PHE A 1 275 . 275 PHE A H06 54.534 58.728 40.9 1 4141 +ATOM H H07 . PHE A 1 275 . 275 PHE A H07 55.577 55.571 43.642 1 4142 +ATOM H H08 . PHE A 1 275 . 275 PHE A H08 54.807 56.042 37.621 1 4143 +ATOM H H09 . PHE A 1 275 . 275 PHE A H09 55.52 57.994 43.068 1 4144 +ATOM N N . ASP A 1 276 . 276 ASP A N 54.663 52.351 36.8 1 4145 +ATOM C CA . ASP A 1 276 . 276 ASP A CA 54.237 51.873 35.502 1 4146 +ATOM C C . ASP A 1 276 . 276 ASP A C 52.739 51.79 35.274 1 4147 +ATOM O O . ASP A 1 276 . 276 ASP A O 52.255 52.294 34.256 1 4148 +ATOM C CB . ASP A 1 276 . 276 ASP A CB 54.969 50.564 35.11 1 4149 +ATOM C CG . ASP A 1 276 . 276 ASP A CG 54.682 49.386 36.029 1 4150 +ATOM O OD1 . ASP A 1 276 . 276 ASP A OD1 53.991 49.524 37.059 1 4151 +ATOM O OD2 . ASP A 1 276 . 276 ASP A OD2 55.168 48.293 35.681 1 4152 +ATOM H H01 . ASP A 1 276 . 276 ASP A H01 54.543 52.663 34.816 1 4153 +ATOM H H02 . ASP A 1 276 . 276 ASP A H02 56.037 50.768 35.19 1 4154 +ATOM H H03 . ASP A 1 276 . 276 ASP A H03 54.664 50.291 34.1 1 4155 +ATOM H H04 . ASP A 1 276 . 276 ASP A H04 55.26 48.273 34.726 1 4156 +ATOM H H10 . ASP A 1 276 . 276 ASP A H10 55.241 51.781 37.401 1 4157 +ATOM N N . VAL A 1 277 . 277 VAL A N 51.98 51.231 36.213 1 4158 +ATOM C CA . VAL A 1 277 . 277 VAL A CA 50.538 51.115 35.992 1 4159 +ATOM C C . VAL A 1 277 . 277 VAL A C 49.814 52.461 35.927 1 4160 +ATOM O O . VAL A 1 277 . 277 VAL A O 49.12 52.77 34.945 1 4161 +ATOM C CB . VAL A 1 277 . 277 VAL A CB 49.873 50.244 37.082 1 4162 +ATOM C CG1 . VAL A 1 277 . 277 VAL A CG1 48.367 50.198 36.871 1 4163 +ATOM C CG2 . VAL A 1 277 . 277 VAL A CG2 50.445 48.836 37.025 1 4164 +ATOM H H01 . VAL A 1 277 . 277 VAL A H01 50.44 50.64 35.016 1 4165 +ATOM H H02 . VAL A 1 277 . 277 VAL A H02 50.077 50.68 38.06 1 4166 +ATOM H H03 . VAL A 1 277 . 277 VAL A H03 47.91 51.077 37.325 1 4167 +ATOM H H04 . VAL A 1 277 . 277 VAL A H04 48.149 50.187 35.803 1 4168 +ATOM H H05 . VAL A 1 277 . 277 VAL A H05 52.394 50.891 37.069 1 4169 +ATOM H H06 . VAL A 1 277 . 277 VAL A H06 47.962 49.298 37.335 1 4170 +ATOM H H07 . VAL A 1 277 . 277 VAL A H07 51.409 48.815 37.533 1 4171 +ATOM H H08 . VAL A 1 277 . 277 VAL A H08 49.761 48.145 37.517 1 4172 +ATOM H H09 . VAL A 1 277 . 277 VAL A H09 50.577 48.539 35.985 1 4173 +ATOM N N . TRP A 1 278 . 278 TRP A N 49.969 53.275 36.968 1 4174 +ATOM C CA . TRP A 1 278 . 278 TRP A CA 49.299 54.565 36.967 1 4175 +ATOM C C . TRP A 1 278 . 278 TRP A C 49.855 55.498 35.909 1 4176 +ATOM O O . TRP A 1 278 . 278 TRP A O 49.105 56.271 35.317 1 4177 +ATOM C CB . TRP A 1 278 . 278 TRP A CB 49.418 55.238 38.341 1 4178 +ATOM C CG . TRP A 1 278 . 278 TRP A CG 48.568 54.604 39.391 1 4179 +ATOM C CD1 . TRP A 1 278 . 278 TRP A CD1 47.773 53.501 39.254 1 4180 +ATOM C CD2 . TRP A 1 278 . 278 TRP A CD2 48.415 55.043 40.747 1 4181 +ATOM C CE2 . TRP A 1 278 . 278 TRP A CE2 47.513 54.155 41.375 1 4182 +ATOM C CE3 . TRP A 1 278 . 278 TRP A CE3 48.954 56.101 41.491 1 4183 +ATOM N NE1 . TRP A 1 278 . 278 TRP A NE1 47.135 53.225 40.442 1 4184 +ATOM C CZ2 . TRP A 1 278 . 278 TRP A CZ2 47.137 54.291 42.714 1 4185 +ATOM C CZ3 . TRP A 1 278 . 278 TRP A CZ3 48.581 56.237 42.823 1 4186 +ATOM C CH2 . TRP A 1 278 . 278 TRP A CH2 47.68 55.335 43.42 1 4187 +ATOM H H01 . TRP A 1 278 . 278 TRP A H01 48.251 54.372 36.737 1 4188 +ATOM H H02 . TRP A 1 278 . 278 TRP A H02 49.071 56.265 38.225 1 4189 +ATOM H H03 . TRP A 1 278 . 278 TRP A H03 50.459 55.187 38.662 1 4190 +ATOM H H04 . TRP A 1 278 . 278 TRP A H04 47.66 52.923 38.337 1 4191 +ATOM H H05 . TRP A 1 278 . 278 TRP A H05 49.652 56.803 41.034 1 4192 +ATOM H H06 . TRP A 1 278 . 278 TRP A H06 46.493 52.462 40.6 1 4193 +ATOM H H07 . TRP A 1 278 . 278 TRP A H07 46.439 53.595 43.18 1 4194 +ATOM H H08 . TRP A 1 278 . 278 TRP A H08 48.993 57.055 43.415 1 4195 +ATOM H H09 . TRP A 1 278 . 278 TRP A H09 47.407 55.469 44.467 1 4196 +ATOM H H10 . TRP A 1 278 . 278 TRP A H10 50.546 53.0 37.75 1 4197 +ATOM N N . GLY A 1 279 . 279 GLY A N 51.166 55.428 35.681 1 4198 +ATOM C CA . GLY A 1 279 . 279 GLY A CA 51.803 56.292 34.698 1 4199 +ATOM C C . GLY A 1 279 . 279 GLY A C 51.274 56.051 33.291 1 4200 +ATOM O O . GLY A 1 279 . 279 GLY A O 50.907 56.989 32.576 1 4201 +ATOM H H01 . GLY A 1 279 . 279 GLY A H01 52.87 56.067 34.697 1 4202 +ATOM H H02 . GLY A 1 279 . 279 GLY A H02 51.617 57.331 34.97 1 4203 +ATOM H H10 . GLY A 1 279 . 279 GLY A H10 51.726 54.765 36.198 1 4204 +ATOM N N . LEU A 1 280 . 280 LEU A N 51.243 54.786 32.889 1 4205 +ATOM C CA . LEU A 1 280 . 280 LEU A CA 50.744 54.439 31.561 1 4206 +ATOM C C . LEU A 1 280 . 280 LEU A C 49.263 54.793 31.458 1 4207 +ATOM O O . LEU A 1 280 . 280 LEU A O 48.825 55.355 30.454 1 4208 +ATOM C CB . LEU A 1 280 . 280 LEU A CB 50.936 52.945 31.294 1 4209 +ATOM C CG . LEU A 1 280 . 280 LEU A CG 50.422 52.385 29.956 1 4210 +ATOM C CD1 . LEU A 1 280 . 280 LEU A CD1 51.144 53.029 28.793 1 4211 +ATOM C CD2 . LEU A 1 280 . 280 LEU A CD2 50.647 50.875 29.935 1 4212 +ATOM H H01 . LEU A 1 280 . 280 LEU A H01 51.305 55.004 30.817 1 4213 +ATOM H H02 . LEU A 1 280 . 280 LEU A H02 50.328 52.45 32.051 1 4214 +ATOM H H03 . LEU A 1 280 . 280 LEU A H03 51.568 54.057 33.508 1 4215 +ATOM H H04 . LEU A 1 280 . 280 LEU A H04 52.005 52.74 31.344 1 4216 +ATOM H H05 . LEU A 1 280 . 280 LEU A H05 49.359 52.606 29.86 1 4217 +ATOM H H06 . LEU A 1 280 . 280 LEU A H06 52.143 52.603 28.705 1 4218 +ATOM H H07 . LEU A 1 280 . 280 LEU A H07 50.589 52.845 27.873 1 4219 +ATOM H H08 . LEU A 1 280 . 280 LEU A H08 51.221 54.103 28.962 1 4220 +ATOM H H09 . LEU A 1 280 . 280 LEU A H09 51.584 50.641 30.441 1 4221 +ATOM H H10 . LEU A 1 280 . 280 LEU A H10 49.824 50.377 30.447 1 4222 +ATOM H H11 . LEU A 1 280 . 280 LEU A H11 50.696 50.529 28.903 1 4223 +ATOM N N . SER A 1 281 . 281 SER A N 48.497 54.463 32.493 1 4224 +ATOM C CA . SER A 1 281 . 281 SER A CA 47.06 54.753 32.494 1 4225 +ATOM C C . SER A 1 281 . 281 SER A C 46.79 56.251 32.284 1 4226 +ATOM O O . SER A 1 281 . 281 SER A O 46.01 56.647 31.405 1 4227 +ATOM C CB . SER A 1 281 . 281 SER A CB 46.442 54.28 33.811 1 4228 +ATOM O OG . SER A 1 281 . 281 SER A OG 45.053 54.536 33.855 1 4229 +ATOM H H01 . SER A 1 281 . 281 SER A H01 46.601 54.216 31.664 1 4230 +ATOM H H02 . SER A 1 281 . 281 SER A H02 46.915 54.828 34.626 1 4231 +ATOM H H03 . SER A 1 281 . 281 SER A H03 46.608 53.208 33.916 1 4232 +ATOM H H04 . SER A 1 281 . 281 SER A H04 44.749 54.803 32.985 1 4233 +ATOM H H12 . SER A 1 281 . 281 SER A H12 48.912 54.005 33.292 1 4234 +ATOM N N . ARG A 1 282 . 282 ARG A N 47.446 57.088 33.085 1 4235 +ATOM C CA . ARG A 1 282 . 282 ARG A CA 47.252 58.537 32.992 1 4236 +ATOM C C . ARG A 1 282 . 282 ARG A C 47.74 59.113 31.67 1 4237 +ATOM O O . ARG A 1 282 . 282 ARG A O 47.118 60.027 31.115 1 4238 +ATOM C CB . ARG A 1 282 . 282 ARG A CB 47.954 59.238 34.166 1 4239 +ATOM C CG . ARG A 1 282 . 282 ARG A CG 47.402 58.798 35.513 1 4240 +ATOM C CD . ARG A 1 282 . 282 ARG A CD 48.211 59.345 36.694 1 4241 +ATOM N NE . ARG A 1 282 . 282 ARG A NE 47.627 58.936 37.971 1 4242 +ATOM C CZ . ARG A 1 282 . 282 ARG A CZ 48.068 59.326 39.165 1 4243 +ATOM N NH1 . ARG A 1 282 . 282 ARG A NH1 49.109 60.141 39.262 1 4244 +ATOM N NH2 . ARG A 1 282 . 282 ARG A NH2 47.458 58.909 40.267 1 4245 +ATOM H H01 . ARG A 1 282 . 282 ARG A H01 46.178 58.72 33.04 1 4246 +ATOM H H02 . ARG A 1 282 . 282 ARG A H02 47.779 60.309 34.068 1 4247 +ATOM H H03 . ARG A 1 282 . 282 ARG A H03 49.018 59.005 34.129 1 4248 +ATOM H H04 . ARG A 1 282 . 282 ARG A H04 47.46 57.71 35.552 1 4249 +ATOM H H05 . ARG A 1 282 . 282 ARG A H05 48.089 56.717 33.77 1 4250 +ATOM H H06 . ARG A 1 282 . 282 ARG A H06 46.374 59.151 35.601 1 4251 +ATOM H H07 . ARG A 1 282 . 282 ARG A H07 48.201 60.434 36.644 1 4252 +ATOM H H08 . ARG A 1 282 . 282 ARG A H08 49.232 58.967 36.632 1 4253 +ATOM H H09 . ARG A 1 282 . 282 ARG A H09 46.832 58.313 37.945 1 4254 +ATOM H H10 . ARG A 1 282 . 282 ARG A H10 49.436 60.437 40.171 1 4255 +ATOM H H11 . ARG A 1 282 . 282 ARG A H11 49.575 60.466 38.427 1 4256 +ATOM H H12 . ARG A 1 282 . 282 ARG A H12 47.79 59.209 41.172 1 4257 +ATOM N N . LEU A 1 283 . 283 LEU A N 48.85 58.589 31.161 1 4258 +ATOM C CA . LEU A 1 283 . 283 LEU A CA 49.385 59.065 29.896 1 4259 +ATOM C C . LEU A 1 283 . 283 LEU A C 48.378 58.825 28.77 1 4260 +ATOM O O . LEU A 1 283 . 283 LEU A O 48.068 59.731 27.989 1 4261 +ATOM C CB . LEU A 1 283 . 283 LEU A CB 50.691 58.339 29.569 1 4262 +ATOM C CG . LEU A 1 283 . 283 LEU A CG 51.292 58.67 28.202 1 4263 +ATOM C CD1 . LEU A 1 283 . 283 LEU A CD1 51.815 60.101 28.209 1 4264 +ATOM C CD2 . LEU A 1 283 . 283 LEU A CD2 52.408 57.7 27.887 1 4265 +ATOM H H01 . LEU A 1 283 . 283 LEU A H01 49.577 60.134 29.985 1 4266 +ATOM H H02 . LEU A 1 283 . 283 LEU A H02 50.45 57.276 29.545 1 4267 +ATOM H H03 . LEU A 1 283 . 283 LEU A H03 51.424 58.6 30.333 1 4268 +ATOM H H04 . LEU A 1 283 . 283 LEU A H04 50.525 58.579 27.433 1 4269 +ATOM H H05 . LEU A 1 283 . 283 LEU A H05 52.776 60.139 27.695 1 4270 +ATOM H H06 . LEU A 1 283 . 283 LEU A H06 51.104 60.751 27.698 1 4271 +ATOM H H07 . LEU A 1 283 . 283 LEU A H07 51.941 60.437 29.238 1 4272 +ATOM H H08 . LEU A 1 283 . 283 LEU A H08 53.286 57.952 28.481 1 4273 +ATOM H H09 . LEU A 1 283 . 283 LEU A H09 52.087 56.686 28.126 1 4274 +ATOM H H10 . LEU A 1 283 . 283 LEU A H10 52.657 57.763 26.828 1 4275 +ATOM H H13 . LEU A 1 283 . 283 LEU A H13 49.328 57.851 31.659 1 4276 +ATOM N N . VAL A 1 284 . 284 VAL A N 47.865 57.601 28.693 1 4277 +ATOM C CA . VAL A 1 284 . 284 VAL A CA 46.917 57.258 27.643 1 4278 +ATOM C C . VAL A 1 284 . 284 VAL A C 45.576 57.97 27.833 1 4279 +ATOM O O . VAL A 1 284 . 284 VAL A O 45.041 58.56 26.89 1 4280 +ATOM C CB . VAL A 1 284 . 284 VAL A CB 46.711 55.72 27.568 1 4281 +ATOM C CG1 . VAL A 1 284 . 284 VAL A CG1 45.655 55.375 26.523 1 4282 +ATOM C CG2 . VAL A 1 284 . 284 VAL A CG2 48.032 55.042 27.193 1 4283 +ATOM H H01 . VAL A 1 284 . 284 VAL A H01 47.341 57.6 26.699 1 4284 +ATOM H H02 . VAL A 1 284 . 284 VAL A H02 46.376 55.365 28.543 1 4285 +ATOM H H03 . VAL A 1 284 . 284 VAL A H03 44.798 56.039 26.64 1 4286 +ATOM H H04 . VAL A 1 284 . 284 VAL A H04 46.076 55.499 25.525 1 4287 +ATOM H H05 . VAL A 1 284 . 284 VAL A H05 45.334 54.342 26.657 1 4288 +ATOM H H06 . VAL A 1 284 . 284 VAL A H06 48.808 55.346 27.895 1 4289 +ATOM H H07 . VAL A 1 284 . 284 VAL A H07 47.91 53.96 27.234 1 4290 +ATOM H H08 . VAL A 1 284 . 284 VAL A H08 48.319 55.338 26.184 1 4291 +ATOM H H11 . VAL A 1 284 . 284 VAL A H11 48.136 56.902 29.37 1 4292 +ATOM N N . HIS A 1 285 . 285 HIS A N 45.04 57.942 29.051 1 4293 +ATOM C CA . HIS A 1 285 . 285 HIS A CA 43.76 58.594 29.312 1 4294 +ATOM C C . HIS A 1 285 . 285 HIS A C 43.838 60.09 28.992 1 4295 +ATOM O O . HIS A 1 285 . 285 HIS A O 42.916 60.664 28.413 1 4296 +ATOM C CB . HIS A 1 285 . 285 HIS A CB 43.369 58.418 30.778 1 4297 +ATOM C CG . HIS A 1 285 . 285 HIS A CG 42.021 58.977 31.113 1 4298 +ATOM C CD2 . HIS A 1 285 . 285 HIS A CD2 41.659 60.171 31.637 1 4299 +ATOM N ND1 . HIS A 1 285 . 285 HIS A ND1 40.851 58.272 30.924 1 4300 +ATOM C CE1 . HIS A 1 285 . 285 HIS A CE1 39.827 59.007 31.324 1 4301 +ATOM N NE2 . HIS A 1 285 . 285 HIS A NE2 40.292 60.163 31.762 1 4302 +ATOM H H01 . HIS A 1 285 . 285 HIS A H01 43.009 58.13 28.672 1 4303 +ATOM H H02 . HIS A 1 285 . 285 HIS A H02 44.099 58.97 31.37 1 4304 +ATOM H H03 . HIS A 1 285 . 285 HIS A H03 43.368 57.353 31.009 1 4305 +ATOM H H04 . HIS A 1 285 . 285 HIS A H04 42.328 60.987 31.909 1 4306 +ATOM H H05 . HIS A 1 285 . 285 HIS A H05 38.779 58.71 31.297 1 4307 +ATOM H H06 . HIS A 1 285 . 285 HIS A H06 39.732 60.919 32.13 1 4308 +ATOM H H09 . HIS A 1 285 . 285 HIS A H09 45.522 57.466 29.8 1 4309 +ATOM N N . GLY A 1 286 . 286 GLY A N 44.947 60.718 29.376 1 4310 +ATOM C CA . GLY A 1 286 . 286 GLY A CA 45.11 62.138 29.122 1 4311 +ATOM C C . GLY A 1 286 . 286 GLY A C 45.217 62.462 27.649 1 4312 +ATOM O O . GLY A 1 286 . 286 GLY A O 44.655 63.467 27.183 1 4313 +ATOM H H01 . GLY A 1 286 . 286 GLY A H01 46.041 62.451 29.595 1 4314 +ATOM H H02 . GLY A 1 286 . 286 GLY A H02 44.251 62.667 29.534 1 4315 +ATOM H H07 . GLY A 1 286 . 286 GLY A H07 45.678 60.204 29.846 1 4316 +ATOM N N . SER A 1 287 . 287 SER A N 45.93 61.617 26.902 1 4317 +ATOM C CA . SER A 1 287 . 287 SER A CA 46.096 61.846 25.471 1 4318 +ATOM C C . SER A 1 287 . 287 SER A C 44.754 61.698 24.77 1 4319 +ATOM O O . SER A 1 287 . 287 SER A O 44.438 62.478 23.87 1 4320 +ATOM C CB . SER A 1 287 . 287 SER A CB 47.11 60.868 24.876 1 4321 +ATOM O OG . SER A 1 287 . 287 SER A OG 48.396 61.082 25.442 1 4322 +ATOM H H01 . SER A 1 287 . 287 SER A H01 46.473 62.858 25.323 1 4323 +ATOM H H02 . SER A 1 287 . 287 SER A H02 47.167 61.029 23.799 1 4324 +ATOM H H03 . SER A 1 287 . 287 SER A H03 46.359 60.81 27.332 1 4325 +ATOM H H04 . SER A 1 287 . 287 SER A H04 46.79 59.847 25.085 1 4326 +ATOM H H05 . SER A 1 287 . 287 SER A H05 48.734 60.254 25.791 1 4327 +ATOM N N . LEU A 1 288 . 288 LEU A N 43.968 60.704 25.186 1 4328 +ATOM C CA . LEU A 1 288 . 288 LEU A CA 42.641 60.479 24.601 1 4329 +ATOM C C . LEU A 1 288 . 288 LEU A C 41.74 61.692 24.843 1 4330 +ATOM O O . LEU A 1 288 . 288 LEU A O 41.052 62.169 23.929 1 4331 +ATOM C CB . LEU A 1 288 . 288 LEU A CB 41.979 59.241 25.226 1 4332 +ATOM C CG . LEU A 1 288 . 288 LEU A CG 42.626 57.887 24.898 1 4333 +ATOM C CD1 . LEU A 1 288 . 288 LEU A CD1 42.049 56.815 25.835 1 4334 +ATOM C CD2 . LEU A 1 288 . 288 LEU A CD2 42.366 57.525 23.428 1 4335 +ATOM H H01 . LEU A 1 288 . 288 LEU A H01 42.769 60.323 23.53 1 4336 +ATOM H H02 . LEU A 1 288 . 288 LEU A H02 40.978 59.197 24.796 1 4337 +ATOM H H03 . LEU A 1 288 . 288 LEU A H03 41.996 59.365 26.309 1 4338 +ATOM H H04 . LEU A 1 288 . 288 LEU A H04 43.704 57.945 25.047 1 4339 +ATOM H H05 . LEU A 1 288 . 288 LEU A H05 41.048 56.542 25.501 1 4340 +ATOM H H06 . LEU A 1 288 . 288 LEU A H06 44.294 60.092 25.921 1 4341 +ATOM H H07 . LEU A 1 288 . 288 LEU A H07 42.69 55.933 25.818 1 4342 +ATOM H H08 . LEU A 1 288 . 288 LEU A H08 41.998 57.208 26.85 1 4343 +ATOM H H09 . LEU A 1 288 . 288 LEU A H09 41.601 58.186 23.021 1 4344 +ATOM H H10 . LEU A 1 288 . 288 LEU A H10 43.287 57.641 22.856 1 4345 +ATOM H H11 . LEU A 1 288 . 288 LEU A H11 42.025 56.492 23.363 1 4346 +ATOM N N . ALA A 1 289 . 289 ALA A N 41.743 62.185 26.078 1 4347 +ATOM C CA . ALA A 1 289 . 289 ALA A CA 40.92 63.332 26.438 1 4348 +ATOM C C . ALA A 1 289 . 289 ALA A C 41.283 64.555 25.603 1 4349 +ATOM O O . ALA A 1 289 . 289 ALA A O 40.4 65.237 25.076 1 4350 +ATOM C CB . ALA A 1 289 . 289 ALA A CB 41.078 63.654 27.924 1 4351 +ATOM H H01 . ALA A 1 289 . 289 ALA A H01 39.881 63.073 26.235 1 4352 +ATOM H H02 . ALA A 1 289 . 289 ALA A H02 41.693 64.547 28.038 1 4353 +ATOM H H03 . ALA A 1 289 . 289 ALA A H03 40.097 63.83 28.364 1 4354 +ATOM H H04 . ALA A 1 289 . 289 ALA A H04 41.558 62.816 28.429 1 4355 +ATOM H H12 . ALA A 1 289 . 289 ALA A H12 42.328 61.756 26.781 1 4356 +ATOM N N . GLN A 1 290 . 290 GLN A N 42.58 64.83 25.482 1 4357 +ATOM C CA . GLN A 1 290 . 290 GLN A CA 43.035 65.982 24.721 1 4358 +ATOM C C . GLN A 1 290 . 290 GLN A C 42.663 65.834 23.256 1 4359 +ATOM O O . GLN A 1 290 . 290 GLN A O 42.217 66.794 22.617 1 4360 +ATOM C CB . GLN A 1 290 . 290 GLN A CB 44.547 66.14 24.843 1 4361 +ATOM C CG . GLN A 1 290 . 290 GLN A CG 45.003 66.626 26.199 1 4362 +ATOM C CD . GLN A 1 290 . 290 GLN A CD 46.511 66.593 26.34 1 4363 +ATOM N NE2 . GLN A 1 290 . 290 GLN A NE2 46.989 66.246 27.535 1 4364 +ATOM O OE1 . GLN A 1 290 . 290 GLN A OE1 47.243 66.886 25.387 1 4365 +ATOM H H01 . GLN A 1 290 . 290 GLN A H01 42.547 66.868 25.128 1 4366 +ATOM H H02 . GLN A 1 290 . 290 GLN A H02 44.846 66.897 24.118 1 4367 +ATOM H H03 . GLN A 1 290 . 290 GLN A H03 45.011 65.173 24.65 1 4368 +ATOM H H04 . GLN A 1 290 . 290 GLN A H04 44.582 65.96 26.952 1 4369 +ATOM H H05 . GLN A 1 290 . 290 GLN A H05 43.259 64.228 25.926 1 4370 +ATOM H H06 . GLN A 1 290 . 290 GLN A H06 44.659 67.651 26.34 1 4371 +ATOM H H07 . GLN A 1 290 . 290 GLN A H07 47.987 66.21 27.689 1 4372 +ATOM H H08 . GLN A 1 290 . 290 GLN A H08 46.354 66.019 28.286 1 4373 +ATOM N N . ALA A 1 291 . 291 ALA A N 42.845 64.629 22.721 1 4374 +ATOM C CA . ALA A 1 291 . 291 ALA A CA 42.529 64.384 21.32 1 4375 +ATOM C C . ALA A 1 291 . 291 ALA A C 41.043 64.607 21.033 1 4376 +ATOM O O . ALA A 1 291 . 291 ALA A O 40.689 65.321 20.088 1 4377 +ATOM C CB . ALA A 1 291 . 291 ALA A CB 42.938 62.952 20.927 1 4378 +ATOM H H01 . ALA A 1 291 . 291 ALA A H01 43.095 65.097 20.72 1 4379 +ATOM H H02 . ALA A 1 291 . 291 ALA A H02 43.916 62.725 21.351 1 4380 +ATOM H H03 . ALA A 1 291 . 291 ALA A H03 42.203 62.245 21.312 1 4381 +ATOM H H04 . ALA A 1 291 . 291 ALA A H04 42.986 62.872 19.841 1 4382 +ATOM H H09 . ALA A 1 291 . 291 ALA A H09 43.205 63.878 23.291 1 4383 +ATOM N N . ARG A 1 292 . 292 ARG A N 40.176 64.001 21.839 1 4384 +ATOM C CA . ARG A 1 292 . 292 ARG A CA 38.737 64.151 21.635 1 4385 +ATOM C C . ARG A 1 292 . 292 ARG A C 38.319 65.608 21.775 1 4386 +ATOM O O . ARG A 1 292 . 292 ARG A O 37.504 66.117 20.995 1 4387 +ATOM C CB . ARG A 1 292 . 292 ARG A CB 37.952 63.296 22.632 1 4388 +ATOM C CG . ARG A 1 292 . 292 ARG A CG 38.115 61.787 22.418 1 4389 +ATOM C CD . ARG A 1 292 . 292 ARG A CD 37.057 61.009 23.192 1 4390 +ATOM N NE . ARG A 1 292 . 292 ARG A NE 37.234 59.56 23.081 1 4391 +ATOM C CZ . ARG A 1 292 . 292 ARG A CZ 37.747 58.801 24.043 1 4392 +ATOM N NH1 . ARG A 1 292 . 292 ARG A NH1 38.132 59.356 25.187 1 4393 +ATOM N NH2 . ARG A 1 292 . 292 ARG A NH2 37.874 57.492 23.863 1 4394 +ATOM H H01 . ARG A 1 292 . 292 ARG A H01 38.511 63.812 20.624 1 4395 +ATOM H H02 . ARG A 1 292 . 292 ARG A H02 36.897 63.525 22.483 1 4396 +ATOM H H03 . ARG A 1 292 . 292 ARG A H03 38.294 63.54 23.638 1 4397 +ATOM H H04 . ARG A 1 292 . 292 ARG A H04 39.099 61.489 22.78 1 4398 +ATOM H H05 . ARG A 1 292 . 292 ARG A H05 40.516 63.431 22.6 1 4399 +ATOM H H06 . ARG A 1 292 . 292 ARG A H06 38.017 61.564 21.356 1 4400 +ATOM H H07 . ARG A 1 292 . 292 ARG A H07 36.085 61.257 22.765 1 4401 +ATOM H H08 . ARG A 1 292 . 292 ARG A H08 37.115 61.289 24.244 1 4402 +ATOM H H09 . ARG A 1 292 . 292 ARG A H09 36.949 59.111 22.222 1 4403 +ATOM H H10 . ARG A 1 292 . 292 ARG A H10 38.522 58.782 25.921 1 4404 +ATOM H H11 . ARG A 1 292 . 292 ARG A H11 38.035 60.352 25.323 1 4405 +ATOM H H12 . ARG A 1 292 . 292 ARG A H12 38.264 56.917 24.596 1 4406 +ATOM N N . ALA A 1 293 . 293 ALA A N 38.885 66.285 22.767 1 4407 +ATOM C CA . ALA A 1 293 . 293 ALA A CA 38.564 67.69 22.988 1 4408 +ATOM C C . ALA A 1 293 . 293 ALA A C 38.926 68.533 21.769 1 4409 +ATOM O O . ALA A 1 293 . 293 ALA A O 38.176 69.429 21.38 1 4410 +ATOM C CB . ALA A 1 293 . 293 ALA A CB 39.309 68.207 24.216 1 4411 +ATOM H H01 . ALA A 1 293 . 293 ALA A H01 37.49 67.772 23.154 1 4412 +ATOM H H02 . ALA A 1 293 . 293 ALA A H02 40.209 68.733 23.899 1 4413 +ATOM H H03 . ALA A 1 293 . 293 ALA A H03 38.666 68.89 24.771 1 4414 +ATOM H H04 . ALA A 1 293 . 293 ALA A H04 39.585 67.368 24.854 1 4415 +ATOM H H13 . ALA A 1 293 . 293 ALA A H13 39.545 65.822 23.375 1 4416 +ATOM N N . SER A 1 294 . 294 SER A N 40.072 68.242 21.161 1 4417 +ATOM C CA . SER A 1 294 . 294 SER A CA 40.534 69.001 20.012 1 4418 +ATOM C C . SER A 1 294 . 294 SER A C 39.744 68.686 18.748 1 4419 +ATOM O O . SER A 1 294 . 294 SER A O 39.811 69.432 17.767 1 4420 +ATOM C CB . SER A 1 294 . 294 SER A CB 42.024 68.748 19.771 1 4421 +ATOM O OG . SER A 1 294 . 294 SER A OG 42.258 67.459 19.233 1 4422 +ATOM H H01 . SER A 1 294 . 294 SER A H01 40.373 70.054 20.243 1 4423 +ATOM H H02 . SER A 1 294 . 294 SER A H02 42.538 68.813 20.73 1 4424 +ATOM H H03 . SER A 1 294 . 294 SER A H03 42.401 69.496 19.074 1 4425 +ATOM H H04 . SER A 1 294 . 294 SER A H04 42.211 67.498 18.275 1 4426 +ATOM H H05 . SER A 1 294 . 294 SER A H05 40.634 67.476 21.506 1 4427 +ATOM N N . ALA A 1 295 . 295 ALA A N 38.993 67.589 18.773 1 4428 +ATOM C CA . ALA A 1 295 . 295 ALA A CA 38.201 67.201 17.608 1 4429 +ATOM C C . ALA A 1 295 . 295 ALA A C 36.808 67.815 17.633 1 4430 +ATOM O O . ALA A 1 295 . 295 ALA A O 36.115 67.826 16.621 1 4431 +ATOM C CB . ALA A 1 295 . 295 ALA A CB 38.091 65.68 17.532 1 4432 +ATOM H H01 . ALA A 1 295 . 295 ALA A H01 38.715 67.579 16.725 1 4433 +ATOM H H02 . ALA A 1 295 . 295 ALA A H02 37.194 65.409 16.975 1 4434 +ATOM H H03 . ALA A 1 295 . 295 ALA A H03 38.968 65.277 17.026 1 4435 +ATOM H H04 . ALA A 1 295 . 295 ALA A H04 38.031 65.269 18.54 1 4436 +ATOM H H05 . ALA A 1 295 . 295 ALA A H05 38.97 67.018 19.606 1 4437 +ATOM N N . LYS A 1 296 . 296 LYS A N 36.391 68.321 18.786 1 4438 +ATOM C CA . LYS A 1 296 . 296 LYS A CA 35.063 68.907 18.911 1 4439 +ATOM C C . LYS A 1 296 . 296 LYS A C 34.78 70.038 17.927 1 4440 +ATOM O O . LYS A 1 296 . 296 LYS A O 33.628 70.256 17.564 1 4441 +ATOM C CB . LYS A 1 296 . 296 LYS A CB 34.832 69.402 20.34 1 4442 +ATOM C CG . LYS A 1 296 . 296 LYS A CG 34.505 68.292 21.326 1 4443 +ATOM C CD . LYS A 1 296 . 296 LYS A CD 33.151 67.672 21.013 1 4444 +ATOM C CE . LYS A 1 296 . 296 LYS A CE 32.837 66.491 21.917 1 4445 +ATOM N NZ . LYS A 1 296 . 296 LYS A NZ 32.767 66.883 23.348 1 4446 +ATOM H H01 . LYS A 1 296 . 296 LYS A H01 34.367 68.105 18.665 1 4447 +ATOM H H02 . LYS A 1 296 . 296 LYS A H02 33.968 70.066 20.311 1 4448 +ATOM H H03 . LYS A 1 296 . 296 LYS A H03 35.735 69.91 20.679 1 4449 +ATOM H H04 . LYS A 1 296 . 296 LYS A H04 34.471 68.716 22.33 1 4450 +ATOM H H05 . LYS A 1 296 . 296 LYS A H05 37.003 68.3 19.589 1 4451 +ATOM H H06 . LYS A 1 296 . 296 LYS A H06 35.274 67.522 21.266 1 4452 +ATOM H H07 . LYS A 1 296 . 296 LYS A H07 33.179 67.308 19.986 1 4453 +ATOM H H08 . LYS A 1 296 . 296 LYS A H08 32.379 68.43 21.144 1 4454 +ATOM H H09 . LYS A 1 296 . 296 LYS A H09 33.642 65.764 21.808 1 4455 +ATOM H H10 . LYS A 1 296 . 296 LYS A H10 31.877 66.067 21.621 1 4456 +ATOM H H11 . LYS A 1 296 . 296 LYS A H11 32.979 66.083 23.927 1 4457 +ATOM H H12 . LYS A 1 296 . 296 LYS A H12 33.436 67.618 23.53 1 4458 +ATOM N N . GLN A 1 297 . 297 GLN A N 35.826 70.737 17.491 1 4459 +ATOM C CA . GLN A 1 297 . 297 GLN A CA 35.678 71.858 16.56 1 4460 +ATOM C C . GLN A 1 297 . 297 GLN A C 35.174 71.425 15.185 1 4461 +ATOM O O . GLN A 1 297 . 297 GLN A O 34.765 72.262 14.369 1 4462 +ATOM C CB . GLN A 1 297 . 297 GLN A CB 37.017 72.578 16.401 1 4463 +ATOM C CG . GLN A 1 297 . 297 GLN A CG 38.107 71.691 15.821 1 4464 +ATOM C CD . GLN A 1 297 . 297 GLN A CD 39.449 72.384 15.701 1 4465 +ATOM N NE2 . GLN A 1 297 . 297 GLN A NE2 40.433 71.899 16.45 1 4466 +ATOM O OE1 . GLN A 1 297 . 297 GLN A OE1 39.605 73.342 14.941 1 4467 +ATOM H H01 . GLN A 1 297 . 297 GLN A H01 34.93 72.526 16.986 1 4468 +ATOM H H02 . GLN A 1 297 . 297 GLN A H02 37.342 72.882 17.396 1 4469 +ATOM H H03 . GLN A 1 297 . 297 GLN A H03 36.876 73.432 15.739 1 4470 +ATOM H H04 . GLN A 1 297 . 297 GLN A H04 37.796 71.418 14.813 1 4471 +ATOM H H05 . GLN A 1 297 . 297 GLN A H05 38.224 70.82 16.466 1 4472 +ATOM H H06 . GLN A 1 297 . 297 GLN A H06 41.351 72.318 16.411 1 4473 +ATOM H H07 . GLN A 1 297 . 297 GLN A H07 40.263 71.111 17.058 1 4474 +ATOM H H13 . GLN A 1 297 . 297 GLN A H13 36.75 70.486 17.812 1 4475 +ATOM N N . PHE A 1 298 . 298 PHE A N 35.196 70.12 14.929 1 4476 +ATOM C CA . PHE A 1 298 . 298 PHE A CA 34.752 69.597 13.645 1 4477 +ATOM C C . PHE A 1 298 . 298 PHE A C 33.314 69.093 13.658 1 4478 +ATOM O O . PHE A 1 298 . 298 PHE A O 32.82 68.594 12.645 1 4479 +ATOM C CB . PHE A 1 298 . 298 PHE A CB 35.693 68.474 13.204 1 4480 +ATOM C CG . PHE A 1 298 . 298 PHE A CG 37.105 68.92 13.019 1 4481 +ATOM C CD1 . PHE A 1 298 . 298 PHE A CD1 37.471 69.659 11.898 1 4482 +ATOM C CD2 . PHE A 1 298 . 298 PHE A CD2 38.072 68.63 13.978 1 4483 +ATOM C CE1 . PHE A 1 298 . 298 PHE A CE1 38.769 70.101 11.734 1 4484 +ATOM C CE2 . PHE A 1 298 . 298 PHE A CE2 39.38 69.07 13.822 1 4485 +ATOM C CZ . PHE A 1 298 . 298 PHE A CZ 39.731 69.807 12.697 1 4486 +ATOM H H01 . PHE A 1 298 . 298 PHE A H01 34.78 70.426 12.937 1 4487 +ATOM H H02 . PHE A 1 298 . 298 PHE A H02 35.339 68.121 12.236 1 4488 +ATOM H H03 . PHE A 1 298 . 298 PHE A H03 35.674 67.69 13.961 1 4489 +ATOM H H04 . PHE A 1 298 . 298 PHE A H04 36.724 69.892 11.14 1 4490 +ATOM H H05 . PHE A 1 298 . 298 PHE A H05 37.799 68.051 14.861 1 4491 +ATOM H H06 . PHE A 1 298 . 298 PHE A H06 39.04 70.68 10.851 1 4492 +ATOM H H07 . PHE A 1 298 . 298 PHE A H07 40.129 68.838 14.579 1 4493 +ATOM H H08 . PHE A 1 298 . 298 PHE A H08 35.526 69.481 15.638 1 4494 +ATOM H H09 . PHE A 1 298 . 298 PHE A H09 40.756 70.154 12.569 1 4495 +ATOM N N . ALA A 1 299 . 299 ALA A N 32.644 69.229 14.797 1 4496 +ATOM C CA . ALA A 1 299 . 299 ALA A CA 31.266 68.78 14.94 1 4497 +ATOM C C . ALA A 1 299 . 299 ALA A C 30.356 69.476 13.935 1 4498 +ATOM O O . ALA A 1 299 . 299 ALA A O 30.619 70.607 13.52 1 4499 +ATOM C CB . ALA A 1 299 . 299 ALA A CB 30.775 69.038 16.348 1 4500 +ATOM H H01 . ALA A 1 299 . 299 ALA A H01 31.238 67.708 14.742 1 4501 +ATOM H H02 . ALA A 1 299 . 299 ALA A H02 29.748 68.685 16.444 1 4502 +ATOM H H03 . ALA A 1 299 . 299 ALA A H03 31.409 68.507 17.058 1 4503 +ATOM H H04 . ALA A 1 299 . 299 ALA A H04 30.812 70.107 16.556 1 4504 +ATOM H H10 . ALA A 1 299 . 299 ALA A H10 33.104 69.657 15.588 1 4505 +ATOM N N . GLY A 1 300 . 300 GLY A N 29.285 68.784 13.562 1 4506 +ATOM C CA . GLY A 1 300 . 300 GLY A CA 28.343 69.309 12.593 1 4507 +ATOM C C . GLY A 1 300 . 300 GLY A C 28.624 68.704 11.226 1 4508 +ATOM O O . GLY A 1 300 . 300 GLY A O 28.714 69.478 10.244 1 4509 +ATOM H H01 . GLY A 1 300 . 300 GLY A H01 28.449 70.392 12.537 1 4510 +ATOM H H02 . GLY A 1 300 . 300 GLY A H02 27.328 69.057 12.9 1 4511 +ATOM H H03 . GLY A 1 300 . 300 GLY A H03 28.744 67.628 11.104 1 4512 +ATOM H H05 . GLY A 1 300 . 300 GLY A H05 29.123 67.871 13.963 1 4513 +HETATM MG MG . MG G 2 1001 . 1001 MG G MG 50.74 49.394 51.965 1 4514 +HETATM C CA . PYR H 3 2001 . 2001 PYR H CA 51.932 51.461 50.587 1 4515 +HETATM C C . PYR H 3 2001 . 2001 PYR H C 50.729 52.176 51.152 1 4516 +HETATM O O . PYR H 3 2001 . 2001 PYR H O 49.988 51.424 51.814 1 4517 +HETATM C CB . PYR H 3 2001 . 2001 PYR H CB 52.839 52.326 49.813 1 4518 +HETATM O OXT . PYR H 3 2001 . 2001 PYR H OXT 50.518 53.399 50.963 1 4519 +HETATM O O3 . PYR H 3 2001 . 2001 PYR H O3 52.163 50.252 50.752 1 4520 +HETATM H H01 . PYR H 3 2001 . 2001 PYR H H01 52.393 52.544 48.842 1 4521 +HETATM H H02 . PYR H 3 2001 . 2001 PYR H H02 53.005 53.257 50.355 1 4522 +HETATM H H03 . PYR H 3 2001 . 2001 PYR H H03 50.421 53.566 50.023 1 4523 +HETATM H H04 . PYR H 3 2001 . 2001 PYR H H04 53.791 51.816 49.668 1 4524 +HETATM O O . HOH I 4 2036 . 2036 HOH I O 51.725 47.439 52.042 1 4525 +HETATM H H01 . HOH I 4 2036 . 2036 HOH I H01 52.538 47.335 52.542 1 4526 +HETATM H H04 . HOH I 4 2036 . 2036 HOH I H04 51.401 46.576 51.775 1 4527 +HETATM O O . HOH I 4 2245 . 2245 HOH I O 49.669 49.007 50.001 1 4528 +HETATM H H01 . HOH I 4 2245 . 2245 HOH I H01 48.745 48.753 50.061 1 4529 +HETATM H H02 . HOH I 4 2245 . 2245 HOH I H02 49.964 49.346 50.849 1 4530 +# +_cell.length_a 106.678 +_cell.length_b 106.678 +_cell.length_c 120.755 +_cell.angle_alpha 90.0 +_cell.angle_beta 90.0 +_cell.angle_gamma 120.0 +# diff --git a/tests/data/io/interactions/7MKV_UstD_2_dimer.cif b/tests/data/io/interactions/7MKV_UstD_2_dimer.cif new file mode 100644 index 00000000..14f56063 --- /dev/null +++ b/tests/data/io/interactions/7MKV_UstD_2_dimer.cif @@ -0,0 +1,6720 @@ +data_structure +# +loop_ +_struct_conn.id +_struct_conn.conn_type_id +_struct_conn.pdbx_value_order +_struct_conn.ptnr1_label_asym_id +_struct_conn.ptnr2_label_asym_id +_struct_conn.ptnr1_label_comp_id +_struct_conn.ptnr2_label_comp_id +_struct_conn.ptnr1_label_seq_id +_struct_conn.ptnr2_label_seq_id +_struct_conn.ptnr1_label_atom_id +_struct_conn.ptnr2_label_atom_id +_struct_conn.pdbx_ptnr1_PDB_ins_code +_struct_conn.pdbx_ptnr2_PDB_ins_code +1 covale sing B B LEU CSD 146 147 C N . . +2 covale sing B B CSD HIS 147 148 C N . . +3 covale sing B B TYR LLP 257 258 C N . . +4 covale sing B B LLP LEU 258 259 C N . . +5 covale sing C C LEU CSD 146 147 C N . . +6 covale sing C C CSD HIS 147 148 C N . . +7 covale sing C C TYR LLP 257 258 C N . . +8 covale sing C C LLP LEU 258 259 C N . . +# +loop_ +_chem_comp_bond.pdbx_ordinal +_chem_comp_bond.comp_id +_chem_comp_bond.atom_id_1 +_chem_comp_bond.atom_id_2 +_chem_comp_bond.value_order +_chem_comp_bond.pdbx_aromatic_flag +_chem_comp_bond.pdbx_stereo_config +1 THR N CA SING N ? +2 THR CA C SING N ? +3 THR CA CB SING N ? +4 THR C O DOUB N ? +5 PRO N CA SING N ? +6 PRO N CD SING N ? +7 PRO CA C SING N ? +8 PRO CA CB SING N ? +9 PRO C O DOUB N ? +10 PRO CB CG SING N ? +11 PRO CG CD SING N ? +12 LEU N CA SING N ? +13 LEU CA C SING N ? +14 LEU CA CB SING N ? +15 LEU C O DOUB N ? +16 LEU CB CG SING N ? +17 LEU CG CD1 SING N ? +18 LEU CG CD2 SING N ? +19 GLU N CA SING N ? +20 GLU CA C SING N ? +21 GLU CA CB SING N ? +22 GLU C O DOUB N ? +23 GLU CB CG SING N ? +24 GLU CG CD SING N ? +25 GLU CD OE1 DOUB N ? +26 GLU CD OE2 SING N ? +27 ASN N CA SING N ? +28 ASN CA C SING N ? +29 ASN CA CB SING N ? +30 ASN C O DOUB N ? +31 ASN CB CG SING N ? +32 ASN CG OD1 DOUB N ? +33 ASN CG ND2 SING N ? +34 VAL N CA SING N ? +35 VAL CA C SING N ? +36 VAL CA CB SING N ? +37 VAL C O DOUB N ? +38 VAL CB CG1 SING N ? +39 VAL CB CG2 SING N ? +40 ILE N CA SING N ? +41 ILE CA C SING N ? +42 ILE CA CB SING N ? +43 ILE C O DOUB N ? +44 ILE CB CG1 SING N ? +45 ILE CB CG2 SING N ? +46 ILE CG1 CD1 SING N ? +47 ASP N CA SING N ? +48 ASP CA C SING N ? +49 ASP CA CB SING N ? +50 ASP C O DOUB N ? +51 ASP CB CG SING N ? +52 ASP CG OD1 DOUB N ? +53 ASP CG OD2 SING N ? +54 SER N CA SING N ? +55 SER CA C SING N ? +56 SER CA CB SING N ? +57 SER C O DOUB N ? +58 SER CB OG SING N ? +59 ARG N CA SING N ? +60 ARG CA C SING N ? +61 ARG CA CB SING N ? +62 ARG C O DOUB N ? +63 ARG CB CG SING N ? +64 ARG CG CD SING N ? +65 ARG CD NE SING N ? +66 ARG NE CZ SING N ? +67 ARG CZ NH1 SING N ? +68 ARG CZ NH2 DOUB N ? +69 HIS N CA SING N ? +70 HIS CA C SING N ? +71 HIS CA CB SING N ? +72 HIS C O DOUB N ? +73 HIS CB CG SING N ? +74 HIS CG ND1 SING Y ? +75 HIS CG CD2 DOUB Y ? +76 HIS ND1 CE1 DOUB Y ? +77 HIS CD2 NE2 SING Y ? +78 HIS CE1 NE2 SING Y ? +79 PHE N CA SING N ? +80 PHE CA C SING N ? +81 PHE CA CB SING N ? +82 PHE C O DOUB N ? +83 PHE CB CG SING N ? +84 PHE CG CD1 DOUB Y ? +85 PHE CG CD2 SING Y ? +86 PHE CD1 CE1 SING Y ? +87 PHE CD2 CE2 DOUB Y ? +88 PHE CE1 CZ DOUB Y ? +89 PHE CE2 CZ SING Y ? +90 GLY N CA SING N ? +91 GLY CA C SING N ? +92 GLY C O DOUB N ? +93 THR CB OG1 SING N ? +94 THR CB CG2 SING N ? +95 ALA N CA SING N ? +96 ALA CA C SING N ? +97 ALA CA CB SING N ? +98 ALA C O DOUB N ? +99 LYS N CA SING N ? +100 LYS CA C SING N ? +101 LYS CA CB SING N ? +102 LYS C O DOUB N ? +103 LYS CB CG SING N ? +104 LYS CG CD SING N ? +105 LYS CD CE SING N ? +106 LYS CE NZ SING N ? +107 MET N CA SING N ? +108 MET CA C SING N ? +109 MET CA CB SING N ? +110 MET C O DOUB N ? +111 MET CB CG SING N ? +112 MET CG SD SING N ? +113 MET SD CE SING N ? +114 TYR N CA SING N ? +115 TYR CA C SING N ? +116 TYR CA CB SING N ? +117 TYR C O DOUB N ? +118 TYR CB CG SING N ? +119 TYR CG CD1 DOUB Y ? +120 TYR CG CD2 SING Y ? +121 TYR CD1 CE1 SING Y ? +122 TYR CD2 CE2 DOUB Y ? +123 TYR CE1 CZ DOUB Y ? +124 TYR CE2 CZ SING Y ? +125 TYR CZ OH SING N ? +126 GLN N CA SING N ? +127 GLN CA C SING N ? +128 GLN CA CB SING N ? +129 GLN C O DOUB N ? +130 GLN CB CG SING N ? +131 GLN CG CD SING N ? +132 GLN CD OE1 DOUB N ? +133 GLN CD NE2 SING N ? +134 CYS N CA SING N ? +135 CYS CA C SING N ? +136 CYS CA CB SING N ? +137 CYS C O DOUB N ? +138 CYS CB SG SING N ? +139 CSD N CA SING N ? +140 CSD CA CB SING N ? +141 CSD CA C SING N ? +142 CSD CB SG SING N ? +143 CSD SG OD1 DOUB N ? +144 CSD SG OD2 SING N ? +145 CSD C O DOUB N ? +146 TRP N CA SING N ? +147 TRP CA C SING N ? +148 TRP CA CB SING N ? +149 TRP C O DOUB N ? +150 TRP CB CG SING N ? +151 TRP CG CD1 DOUB Y ? +152 TRP CG CD2 SING Y ? +153 TRP CD1 NE1 SING Y ? +154 TRP CD2 CE2 DOUB Y ? +155 TRP CD2 CE3 SING Y ? +156 TRP CE2 NE1 SING Y ? +157 TRP CE2 CZ2 SING Y ? +158 TRP CE3 CZ3 DOUB Y ? +159 TRP CZ2 CH2 DOUB Y ? +160 TRP CZ3 CH2 SING Y ? +161 LLP N1 C2 DOUB Y ? +162 LLP N1 C6 SING Y ? +163 LLP "C2'" C2 SING N ? +164 LLP C2 C3 SING Y ? +165 LLP C3 O3 SING N ? +166 LLP C3 C4 DOUB Y ? +167 LLP "C4'" C4 SING N ? +168 LLP C4 C5 SING Y ? +169 LLP NZ "C4'" DOUB N ? +170 LLP C5 C6 DOUB Y ? +171 LLP "C5'" C5 SING N ? +172 LLP "C5'" OP4 SING N ? +173 LLP P OP4 SING N ? +174 LLP P OP1 DOUB N ? +175 LLP P OP2 SING N ? +176 LLP P OP3 SING N ? +177 LLP N CA SING N ? +178 LLP CA CB SING N ? +179 LLP CA C SING N ? +180 LLP CB CG SING N ? +181 LLP CG CD SING N ? +182 LLP CD CE SING N ? +183 LLP CE NZ SING N ? +184 LLP C O DOUB N ? +# +loop_ +_atom_site.group_PDB +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.pdbx_PDB_model_num +_atom_site.id +ATOM N N . THR B 1 31 . 31 THR B N 55.623 5.084 79.442 1 1 +ATOM C CA . THR B 1 31 . 31 THR B CA 56.449 4.729 78.25 1 2 +ATOM C C . THR B 1 31 . 31 THR B C 55.91 5.459 77.019 1 3 +ATOM O O . THR B 1 31 . 31 THR B O 54.705 5.437 76.776 1 4 +ATOM C CB . THR B 1 31 . 31 THR B CB 56.471 3.21 78.031 1 5 +ATOM N N . PRO B 1 32 . 32 PRO B N 56.768 6.14 76.218 1 6 +ATOM C CA . PRO B 1 32 . 32 PRO B CA 56.339 6.725 74.945 1 7 +ATOM C C . PRO B 1 32 . 32 PRO B C 55.932 5.636 73.938 1 8 +ATOM O O . PRO B 1 32 . 32 PRO B O 56.685 4.694 73.773 1 9 +ATOM C CB . PRO B 1 32 . 32 PRO B CB 57.57 7.494 74.431 1 10 +ATOM C CG . PRO B 1 32 . 32 PRO B CG 58.49 7.615 75.633 1 11 +ATOM C CD . PRO B 1 32 . 32 PRO B CD 58.187 6.411 76.5 1 12 +ATOM N N . LEU B 1 33 . 33 LEU B N 54.764 5.785 73.304 1 13 +ATOM C CA . LEU B 1 33 . 33 LEU B CA 54.205 4.797 72.337 1 14 +ATOM C C . LEU B 1 33 . 33 LEU B C 55.109 4.719 71.101 1 15 +ATOM O O . LEU B 1 33 . 33 LEU B O 55.446 5.783 70.548 1 16 +ATOM C CB . LEU B 1 33 . 33 LEU B CB 52.777 5.197 71.951 1 17 +ATOM C CG . LEU B 1 33 . 33 LEU B CG 51.691 4.804 72.952 1 18 +ATOM C CD1 . LEU B 1 33 . 33 LEU B CD1 50.37 5.478 72.611 1 19 +ATOM C CD2 . LEU B 1 33 . 33 LEU B CD2 51.517 3.292 73.007 1 20 +ATOM N N . GLU B 1 34 . 34 GLU B N 55.473 3.498 70.695 1 21 +ATOM C CA . GLU B 1 34 . 34 GLU B CA 56.384 3.214 69.554 1 22 +ATOM C C . GLU B 1 34 . 34 GLU B C 55.635 2.398 68.494 1 23 +ATOM O O . GLU B 1 34 . 34 GLU B O 54.574 1.83 68.82 1 24 +ATOM C CB . GLU B 1 34 . 34 GLU B CB 57.619 2.45 70.036 1 25 +ATOM C CG . GLU B 1 34 . 34 GLU B CG 58.461 3.217 71.04 1 26 +ATOM C CD . GLU B 1 34 . 34 GLU B CD 59.599 2.415 71.648 1 27 +ATOM O OE1 . GLU B 1 34 . 34 GLU B OE1 60.011 1.412 71.03 1 28 +ATOM O OE2 . GLU B 1 34 . 34 GLU B OE2 60.072 2.796 72.739 1 29 +ATOM N N . ASN B 1 35 . 35 ASN B N 56.178 2.35 67.275 1 30 +ATOM C CA . ASN B 1 35 . 35 ASN B CA 55.682 1.494 66.166 1 31 +ATOM C C . ASN B 1 35 . 35 ASN B C 55.711 0.031 66.62 1 32 +ATOM O O . ASN B 1 35 . 35 ASN B O 56.666 -0.352 67.325 1 33 +ATOM C CB . ASN B 1 35 . 35 ASN B CB 56.501 1.695 64.887 1 34 +ATOM C CG . ASN B 1 35 . 35 ASN B CG 56.33 3.078 64.297 1 35 +ATOM N ND2 . ASN B 1 35 . 35 ASN B ND2 57.429 3.785 64.083 1 36 +ATOM O OD1 . ASN B 1 35 . 35 ASN B OD1 55.21 3.51 64.038 1 37 +ATOM N N . VAL B 1 36 . 36 VAL B N 54.688 -0.741 66.248 1 38 +ATOM C CA . VAL B 1 36 . 36 VAL B CA 54.606 -2.212 66.49 1 39 +ATOM C C . VAL B 1 36 . 36 VAL B C 55.228 -2.935 65.285 1 40 +ATOM O O . VAL B 1 36 . 36 VAL B O 55.959 -3.921 65.503 1 41 +ATOM C CB . VAL B 1 36 . 36 VAL B CB 53.153 -2.656 66.752 1 42 +ATOM C CG1 . VAL B 1 36 . 36 VAL B CG1 53.047 -4.157 66.981 1 43 +ATOM C CG2 . VAL B 1 36 . 36 VAL B CG2 52.536 -1.898 67.921 1 44 +ATOM N N . ILE B 1 37 . 37 ILE B N 54.957 -2.454 64.065 1 45 +ATOM C CA . ILE B 1 37 . 37 ILE B CA 55.515 -3.009 62.793 1 46 +ATOM C C . ILE B 1 37 . 37 ILE B C 57.002 -2.643 62.688 1 47 +ATOM O O . ILE B 1 37 . 37 ILE B O 57.43 -1.68 63.356 1 48 +ATOM C CB . ILE B 1 37 . 37 ILE B CB 54.741 -2.502 61.556 1 49 +ATOM C CG1 . ILE B 1 37 . 37 ILE B CG1 54.827 -0.98 61.4 1 50 +ATOM C CG2 . ILE B 1 37 . 37 ILE B CG2 53.298 -2.983 61.572 1 51 +ATOM C CD1 . ILE B 1 37 . 37 ILE B CD1 54.782 -0.506 59.968 1 52 +ATOM N N . ASP B 1 38 . 38 ASP B N 57.747 -3.385 61.865 1 53 +ATOM C CA . ASP B 1 38 . 38 ASP B CA 59.074 -2.973 61.332 1 54 +ATOM C C . ASP B 1 38 . 38 ASP B C 58.82 -2.016 60.161 1 55 +ATOM O O . ASP B 1 38 . 38 ASP B O 58.408 -2.503 59.089 1 56 +ATOM C CB . ASP B 1 38 . 38 ASP B CB 59.91 -4.19 60.919 1 57 +ATOM C CG . ASP B 1 38 . 38 ASP B CG 61.31 -3.865 60.415 1 58 +ATOM O OD1 . ASP B 1 38 . 38 ASP B OD1 61.669 -2.666 60.379 1 59 +ATOM O OD2 . ASP B 1 38 . 38 ASP B OD2 62.037 -4.817 60.065 1 60 +ATOM N N . VAL B 1 39 . 39 VAL B N 59.043 -0.712 60.371 1 61 +ATOM C CA . VAL B 1 39 . 39 VAL B CA 58.7 0.386 59.413 1 62 +ATOM C C . VAL B 1 39 . 39 VAL B C 59.35 0.09 58.052 1 63 +ATOM O O . VAL B 1 39 . 39 VAL B O 58.645 0.187 57.027 1 64 +ATOM C CB . VAL B 1 39 . 39 VAL B CB 59.121 1.766 59.964 1 65 +ATOM C CG1 . VAL B 1 39 . 39 VAL B CG1 58.958 2.874 58.931 1 66 +ATOM C CG2 . VAL B 1 39 . 39 VAL B CG2 58.363 2.119 61.238 1 67 +ATOM N N . GLU B 1 40 . 40 GLU B N 60.634 -0.283 58.051 1 68 +ATOM C CA . GLU B 1 40 . 40 GLU B CA 61.454 -0.495 56.826 1 69 +ATOM C C . GLU B 1 40 . 40 GLU B C 60.914 -1.684 56.02 1 70 +ATOM O O . GLU B 1 40 . 40 GLU B O 60.832 -1.553 54.783 1 71 +ATOM C CB . GLU B 1 40 . 40 GLU B CB 62.924 -0.728 57.186 1 72 +ATOM C CG . GLU B 1 40 . 40 GLU B CG 63.86 -0.603 55.993 1 73 +ATOM C CD . GLU B 1 40 . 40 GLU B CD 65.24 -1.218 56.163 1 74 +ATOM O OE1 . GLU B 1 40 . 40 GLU B OE1 65.526 -1.759 57.254 1 75 +ATOM O OE2 . GLU B 1 40 . 40 GLU B OE2 66.033 -1.153 55.197 1 76 +ATOM N N . SER B 1 41 . 41 SER B N 60.592 -2.798 56.688 1 77 +ATOM C CA . SER B 1 41 . 41 SER B CA 60.117 -4.067 56.069 1 78 +ATOM C C . SER B 1 41 . 41 SER B C 58.778 -3.836 55.359 1 79 +ATOM O O . SER B 1 41 . 41 SER B O 58.669 -4.208 54.176 1 80 +ATOM C CB . SER B 1 41 . 41 SER B CB 59.999 -5.173 57.085 1 81 +ATOM O OG . SER B 1 41 . 41 SER B OG 61.272 -5.523 57.61 1 82 +ATOM N N . VAL B 1 42 . 42 VAL B N 57.812 -3.243 56.065 1 83 +ATOM C CA . VAL B 1 42 . 42 VAL B CA 56.442 -2.935 55.553 1 84 +ATOM C C . VAL B 1 42 . 42 VAL B C 56.561 -1.974 54.362 1 85 +ATOM O O . VAL B 1 42 . 42 VAL B O 56.03 -2.307 53.291 1 86 +ATOM C CB . VAL B 1 42 . 42 VAL B CB 55.546 -2.366 56.672 1 87 +ATOM C CG1 . VAL B 1 42 . 42 VAL B CG1 54.27 -1.737 56.131 1 88 +ATOM C CG2 . VAL B 1 42 . 42 VAL B CG2 55.22 -3.431 57.708 1 89 +ATOM N N . ARG B 1 43 . 43 ARG B N 57.266 -0.852 54.539 1 90 +ATOM C CA . ARG B 1 43 . 43 ARG B CA 57.358 0.258 53.549 1 91 +ATOM C C . ARG B 1 43 . 43 ARG B C 58.215 -0.143 52.34 1 92 +ATOM O O . ARG B 1 43 . 43 ARG B O 58.167 0.594 51.334 1 93 +ATOM C CB . ARG B 1 43 . 43 ARG B CB 57.907 1.524 54.211 1 94 +ATOM C CG . ARG B 1 43 . 43 ARG B CG 56.919 2.186 55.16 1 95 +ATOM C CD . ARG B 1 43 . 43 ARG B CD 57.249 3.639 55.433 1 96 +ATOM N NE . ARG B 1 43 . 43 ARG B NE 56.073 4.34 55.926 1 97 +ATOM C CZ . ARG B 1 43 . 43 ARG B CZ 55.939 5.66 55.999 1 98 +ATOM N NH1 . ARG B 1 43 . 43 ARG B NH1 56.916 6.464 55.612 1 99 +ATOM N NH2 . ARG B 1 43 . 43 ARG B NH2 54.811 6.173 56.456 1 100 +ATOM N N . SER B 1 44 . 44 SER B N 58.957 -1.255 52.415 1 101 +ATOM C CA . SER B 1 44 . 44 SER B CA 59.684 -1.85 51.259 1 102 +ATOM C C . SER B 1 44 . 44 SER B C 58.676 -2.383 50.229 1 103 +ATOM O O . SER B 1 44 . 44 SER B O 59.072 -2.55 49.06 1 104 +ATOM C CB . SER B 1 44 . 44 SER B CB 60.661 -2.924 51.69 1 105 +ATOM O OG . SER B 1 44 . 44 SER B OG 59.997 -4.15 51.963 1 106 +ATOM N N . HIS B 1 45 . 45 HIS B N 57.427 -2.634 50.648 1 107 +ATOM C CA . HIS B 1 45 . 45 HIS B CA 56.311 -3.107 49.784 1 108 +ATOM C C . HIS B 1 45 . 45 HIS B C 55.572 -1.921 49.144 1 109 +ATOM O O . HIS B 1 45 . 45 HIS B O 54.721 -2.174 48.268 1 110 +ATOM C CB . HIS B 1 45 . 45 HIS B CB 55.353 -4.003 50.583 1 111 +ATOM C CG . HIS B 1 45 . 45 HIS B CG 55.988 -5.238 51.125 1 112 +ATOM C CD2 . HIS B 1 45 . 45 HIS B CD2 56.634 -5.474 52.287 1 113 +ATOM N ND1 . HIS B 1 45 . 45 HIS B ND1 55.993 -6.43 50.433 1 114 +ATOM C CE1 . HIS B 1 45 . 45 HIS B CE1 56.624 -7.346 51.14 1 115 +ATOM N NE2 . HIS B 1 45 . 45 HIS B NE2 57.025 -6.785 52.286 1 116 +ATOM N N . PHE B 1 46 . 46 PHE B N 55.88 -0.684 49.556 1 117 +ATOM C CA . PHE B 1 46 . 46 PHE B CA 55.186 0.561 49.126 1 118 +ATOM C C . PHE B 1 46 . 46 PHE B C 56.228 1.636 48.818 1 119 +ATOM O O . PHE B 1 46 . 46 PHE B O 56.373 2.598 49.569 1 120 +ATOM C CB . PHE B 1 46 . 46 PHE B CB 54.191 0.998 50.205 1 121 +ATOM C CG . PHE B 1 46 . 46 PHE B CG 53.217 -0.072 50.631 1 122 +ATOM C CD1 . PHE B 1 46 . 46 PHE B CD1 52.105 -0.37 49.858 1 123 +ATOM C CD2 . PHE B 1 46 . 46 PHE B CD2 53.416 -0.789 51.801 1 124 +ATOM C CE1 . PHE B 1 46 . 46 PHE B CE1 51.211 -1.354 50.253 1 125 +ATOM C CE2 . PHE B 1 46 . 46 PHE B CE2 52.523 -1.774 52.193 1 126 +ATOM C CZ . PHE B 1 46 . 46 PHE B CZ 51.419 -2.053 51.42 1 127 +ATOM N N . PRO B 1 47 . 47 PRO B N 56.957 1.519 47.683 1 128 +ATOM C CA . PRO B 1 47 . 47 PRO B CA 58.179 2.296 47.445 1 129 +ATOM C C . PRO B 1 47 . 47 PRO B C 58.054 3.829 47.499 1 130 +ATOM O O . PRO B 1 47 . 47 PRO B O 59.033 4.465 47.845 1 131 +ATOM C CB . PRO B 1 47 . 47 PRO B CB 58.593 1.881 46.021 1 132 +ATOM C CG . PRO B 1 47 . 47 PRO B CG 57.963 0.517 45.83 1 133 +ATOM C CD . PRO B 1 47 . 47 PRO B CD 56.643 0.618 46.561 1 134 +ATOM N N . VAL B 1 48 . 48 VAL B N 56.884 4.383 47.163 1 135 +ATOM C CA . VAL B 1 48 . 48 VAL B CA 56.65 5.856 47.042 1 136 +ATOM C C . VAL B 1 48 . 48 VAL B C 56.565 6.499 48.438 1 137 +ATOM O O . VAL B 1 48 . 48 VAL B O 56.742 7.732 48.52 1 138 +ATOM C CB . VAL B 1 48 . 48 VAL B CB 55.397 6.158 46.193 1 139 +ATOM C CG1 . VAL B 1 48 . 48 VAL B CG1 54.105 5.878 46.945 1 140 +ATOM C CG2 . VAL B 1 48 . 48 VAL B CG2 55.404 7.582 45.653 1 141 +ATOM N N . LEU B 1 49 . 49 LEU B N 56.322 5.71 49.493 1 142 +ATOM C CA . LEU B 1 49 . 49 LEU B CA 56.178 6.2 50.897 1 143 +ATOM C C . LEU B 1 49 . 49 LEU B C 57.516 6.713 51.458 1 144 +ATOM O O . LEU B 1 49 . 49 LEU B O 57.505 7.248 52.583 1 145 +ATOM C CB . LEU B 1 49 . 49 LEU B CB 55.613 5.078 51.777 1 146 +ATOM C CG . LEU B 1 49 . 49 LEU B CG 54.133 4.755 51.571 1 147 +ATOM C CD1 . LEU B 1 49 . 49 LEU B CD1 53.703 3.599 52.46 1 148 +ATOM C CD2 . LEU B 1 49 . 49 LEU B CD2 53.261 5.973 51.836 1 149 +ATOM N N . GLY B 1 50 . 50 GLY B N 58.623 6.576 50.723 1 150 +ATOM C CA . GLY B 1 50 . 50 GLY B CA 59.933 7.137 51.115 1 151 +ATOM C C . GLY B 1 50 . 50 GLY B C 59.98 8.66 51.033 1 152 +ATOM O O . GLY B 1 50 . 50 GLY B O 60.875 9.245 51.676 1 153 +ATOM N N . GLY B 1 51 . 51 GLY B N 59.045 9.283 50.3 1 154 +ATOM C CA . GLY B 1 51 . 51 GLY B CA 59.115 10.688 49.846 1 155 +ATOM C C . GLY B 1 51 . 51 GLY B C 58.768 11.704 50.926 1 156 +ATOM O O . GLY B 1 51 . 51 GLY B O 58.841 11.358 52.125 1 157 +ATOM N N . GLU B 1 52 . 52 GLU B N 58.409 12.922 50.498 1 158 +ATOM C CA . GLU B 1 52 . 52 GLU B CA 58.225 14.128 51.353 1 159 +ATOM C C . GLU B 1 52 . 52 GLU B C 56.819 14.148 51.967 1 160 +ATOM O O . GLU B 1 52 . 52 GLU B O 56.662 14.753 53.05 1 161 +ATOM C CB . GLU B 1 52 . 52 GLU B CB 58.454 15.397 50.528 1 162 +ATOM N N . THR B 1 53 . 53 THR B N 55.841 13.526 51.3 1 163 +ATOM C CA . THR B 1 53 . 53 THR B CA 54.405 13.531 51.689 1 164 +ATOM C C . THR B 1 53 . 53 THR B C 54.057 12.216 52.397 1 165 +ATOM O O . THR B 1 53 . 53 THR B O 54.299 11.145 51.812 1 166 +ATOM C CB . THR B 1 53 . 53 THR B CB 53.505 13.764 50.467 1 167 +ATOM C CG2 . THR B 1 53 . 53 THR B CG2 52.07 14.066 50.839 1 168 +ATOM O OG1 . THR B 1 53 . 53 THR B OG1 54.033 14.855 49.713 1 169 +ATOM N N . ALA B 1 54 . 54 ALA B N 53.506 12.306 53.611 1 170 +ATOM C CA . ALA B 1 54 . 54 ALA B CA 52.897 11.179 54.357 1 171 +ATOM C C . ALA B 1 54 . 54 ALA B C 51.457 10.98 53.87 1 172 +ATOM O O . ALA B 1 54 . 54 ALA B O 50.733 11.987 53.745 1 173 +ATOM C CB . ALA B 1 54 . 54 ALA B CB 52.943 11.453 55.838 1 174 +ATOM N N . ALA B 1 55 . 55 ALA B N 51.066 9.732 53.607 1 175 +ATOM C CA . ALA B 1 55 . 55 ALA B CA 49.731 9.346 53.091 1 176 +ATOM C C . ALA B 1 55 . 55 ALA B C 48.759 9.188 54.266 1 177 +ATOM O O . ALA B 1 55 . 55 ALA B O 48.812 8.137 54.929 1 178 +ATOM C CB . ALA B 1 55 . 55 ALA B CB 49.843 8.078 52.279 1 179 +ATOM N N . PHE B 1 56 . 56 PHE B N 47.918 10.202 54.507 1 180 +ATOM C CA . PHE B 1 56 . 56 PHE B CA 46.958 10.296 55.642 1 181 +ATOM C C . PHE B 1 56 . 56 PHE B C 45.52 10.416 55.118 1 182 +ATOM O O . PHE B 1 56 . 56 PHE B O 44.649 10.895 55.875 1 183 +ATOM C CB . PHE B 1 56 . 56 PHE B CB 47.324 11.486 56.536 1 184 +ATOM C CG . PHE B 1 56 . 56 PHE B CG 48.351 11.217 57.609 1 185 +ATOM C CD1 . PHE B 1 56 . 56 PHE B CD1 49.206 10.126 57.543 1 186 +ATOM C CD2 . PHE B 1 56 . 56 PHE B CD2 48.476 12.083 58.685 1 187 +ATOM C CE1 . PHE B 1 56 . 56 PHE B CE1 50.143 9.897 58.539 1 188 +ATOM C CE2 . PHE B 1 56 . 56 PHE B CE2 49.414 11.853 59.68 1 189 +ATOM C CZ . PHE B 1 56 . 56 PHE B CZ 50.244 10.759 59.606 1 190 +ATOM N N . ASN B 1 57 . 57 ASN B N 45.275 9.999 53.871 1 191 +ATOM C CA . ASN B 1 57 . 57 ASN B CA 43.925 9.982 53.244 1 192 +ATOM C C . ASN B 1 57 . 57 ASN B C 43.695 8.605 52.612 1 193 +ATOM O O . ASN B 1 57 . 57 ASN B O 43.227 8.546 51.458 1 194 +ATOM C CB . ASN B 1 57 . 57 ASN B CB 43.75 11.119 52.235 1 195 +ATOM C CG . ASN B 1 57 . 57 ASN B CG 42.308 11.299 51.814 1 196 +ATOM N ND2 . ASN B 1 57 . 57 ASN B ND2 42.093 11.772 50.597 1 197 +ATOM O OD1 . ASN B 1 57 . 57 ASN B OD1 41.393 10.999 52.579 1 198 +ATOM N N . ASN B 1 58 . 58 ASN B N 43.998 7.544 53.365 1 199 +ATOM C CA . ASN B 1 58 . 58 ASN B CA 43.985 6.129 52.906 1 200 +ATOM C C . ASN B 1 58 . 58 ASN B C 42.543 5.649 52.678 1 201 +ATOM O O . ASN B 1 58 . 58 ASN B O 42.375 4.68 51.915 1 202 +ATOM C CB . ASN B 1 58 . 58 ASN B CB 44.749 5.234 53.884 1 203 +ATOM C CG . ASN B 1 58 . 58 ASN B CG 46.239 5.503 53.851 1 204 +ATOM N ND2 . ASN B 1 58 . 58 ASN B ND2 46.793 5.991 54.953 1 205 +ATOM O OD1 . ASN B 1 58 . 58 ASN B OD1 46.886 5.277 52.828 1 206 +ATOM N N . ALA B 1 59 . 59 ALA B N 41.546 6.308 53.279 1 207 +ATOM C CA . ALA B 1 59 . 59 ALA B CA 40.106 5.988 53.113 1 208 +ATOM C C . ALA B 1 59 . 59 ALA B C 39.595 6.448 51.735 1 209 +ATOM O O . ALA B 1 59 . 59 ALA B O 38.458 6.07 51.384 1 210 +ATOM C CB . ALA B 1 59 . 59 ALA B CB 39.301 6.606 54.23 1 211 +ATOM N N . SER B 1 60 . 60 SER B N 40.38 7.236 50.988 1 212 +ATOM C CA . SER B 1 60 . 60 SER B CA 40.07 7.654 49.591 1 213 +ATOM C C . SER B 1 60 . 60 SER B C 40.899 6.842 48.585 1 214 +ATOM O O . SER B 1 60 . 60 SER B O 40.608 6.935 47.376 1 215 +ATOM C CB . SER B 1 60 . 60 SER B CB 40.276 9.137 49.397 1 216 +ATOM O OG . SER B 1 60 . 60 SER B OG 39.209 9.877 49.975 1 217 +ATOM N N . GLY B 1 61 . 61 GLY B N 41.889 6.08 49.059 1 218 +ATOM C CA . GLY B 1 61 . 61 GLY B CA 42.772 5.256 48.212 1 219 +ATOM C C . GLY B 1 61 . 61 GLY B C 44.139 5.063 48.843 1 220 +ATOM O O . GLY B 1 61 . 61 GLY B O 44.727 6.064 49.3 1 221 +ATOM N N . THR B 1 62 . 62 THR B N 44.626 3.82 48.864 1 222 +ATOM C CA . THR B 1 62 . 62 THR B CA 45.938 3.422 49.439 1 223 +ATOM C C . THR B 1 62 . 62 THR B C 46.991 3.333 48.329 1 224 +ATOM O O . THR B 1 62 . 62 THR B O 46.609 3.19 47.147 1 225 +ATOM C CB . THR B 1 62 . 62 THR B CB 45.837 2.082 50.18 1 226 +ATOM C CG2 . THR B 1 62 . 62 THR B CG2 45.069 2.189 51.48 1 227 +ATOM O OG1 . THR B 1 62 . 62 THR B OG1 45.203 1.133 49.32 1 228 +ATOM N N . VAL B 1 63 . 63 VAL B N 48.269 3.403 48.707 1 229 +ATOM C CA . VAL B 1 63 . 63 VAL B CA 49.415 3.072 47.81 1 230 +ATOM C C . VAL B 1 63 . 63 VAL B C 49.289 1.586 47.456 1 231 +ATOM O O . VAL B 1 63 . 63 VAL B O 48.992 0.784 48.369 1 232 +ATOM C CB . VAL B 1 63 . 63 VAL B CB 50.777 3.375 48.463 1 233 +ATOM C CG1 . VAL B 1 63 . 63 VAL B CG1 51.928 3.049 47.525 1 234 +ATOM C CG2 . VAL B 1 63 . 63 VAL B CG2 50.876 4.815 48.95 1 235 +ATOM N N . VAL B 1 64 . 64 VAL B N 49.499 1.231 46.187 1 236 +ATOM C CA . VAL B 1 64 . 64 VAL B CA 49.328 -0.166 45.692 1 237 +ATOM C C . VAL B 1 64 . 64 VAL B C 50.525 -1.002 46.164 1 238 +ATOM O O . VAL B 1 64 . 64 VAL B O 51.658 -0.479 46.164 1 239 +ATOM C CB . VAL B 1 64 . 64 VAL B CB 49.152 -0.218 44.162 1 240 +ATOM C CG1 . VAL B 1 64 . 64 VAL B CG1 48.994 -1.648 43.664 1 241 +ATOM C CG2 . VAL B 1 64 . 64 VAL B CG2 47.981 0.643 43.702 1 242 +ATOM N N . LEU B 1 65 . 65 LEU B N 50.262 -2.25 46.567 1 243 +ATOM C CA . LEU B 1 65 . 65 LEU B CA 51.293 -3.234 46.991 1 244 +ATOM C C . LEU B 1 65 . 65 LEU B C 52.183 -3.567 45.784 1 245 +ATOM O O . LEU B 1 65 . 65 LEU B O 51.636 -3.868 44.701 1 246 +ATOM C CB . LEU B 1 65 . 65 LEU B CB 50.593 -4.48 47.547 1 247 +ATOM C CG . LEU B 1 65 . 65 LEU B CG 51.499 -5.504 48.232 1 248 +ATOM C CD1 . LEU B 1 65 . 65 LEU B CD1 51.987 -4.989 49.576 1 249 +ATOM C CD2 . LEU B 1 65 . 65 LEU B CD2 50.784 -6.835 48.408 1 250 +ATOM N N . LYS B 1 66 . 66 LYS B N 53.505 -3.487 45.964 1 251 +ATOM C CA . LYS B 1 66 . 66 LYS B CA 54.525 -3.796 44.924 1 252 +ATOM C C . LYS B 1 66 . 66 LYS B C 54.252 -5.191 44.345 1 253 +ATOM O O . LYS B 1 66 . 66 LYS B O 54.272 -5.33 43.103 1 254 +ATOM C CB . LYS B 1 66 . 66 LYS B CB 55.929 -3.704 45.531 1 255 +ATOM C CG . LYS B 1 66 . 66 LYS B CG 57.088 -3.965 44.577 1 256 +ATOM C CD . LYS B 1 66 . 66 LYS B CD 58.425 -4.127 45.28 1 257 +ATOM C CE . LYS B 1 66 . 66 LYS B CE 59.596 -3.624 44.461 1 258 +ATOM N NZ . LYS B 1 66 . 66 LYS B NZ 59.612 -4.211 43.099 1 259 +ATOM N N . GLU B 1 67 . 67 GLU B N 53.978 -6.172 45.214 1 260 +ATOM C CA . GLU B 1 67 . 67 GLU B CA 53.802 -7.607 44.847 1 261 +ATOM C C . GLU B 1 67 . 67 GLU B C 52.593 -7.743 43.911 1 262 +ATOM O O . GLU B 1 67 . 67 GLU B O 52.621 -8.617 43.024 1 263 +ATOM C CB . GLU B 1 67 . 67 GLU B CB 53.615 -8.503 46.079 1 264 +ATOM C CG . GLU B 1 67 . 67 GLU B CG 54.398 -8.084 47.319 1 265 +ATOM C CD . GLU B 1 67 . 67 GLU B CD 55.875 -7.799 47.117 1 266 +ATOM O OE1 . GLU B 1 67 . 67 GLU B OE1 56.501 -8.478 46.278 1 267 +ATOM O OE2 . GLU B 1 67 . 67 GLU B OE2 56.395 -6.896 47.798 1 268 +ATOM N N . ALA B 1 68 . 68 ALA B N 51.568 -6.911 44.116 1 269 +ATOM C CA . ALA B 1 68 . 68 ALA B CA 50.328 -6.875 43.306 1 270 +ATOM C C . ALA B 1 68 . 68 ALA B C 50.643 -6.37 41.892 1 271 +ATOM O O . ALA B 1 68 . 68 ALA B O 50.165 -6.992 40.928 1 272 +ATOM C CB . ALA B 1 68 . 68 ALA B CB 49.292 -6.017 43.992 1 273 +ATOM N N . ILE B 1 69 . 69 ILE B N 51.424 -5.29 41.774 1 274 +ATOM C CA . ILE B 1 69 . 69 ILE B CA 51.814 -4.674 40.468 1 275 +ATOM C C . ILE B 1 69 . 69 ILE B C 52.649 -5.689 39.673 1 276 +ATOM O O . ILE B 1 69 . 69 ILE B O 52.408 -5.824 38.457 1 277 +ATOM C CB . ILE B 1 69 . 69 ILE B CB 52.558 -3.338 40.675 1 278 +ATOM C CG1 . ILE B 1 69 . 69 ILE B CG1 51.673 -2.297 41.368 1 279 +ATOM C CG2 . ILE B 1 69 . 69 ILE B CG2 53.102 -2.811 39.355 1 280 +ATOM C CD1 . ILE B 1 69 . 69 ILE B CD1 52.432 -1.101 41.902 1 281 +ATOM N N . GLU B 1 70 . 70 GLU B N 53.59 -6.372 40.333 1 282 +ATOM C CA . GLU B 1 70 . 70 GLU B CA 54.462 -7.417 39.725 1 283 +ATOM C C . GLU B 1 70 . 70 GLU B C 53.589 -8.545 39.155 1 284 +ATOM O O . GLU B 1 70 . 70 GLU B O 53.918 -9.046 38.063 1 285 +ATOM C CB . GLU B 1 70 . 70 GLU B CB 55.455 -7.97 40.753 1 286 +ATOM C CG . GLU B 1 70 . 70 GLU B CG 56.544 -6.986 41.158 1 287 +ATOM C CD . GLU B 1 70 . 70 GLU B CD 57.44 -7.441 42.305 1 288 +ATOM O OE1 . GLU B 1 70 . 70 GLU B OE1 56.936 -8.121 43.224 1 289 +ATOM O OE2 . GLU B 1 70 . 70 GLU B OE2 58.649 -7.118 42.278 1 290 +ATOM N N . SER B 1 71 . 71 SER B N 52.521 -8.923 39.866 1 291 +ATOM C CA . SER B 1 71 . 71 SER B CA 51.594 -10.028 39.502 1 292 +ATOM C C . SER B 1 71 . 71 SER B C 50.836 -9.689 38.21 1 293 +ATOM O O . SER B 1 71 . 71 SER B O 50.711 -10.589 37.357 1 294 +ATOM C CB . SER B 1 71 . 71 SER B CB 50.648 -10.337 40.635 1 295 +ATOM O OG . SER B 1 71 . 71 SER B OG 49.811 -11.442 40.32 1 296 +ATOM N N . THR B 1 72 . 72 THR B N 50.359 -8.445 38.07 1 297 +ATOM C CA . THR B 1 72 . 72 THR B CA 49.518 -7.983 36.927 1 298 +ATOM C C . THR B 1 72 . 72 THR B C 50.398 -7.745 35.692 1 299 +ATOM O O . THR B 1 72 . 72 THR B O 49.92 -8.038 34.58 1 300 +ATOM C CB . THR B 1 72 . 72 THR B CB 48.665 -6.76 37.3 1 301 +ATOM C CG2 . THR B 1 72 . 72 THR B CG2 49.449 -5.476 37.462 1 302 +ATOM O OG1 . THR B 1 72 . 72 THR B OG1 47.685 -6.581 36.278 1 303 +ATOM N N . SER B 1 73 . 73 SER B N 51.625 -7.24 35.875 1 304 +ATOM C CA . SER B 1 73 . 73 SER B CA 52.642 -7.068 34.802 1 305 +ATOM C C . SER B 1 73 . 73 SER B C 52.956 -8.427 34.168 1 306 +ATOM O O . SER B 1 73 . 73 SER B O 52.934 -8.52 32.924 1 307 +ATOM C CB . SER B 1 73 . 73 SER B CB 53.896 -6.414 35.325 1 308 +ATOM O OG . SER B 1 73 . 73 SER B OG 53.624 -5.093 35.768 1 309 +ATOM N N . ASN B 1 74 . 74 ASN B N 53.226 -9.437 35.002 1 310 +ATOM C CA . ASN B 1 74 . 74 ASN B CA 53.552 -10.827 34.581 1 311 +ATOM C C . ASN B 1 74 . 74 ASN B C 52.387 -11.415 33.772 1 312 +ATOM O O . ASN B 1 74 . 74 ASN B O 52.669 -12.073 32.751 1 313 +ATOM C CB . ASN B 1 74 . 74 ASN B CB 53.915 -11.71 35.779 1 314 +ATOM C CG . ASN B 1 74 . 74 ASN B CG 55.276 -11.386 36.361 1 315 +ATOM N ND2 . ASN B 1 74 . 74 ASN B ND2 55.538 -11.861 37.568 1 316 +ATOM O OD1 . ASN B 1 74 . 74 ASN B OD1 56.091 -10.716 35.727 1 317 +ATOM N N . PHE B 1 75 . 75 PHE B N 51.138 -11.185 34.196 1 318 +ATOM C CA . PHE B 1 75 . 75 PHE B CA 49.918 -11.67 33.495 1 319 +ATOM C C . PHE B 1 75 . 75 PHE B C 49.787 -10.981 32.129 1 320 +ATOM O O . PHE B 1 75 . 75 PHE B O 49.487 -11.673 31.139 1 321 +ATOM C CB . PHE B 1 75 . 75 PHE B CB 48.648 -11.442 34.322 1 322 +ATOM C CG . PHE B 1 75 . 75 PHE B CG 47.381 -11.832 33.601 1 323 +ATOM C CD1 . PHE B 1 75 . 75 PHE B CD1 47.009 -13.164 33.489 1 324 +ATOM C CD2 . PHE B 1 75 . 75 PHE B CD2 46.58 -10.873 32.996 1 325 +ATOM C CE1 . PHE B 1 75 . 75 PHE B CE1 45.854 -13.527 32.809 1 326 +ATOM C CE2 . PHE B 1 75 . 75 PHE B CE2 45.427 -11.237 32.317 1 327 +ATOM C CZ . PHE B 1 75 . 75 PHE B CZ 45.066 -12.563 32.225 1 328 +ATOM N N . MET B 1 76 . 76 MET B N 50.001 -9.661 32.082 1 329 +ATOM C CA . MET B 1 76 . 76 MET B CA 49.763 -8.816 30.879 1 330 +ATOM C C . MET B 1 76 . 76 MET B C 50.904 -8.993 29.865 1 331 +ATOM O O . MET B 1 76 . 76 MET B O 50.655 -8.74 28.67 1 332 +ATOM C CB . MET B 1 76 . 76 MET B CB 49.6 -7.341 31.266 1 333 +ATOM C CG . MET B 1 76 . 76 MET B CG 48.264 -7.058 31.948 1 334 +ATOM S SD . MET B 1 76 . 76 MET B SD 47.901 -5.298 32.226 1 335 +ATOM C CE . MET B 1 76 . 76 MET B CE 49.099 -4.894 33.495 1 336 +ATOM N N . TYR B 1 77 . 77 TYR B N 52.088 -9.435 30.311 1 337 +ATOM C CA . TYR B 1 77 . 77 TYR B CA 53.242 -9.797 29.441 1 338 +ATOM C C . TYR B 1 77 . 77 TYR B C 53.118 -11.254 28.971 1 339 +ATOM O O . TYR B 1 77 . 77 TYR B O 53.86 -11.64 28.049 1 340 +ATOM C CB . TYR B 1 77 . 77 TYR B CB 54.578 -9.578 30.161 1 341 +ATOM C CG . TYR B 1 77 . 77 TYR B CG 55.133 -8.178 30.07 1 342 +ATOM C CD1 . TYR B 1 77 . 77 TYR B CD1 55.412 -7.597 28.842 1 343 +ATOM C CD2 . TYR B 1 77 . 77 TYR B CD2 55.402 -7.435 31.21 1 344 +ATOM C CE1 . TYR B 1 77 . 77 TYR B CE1 55.926 -6.314 28.747 1 345 +ATOM C CE2 . TYR B 1 77 . 77 TYR B CE2 55.917 -6.15 31.133 1 346 +ATOM C CZ . TYR B 1 77 . 77 TYR B CZ 56.182 -5.588 29.896 1 347 +ATOM O OH . TYR B 1 77 . 77 TYR B OH 56.69 -4.324 29.813 1 348 +ATOM N N . SER B 1 78 . 78 SER B N 52.218 -12.033 29.582 1 349 +ATOM C CA . SER B 1 78 . 78 SER B CA 51.961 -13.466 29.269 1 350 +ATOM C C . SER B 1 78 . 78 SER B C 50.964 -13.587 28.107 1 351 +ATOM O O . SER B 1 78 . 78 SER B O 50.388 -12.555 27.698 1 352 +ATOM C CB . SER B 1 78 . 78 SER B CB 51.472 -14.204 30.494 1 353 +ATOM O OG . SER B 1 78 . 78 SER B OG 51.155 -15.554 30.185 1 354 +ATOM N N . PHE B 1 79 . 79 PHE B N 50.761 -14.811 27.608 1 355 +ATOM C CA . PHE B 1 79 . 79 PHE B CA 49.844 -15.121 26.48 1 356 +ATOM C C . PHE B 1 79 . 79 PHE B C 48.419 -14.776 26.901 1 357 +ATOM O O . PHE B 1 79 . 79 PHE B O 47.98 -15.161 27.984 1 358 +ATOM C CB . PHE B 1 79 . 79 PHE B CB 49.964 -16.583 26.039 1 359 +ATOM C CG . PHE B 1 79 . 79 PHE B CG 49.504 -16.836 24.625 1 360 +ATOM C CD1 . PHE B 1 79 . 79 PHE B CD1 50.384 -16.71 23.561 1 361 +ATOM C CD2 . PHE B 1 79 . 79 PHE B CD2 48.187 -17.176 24.354 1 362 +ATOM C CE1 . PHE B 1 79 . 79 PHE B CE1 49.959 -16.931 22.259 1 363 +ATOM C CE2 . PHE B 1 79 . 79 PHE B CE2 47.763 -17.395 23.051 1 364 +ATOM C CZ . PHE B 1 79 . 79 PHE B CZ 48.65 -17.273 22.007 1 365 +ATOM N N . PRO B 1 80 . 80 PRO B N 47.657 -14.037 26.063 1 366 +ATOM C CA . PRO B 1 80 . 80 PRO B CA 46.345 -13.528 26.461 1 367 +ATOM C C . PRO B 1 80 . 80 PRO B C 45.291 -14.627 26.664 1 368 +ATOM O O . PRO B 1 80 . 80 PRO B O 45.41 -15.684 26.065 1 369 +ATOM C CB . PRO B 1 80 . 80 PRO B CB 45.943 -12.62 25.288 1 370 +ATOM C CG . PRO B 1 80 . 80 PRO B CG 46.697 -13.19 24.103 1 371 +ATOM C CD . PRO B 1 80 . 80 PRO B CD 48.013 -13.656 24.687 1 372 +ATOM N N . PHE B 1 81 . 81 PHE B N 44.303 -14.347 27.519 1 373 +ATOM C CA . PHE B 1 81 . 81 PHE B CA 43.025 -15.1 27.631 1 374 +ATOM C C . PHE B 1 81 . 81 PHE B C 42.184 -14.774 26.397 1 375 +ATOM O O . PHE B 1 81 . 81 PHE B O 42.29 -13.671 25.861 1 376 +ATOM C CB . PHE B 1 81 . 81 PHE B CB 42.324 -14.755 28.95 1 377 +ATOM C CG . PHE B 1 81 . 81 PHE B CG 42.85 -15.473 30.171 1 378 +ATOM C CD1 . PHE B 1 81 . 81 PHE B CD1 44.099 -16.08 30.177 1 379 +ATOM C CD2 . PHE B 1 81 . 81 PHE B CD2 42.093 -15.526 31.333 1 380 +ATOM C CE1 . PHE B 1 81 . 81 PHE B CE1 44.567 -16.734 31.307 1 381 +ATOM C CE2 . PHE B 1 81 . 81 PHE B CE2 42.563 -16.179 32.463 1 382 +ATOM C CZ . PHE B 1 81 . 81 PHE B CZ 43.799 -16.782 32.448 1 383 +ATOM N N . PRO B 1 82 . 82 PRO B N 41.328 -15.694 25.89 1 384 +ATOM C CA . PRO B 1 82 . 82 PRO B CA 40.979 -16.939 26.58 1 385 +ATOM C C . PRO B 1 82 . 82 PRO B C 42.047 -18.024 26.455 1 386 +ATOM O O . PRO B 1 82 . 82 PRO B O 42.648 -18.188 25.396 1 387 +ATOM C CB . PRO B 1 82 . 82 PRO B CB 39.687 -17.362 25.864 1 388 +ATOM C CG . PRO B 1 82 . 82 PRO B CG 39.88 -16.871 24.445 1 389 +ATOM C CD . PRO B 1 82 . 82 PRO B CD 40.668 -15.583 24.58 1 390 +ATOM N N . PRO B 1 83 . 83 PRO B N 42.306 -18.804 27.53 1 391 +ATOM C CA . PRO B 1 83 . 83 PRO B CA 43.379 -19.8 27.528 1 392 +ATOM C C . PRO B 1 83 . 83 PRO B C 43.06 -21.045 26.685 1 393 +ATOM O O . PRO B 1 83 . 83 PRO B O 41.893 -21.325 26.473 1 394 +ATOM C CB . PRO B 1 83 . 83 PRO B CB 43.504 -20.178 29.013 1 395 +ATOM C CG . PRO B 1 83 . 83 PRO B CG 42.108 -19.964 29.566 1 396 +ATOM C CD . PRO B 1 83 . 83 PRO B CD 41.565 -18.775 28.803 1 397 +ATOM N N . GLY B 1 84 . 84 GLY B N 44.103 -21.754 26.239 1 398 +ATOM C CA . GLY B 1 84 . 84 GLY B CA 44.001 -23.006 25.461 1 399 +ATOM C C . GLY B 1 84 . 84 GLY B C 43.887 -24.226 26.361 1 400 +ATOM O O . GLY B 1 84 . 84 GLY B O 43.404 -24.079 27.504 1 401 +ATOM N N . VAL B 1 85 . 85 VAL B N 44.31 -25.392 25.859 1 402 +ATOM C CA . VAL B 1 85 . 85 VAL B CA 44.3 -26.699 26.587 1 403 +ATOM C C . VAL B 1 85 . 85 VAL B C 45.747 -27.135 26.877 1 404 +ATOM O O . VAL B 1 85 . 85 VAL B O 45.921 -28.158 27.569 1 405 +ATOM C CB . VAL B 1 85 . 85 VAL B CB 43.527 -27.773 25.792 1 406 +ATOM C CG1 . VAL B 1 85 . 85 VAL B CG1 43.076 -28.927 26.678 1 407 +ATOM C CG2 . VAL B 1 85 . 85 VAL B CG2 42.334 -27.182 25.053 1 408 +ATOM N N . ASP B 1 86 . 86 ASP B N 46.742 -26.38 26.388 1 409 +ATOM C CA . ASP B 1 86 . 86 ASP B CA 48.195 -26.653 26.581 1 410 +ATOM C C . ASP B 1 86 . 86 ASP B C 48.57 -26.449 28.056 1 411 +ATOM O O . ASP B 1 86 . 86 ASP B O 47.809 -25.775 28.772 1 412 +ATOM C CB . ASP B 1 86 . 86 ASP B CB 49.064 -25.781 25.669 1 413 +ATOM C CG . ASP B 1 86 . 86 ASP B CG 48.898 -24.287 25.889 1 414 +ATOM O OD1 . ASP B 1 86 . 86 ASP B OD1 47.843 -23.753 25.497 1 415 +ATOM O OD2 . ASP B 1 86 . 86 ASP B OD2 49.823 -23.671 26.455 1 416 +ATOM N N . ALA B 1 87 . 87 ALA B N 49.72 -26.991 28.472 1 417 +ATOM C CA . ALA B 1 87 . 87 ALA B CA 50.182 -27.083 29.879 1 418 +ATOM C C . ALA B 1 87 . 87 ALA B C 50.337 -25.688 30.499 1 419 +ATOM O O . ALA B 1 87 . 87 ALA B O 49.921 -25.52 31.663 1 420 +ATOM C CB . ALA B 1 87 . 87 ALA B CB 51.475 -27.861 29.946 1 421 +ATOM N N . LYS B 1 88 . 88 LYS B N 50.922 -24.736 29.762 1 422 +ATOM C CA . LYS B 1 88 . 88 LYS B CA 51.131 -23.332 30.219 1 423 +ATOM C C . LYS B 1 88 . 88 LYS B C 49.775 -22.634 30.393 1 424 +ATOM O O . LYS B 1 88 . 88 LYS B O 49.632 -21.868 31.367 1 425 +ATOM C CB . LYS B 1 88 . 88 LYS B CB 52.024 -22.557 29.244 1 426 +ATOM C CG . LYS B 1 88 . 88 LYS B CG 53.517 -22.64 29.531 1 427 +ATOM C CD . LYS B 1 88 . 88 LYS B CD 54.336 -21.642 28.741 1 428 +ATOM C CE . LYS B 1 88 . 88 LYS B CE 55.774 -21.554 29.207 1 429 +ATOM N NZ . LYS B 1 88 . 88 LYS B NZ 56.546 -20.565 28.418 1 430 +ATOM N N . SER B 1 89 . 89 SER B N 48.83 -22.883 29.479 1 431 +ATOM C CA . SER B 1 89 . 89 SER B CA 47.44 -22.354 29.517 1 432 +ATOM C C . SER B 1 89 . 89 SER B C 46.705 -22.908 30.744 1 433 +ATOM O O . SER B 1 89 . 89 SER B O 46.059 -22.11 31.45 1 434 +ATOM C CB . SER B 1 89 . 89 SER B CB 46.69 -22.664 28.245 1 435 +ATOM O OG . SER B 1 89 . 89 SER B OG 47.122 -21.824 27.185 1 436 +ATOM N N . MET B 1 90 . 90 MET B N 46.808 -24.221 30.982 1 437 +ATOM C CA . MET B 1 90 . 90 MET B CA 46.178 -24.918 32.139 1 438 +ATOM C C . MET B 1 90 . 90 MET B C 46.716 -24.324 33.447 1 439 +ATOM O O . MET B 1 90 . 90 MET B O 45.937 -24.235 34.416 1 440 +ATOM C CB . MET B 1 90 . 90 MET B CB 46.473 -26.422 32.119 1 441 +ATOM C CG . MET B 1 90 . 90 MET B CG 45.679 -27.193 31.08 1 442 +ATOM S SD . MET B 1 90 . 90 MET B SD 45.676 -28.982 31.392 1 443 +ATOM C CE . MET B 1 90 . 90 MET B CE 47.434 -29.325 31.381 1 444 +ATOM N N . GLU B 1 91 . 91 GLU B N 47.999 -23.945 33.453 1 445 +ATOM C CA . GLU B 1 91 . 91 GLU B CA 48.711 -23.371 34.628 1 446 +ATOM C C . GLU B 1 91 . 91 GLU B C 48.175 -21.963 34.925 1 447 +ATOM O O . GLU B 1 91 . 91 GLU B O 48.115 -21.602 36.115 1 448 +ATOM C CB . GLU B 1 91 . 91 GLU B CB 50.22 -23.356 34.371 1 449 +ATOM C CG . GLU B 1 91 . 91 GLU B CG 51.054 -23.136 35.622 1 450 +ATOM C CD . GLU B 1 91 . 91 GLU B CD 52.526 -23.495 35.482 1 451 +ATOM O OE1 . GLU B 1 91 . 91 GLU B OE1 52.916 -24.017 34.413 1 452 +ATOM O OE2 . GLU B 1 91 . 91 GLU B OE2 53.283 -23.258 36.447 1 453 +ATOM N N . ALA B 1 92 . 92 ALA B N 47.803 -21.208 33.885 1 454 +ATOM C CA . ALA B 1 92 . 92 ALA B CA 47.252 -19.834 33.978 1 455 +ATOM C C . ALA B 1 92 . 92 ALA B C 45.801 -19.874 34.478 1 456 +ATOM O O . ALA B 1 92 . 92 ALA B O 45.401 -18.915 35.164 1 457 +ATOM C CB . ALA B 1 92 . 92 ALA B CB 47.357 -19.138 32.644 1 458 +ATOM N N . ILE B 1 93 . 93 ILE B N 45.045 -20.928 34.144 1 459 +ATOM C CA . ILE B 1 93 . 93 ILE B CA 43.657 -21.164 34.651 1 460 +ATOM C C . ILE B 1 93 . 93 ILE B C 43.747 -21.508 36.143 1 461 +ATOM O O . ILE B 1 93 . 93 ILE B O 42.976 -20.924 36.928 1 462 +ATOM C CB . ILE B 1 93 . 93 ILE B CB 42.925 -22.263 33.847 1 463 +ATOM C CG1 . ILE B 1 93 . 93 ILE B CG1 42.776 -21.893 32.368 1 464 +ATOM C CG2 . ILE B 1 93 . 93 ILE B CG2 41.57 -22.584 34.47 1 465 +ATOM C CD1 . ILE B 1 93 . 93 ILE B CD1 42.363 -23.05 31.481 1 466 +ATOM N N . THR B 1 94 . 94 THR B N 44.652 -22.424 36.51 1 467 +ATOM C CA . THR B 1 94 . 94 THR B CA 44.939 -22.84 37.911 1 468 +ATOM C C . THR B 1 94 . 94 THR B C 45.331 -21.612 38.741 1 469 +ATOM O O . THR B 1 94 . 94 THR B O 44.893 -21.531 39.903 1 470 +ATOM C CB . THR B 1 94 . 94 THR B CB 46.037 -23.911 37.961 1 471 +ATOM C CG2 . THR B 1 94 . 94 THR B CG2 46.391 -24.338 39.369 1 472 +ATOM O OG1 . THR B 1 94 . 94 THR B OG1 45.588 -25.048 37.223 1 473 +ATOM N N . ALA B 1 95 . 95 ALA B N 46.133 -20.707 38.167 1 474 +ATOM C CA . ALA B 1 95 . 95 ALA B CA 46.598 -19.45 38.801 1 475 +ATOM C C . ALA B 1 95 . 95 ALA B C 45.408 -18.503 39.001 1 476 +ATOM O O . ALA B 1 95 . 95 ALA B O 45.33 -17.876 40.077 1 477 +ATOM C CB . ALA B 1 95 . 95 ALA B CB 47.679 -18.809 37.963 1 478 +ATOM N N . TYR B 1 96 . 96 TYR B N 44.521 -18.414 38.002 1 479 +ATOM C CA . TYR B 1 96 . 96 TYR B CA 43.297 -17.569 38.014 1 480 +ATOM C C . TYR B 1 96 . 96 TYR B C 42.298 -18.114 39.045 1 481 +ATOM O O . TYR B 1 96 . 96 TYR B O 41.798 -17.32 39.861 1 482 +ATOM C CB . TYR B 1 96 . 96 TYR B CB 42.677 -17.492 36.615 1 483 +ATOM C CG . TYR B 1 96 . 96 TYR B CG 41.343 -16.791 36.561 1 484 +ATOM C CD1 . TYR B 1 96 . 96 TYR B CD1 41.247 -15.425 36.773 1 485 +ATOM C CD2 . TYR B 1 96 . 96 TYR B CD2 40.173 -17.493 36.314 1 486 +ATOM C CE1 . TYR B 1 96 . 96 TYR B CE1 40.025 -14.773 36.742 1 487 +ATOM C CE2 . TYR B 1 96 . 96 TYR B CE2 38.943 -16.855 36.277 1 488 +ATOM C CZ . TYR B 1 96 . 96 TYR B CZ 38.869 -15.489 36.488 1 489 +ATOM O OH . TYR B 1 96 . 96 TYR B OH 37.665 -14.847 36.451 1 490 +ATOM N N . THR B 1 97 . 97 THR B N 42.019 -19.421 38.999 1 491 +ATOM C CA . THR B 1 97 . 97 THR B CA 41.099 -20.14 39.925 1 492 +ATOM C C . THR B 1 97 . 97 THR B C 41.649 -20.066 41.357 1 493 +ATOM O O . THR B 1 97 . 97 THR B O 40.842 -19.843 42.284 1 494 +ATOM C CB . THR B 1 97 . 97 THR B CB 40.883 -21.591 39.47 1 495 +ATOM C CG2 . THR B 1 97 . 97 THR B CG2 40.056 -22.41 40.438 1 496 +ATOM O OG1 . THR B 1 97 . 97 THR B OG1 40.227 -21.55 38.203 1 497 +ATOM N N . GLY B 1 98 . 98 GLY B N 42.964 -20.251 41.52 1 498 +ATOM C CA . GLY B 1 98 . 98 GLY B CA 43.675 -20.172 42.812 1 499 +ATOM C C . GLY B 1 98 . 98 GLY B C 43.565 -18.793 43.443 1 500 +ATOM O O . GLY B 1 98 . 98 GLY B O 43.334 -18.73 44.667 1 501 +ATOM N N . ASN B 1 99 . 99 ASN B N 43.732 -17.732 42.643 1 502 +ATOM C CA . ASN B 1 99 . 99 ASN B CA 43.627 -16.311 43.075 1 503 +ATOM C C . ASN B 1 99 . 99 ASN B C 42.217 -16.059 43.627 1 504 +ATOM O O . ASN B 1 99 . 99 ASN B O 42.109 -15.473 44.72 1 505 +ATOM C CB . ASN B 1 99 . 99 ASN B CB 43.992 -15.354 41.935 1 506 +ATOM C CG . ASN B 1 99 . 99 ASN B CG 43.706 -13.899 42.248 1 507 +ATOM N ND2 . ASN B 1 99 . 99 ASN B ND2 42.473 -13.468 42.024 1 508 +ATOM O OD1 . ASN B 1 99 . 99 ASN B OD1 44.595 -13.167 42.679 1 509 +ATOM N N . LYS B 1 100 . 100 LYS B N 41.184 -16.492 42.897 1 510 +ATOM C CA . LYS B 1 100 . 100 LYS B CA 39.753 -16.375 43.298 1 511 +ATOM C C . LYS B 1 100 . 100 LYS B C 39.536 -17.115 44.625 1 512 +ATOM O O . LYS B 1 100 . 100 LYS B O 38.879 -16.542 45.518 1 513 +ATOM C CB . LYS B 1 100 . 100 LYS B CB 38.846 -16.945 42.202 1 514 +ATOM C CG . LYS B 1 100 . 100 LYS B CG 37.351 -16.707 42.387 1 515 +ATOM C CD . LYS B 1 100 . 100 LYS B CD 36.803 -15.591 41.522 1 516 +ATOM C CE . LYS B 1 100 . 100 LYS B CE 35.297 -15.455 41.592 1 517 +ATOM N NZ . LYS B 1 100 . 100 LYS B NZ 34.609 -16.7 41.175 1 518 +ATOM N N . GLY B 1 101 . 101 GLY B N 40.073 -18.335 44.737 1 519 +ATOM C CA . GLY B 1 101 . 101 GLY B CA 39.952 -19.209 45.922 1 520 +ATOM C C . GLY B 1 101 . 101 GLY B C 40.589 -18.593 47.157 1 521 +ATOM O O . GLY B 1 101 . 101 GLY B O 40.042 -18.792 48.263 1 522 +ATOM N N . LYS B 1 102 . 102 LYS B N 41.702 -17.873 46.977 1 523 +ATOM C CA . LYS B 1 102 . 102 LYS B CA 42.422 -17.152 48.06 1 524 +ATOM C C . LYS B 1 102 . 102 LYS B C 41.601 -15.93 48.488 1 525 +ATOM O O . LYS B 1 102 . 102 LYS B O 41.498 -15.703 49.708 1 526 +ATOM C CB . LYS B 1 102 . 102 LYS B CB 43.835 -16.765 47.61 1 527 +ATOM C CG . LYS B 1 102 . 102 LYS B CG 44.825 -17.921 47.539 1 528 +ATOM C CD . LYS B 1 102 . 102 LYS B CD 46.031 -17.637 46.663 1 529 +ATOM C CE . LYS B 1 102 . 102 LYS B CE 47.057 -18.753 46.667 1 530 +ATOM N NZ . LYS B 1 102 . 102 LYS B NZ 46.533 -19.99 46.042 1 531 +ATOM N N . VAL B 1 103 . 103 VAL B N 41.034 -15.182 47.533 1 532 +ATOM C CA . VAL B 1 103 . 103 VAL B CA 40.217 -13.96 47.811 1 533 +ATOM C C . VAL B 1 103 . 103 VAL B C 38.927 -14.374 48.531 1 534 +ATOM O O . VAL B 1 103 . 103 VAL B O 38.536 -13.668 49.469 1 535 +ATOM C CB . VAL B 1 103 . 103 VAL B CB 39.93 -13.149 46.532 1 536 +ATOM C CG1 . VAL B 1 103 . 103 VAL B CG1 38.811 -12.136 46.734 1 537 +ATOM C CG2 . VAL B 1 103 . 103 VAL B CG2 41.188 -12.459 46.027 1 538 +ATOM N N . ALA B 1 104 . 104 ALA B N 38.3 -15.479 48.121 1 539 +ATOM C CA . ALA B 1 104 . 104 ALA B CA 37.086 -16.036 48.765 1 540 +ATOM C C . ALA B 1 104 . 104 ALA B C 37.408 -16.428 50.214 1 541 +ATOM O O . ALA B 1 104 . 104 ALA B O 36.621 -16.07 51.106 1 542 +ATOM C CB . ALA B 1 104 . 104 ALA B CB 36.558 -17.209 47.974 1 543 +ATOM N N . ALA B 1 105 . 105 ALA B N 38.536 -17.113 50.432 1 544 +ATOM C CA . ALA B 1 105 . 105 ALA B CA 39.021 -17.569 51.76 1 545 +ATOM C C . ALA B 1 105 . 105 ALA B C 39.36 -16.368 52.654 1 546 +ATOM O O . ALA B 1 105 . 105 ALA B O 39.193 -16.497 53.884 1 547 +ATOM C CB . ALA B 1 105 . 105 ALA B CB 40.214 -18.48 51.597 1 548 +ATOM N N . PHE B 1 106 . 106 PHE B N 39.814 -15.255 52.063 1 549 +ATOM C CA . PHE B 1 106 . 106 PHE B CA 40.182 -13.986 52.752 1 550 +ATOM C C . PHE B 1 106 . 106 PHE B C 39.004 -13.47 53.593 1 551 +ATOM O O . PHE B 1 106 . 106 PHE B O 39.256 -12.859 54.649 1 552 +ATOM C CB . PHE B 1 106 . 106 PHE B CB 40.633 -12.941 51.724 1 553 +ATOM C CG . PHE B 1 106 . 106 PHE B CG 40.977 -11.578 52.274 1 554 +ATOM C CD1 . PHE B 1 106 . 106 PHE B CD1 41.929 -11.433 53.271 1 555 +ATOM C CD2 . PHE B 1 106 . 106 PHE B CD2 40.375 -10.433 51.767 1 556 +ATOM C CE1 . PHE B 1 106 . 106 PHE B CE1 42.258 -10.179 53.762 1 557 +ATOM C CE2 . PHE B 1 106 . 106 PHE B CE2 40.707 -9.18 52.259 1 558 +ATOM C CZ . PHE B 1 106 . 106 PHE B CZ 41.649 -9.056 53.254 1 559 +ATOM N N . ILE B 1 107 . 107 ILE B N 37.77 -13.693 53.126 1 560 +ATOM C CA . ILE B 1 107 . 107 ILE B CA 36.504 -13.251 53.786 1 561 +ATOM C C . ILE B 1 107 . 107 ILE B C 35.719 -14.483 54.261 1 562 +ATOM O O . ILE B 1 107 . 107 ILE B O 34.564 -14.312 54.678 1 563 +ATOM C CB . ILE B 1 107 . 107 ILE B CB 35.677 -12.35 52.841 1 564 +ATOM C CG1 . ILE B 1 107 . 107 ILE B CG1 35.155 -13.1 51.611 1 565 +ATOM C CG2 . ILE B 1 107 . 107 ILE B CG2 36.483 -11.124 52.436 1 566 +ATOM C CD1 . ILE B 1 107 . 107 ILE B CD1 34.178 -12.3 50.774 1 567 +ATOM N N . ASN B 1 108 . 108 ASN B N 36.343 -15.667 54.232 1 568 +ATOM C CA . ASN B 1 108 . 108 ASN B CA 35.753 -16.979 54.623 1 569 +ATOM C C . ASN B 1 108 . 108 ASN B C 34.48 -17.253 53.806 1 570 +ATOM O O . ASN B 1 108 . 108 ASN B O 33.513 -17.803 54.369 1 571 +ATOM C CB . ASN B 1 108 . 108 ASN B CB 35.482 -17.062 56.129 1 572 +ATOM C CG . ASN B 1 108 . 108 ASN B CG 35.216 -18.478 56.603 1 573 +ATOM N ND2 . ASN B 1 108 . 108 ASN B ND2 34.243 -18.647 57.482 1 574 +ATOM O OD1 . ASN B 1 108 . 108 ASN B OD1 35.884 -19.418 56.181 1 575 +ATOM N N . ALA B 1 109 . 109 ALA B N 34.485 -16.898 52.52 1 576 +ATOM C CA . ALA B 1 109 . 109 ALA B CA 33.413 -17.227 51.551 1 577 +ATOM C C . ALA B 1 109 . 109 ALA B C 33.894 -18.362 50.637 1 578 +ATOM O O . ALA B 1 109 . 109 ALA B O 35.113 -18.62 50.606 1 579 +ATOM C CB . ALA B 1 109 . 109 ALA B CB 33.036 -15.994 50.768 1 580 +ATOM N N . LEU B 1 110 . 110 LEU B N 32.968 -19.03 49.943 1 581 +ATOM C CA . LEU B 1 110 . 110 LEU B CA 33.284 -20.028 48.886 1 582 +ATOM C C . LEU B 1 110 . 110 LEU B C 33.478 -19.296 47.559 1 583 +ATOM O O . LEU B 1 110 . 110 LEU B O 32.988 -18.179 47.39 1 584 +ATOM C CB . LEU B 1 110 . 110 LEU B CB 32.154 -21.06 48.795 1 585 +ATOM C CG . LEU B 1 110 . 110 LEU B CG 32.006 -22.002 49.991 1 586 +ATOM C CD1 . LEU B 1 110 . 110 LEU B CD1 30.857 -22.977 49.771 1 587 +ATOM C CD2 . LEU B 1 110 . 110 LEU B CD2 33.295 -22.765 50.263 1 588 +ATOM N N . PRO B 1 111 . 111 PRO B N 34.206 -19.89 46.585 1 589 +ATOM C CA . PRO B 1 111 . 111 PRO B CA 34.486 -19.226 45.308 1 590 +ATOM C C . PRO B 1 111 . 111 PRO B C 33.238 -18.855 44.489 1 591 +ATOM O O . PRO B 1 111 . 111 PRO B O 33.259 -17.838 43.813 1 592 +ATOM C CB . PRO B 1 111 . 111 PRO B CB 35.33 -20.253 44.533 1 593 +ATOM C CG . PRO B 1 111 . 111 PRO B CG 35.904 -21.162 45.6 1 594 +ATOM C CD . PRO B 1 111 . 111 PRO B CD 34.836 -21.217 46.673 1 595 +ATOM N N . ASP B 1 112 . 112 ASP B N 32.184 -19.673 44.576 1 596 +ATOM C CA . ASP B 1 112 . 112 ASP B CA 30.903 -19.477 43.844 1 597 +ATOM C C . ASP B 1 112 . 112 ASP B C 30.03 -18.44 44.575 1 598 +ATOM O O . ASP B 1 112 . 112 ASP B O 28.898 -18.202 44.105 1 599 +ATOM C CB . ASP B 1 112 . 112 ASP B CB 30.182 -20.815 43.635 1 600 +ATOM C CG . ASP B 1 112 . 112 ASP B CG 29.747 -21.516 44.914 1 601 +ATOM O OD1 . ASP B 1 112 . 112 ASP B OD1 28.939 -20.935 45.66 1 602 +ATOM O OD2 . ASP B 1 112 . 112 ASP B OD2 30.215 -22.641 45.155 1 603 +ATOM N N . GLU B 1 113 . 113 GLU B N 30.528 -17.847 45.67 1 604 +ATOM C CA . GLU B 1 113 . 113 GLU B CA 29.796 -16.853 46.506 1 605 +ATOM C C . GLU B 1 113 . 113 GLU B C 30.417 -15.454 46.377 1 606 +ATOM O O . GLU B 1 113 . 113 GLU B O 29.864 -14.527 46.991 1 607 +ATOM C CB . GLU B 1 113 . 113 GLU B CB 29.78 -17.296 47.971 1 608 +ATOM C CG . GLU B 1 113 . 113 GLU B CG 28.995 -18.572 48.205 1 609 +ATOM C CD . GLU B 1 113 . 113 GLU B CD 29.038 -19.089 49.631 1 610 +ATOM O OE1 . GLU B 1 113 . 113 GLU B OE1 29.995 -18.748 50.357 1 611 +ATOM O OE2 . GLU B 1 113 . 113 GLU B OE2 28.11 -19.833 50.015 1 612 +ATOM N N . ILE B 1 114 . 114 ILE B N 31.495 -15.293 45.6 1 613 +ATOM C CA . ILE B 1 114 . 114 ILE B CA 32.146 -13.968 45.355 1 614 +ATOM C C . ILE B 1 114 . 114 ILE B C 32.18 -13.681 43.847 1 615 +ATOM O O . ILE B 1 114 . 114 ILE B O 32.223 -14.64 43.052 1 616 +ATOM C CB . ILE B 1 114 . 114 ILE B CB 33.55 -13.898 45.993 1 617 +ATOM C CG1 . ILE B 1 114 . 114 ILE B CG1 34.563 -14.808 45.294 1 618 +ATOM C CG2 . ILE B 1 114 . 114 ILE B CG2 33.479 -14.188 47.486 1 619 +ATOM C CD1 . ILE B 1 114 . 114 ILE B CD1 36.0 -14.393 45.511 1 620 +ATOM N N . THR B 1 115 . 115 THR B N 32.15 -12.397 43.479 1 621 +ATOM C CA . THR B 1 115 . 115 THR B CA 32.343 -11.908 42.088 1 622 +ATOM C C . THR B 1 115 . 115 THR B C 33.097 -10.576 42.113 1 623 +ATOM O O . THR B 1 115 . 115 THR B O 32.849 -9.772 43.032 1 624 +ATOM C CB . THR B 1 115 . 115 THR B CB 31.012 -11.782 41.337 1 625 +ATOM C CG2 . THR B 1 115 . 115 THR B CG2 30.161 -10.618 41.798 1 626 +ATOM O OG1 . THR B 1 115 . 115 THR B OG1 31.333 -11.645 39.953 1 627 +ATOM N N . PHE B 1 116 . 116 PHE B N 33.976 -10.368 41.129 1 628 +ATOM C CA . PHE B 1 116 . 116 PHE B CA 34.746 -9.116 40.912 1 629 +ATOM C C . PHE B 1 116 . 116 PHE B C 33.928 -8.189 40.009 1 630 +ATOM O O . PHE B 1 116 . 116 PHE B O 33.256 -8.7 39.097 1 631 +ATOM C CB . PHE B 1 116 . 116 PHE B CB 36.118 -9.434 40.314 1 632 +ATOM C CG . PHE B 1 116 . 116 PHE B CG 37.084 -10.092 41.267 1 633 +ATOM C CD1 . PHE B 1 116 . 116 PHE B CD1 37.829 -9.332 42.156 1 634 +ATOM C CD2 . PHE B 1 116 . 116 PHE B CD2 37.263 -11.468 41.265 1 635 +ATOM C CE1 . PHE B 1 116 . 116 PHE B CE1 38.727 -9.931 43.027 1 636 +ATOM C CE2 . PHE B 1 116 . 116 PHE B CE2 38.163 -12.065 42.136 1 637 +ATOM C CZ . PHE B 1 116 . 116 PHE B CZ 38.89 -11.297 43.017 1 638 +ATOM N N . GLY B 1 117 . 117 GLY B N 33.976 -6.876 40.266 1 639 +ATOM C CA . GLY B 1 117 . 117 GLY B CA 33.208 -5.859 39.519 1 640 +ATOM C C . GLY B 1 117 . 117 GLY B C 33.979 -4.56 39.356 1 641 +ATOM O O . GLY B 1 117 . 117 GLY B O 35.053 -4.428 39.972 1 642 +ATOM N N . GLN B 1 118 . 118 GLN B N 33.435 -3.637 38.557 1 643 +ATOM C CA . GLN B 1 118 . 118 GLN B CA 33.991 -2.285 38.273 1 644 +ATOM C C . GLN B 1 118 . 118 GLN B C 34.329 -1.573 39.586 1 645 +ATOM O O . GLN B 1 118 . 118 GLN B O 35.433 -1.007 39.687 1 646 +ATOM C CB . GLN B 1 118 . 118 GLN B CB 32.966 -1.473 37.476 1 647 +ATOM C CG . GLN B 1 118 . 118 GLN B CG 33.303 0.005 37.299 1 648 +ATOM C CD . GLN B 1 118 . 118 GLN B CD 32.248 0.718 36.485 1 649 +ATOM N NE2 . GLN B 1 118 . 118 GLN B NE2 32.241 2.041 36.553 1 650 +ATOM O OE1 . GLN B 1 118 . 118 GLN B OE1 31.438 0.095 35.801 1 651 +ATOM N N . SER B 1 119 . 119 SER B N 33.388 -1.604 40.533 1 652 +ATOM C CA . SER B 1 119 . 119 SER B CA 33.401 -0.851 41.814 1 653 +ATOM C C . SER B 1 119 . 119 SER B C 32.257 -1.35 42.701 1 654 +ATOM O O . SER B 1 119 . 119 SER B O 31.419 -2.139 42.202 1 655 +ATOM C CB . SER B 1 119 . 119 SER B CB 33.274 0.625 41.561 1 656 +ATOM O OG . SER B 1 119 . 119 SER B OG 32.034 0.918 40.929 1 657 +ATOM N N . THR B 1 120 . 120 THR B N 32.217 -0.892 43.955 1 658 +ATOM C CA . THR B 1 120 . 120 THR B CA 31.146 -1.205 44.937 1 659 +ATOM C C . THR B 1 120 . 120 THR B C 29.846 -0.524 44.488 1 660 +ATOM O O . THR B 1 120 . 120 THR B O 28.796 -1.191 44.529 1 661 +ATOM C CB . THR B 1 120 . 120 THR B CB 31.567 -0.821 46.363 1 662 +ATOM C CG2 . THR B 1 120 . 120 THR B CG2 30.411 -0.802 47.339 1 663 +ATOM O OG1 . THR B 1 120 . 120 THR B OG1 32.54 -1.775 46.795 1 664 +ATOM N N . THR B 1 121 . 121 THR B N 29.916 0.748 44.083 1 665 +ATOM C CA . THR B 1 121 . 121 THR B CA 28.766 1.531 43.551 1 666 +ATOM C C . THR B 1 121 . 121 THR B C 28.103 0.748 42.407 1 667 +ATOM O O . THR B 1 121 . 121 THR B O 26.859 0.646 42.399 1 668 +ATOM C CB . THR B 1 121 . 121 THR B CB 29.211 2.921 43.076 1 669 +ATOM C CG2 . THR B 1 121 . 121 THR B CG2 28.056 3.784 42.616 1 670 +ATOM O OG1 . THR B 1 121 . 121 THR B OG1 29.901 3.576 44.143 1 671 +ATOM N N . ALA B 1 122 . 122 ALA B N 28.909 0.213 41.484 1 672 +ATOM C CA . ALA B 1 122 . 122 ALA B CA 28.455 -0.547 40.297 1 673 +ATOM C C . ALA B 1 122 . 122 ALA B C 27.814 -1.865 40.74 1 674 +ATOM O O . ALA B 1 122 . 122 ALA B O 26.693 -2.149 40.291 1 675 +ATOM C CB . ALA B 1 122 . 122 ALA B CB 29.611 -0.787 39.354 1 676 +ATOM N N . LEU B 1 123 . 123 LEU B N 28.505 -2.628 41.593 1 677 +ATOM C CA . LEU B 1 123 . 123 LEU B CA 28.074 -3.978 42.051 1 678 +ATOM C C . LEU B 1 123 . 123 LEU B C 26.771 -3.875 42.852 1 679 +ATOM O O . LEU B 1 123 . 123 LEU B O 25.935 -4.794 42.72 1 680 +ATOM C CB . LEU B 1 123 . 123 LEU B CB 29.188 -4.624 42.88 1 681 +ATOM C CG . LEU B 1 123 . 123 LEU B CG 30.317 -5.265 42.072 1 682 +ATOM C CD1 . LEU B 1 123 . 123 LEU B CD1 31.506 -5.588 42.963 1 683 +ATOM C CD2 . LEU B 1 123 . 123 LEU B CD2 29.831 -6.52 41.359 1 684 +ATOM N N . PHE B 1 124 . 124 PHE B N 26.604 -2.815 43.652 1 685 +ATOM C CA . PHE B 1 124 . 124 PHE B CA 25.342 -2.531 44.388 1 686 +ATOM C C . PHE B 1 124 . 124 PHE B C 24.224 -2.244 43.378 1 687 +ATOM O O . PHE B 1 124 . 124 PHE B O 23.118 -2.789 43.533 1 688 +ATOM C CB . PHE B 1 124 . 124 PHE B CB 25.517 -1.389 45.395 1 689 +ATOM C CG . PHE B 1 124 . 124 PHE B CG 26.139 -1.767 46.718 1 690 +ATOM C CD1 . PHE B 1 124 . 124 PHE B CD1 25.987 -3.039 47.258 1 691 +ATOM C CD2 . PHE B 1 124 . 124 PHE B CD2 26.844 -0.826 47.452 1 692 +ATOM C CE1 . PHE B 1 124 . 124 PHE B CE1 26.55 -3.363 48.484 1 693 +ATOM C CE2 . PHE B 1 124 . 124 PHE B CE2 27.403 -1.151 48.679 1 694 +ATOM C CZ . PHE B 1 124 . 124 PHE B CZ 27.257 -2.419 49.193 1 695 +ATOM N N . ARG B 1 125 . 125 ARG B N 24.507 -1.444 42.348 1 696 +ATOM C CA . ARG B 1 125 . 125 ARG B CA 23.518 -1.127 41.285 1 697 +ATOM C C . ARG B 1 125 . 125 ARG B C 23.096 -2.429 40.592 1 698 +ATOM O O . ARG B 1 125 . 125 ARG B O 21.869 -2.647 40.456 1 699 +ATOM C CB . ARG B 1 125 . 125 ARG B CB 24.071 -0.118 40.276 1 700 +ATOM C CG . ARG B 1 125 . 125 ARG B CG 22.985 0.599 39.482 1 701 +ATOM C CD . ARG B 1 125 . 125 ARG B CD 22.14 1.515 40.352 1 702 +ATOM N NE . ARG B 1 125 . 125 ARG B NE 22.99 2.487 41.036 1 703 +ATOM C CZ . ARG B 1 125 . 125 ARG B CZ 23.256 3.717 40.601 1 704 +ATOM N NH1 . ARG B 1 125 . 125 ARG B NH1 22.714 4.167 39.479 1 705 +ATOM N NH2 . ARG B 1 125 . 125 ARG B NH2 24.056 4.501 41.304 1 706 +ATOM N N . LEU B 1 126 . 126 LEU B N 24.067 -3.26 40.191 1 707 +ATOM C CA . LEU B 1 126 . 126 LEU B CA 23.83 -4.547 39.481 1 708 +ATOM C C . LEU B 1 126 . 126 LEU B C 23.099 -5.53 40.407 1 709 +ATOM O O . LEU B 1 126 . 126 LEU B O 22.216 -6.251 39.905 1 710 +ATOM C CB . LEU B 1 126 . 126 LEU B CB 25.162 -5.123 38.986 1 711 +ATOM C CG . LEU B 1 126 . 126 LEU B CG 25.877 -4.323 37.894 1 712 +ATOM C CD1 . LEU B 1 126 . 126 LEU B CD1 27.103 -5.072 37.396 1 713 +ATOM C CD2 . LEU B 1 126 . 126 LEU B CD2 24.947 -4.01 36.73 1 714 +ATOM N N . LEU B 1 127 . 127 LEU B N 23.421 -5.555 41.705 1 715 +ATOM C CA . LEU B 1 127 . 127 LEU B CA 22.682 -6.389 42.695 1 716 +ATOM C C . LEU B 1 127 . 127 LEU B C 21.207 -5.96 42.71 1 717 +ATOM O O . LEU B 1 127 . 127 LEU B O 20.335 -6.852 42.679 1 718 +ATOM C CB . LEU B 1 127 . 127 LEU B CB 23.316 -6.274 44.085 1 719 +ATOM C CG . LEU B 1 127 . 127 LEU B CG 22.63 -7.091 45.181 1 720 +ATOM C CD1 . LEU B 1 127 . 127 LEU B CD1 22.532 -8.56 44.795 1 721 +ATOM C CD2 . LEU B 1 127 . 127 LEU B CD2 23.354 -6.941 46.509 1 722 +ATOM N N . GLY B 1 128 . 128 GLY B N 20.942 -4.65 42.727 1 723 +ATOM C CA . GLY B 1 128 . 128 GLY B CA 19.586 -4.08 42.616 1 724 +ATOM C C . GLY B 1 128 . 128 GLY B C 18.828 -4.652 41.428 1 725 +ATOM O O . GLY B 1 128 . 128 GLY B O 17.658 -5.038 41.606 1 726 +ATOM N N . LEU B 1 129 . 129 LEU B N 19.467 -4.714 40.254 1 727 +ATOM C CA . LEU B 1 129 . 129 LEU B CA 18.861 -5.256 39.008 1 728 +ATOM C C . LEU B 1 129 . 129 LEU B C 18.566 -6.752 39.185 1 729 +ATOM O O . LEU B 1 129 . 129 LEU B O 17.534 -7.21 38.671 1 730 +ATOM C CB . LEU B 1 129 . 129 LEU B CB 19.806 -5.012 37.826 1 731 +ATOM C CG . LEU B 1 129 . 129 LEU B CG 20.052 -3.548 37.459 1 732 +ATOM C CD1 . LEU B 1 129 . 129 LEU B CD1 21.074 -3.444 36.34 1 733 +ATOM C CD2 . LEU B 1 129 . 129 LEU B CD2 18.759 -2.849 37.056 1 734 +ATOM N N . SER B 1 130 . 130 SER B N 19.433 -7.477 39.898 1 735 +ATOM C CA . SER B 1 130 . 130 SER B CA 19.32 -8.94 40.148 1 736 +ATOM C C . SER B 1 130 . 130 SER B C 18.18 -9.238 41.132 1 737 +ATOM O O . SER B 1 130 . 130 SER B O 17.515 -10.276 40.953 1 738 +ATOM C CB . SER B 1 130 . 130 SER B CB 20.629 -9.509 40.634 1 739 +ATOM O OG . SER B 1 130 . 130 SER B OG 20.526 -10.909 40.82 1 740 +ATOM N N . LEU B 1 131 . 131 LEU B N 17.964 -8.372 42.127 1 741 +ATOM C CA . LEU B 1 131 . 131 LEU B CA 16.935 -8.565 43.188 1 742 +ATOM C C . LEU B 1 131 . 131 LEU B C 15.555 -8.108 42.695 1 743 +ATOM O O . LEU B 1 131 . 131 LEU B O 14.552 -8.686 43.158 1 744 +ATOM C CB . LEU B 1 131 . 131 LEU B CB 17.339 -7.785 44.442 1 745 +ATOM C CG . LEU B 1 131 . 131 LEU B CG 18.626 -8.234 45.129 1 746 +ATOM C CD1 . LEU B 1 131 . 131 LEU B CD1 19.004 -7.257 46.233 1 747 +ATOM C CD2 . LEU B 1 131 . 131 LEU B CD2 18.492 -9.646 45.687 1 748 +ATOM N N . LYS B 1 132 . 132 LYS B N 15.506 -7.119 41.797 1 749 +ATOM C CA . LYS B 1 132 . 132 LYS B CA 14.267 -6.389 41.405 1 750 +ATOM C C . LYS B 1 132 . 132 LYS B C 13.161 -7.35 40.965 1 751 +ATOM O O . LYS B 1 132 . 132 LYS B O 12.037 -7.224 41.439 1 752 +ATOM C CB . LYS B 1 132 . 132 LYS B CB 14.559 -5.366 40.303 1 753 +ATOM C CG . LYS B 1 132 . 132 LYS B CG 13.393 -4.451 39.951 1 754 +ATOM C CD . LYS B 1 132 . 132 LYS B CD 13.518 -3.81 38.587 1 755 +ATOM C CE . LYS B 1 132 . 132 LYS B CE 14.829 -3.078 38.409 1 756 +ATOM N NZ . LYS B 1 132 . 132 LYS B NZ 14.875 -2.348 37.121 1 757 +ATOM N N . PRO B 1 133 . 133 PRO B N 13.402 -8.32 40.048 1 758 +ATOM C CA . PRO B 1 133 . 133 PRO B CA 12.315 -9.141 39.503 1 759 +ATOM C C . PRO B 1 133 . 133 PRO B C 11.431 -9.828 40.561 1 760 +ATOM O O . PRO B 1 133 . 133 PRO B O 10.256 -10.012 40.299 1 761 +ATOM C CB . PRO B 1 133 . 133 PRO B CB 13.044 -10.193 38.65 1 762 +ATOM C CG . PRO B 1 133 . 133 PRO B CG 14.347 -9.519 38.268 1 763 +ATOM C CD . PRO B 1 133 . 133 PRO B CD 14.71 -8.679 39.474 1 764 +ATOM N N . MET B 1 134 . 134 MET B N 12.004 -10.183 41.717 1 765 +ATOM C CA . MET B 1 134 . 134 MET B CA 11.307 -10.92 42.807 1 766 +ATOM C C . MET B 1 134 . 134 MET B C 10.846 -9.949 43.907 1 767 +ATOM O O . MET B 1 134 . 134 MET B O 10.371 -10.437 44.949 1 768 +ATOM C CB . MET B 1 134 . 134 MET B CB 12.224 -12.002 43.394 1 769 +ATOM C CG . MET B 1 134 . 134 MET B CG 12.51 -13.134 42.413 1 770 +ATOM S SD . MET B 1 134 . 134 MET B SD 13.642 -14.404 43.056 1 771 +ATOM C CE . MET B 1 134 . 134 MET B CE 13.654 -15.54 41.67 1 772 +ATOM N N . LEU B 1 135 . 135 LEU B N 10.952 -8.634 43.681 1 773 +ATOM C CA . LEU B 1 135 . 135 LEU B CA 10.448 -7.578 44.6 1 774 +ATOM C C . LEU B 1 135 . 135 LEU B C 9.296 -6.832 43.922 1 775 +ATOM O O . LEU B 1 135 . 135 LEU B O 9.108 -7.016 42.708 1 776 +ATOM C CB . LEU B 1 135 . 135 LEU B CB 11.587 -6.617 44.954 1 777 +ATOM C CG . LEU B 1 135 . 135 LEU B CG 12.778 -7.238 45.681 1 778 +ATOM C CD1 . LEU B 1 135 . 135 LEU B CD1 13.939 -6.26 45.739 1 779 +ATOM C CD2 . LEU B 1 135 . 135 LEU B CD2 12.392 -7.695 47.078 1 780 +ATOM N N . ASN B 1 136 . 136 ASN B N 8.563 -6.019 44.688 1 781 +ATOM C CA . ASN B 1 136 . 136 ASN B CA 7.404 -5.224 44.201 1 782 +ATOM C C . ASN B 1 136 . 136 ASN B C 7.133 -4.077 45.183 1 783 +ATOM O O . ASN B 1 136 . 136 ASN B O 7.873 -3.972 46.176 1 784 +ATOM C CB . ASN B 1 136 . 136 ASN B CB 6.181 -6.12 43.984 1 785 +ATOM C CG . ASN B 1 136 . 136 ASN B CG 5.765 -6.872 45.232 1 786 +ATOM N ND2 . ASN B 1 136 . 136 ASN B ND2 5.51 -8.16 45.087 1 787 +ATOM O OD1 . ASN B 1 136 . 136 ASN B OD1 5.668 -6.295 46.315 1 788 +ATOM N N . ASN B 1 137 . 137 ASN B N 6.095 -3.274 44.917 1 789 +ATOM C CA . ASN B 1 137 . 137 ASN B CA 5.741 -2.033 45.663 1 790 +ATOM C C . ASN B 1 137 . 137 ASN B C 5.216 -2.356 47.073 1 791 +ATOM O O . ASN B 1 137 . 137 ASN B O 5.117 -1.417 47.881 1 792 +ATOM C CB . ASN B 1 137 . 137 ASN B CB 4.715 -1.187 44.897 1 793 +ATOM C CG . ASN B 1 137 . 137 ASN B CG 3.44 -1.935 44.566 1 794 +ATOM N ND2 . ASN B 1 137 . 137 ASN B ND2 2.318 -1.235 44.57 1 795 +ATOM O OD1 . ASN B 1 137 . 137 ASN B OD1 3.463 -3.136 44.296 1 796 +ATOM N N . ASP B 1 138 . 138 ASP B N 4.88 -3.62 47.35 1 797 +ATOM C CA . ASP B 1 138 . 138 ASP B CA 4.328 -4.09 48.651 1 798 +ATOM C C . ASP B 1 138 . 138 ASP B C 5.467 -4.434 49.623 1 799 +ATOM O O . ASP B 1 138 . 138 ASP B O 5.208 -4.487 50.84 1 800 +ATOM C CB . ASP B 1 138 . 138 ASP B CB 3.395 -5.286 48.438 1 801 +ATOM C CG . ASP B 1 138 . 138 ASP B CG 2.498 -5.623 49.618 1 802 +ATOM O OD1 . ASP B 1 138 . 138 ASP B OD1 2.067 -4.687 50.323 1 803 +ATOM O OD2 . ASP B 1 138 . 138 ASP B OD2 2.226 -6.827 49.818 1 804 +ATOM N N . CYS B 1 139 . 139 CYS B N 6.681 -4.669 49.119 1 805 +ATOM C CA . CYS B 1 139 . 139 CYS B CA 7.876 -4.974 49.949 1 806 +ATOM C C . CYS B 1 139 . 139 CYS B C 8.45 -3.663 50.501 1 807 +ATOM O O . CYS B 1 139 . 139 CYS B O 8.173 -2.601 49.91 1 808 +ATOM C CB . CYS B 1 139 . 139 CYS B CB 8.92 -5.755 49.16 1 809 +ATOM S SG . CYS B 1 139 . 139 CYS B SG 10.156 -4.714 48.345 1 810 +ATOM N N . GLU B 1 140 . 140 GLU B N 9.211 -3.746 51.596 1 811 +ATOM C CA . GLU B 1 140 . 140 GLU B CA 9.765 -2.571 52.323 1 812 +ATOM C C . GLU B 1 140 . 140 GLU B C 11.294 -2.627 52.315 1 813 +ATOM O O . GLU B 1 140 . 140 GLU B O 11.85 -3.728 52.479 1 814 +ATOM C CB . GLU B 1 140 . 140 GLU B CB 9.224 -2.516 53.75 1 815 +ATOM C CG . GLU B 1 140 . 140 GLU B CG 7.755 -2.151 53.811 1 816 +ATOM C CD . GLU B 1 140 . 140 GLU B CD 7.119 -2.227 55.189 1 817 +ATOM O OE1 . GLU B 1 140 . 140 GLU B OE1 7.846 -2.499 56.168 1 818 +ATOM O OE2 . GLU B 1 140 . 140 GLU B OE2 5.889 -2.01 55.276 1 819 +ATOM N N . ILE B 1 141 . 141 ILE B N 11.93 -1.466 52.137 1 820 +ATOM C CA . ILE B 1 141 . 141 ILE B CA 13.413 -1.293 52.135 1 821 +ATOM C C . ILE B 1 141 . 141 ILE B C 13.787 -0.33 53.265 1 822 +ATOM O O . ILE B 1 141 . 141 ILE B O 13.237 0.782 53.302 1 823 +ATOM C CB . ILE B 1 141 . 141 ILE B CB 13.911 -0.805 50.759 1 824 +ATOM C CG1 . ILE B 1 141 . 141 ILE B CG1 13.562 -1.815 49.661 1 825 +ATOM C CG2 . ILE B 1 141 . 141 ILE B CG2 15.403 -0.493 50.804 1 826 +ATOM C CD1 . ILE B 1 141 . 141 ILE B CD1 13.971 -1.403 48.267 1 827 +ATOM N N . VAL B 1 142 . 142 VAL B N 14.688 -0.767 54.147 1 828 +ATOM C CA . VAL B 1 142 . 142 VAL B CA 15.221 0.025 55.293 1 829 +ATOM C C . VAL B 1 142 . 142 VAL B C 16.69 0.341 55.005 1 830 +ATOM O O . VAL B 1 142 . 142 VAL B O 17.476 -0.615 54.869 1 831 +ATOM C CB . VAL B 1 142 . 142 VAL B CB 15.055 -0.74 56.619 1 832 +ATOM C CG1 . VAL B 1 142 . 142 VAL B CG1 15.671 0.013 57.787 1 833 +ATOM C CG2 . VAL B 1 142 . 142 VAL B CG2 13.595 -1.06 56.895 1 834 +ATOM N N . CYS B 1 143 . 143 CYS B N 17.032 1.626 54.894 1 835 +ATOM C CA . CYS B 1 143 . 143 CYS B CA 18.412 2.119 54.637 1 836 +ATOM C C . CYS B 1 143 . 143 CYS B C 18.894 2.961 55.823 1 837 +ATOM O O . CYS B 1 143 . 143 CYS B O 18.147 3.864 56.257 1 838 +ATOM C CB . CYS B 1 143 . 143 CYS B CB 18.476 2.943 53.358 1 839 +ATOM S SG . CYS B 1 143 . 143 CYS B SG 17.982 2.021 51.878 1 840 +ATOM N N . SER B 1 144 . 144 SER B N 20.093 2.658 56.325 1 841 +ATOM C CA . SER B 1 144 . 144 SER B CA 20.825 3.457 57.341 1 842 +ATOM C C . SER B 1 144 . 144 SER B C 21.178 4.82 56.738 1 843 +ATOM O O . SER B 1 144 . 144 SER B O 21.612 4.843 55.576 1 844 +ATOM C CB . SER B 1 144 . 144 SER B CB 22.058 2.723 57.816 1 845 +ATOM O OG . SER B 1 144 . 144 SER B OG 22.896 3.565 58.59 1 846 +ATOM N N . THR B 1 145 . 145 THR B N 20.992 5.904 57.498 1 847 +ATOM C CA . THR B 1 145 . 145 THR B CA 21.335 7.293 57.084 1 848 +ATOM C C . THR B 1 145 . 145 THR B C 22.847 7.525 57.225 1 849 +ATOM O O . THR B 1 145 . 145 THR B O 23.295 8.629 56.881 1 850 +ATOM C CB . THR B 1 145 . 145 THR B CB 20.526 8.333 57.871 1 851 +ATOM C CG2 . THR B 1 145 . 145 THR B CG2 19.047 8.298 57.549 1 852 +ATOM O OG1 . THR B 1 145 . 145 THR B OG1 20.712 8.1 59.265 1 853 +ATOM N N . LEU B 1 146 . 146 LEU B N 23.605 6.531 57.698 1 854 +ATOM C CA . LEU B 1 146 . 146 LEU B CA 25.093 6.584 57.746 1 855 +ATOM C C . LEU B 1 146 . 146 LEU B C 25.667 6.007 56.443 1 856 +ATOM O O . LEU B 1 146 . 146 LEU B O 26.9 5.954 56.313 1 857 +ATOM C CB . LEU B 1 146 . 146 LEU B CB 25.6 5.817 58.974 1 858 +ATOM C CG . LEU B 1 146 . 146 LEU B CG 25.298 6.452 60.333 1 859 +ATOM C CD1 . LEU B 1 146 . 146 LEU B CD1 25.735 5.536 61.468 1 860 +ATOM C CD2 . LEU B 1 146 . 146 LEU B CD2 25.968 7.813 60.475 1 861 +HETATM N N . CSD B 1 147 . 147 CSD B N 24.804 5.607 55.506 1 862 +HETATM C CA . CSD B 1 147 . 147 CSD B CA 25.186 4.938 54.233 1 863 +HETATM C C . CSD B 1 147 . 147 CSD B C 25.915 5.927 53.316 1 864 +HETATM O O . CSD B 1 147 . 147 CSD B O 25.432 7.068 53.157 1 865 +HETATM C CB . CSD B 1 147 . 147 CSD B CB 23.979 4.329 53.514 1 866 +HETATM S SG . CSD B 1 147 . 147 CSD B SG 23.739 2.606 54.041 1 867 +HETATM O OD1 . CSD B 1 147 . 147 CSD B OD1 22.612 2.088 53.229 1 868 +HETATM O OD2 . CSD B 1 147 . 147 CSD B OD2 24.996 1.726 53.602 1 869 +ATOM N N . HIS B 1 148 . 148 HIS B N 27.046 5.487 52.756 1 870 +ATOM C CA . HIS B 1 148 . 148 HIS B CA 27.744 6.11 51.599 1 871 +ATOM C C . HIS B 1 148 . 148 HIS B C 26.755 6.162 50.428 1 872 +ATOM O O . HIS B 1 148 . 148 HIS B O 25.926 5.239 50.33 1 873 +ATOM C CB . HIS B 1 148 . 148 HIS B CB 29.011 5.301 51.278 1 874 +ATOM C CG . HIS B 1 148 . 148 HIS B CG 29.966 5.959 50.338 1 875 +ATOM C CD2 . HIS B 1 148 . 148 HIS B CD2 31.151 6.57 50.557 1 876 +ATOM N ND1 . HIS B 1 148 . 148 HIS B ND1 29.763 5.987 48.975 1 877 +ATOM C CE1 . HIS B 1 148 . 148 HIS B CE1 30.769 6.611 48.394 1 878 +ATOM N NE2 . HIS B 1 148 . 148 HIS B NE2 31.636 6.977 49.345 1 879 +ATOM N N . GLU B 1 149 . 149 GLU B N 26.834 7.186 49.575 1 880 +ATOM C CA . GLU B 1 149 . 149 GLU B CA 25.855 7.395 48.472 1 881 +ATOM C C . GLU B 1 149 . 149 GLU B C 25.99 6.274 47.428 1 882 +ATOM O O . GLU B 1 149 . 149 GLU B O 25.004 6.024 46.711 1 883 +ATOM C CB . GLU B 1 149 . 149 GLU B CB 26.015 8.782 47.85 1 884 +ATOM C CG . GLU B 1 149 . 149 GLU B CG 24.734 9.304 47.226 1 885 +ATOM C CD . GLU B 1 149 . 149 GLU B CD 23.634 9.691 48.2 1 886 +ATOM O OE1 . GLU B 1 149 . 149 GLU B OE1 23.856 9.594 49.432 1 887 +ATOM O OE2 . GLU B 1 149 . 149 GLU B OE2 22.546 10.077 47.724 1 888 +ATOM N N . ALA B 1 150 . 150 ALA B N 27.151 5.616 47.352 1 889 +ATOM C CA . ALA B 1 150 . 150 ALA B CA 27.362 4.353 46.604 1 890 +ATOM C C . ALA B 1 150 . 150 ALA B C 26.198 3.389 46.876 1 891 +ATOM O O . ALA B 1 150 . 150 ALA B O 25.636 2.834 45.908 1 892 +ATOM C CB . ALA B 1 150 . 150 ALA B CB 28.68 3.737 46.999 1 893 +ATOM N N . ALA B 1 151 . 151 ALA B N 25.851 3.203 48.152 1 894 +ATOM C CA . ALA B 1 151 . 151 ALA B CA 24.759 2.317 48.613 1 895 +ATOM C C . ALA B 1 151 . 151 ALA B C 23.409 3.021 48.422 1 896 +ATOM O O . ALA B 1 151 . 151 ALA B O 22.513 2.422 47.796 1 897 +ATOM C CB . ALA B 1 151 . 151 ALA B CB 24.992 1.917 50.049 1 898 +ATOM N N . ALA B 1 152 . 152 ALA B N 23.278 4.256 48.917 1 899 +ATOM C CA . ALA B 1 152 . 152 ALA B CA 22.012 5.03 48.927 1 900 +ATOM C C . ALA B 1 152 . 152 ALA B C 21.481 5.202 47.498 1 901 +ATOM O O . ALA B 1 152 . 152 ALA B O 20.263 5.013 47.31 1 902 +ATOM C CB . ALA B 1 152 . 152 ALA B CB 22.211 6.362 49.608 1 903 +ATOM N N . SER B 1 153 . 153 SER B N 22.35 5.542 46.535 1 904 +ATOM C CA . SER B 1 153 . 153 SER B CA 21.986 5.743 45.104 1 905 +ATOM C C . SER B 1 153 . 153 SER B C 21.404 4.45 44.523 1 906 +ATOM O O . SER B 1 153 . 153 SER B O 20.422 4.541 43.768 1 907 +ATOM C CB . SER B 1 153 . 153 SER B CB 23.148 6.227 44.279 1 908 +ATOM O OG . SER B 1 153 . 153 SER B OG 23.321 7.627 44.43 1 909 +ATOM N N . ALA B 1 154 . 154 ALA B N 21.991 3.3 44.862 1 910 +ATOM C CA . ALA B 1 154 . 154 ALA B CA 21.553 1.962 44.403 1 911 +ATOM C C . ALA B 1 154 . 154 ALA B C 20.135 1.677 44.915 1 912 +ATOM O O . ALA B 1 154 . 154 ALA B O 19.288 1.263 44.106 1 913 +ATOM C CB . ALA B 1 154 . 154 ALA B CB 22.531 0.905 44.857 1 914 +ATOM N N . TRP B 1 155 . 155 TRP B N 19.881 1.907 46.205 1 915 +ATOM C CA . TRP B 1 155 . 155 TRP B CA 18.609 1.517 46.875 1 916 +ATOM C C . TRP B 1 155 . 155 TRP B C 17.524 2.566 46.604 1 917 +ATOM O O . TRP B 1 155 . 155 TRP B O 16.352 2.175 46.545 1 918 +ATOM C CB . TRP B 1 155 . 155 TRP B CB 18.833 1.254 48.37 1 919 +ATOM C CG . TRP B 1 155 . 155 TRP B CG 20.064 0.445 48.643 1 920 +ATOM C CD1 . TRP B 1 155 . 155 TRP B CD1 21.022 0.698 49.582 1 921 +ATOM C CD2 . TRP B 1 155 . 155 TRP B CD2 20.503 -0.725 47.928 1 922 +ATOM C CE2 . TRP B 1 155 . 155 TRP B CE2 21.724 -1.127 48.51 1 923 +ATOM C CE3 . TRP B 1 155 . 155 TRP B CE3 19.985 -1.478 46.867 1 924 +ATOM N NE1 . TRP B 1 155 . 155 TRP B NE1 22.015 -0.241 49.514 1 925 +ATOM C CZ2 . TRP B 1 155 . 155 TRP B CZ2 22.43 -2.243 48.061 1 926 +ATOM C CZ3 . TRP B 1 155 . 155 TRP B CZ3 20.683 -2.582 46.425 1 927 +ATOM C CH2 . TRP B 1 155 . 155 TRP B CH2 21.891 -2.956 47.015 1 928 +ATOM N N . ILE B 1 156 . 156 ILE B N 17.899 3.835 46.411 1 929 +ATOM C CA . ILE B 1 156 . 156 ILE B CA 16.97 4.909 45.942 1 930 +ATOM C C . ILE B 1 156 . 156 ILE B C 16.5 4.581 44.517 1 931 +ATOM O O . ILE B 1 156 . 156 ILE B O 15.283 4.69 44.263 1 932 +ATOM C CB . ILE B 1 156 . 156 ILE B CB 17.611 6.31 46.046 1 933 +ATOM C CG1 . ILE B 1 156 . 156 ILE B CG1 17.734 6.757 47.505 1 934 +ATOM C CG2 . ILE B 1 156 . 156 ILE B CG2 16.836 7.328 45.219 1 935 +ATOM C CD1 . ILE B 1 156 . 156 ILE B CD1 18.667 7.931 47.721 1 936 +ATOM N N . HIS B 1 157 . 157 HIS B N 17.424 4.197 43.627 1 937 +ATOM C CA . HIS B 1 157 . 157 HIS B CA 17.14 3.843 42.208 1 938 +ATOM C C . HIS B 1 157 . 157 HIS B C 16.263 2.587 42.156 1 939 +ATOM O O . HIS B 1 157 . 157 HIS B O 15.282 2.592 41.385 1 940 +ATOM C CB . HIS B 1 157 . 157 HIS B CB 18.438 3.672 41.406 1 941 +ATOM C CG . HIS B 1 157 . 157 HIS B CG 18.217 3.282 39.983 1 942 +ATOM C CD2 . HIS B 1 157 . 157 HIS B CD2 18.56 2.167 39.298 1 943 +ATOM N ND1 . HIS B 1 157 . 157 HIS B ND1 17.568 4.107 39.081 1 944 +ATOM C CE1 . HIS B 1 157 . 157 HIS B CE1 17.516 3.515 37.905 1 945 +ATOM N NE2 . HIS B 1 157 . 157 HIS B NE2 18.121 2.324 38.011 1 946 +ATOM N N . LEU B 1 158 . 158 LEU B N 16.604 1.563 42.947 1 947 +ATOM C CA . LEU B 1 158 . 158 LEU B CA 15.821 0.299 43.059 1 948 +ATOM C C . LEU B 1 158 . 158 LEU B C 14.409 0.616 43.57 1 949 +ATOM O O . LEU B 1 158 . 158 LEU B O 13.441 0.084 42.991 1 950 +ATOM C CB . LEU B 1 158 . 158 LEU B CB 16.549 -0.674 43.994 1 951 +ATOM C CG . LEU B 1 158 . 158 LEU B CG 15.832 -1.997 44.277 1 952 +ATOM C CD1 . LEU B 1 158 . 158 LEU B CD1 15.514 -2.743 42.989 1 953 +ATOM C CD2 . LEU B 1 158 . 158 LEU B CD2 16.659 -2.872 45.205 1 954 +ATOM N N . SER B 1 159 . 159 SER B N 14.309 1.454 44.609 1 955 +ATOM C CA . SER B 1 159 . 159 SER B CA 13.041 1.927 45.229 1 956 +ATOM C C . SER B 1 159 . 159 SER B C 12.173 2.654 44.195 1 957 +ATOM O O . SER B 1 159 . 159 SER B O 10.972 2.343 44.116 1 958 +ATOM C CB . SER B 1 159 . 159 SER B CB 13.32 2.811 46.41 1 959 +ATOM O OG . SER B 1 159 . 159 SER B OG 13.955 2.074 47.437 1 960 +ATOM N N . ARG B 1 160 . 160 ARG B N 12.762 3.591 43.445 1 961 +ATOM C CA . ARG B 1 160 . 160 ARG B CA 12.06 4.417 42.424 1 962 +ATOM C C . ARG B 1 160 . 160 ARG B C 11.517 3.516 41.305 1 963 +ATOM O O . ARG B 1 160 . 160 ARG B O 10.386 3.77 40.845 1 964 +ATOM C CB . ARG B 1 160 . 160 ARG B CB 12.997 5.494 41.866 1 965 +ATOM C CG . ARG B 1 160 . 160 ARG B CG 12.3 6.535 41.0 1 966 +ATOM C CD . ARG B 1 160 . 160 ARG B CD 13.235 7.613 40.476 1 967 +ATOM N NE . ARG B 1 160 . 160 ARG B NE 13.729 8.486 41.54 1 968 +ATOM C CZ . ARG B 1 160 . 160 ARG B CZ 14.965 8.483 42.054 1 969 +ATOM N NH1 . ARG B 1 160 . 160 ARG B NH1 15.89 7.645 41.608 1 970 +ATOM N NH2 . ARG B 1 160 . 160 ARG B NH2 15.277 9.336 43.017 1 971 +ATOM N N . GLU B 1 161 . 161 GLU B N 12.287 2.504 40.887 1 972 +ATOM C CA . GLU B 1 161 . 161 GLU B CA 11.904 1.547 39.81 1 973 +ATOM C C . GLU B 1 161 . 161 GLU B C 10.742 0.662 40.28 1 974 +ATOM O O . GLU B 1 161 . 161 GLU B O 9.89 0.331 39.44 1 975 +ATOM C CB . GLU B 1 161 . 161 GLU B CB 13.103 0.692 39.391 1 976 +ATOM C CG . GLU B 1 161 . 161 GLU B CG 14.067 1.415 38.468 1 977 +ATOM C CD . GLU B 1 161 . 161 GLU B CD 15.344 0.654 38.147 1 978 +ATOM O OE1 . GLU B 1 161 . 161 GLU B OE1 15.865 -0.042 39.049 1 979 +ATOM O OE2 . GLU B 1 161 . 161 GLU B OE2 15.818 0.76 36.996 1 980 +ATOM N N . LEU B 1 162 . 162 LEU B N 10.708 0.304 41.567 1 981 +ATOM C CA . LEU B 1 162 . 162 LEU B CA 9.692 -0.611 42.157 1 982 +ATOM C C . LEU B 1 162 . 162 LEU B C 8.458 0.17 42.63 1 983 +ATOM O O . LEU B 1 162 . 162 LEU B O 7.425 -0.475 42.863 1 984 +ATOM C CB . LEU B 1 162 . 162 LEU B CB 10.327 -1.39 43.315 1 985 +ATOM C CG . LEU B 1 162 . 162 LEU B CG 11.293 -2.505 42.91 1 986 +ATOM C CD1 . LEU B 1 162 . 162 LEU B CD1 12.022 -3.06 44.125 1 987 +ATOM C CD2 . LEU B 1 162 . 162 LEU B CD2 10.563 -3.62 42.174 1 988 +ATOM N N . GLY B 1 163 . 163 GLY B N 8.554 1.498 42.755 1 989 +ATOM C CA . GLY B 1 163 . 163 GLY B CA 7.484 2.353 43.306 1 990 +ATOM C C . GLY B 1 163 . 163 GLY B C 7.454 2.302 44.828 1 991 +ATOM O O . GLY B 1 163 . 163 GLY B O 6.364 2.464 45.402 1 992 +ATOM N N . ILE B 1 164 . 164 ILE B N 8.619 2.097 45.454 1 993 +ATOM C CA . ILE B 1 164 . 164 ILE B CA 8.802 1.961 46.93 1 994 +ATOM C C . ILE B 1 164 . 164 ILE B C 9.317 3.29 47.492 1 995 +ATOM O O . ILE B 1 164 . 164 ILE B O 10.139 3.943 46.819 1 996 +ATOM C CB . ILE B 1 164 . 164 ILE B CB 9.768 0.801 47.254 1 997 +ATOM C CG1 . ILE B 1 164 . 164 ILE B CG1 9.146 -0.556 46.926 1 998 +ATOM C CG2 . ILE B 1 164 . 164 ILE B CG2 10.245 0.857 48.698 1 999 +ATOM C CD1 . ILE B 1 164 . 164 ILE B CD1 10.084 -1.718 47.148 1 1000 +ATOM N N . THR B 1 165 . 165 THR B N 8.856 3.653 48.691 1 1001 +ATOM C CA . THR B 1 165 . 165 THR B CA 9.415 4.75 49.521 1 1002 +ATOM C C . THR B 1 165 . 165 THR B C 10.254 4.118 50.636 1 1003 +ATOM O O . THR B 1 165 . 165 THR B O 9.711 3.289 51.389 1 1004 +ATOM C CB . THR B 1 165 . 165 THR B CB 8.306 5.657 50.07 1 1005 +ATOM C CG2 . THR B 1 165 . 165 THR B CG2 8.837 6.804 50.899 1 1006 +ATOM O OG1 . THR B 1 165 . 165 THR B OG1 7.573 6.178 48.961 1 1007 +ATOM N N . ILE B 1 166 . 166 ILE B N 11.531 4.49 50.724 1 1008 +ATOM C CA . ILE B 1 166 . 166 ILE B CA 12.498 3.92 51.706 1 1009 +ATOM C C . ILE B 1 166 . 166 ILE B C 12.065 4.338 53.113 1 1010 +ATOM O O . ILE B 1 166 . 166 ILE B O 11.677 5.508 53.297 1 1011 +ATOM C CB . ILE B 1 166 . 166 ILE B CB 13.943 4.36 51.396 1 1012 +ATOM C CG1 . ILE B 1 166 . 166 ILE B CG1 14.4 3.872 50.019 1 1013 +ATOM C CG2 . ILE B 1 166 . 166 ILE B CG2 14.888 3.903 52.499 1 1014 +ATOM C CD1 . ILE B 1 166 . 166 ILE B CD1 15.705 4.47 49.554 1 1015 +ATOM N N . LYS B 1 167 . 167 LYS B N 12.111 3.395 54.054 1 1016 +ATOM C CA . LYS B 1 167 . 167 LYS B CA 11.966 3.647 55.507 1 1017 +ATOM C C . LYS B 1 167 . 167 LYS B C 13.369 3.858 56.074 1 1018 +ATOM O O . LYS B 1 167 . 167 LYS B O 14.036 2.852 56.371 1 1019 +ATOM C CB . LYS B 1 167 . 167 LYS B CB 11.264 2.469 56.184 1 1020 +ATOM C CG . LYS B 1 167 . 167 LYS B CG 9.933 2.069 55.568 1 1021 +ATOM C CD . LYS B 1 167 . 167 LYS B CD 9.279 0.909 56.28 1 1022 +ATOM C CE . LYS B 1 167 . 167 LYS B CE 8.844 1.244 57.691 1 1023 +ATOM N NZ . LYS B 1 167 . 167 LYS B NZ 8.048 0.145 58.285 1 1024 +ATOM N N . TRP B 1 168 . 168 TRP B N 13.807 5.113 56.185 1 1025 +ATOM C CA . TRP B 1 168 . 168 TRP B CA 15.194 5.462 56.583 1 1026 +ATOM C C . TRP B 1 168 . 168 TRP B C 15.421 5.11 58.057 1 1027 +ATOM O O . TRP B 1 168 . 168 TRP B O 14.519 5.346 58.881 1 1028 +ATOM C CB . TRP B 1 168 . 168 TRP B CB 15.505 6.93 56.284 1 1029 +ATOM C CG . TRP B 1 168 . 168 TRP B CG 15.683 7.204 54.824 1 1030 +ATOM C CD1 . TRP B 1 168 . 168 TRP B CD1 14.761 7.734 53.971 1 1031 +ATOM C CD2 . TRP B 1 168 . 168 TRP B CD2 16.855 6.939 54.032 1 1032 +ATOM C CE2 . TRP B 1 168 . 168 TRP B CE2 16.564 7.353 52.716 1 1033 +ATOM C CE3 . TRP B 1 168 . 168 TRP B CE3 18.12 6.407 54.306 1 1034 +ATOM N NE1 . TRP B 1 168 . 168 TRP B NE1 15.283 7.833 52.711 1 1035 +ATOM C CZ2 . TRP B 1 168 . 168 TRP B CZ2 17.487 7.242 51.678 1 1036 +ATOM C CZ3 . TRP B 1 168 . 168 TRP B CZ3 19.032 6.295 53.279 1 1037 +ATOM C CH2 . TRP B 1 168 . 168 TRP B CH2 18.718 6.709 51.984 1 1038 +ATOM N N . TRP B 1 169 . 169 TRP B N 16.596 4.554 58.346 1 1039 +ATOM C CA . TRP B 1 169 . 169 TRP B CA 17.026 4.058 59.679 1 1040 +ATOM C C . TRP B 1 169 . 169 TRP B C 18.068 5.028 60.246 1 1041 +ATOM O O . TRP B 1 169 . 169 TRP B O 19.195 5.078 59.709 1 1042 +ATOM C CB . TRP B 1 169 . 169 TRP B CB 17.541 2.62 59.526 1 1043 +ATOM C CG . TRP B 1 169 . 169 TRP B CG 18.217 2.014 60.718 1 1044 +ATOM C CD1 . TRP B 1 169 . 169 TRP B CD1 18.086 2.365 62.031 1 1045 +ATOM C CD2 . TRP B 1 169 . 169 TRP B CD2 19.106 0.885 60.692 1 1046 +ATOM C CE2 . TRP B 1 169 . 169 TRP B CE2 19.487 0.63 62.027 1 1047 +ATOM C CE3 . TRP B 1 169 . 169 TRP B CE3 19.621 0.074 59.674 1 1048 +ATOM N NE1 . TRP B 1 169 . 169 TRP B NE1 18.85 1.548 62.819 1 1049 +ATOM C CZ2 . TRP B 1 169 . 169 TRP B CZ2 20.356 -0.407 62.364 1 1050 +ATOM C CZ3 . TRP B 1 169 . 169 TRP B CZ3 20.484 -0.946 60.009 1 1051 +ATOM C CH2 . TRP B 1 169 . 169 TRP B CH2 20.848 -1.179 61.336 1 1052 +ATOM N N . SER B 1 170 . 170 SER B N 17.68 5.797 61.267 1 1053 +ATOM C CA . SER B 1 170 . 170 SER B CA 18.518 6.839 61.91 1 1054 +ATOM C C . SER B 1 170 . 170 SER B C 19.367 6.205 63.007 1 1055 +ATOM O O . SER B 1 170 . 170 SER B O 18.887 5.353 63.756 1 1056 +ATOM C CB . SER B 1 170 . 170 SER B CB 17.677 7.972 62.439 1 1057 +ATOM O OG . SER B 1 170 . 170 SER B OG 17.27 8.819 61.375 1 1058 +ATOM N N . PRO B 1 171 . 171 PRO B N 20.66 6.586 63.119 1 1059 +ATOM C CA . PRO B 1 171 . 171 PRO B CA 21.505 6.119 64.215 1 1060 +ATOM C C . PRO B 1 171 . 171 PRO B C 21.109 6.797 65.536 1 1061 +ATOM O O . PRO B 1 171 . 171 PRO B O 20.363 7.758 65.508 1 1062 +ATOM C CB . PRO B 1 171 . 171 PRO B CB 22.919 6.512 63.764 1 1063 +ATOM C CG . PRO B 1 171 . 171 PRO B CG 22.7 7.736 62.9 1 1064 +ATOM C CD . PRO B 1 171 . 171 PRO B CD 21.381 7.482 62.199 1 1065 +ATOM N N . THR B 1 172 . 172 THR B N 21.608 6.269 66.653 1 1066 +ATOM C CA . THR B 1 172 . 172 THR B CA 21.316 6.747 68.03 1 1067 +ATOM C C . THR B 1 172 . 172 THR B C 22.494 7.594 68.523 1 1068 +ATOM O O . THR B 1 172 . 172 THR B O 23.65 7.192 68.294 1 1069 +ATOM C CB . THR B 1 172 . 172 THR B CB 21.019 5.562 68.958 1 1070 +ATOM C CG2 . THR B 1 172 . 172 THR B CG2 20.655 5.976 70.366 1 1071 +ATOM O OG1 . THR B 1 172 . 172 THR B OG1 19.933 4.826 68.397 1 1072 +ATOM N N . THR B 1 173 . 173 THR B N 22.199 8.739 69.137 1 1073 +ATOM C CA . THR B 1 173 . 173 THR B CA 23.151 9.537 69.951 1 1074 +ATOM C C . THR B 1 173 . 173 THR B C 22.593 9.636 71.375 1 1075 +ATOM O O . THR B 1 173 . 173 THR B O 21.365 9.464 71.551 1 1076 +ATOM C CB . THR B 1 173 . 173 THR B CB 23.42 10.918 69.336 1 1077 +ATOM C CG2 . THR B 1 173 . 173 THR B CG2 24.011 10.844 67.945 1 1078 +ATOM O OG1 . THR B 1 173 . 173 THR B OG1 22.194 11.651 69.301 1 1079 +ATOM N N . THR B 1 174 . 174 THR B N 23.481 9.855 72.347 1 1080 +ATOM C CA . THR B 1 174 . 174 THR B CA 23.162 10.19 73.759 1 1081 +ATOM C C . THR B 1 174 . 174 THR B C 23.583 11.637 74.006 1 1082 +ATOM O O . THR B 1 174 . 174 THR B O 24.457 12.149 73.31 1 1083 +ATOM C CB . THR B 1 174 . 174 THR B CB 23.86 9.234 74.735 1 1084 +ATOM C CG2 . THR B 1 174 . 174 THR B CG2 23.581 7.774 74.441 1 1085 +ATOM O OG1 . THR B 1 174 . 174 THR B OG1 25.264 9.489 74.67 1 1086 +ATOM N N . PRO B 1 175 . 175 PRO B N 22.984 12.347 74.988 1 1087 +ATOM C CA . PRO B 1 175 . 175 PRO B CA 23.35 13.739 75.26 1 1088 +ATOM C C . PRO B 1 175 . 175 PRO B C 24.814 13.918 75.699 1 1089 +ATOM O O . PRO B 1 175 . 175 PRO B O 25.361 14.978 75.449 1 1090 +ATOM C CB . PRO B 1 175 . 175 PRO B CB 22.403 14.159 76.398 1 1091 +ATOM C CG . PRO B 1 175 . 175 PRO B CG 21.266 13.154 76.338 1 1092 +ATOM C CD . PRO B 1 175 . 175 PRO B CD 21.911 11.867 75.872 1 1093 +ATOM N N . ASN B 1 176 . 176 ASN B N 25.4 12.895 76.335 1 1094 +ATOM C CA . ASN B 1 176 . 176 ASN B CA 26.803 12.907 76.833 1 1095 +ATOM C C . ASN B 1 176 . 176 ASN B C 27.784 12.637 75.68 1 1096 +ATOM O O . ASN B 1 176 . 176 ASN B O 28.974 12.962 75.856 1 1097 +ATOM C CB . ASN B 1 176 . 176 ASN B CB 27.016 11.953 78.018 1 1098 +ATOM C CG . ASN B 1 176 . 176 ASN B CG 26.807 10.486 77.692 1 1099 +ATOM N ND2 . ASN B 1 176 . 176 ASN B ND2 27.723 9.636 78.134 1 1100 +ATOM O OD1 . ASN B 1 176 . 176 ASN B OD1 25.809 10.114 77.078 1 1101 +ATOM N N . SER B 1 177 . 177 SER B N 27.32 12.081 74.55 1 1102 +ATOM C CA . SER B 1 177 . 177 SER B CA 28.135 11.835 73.326 1 1103 +ATOM C C . SER B 1 177 . 177 SER B C 27.336 12.204 72.076 1 1104 +ATOM O O . SER B 1 177 . 177 SER B O 27.016 11.343 71.256 1 1105 +ATOM C CB . SER B 1 177 . 177 SER B CB 28.626 10.407 73.278 1 1106 +ATOM O OG . SER B 1 177 . 177 SER B OG 29.665 10.193 74.222 1 1107 +ATOM N N . PRO B 1 178 . 178 PRO B N 27.009 13.502 71.876 1 1108 +ATOM C CA . PRO B 1 178 . 178 PRO B CA 26.061 13.908 70.836 1 1109 +ATOM C C . PRO B 1 178 . 178 PRO B C 26.591 13.761 69.399 1 1110 +ATOM O O . PRO B 1 178 . 178 PRO B O 25.784 13.685 68.501 1 1111 +ATOM C CB . PRO B 1 178 . 178 PRO B CB 25.794 15.386 71.169 1 1112 +ATOM C CG . PRO B 1 178 . 178 PRO B CG 27.069 15.849 71.837 1 1113 +ATOM C CD . PRO B 1 178 . 178 PRO B CD 27.553 14.648 72.624 1 1114 +ATOM N N . ASP B 1 179 . 179 ASP B N 27.916 13.715 69.222 1 1115 +ATOM C CA . ASP B 1 179 . 179 ASP B CA 28.592 13.629 67.899 1 1116 +ATOM C C . ASP B 1 179 . 179 ASP B C 29.11 12.202 67.662 1 1117 +ATOM O O . ASP B 1 179 . 179 ASP B O 29.943 12.028 66.747 1 1118 +ATOM C CB . ASP B 1 179 . 179 ASP B CB 29.729 14.653 67.809 1 1119 +ATOM C CG . ASP B 1 179 . 179 ASP B CG 29.282 16.092 68.002 1 1120 +ATOM O OD1 . ASP B 1 179 . 179 ASP B OD1 28.14 16.41 67.605 1 1121 +ATOM O OD2 . ASP B 1 179 . 179 ASP B OD2 30.083 16.889 68.548 1 1122 +ATOM N N . ASP B 1 180 . 180 ASP B N 28.626 11.226 68.441 1 1123 +ATOM C CA . ASP B 1 180 . 180 ASP B CA 29.046 9.797 68.396 1 1124 +ATOM C C . ASP B 1 180 . 180 ASP B C 27.852 8.959 67.937 1 1125 +ATOM O O . ASP B 1 180 . 180 ASP B O 27.174 8.338 68.754 1 1126 +ATOM C CB . ASP B 1 180 . 180 ASP B CB 29.588 9.363 69.762 1 1127 +ATOM C CG . ASP B 1 180 . 180 ASP B CG 30.255 7.997 69.804 1 1128 +ATOM O OD1 . ASP B 1 180 . 180 ASP B OD1 30.152 7.248 68.814 1 1129 +ATOM O OD2 . ASP B 1 180 . 180 ASP B OD2 30.875 7.688 70.845 1 1130 +ATOM N N . PRO B 1 181 . 181 PRO B N 27.55 8.913 66.617 1 1131 +ATOM C CA . PRO B 1 181 . 181 PRO B CA 26.371 8.212 66.121 1 1132 +ATOM C C . PRO B 1 181 . 181 PRO B C 26.634 6.703 66.12 1 1133 +ATOM O O . PRO B 1 181 . 181 PRO B O 27.616 6.278 65.54 1 1134 +ATOM C CB . PRO B 1 181 . 181 PRO B CB 26.167 8.734 64.689 1 1135 +ATOM C CG . PRO B 1 181 . 181 PRO B CG 27.26 9.774 64.479 1 1136 +ATOM C CD . PRO B 1 181 . 181 PRO B CD 28.326 9.496 65.517 1 1137 +ATOM N N . VAL B 1 182 . 182 VAL B N 25.772 5.935 66.781 1 1138 +ATOM C CA . VAL B 1 182 . 182 VAL B CA 25.892 4.45 66.833 1 1139 +ATOM C C . VAL B 1 182 . 182 VAL B C 24.627 3.84 66.226 1 1140 +ATOM O O . VAL B 1 182 . 182 VAL B O 23.544 4.442 66.347 1 1141 +ATOM C CB . VAL B 1 182 . 182 VAL B CB 26.175 3.942 68.262 1 1142 +ATOM C CG1 . VAL B 1 182 . 182 VAL B CG1 24.979 4.096 69.193 1 1143 +ATOM C CG2 . VAL B 1 182 . 182 VAL B CG2 26.658 2.501 68.241 1 1144 +ATOM N N . LEU B 1 183 . 183 LEU B N 24.788 2.695 65.569 1 1145 +ATOM C CA . LEU B 1 183 . 183 LEU B CA 23.699 1.94 64.906 1 1146 +ATOM C C . LEU B 1 183 . 183 LEU B C 23.307 0.787 65.833 1 1147 +ATOM O O . LEU B 1 183 . 183 LEU B O 24.17 -0.07 66.1 1 1148 +ATOM C CB . LEU B 1 183 . 183 LEU B CB 24.218 1.458 63.546 1 1149 +ATOM C CG . LEU B 1 183 . 183 LEU B CG 23.222 1.478 62.389 1 1150 +ATOM C CD1 . LEU B 1 183 . 183 LEU B CD1 22.638 2.867 62.168 1 1151 +ATOM C CD2 . LEU B 1 183 . 183 LEU B CD2 23.886 0.977 61.118 1 1152 +ATOM N N . THR B 1 184 . 184 THR B N 22.07 0.801 66.341 1 1153 +ATOM C CA . THR B 1 184 . 184 THR B CA 21.543 -0.181 67.326 1 1154 +ATOM C C . THR B 1 184 . 184 THR B C 20.443 -1.034 66.686 1 1155 +ATOM O O . THR B 1 184 . 184 THR B O 19.788 -0.551 65.748 1 1156 +ATOM C CB . THR B 1 184 . 184 THR B CB 21.0 0.518 68.58 1 1157 +ATOM C CG2 . THR B 1 184 . 184 THR B CG2 22.081 1.211 69.381 1 1158 +ATOM O OG1 . THR B 1 184 . 184 THR B OG1 20.007 1.466 68.184 1 1159 +ATOM N N . THR B 1 185 . 185 THR B N 20.249 -2.25 67.202 1 1160 +ATOM C CA . THR B 1 185 . 185 THR B CA 19.112 -3.146 66.869 1 1161 +ATOM C C . THR B 1 185 . 185 THR B C 17.815 -2.538 67.42 1 1162 +ATOM O O . THR B 1 185 . 185 THR B O 16.754 -2.795 66.831 1 1163 +ATOM C CB . THR B 1 185 . 185 THR B CB 19.336 -4.569 67.398 1 1164 +ATOM C CG2 . THR B 1 185 . 185 THR B CG2 20.58 -5.218 66.829 1 1165 +ATOM O OG1 . THR B 1 185 . 185 THR B OG1 19.424 -4.554 68.823 1 1166 +ATOM N N . ASP B 1 186 . 186 ASP B N 17.901 -1.756 68.502 1 1167 +ATOM C CA . ASP B 1 186 . 186 ASP B CA 16.734 -1.115 69.167 1 1168 +ATOM C C . ASP B 1 186 . 186 ASP B C 16.093 -0.097 68.214 1 1169 +ATOM O O . ASP B 1 186 . 186 ASP B O 14.854 -0.015 68.199 1 1170 +ATOM C CB . ASP B 1 186 . 186 ASP B CB 17.125 -0.484 70.506 1 1171 +ATOM C CG . ASP B 1 186 . 186 ASP B CG 17.769 -1.468 71.471 1 1172 +ATOM O OD1 . ASP B 1 186 . 186 ASP B OD1 19.007 -1.638 71.392 1 1173 +ATOM O OD2 . ASP B 1 186 . 186 ASP B OD2 17.031 -2.073 72.278 1 1174 +ATOM N N . SER B 1 187 . 187 SER B N 16.903 0.632 67.439 1 1175 +ATOM C CA . SER B 1 187 . 187 SER B CA 16.441 1.668 66.478 1 1176 +ATOM C C . SER B 1 187 . 187 SER B C 15.97 1.025 65.166 1 1177 +ATOM O O . SER B 1 187 . 187 SER B O 15.211 1.695 64.445 1 1178 +ATOM C CB . SER B 1 187 . 187 SER B CB 17.506 2.71 66.237 1 1179 +ATOM O OG . SER B 1 187 . 187 SER B OG 18.747 2.109 65.906 1 1180 +ATOM N N . LEU B 1 188 . 188 LEU B N 16.379 -0.216 64.872 1 1181 +ATOM C CA . LEU B 1 188 . 188 LEU B CA 15.99 -0.946 63.63 1 1182 +ATOM C C . LEU B 1 188 . 188 LEU B C 14.599 -1.577 63.797 1 1183 +ATOM O O . LEU B 1 188 . 188 LEU B O 13.793 -1.482 62.852 1 1184 +ATOM C CB . LEU B 1 188 . 188 LEU B CB 17.04 -2.017 63.305 1 1185 +ATOM C CG . LEU B 1 188 . 188 LEU B CG 16.732 -2.907 62.097 1 1186 +ATOM C CD1 . LEU B 1 188 . 188 LEU B CD1 16.566 -2.084 60.828 1 1187 +ATOM C CD2 . LEU B 1 188 . 188 LEU B CD2 17.816 -3.957 61.906 1 1188 +ATOM N N . LYS B 1 189 . 189 LYS B N 14.338 -2.203 64.948 1 1189 +ATOM C CA . LYS B 1 189 . 189 LYS B CA 13.154 -3.072 65.194 1 1190 +ATOM C C . LYS B 1 189 . 189 LYS B C 11.851 -2.351 64.846 1 1191 +ATOM O O . LYS B 1 189 . 189 LYS B O 10.993 -2.943 64.201 1 1192 +ATOM C CB . LYS B 1 189 . 189 LYS B CB 13.151 -3.579 66.639 1 1193 +ATOM C CG . LYS B 1 189 . 189 LYS B CG 14.077 -4.758 66.897 1 1194 +ATOM C CD . LYS B 1 189 . 189 LYS B CD 14.091 -5.231 68.336 1 1195 +ATOM C CE . LYS B 1 189 . 189 LYS B CE 15.31 -6.07 68.66 1 1196 +ATOM N NZ . LYS B 1 189 . 189 LYS B NZ 15.165 -6.774 69.955 1 1197 +ATOM N N . PRO B 1 190 . 190 PRO B N 11.63 -1.082 65.265 1 1198 +ATOM C CA . PRO B 1 190 . 190 PRO B CA 10.389 -0.374 64.934 1 1199 +ATOM C C . PRO B 1 190 . 190 PRO B C 10.138 -0.19 63.428 1 1200 +ATOM O O . PRO B 1 190 . 190 PRO B O 8.992 -0.048 63.058 1 1201 +ATOM C CB . PRO B 1 190 . 190 PRO B CB 10.548 0.997 65.616 1 1202 +ATOM C CG . PRO B 1 190 . 190 PRO B CG 11.558 0.752 66.717 1 1203 +ATOM C CD . PRO B 1 190 . 190 PRO B CD 12.504 -0.282 66.14 1 1204 +ATOM N N . LEU B 1 191 . 191 LEU B N 11.193 -0.197 62.608 1 1205 +ATOM C CA . LEU B 1 191 . 191 LEU B CA 11.105 -0.019 61.13 1 1206 +ATOM C C . LEU B 1 191 . 191 LEU B C 10.802 -1.357 60.443 1 1207 +ATOM O O . LEU B 1 191 . 191 LEU B O 10.405 -1.326 59.265 1 1208 +ATOM C CB . LEU B 1 191 . 191 LEU B CB 12.419 0.57 60.612 1 1209 +ATOM C CG . LEU B 1 191 . 191 LEU B CG 12.799 1.928 61.195 1 1210 +ATOM C CD1 . LEU B 1 191 . 191 LEU B CD1 14.238 2.275 60.852 1 1211 +ATOM C CD2 . LEU B 1 191 . 191 LEU B CD2 11.851 3.016 60.708 1 1212 +ATOM N N . LEU B 1 192 . 192 LEU B N 10.977 -2.48 61.149 1 1213 +ATOM C CA . LEU B 1 192 . 192 LEU B CA 10.752 -3.844 60.604 1 1214 +ATOM C C . LEU B 1 192 . 192 LEU B C 9.258 -4.165 60.625 1 1215 +ATOM O O . LEU B 1 192 . 192 LEU B O 8.571 -3.779 61.588 1 1216 +ATOM C CB . LEU B 1 192 . 192 LEU B CB 11.532 -4.875 61.427 1 1217 +ATOM C CG . LEU B 1 192 . 192 LEU B CG 13.05 -4.71 61.434 1 1218 +ATOM C CD1 . LEU B 1 192 . 192 LEU B CD1 13.703 -5.832 62.225 1 1219 +ATOM C CD2 . LEU B 1 192 . 192 LEU B CD2 13.607 -4.659 60.021 1 1220 +ATOM N N . SER B 1 193 . 193 SER B N 8.791 -4.854 59.585 1 1221 +ATOM C CA . SER B 1 193 . 193 SER B CA 7.433 -5.443 59.482 1 1222 +ATOM C C . SER B 1 193 . 193 SER B C 7.56 -6.794 58.79 1 1223 +ATOM O O . SER B 1 193 . 193 SER B O 8.616 -7.11 58.246 1 1224 +ATOM C CB . SER B 1 193 . 193 SER B CB 6.489 -4.516 58.75 1 1225 +ATOM O OG . SER B 1 193 . 193 SER B OG 6.586 -4.682 57.343 1 1226 +ATOM N N . PRO B 1 194 . 194 PRO B N 6.501 -7.632 58.783 1 1227 +ATOM C CA . PRO B 1 194 . 194 PRO B CA 6.513 -8.871 58.001 1 1228 +ATOM C C . PRO B 1 194 . 194 PRO B C 6.842 -8.672 56.509 1 1229 +ATOM O O . PRO B 1 194 . 194 PRO B O 7.177 -9.643 55.853 1 1230 +ATOM C CB . PRO B 1 194 . 194 PRO B CB 5.08 -9.401 58.179 1 1231 +ATOM C CG . PRO B 1 194 . 194 PRO B CG 4.64 -8.831 59.514 1 1232 +ATOM C CD . PRO B 1 194 . 194 PRO B CD 5.258 -7.451 59.55 1 1233 +ATOM N N . LYS B 1 195 . 195 LYS B N 6.742 -7.437 56.005 1 1234 +ATOM C CA . LYS B 1 195 . 195 LYS B CA 6.935 -7.106 54.568 1 1235 +ATOM C C . LYS B 1 195 . 195 LYS B C 8.334 -6.526 54.309 1 1236 +ATOM O O . LYS B 1 195 . 195 LYS B O 8.597 -6.197 53.146 1 1237 +ATOM C CB . LYS B 1 195 . 195 LYS B CB 5.836 -6.147 54.103 1 1238 +ATOM C CG . LYS B 1 195 . 195 LYS B CG 4.441 -6.751 54.079 1 1239 +ATOM C CD . LYS B 1 195 . 195 LYS B CD 3.393 -5.845 53.478 1 1240 +ATOM C CE . LYS B 1 195 . 195 LYS B CE 2.038 -6.51 53.391 1 1241 +ATOM N NZ . LYS B 1 195 . 195 LYS B NZ 1.025 -5.586 52.831 1 1242 +ATOM N N . THR B 1 196 . 196 THR B N 9.206 -6.427 55.322 1 1243 +ATOM C CA . THR B 1 196 . 196 THR B CA 10.628 -6.015 55.15 1 1244 +ATOM C C . THR B 1 196 . 196 THR B C 11.342 -7.049 54.27 1 1245 +ATOM O O . THR B 1 196 . 196 THR B O 11.226 -8.255 54.572 1 1246 +ATOM C CB . THR B 1 196 . 196 THR B CB 11.375 -5.879 56.484 1 1247 +ATOM C CG2 . THR B 1 196 . 196 THR B CG2 12.78 -5.344 56.308 1 1248 +ATOM O OG1 . THR B 1 196 . 196 THR B OG1 10.653 -5.005 57.351 1 1249 +ATOM N N . ARG B 1 197 . 197 ARG B N 12.051 -6.596 53.23 1 1250 +ATOM C CA . ARG B 1 197 . 197 ARG B CA 12.801 -7.483 52.301 1 1251 +ATOM C C . ARG B 1 197 . 197 ARG B C 14.275 -7.07 52.2 1 1252 +ATOM O O . ARG B 1 197 . 197 ARG B O 15.11 -7.981 52.053 1 1253 +ATOM C CB . ARG B 1 197 . 197 ARG B CB 12.104 -7.527 50.938 1 1254 +ATOM C CG . ARG B 1 197 . 197 ARG B CG 10.852 -8.396 50.926 1 1255 +ATOM C CD . ARG B 1 197 . 197 ARG B CD 11.105 -9.804 51.441 1 1256 +ATOM N NE . ARG B 1 197 . 197 ARG B NE 9.906 -10.632 51.44 1 1257 +ATOM C CZ . ARG B 1 197 . 197 ARG B CZ 9.016 -10.701 52.427 1 1258 +ATOM N NH1 . ARG B 1 197 . 197 ARG B NH1 9.165 -9.977 53.526 1 1259 +ATOM N NH2 . ARG B 1 197 . 197 ARG B NH2 7.963 -11.49 52.306 1 1260 +ATOM N N . LEU B 1 198 . 198 LEU B N 14.593 -5.774 52.278 1 1261 +ATOM C CA . LEU B 1 198 . 198 LEU B CA 15.993 -5.272 52.239 1 1262 +ATOM C C . LEU B 1 198 . 198 LEU B C 16.29 -4.437 53.485 1 1263 +ATOM O O . LEU B 1 198 . 198 LEU B O 15.499 -3.524 53.802 1 1264 +ATOM C CB . LEU B 1 198 . 198 LEU B CB 16.202 -4.429 50.98 1 1265 +ATOM C CG . LEU B 1 198 . 198 LEU B CG 16.791 -5.158 49.776 1 1266 +ATOM C CD1 . LEU B 1 198 . 198 LEU B CD1 15.797 -6.144 49.191 1 1267 +ATOM C CD2 . LEU B 1 198 . 198 LEU B CD2 17.23 -4.156 48.723 1 1268 +ATOM N N . VAL B 1 199 . 199 VAL B N 17.406 -4.732 54.15 1 1269 +ATOM C CA . VAL B 1 199 . 199 VAL B CA 18.038 -3.821 55.146 1 1270 +ATOM C C . VAL B 1 199 . 199 VAL B C 19.467 -3.552 54.67 1 1271 +ATOM O O . VAL B 1 199 . 199 VAL B O 20.18 -4.522 54.34 1 1272 +ATOM C CB . VAL B 1 199 . 199 VAL B CB 17.981 -4.397 56.572 1 1273 +ATOM C CG1 . VAL B 1 199 . 199 VAL B CG1 18.542 -3.419 57.592 1 1274 +ATOM C CG2 . VAL B 1 199 . 199 VAL B CG2 16.558 -4.798 56.939 1 1275 +ATOM N N . THR B 1 200 . 200 THR B N 19.843 -2.277 54.587 1 1276 +ATOM C CA . THR B 1 200 . 200 THR B CA 21.158 -1.819 54.073 1 1277 +ATOM C C . THR B 1 200 . 200 THR B C 21.838 -0.991 55.163 1 1278 +ATOM O O . THR B 1 200 . 200 THR B O 21.16 -0.145 55.779 1 1279 +ATOM C CB . THR B 1 200 . 200 THR B CB 21.001 -1.057 52.749 1 1280 +ATOM C CG2 . THR B 1 200 . 200 THR B CG2 20.009 -1.718 51.817 1 1281 +ATOM O OG1 . THR B 1 200 . 200 THR B OG1 20.587 0.283 53.017 1 1282 +ATOM N N . CYS B 1 201 . 201 CYS B N 23.121 -1.246 55.401 1 1283 +ATOM C CA . CYS B 1 201 . 201 CYS B CA 23.979 -0.436 56.3 1 1284 +ATOM C C . CYS B 1 201 . 201 CYS B C 25.442 -0.707 55.949 1 1285 +ATOM O O . CYS B 1 201 . 201 CYS B O 25.691 -1.553 55.072 1 1286 +ATOM C CB . CYS B 1 201 . 201 CYS B CB 23.688 -0.737 57.766 1 1287 +ATOM S SG . CYS B 1 201 . 201 CYS B SG 24.151 -2.412 58.266 1 1288 +ATOM N N . ASN B 1 202 . 202 ASN B N 26.368 0.008 56.584 1 1289 +ATOM C CA . ASN B 1 202 . 202 ASN B CA 27.822 -0.214 56.384 1 1290 +ATOM C C . ASN B 1 202 . 202 ASN B C 28.378 -0.853 57.657 1 1291 +ATOM O O . ASN B 1 202 . 202 ASN B O 27.774 -0.66 58.733 1 1292 +ATOM C CB . ASN B 1 202 . 202 ASN B CB 28.56 1.055 55.943 1 1293 +ATOM C CG . ASN B 1 202 . 202 ASN B CG 28.119 2.307 56.668 1 1294 +ATOM N ND2 . ASN B 1 202 . 202 ASN B ND2 28.131 3.433 55.972 1 1295 +ATOM O OD1 . ASN B 1 202 . 202 ASN B OD1 27.78 2.261 57.849 1 1296 +ATOM N N . HIS B 1 203 . 203 HIS B N 29.441 -1.644 57.504 1 1297 +ATOM C CA . HIS B 1 203 . 203 HIS B CA 30.249 -2.218 58.609 1 1298 +ATOM C C . HIS B 1 203 . 203 HIS B C 30.914 -1.058 59.358 1 1299 +ATOM O O . HIS B 1 203 . 203 HIS B O 30.888 -1.055 60.602 1 1300 +ATOM C CB . HIS B 1 203 . 203 HIS B CB 31.269 -3.219 58.054 1 1301 +ATOM C CG . HIS B 1 203 . 203 HIS B CG 31.794 -4.168 59.078 1 1302 +ATOM C CD2 . HIS B 1 203 . 203 HIS B CD2 31.712 -4.16 60.427 1 1303 +ATOM N ND1 . HIS B 1 203 . 203 HIS B ND1 32.481 -5.315 58.734 1 1304 +ATOM C CE1 . HIS B 1 203 . 203 HIS B CE1 32.822 -5.962 59.832 1 1305 +ATOM N NE2 . HIS B 1 203 . 203 HIS B NE2 32.356 -5.275 60.885 1 1306 +ATOM N N . VAL B 1 204 . 204 VAL B N 31.48 -0.115 58.599 1 1307 +ATOM C CA . VAL B 1 204 . 204 VAL B CA 32.072 1.165 59.089 1 1308 +ATOM C C . VAL B 1 204 . 204 VAL B C 31.67 2.275 58.11 1 1309 +ATOM O O . VAL B 1 204 . 204 VAL B O 31.724 2.028 56.889 1 1310 +ATOM C CB . VAL B 1 204 . 204 VAL B CB 33.605 1.071 59.216 1 1311 +ATOM C CG1 . VAL B 1 204 . 204 VAL B CG1 34.21 2.39 59.665 1 1312 +ATOM C CG2 . VAL B 1 204 . 204 VAL B CG2 34.045 -0.058 60.138 1 1313 +ATOM N N . SER B 1 205 . 205 SER B N 31.286 3.444 58.629 1 1314 +ATOM C CA . SER B 1 205 . 205 SER B CA 31.054 4.683 57.841 1 1315 +ATOM C C . SER B 1 205 . 205 SER B C 32.398 5.369 57.566 1 1316 +ATOM O O . SER B 1 205 . 205 SER B O 33.22 5.464 58.505 1 1317 +ATOM C CB . SER B 1 205 . 205 SER B CB 30.105 5.614 58.541 1 1318 +ATOM O OG . SER B 1 205 . 205 SER B OG 29.996 6.841 57.833 1 1319 +ATOM N N . ASN B 1 206 . 206 ASN B N 32.586 5.854 56.334 1 1320 +ATOM C CA . ASN B 1 206 . 206 ASN B CA 33.835 6.504 55.851 1 1321 +ATOM C C . ASN B 1 206 . 206 ASN B C 34.001 7.911 56.448 1 1322 +ATOM O O . ASN B 1 206 . 206 ASN B O 35.077 8.493 56.227 1 1323 +ATOM C CB . ASN B 1 206 . 206 ASN B CB 33.886 6.562 54.319 1 1324 +ATOM C CG . ASN B 1 206 . 206 ASN B CG 32.812 7.441 53.709 1 1325 +ATOM N ND2 . ASN B 1 206 . 206 ASN B ND2 33.198 8.324 52.801 1 1326 +ATOM O OD1 . ASN B 1 206 . 206 ASN B OD1 31.636 7.316 54.044 1 1327 +ATOM N N . VAL B 1 207 . 207 VAL B N 32.999 8.445 57.162 1 1328 +ATOM C CA . VAL B 1 207 . 207 VAL B CA 33.041 9.806 57.787 1 1329 +ATOM C C . VAL B 1 207 . 207 VAL B C 32.814 9.715 59.306 1 1330 +ATOM O O . VAL B 1 207 . 207 VAL B O 32.558 10.772 59.916 1 1331 +ATOM C CB . VAL B 1 207 . 207 VAL B CB 32.014 10.757 57.137 1 1332 +ATOM C CG1 . VAL B 1 207 . 207 VAL B CG1 32.276 10.938 55.653 1 1333 +ATOM C CG2 . VAL B 1 207 . 207 VAL B CG2 30.582 10.305 57.373 1 1334 +ATOM N N . VAL B 1 208 . 208 VAL B N 32.894 8.514 59.895 1 1335 +ATOM C CA . VAL B 1 208 . 208 VAL B CA 32.669 8.278 61.357 1 1336 +ATOM C C . VAL B 1 208 . 208 VAL B C 33.778 7.367 61.898 1 1337 +ATOM O O . VAL B 1 208 . 208 VAL B O 34.43 7.756 62.882 1 1338 +ATOM C CB . VAL B 1 208 . 208 VAL B CB 31.267 7.692 61.619 1 1339 +ATOM C CG1 . VAL B 1 208 . 208 VAL B CG1 31.027 7.411 63.094 1 1340 +ATOM C CG2 . VAL B 1 208 . 208 VAL B CG2 30.17 8.592 61.073 1 1341 +ATOM N N . GLY B 1 209 . 209 GLY B N 33.955 6.186 61.296 1 1342 +ATOM C CA . GLY B 1 209 . 209 GLY B CA 35.01 5.215 61.649 1 1343 +ATOM C C . GLY B 1 209 . 209 GLY B C 34.542 4.184 62.665 1 1344 +ATOM O O . GLY B 1 209 . 209 GLY B O 35.321 3.263 62.962 1 1345 +ATOM N N . THR B 1 210 . 210 THR B N 33.317 4.312 63.179 1 1346 +ATOM C CA . THR B 1 210 . 210 THR B CA 32.75 3.405 64.212 1 1347 +ATOM C C . THR B 1 210 . 210 THR B C 32.431 2.048 63.574 1 1348 +ATOM O O . THR B 1 210 . 210 THR B O 31.864 2.032 62.468 1 1349 +ATOM C CB . THR B 1 210 . 210 THR B CB 31.526 4.032 64.892 1 1350 +ATOM C CG2 . THR B 1 210 . 210 THR B CG2 30.85 3.096 65.871 1 1351 +ATOM O OG1 . THR B 1 210 . 210 THR B OG1 31.972 5.206 65.572 1 1352 +ATOM N N . ILE B 1 211 . 211 ILE B N 32.801 0.958 64.252 1 1353 +ATOM C CA . ILE B 1 211 . 211 ILE B CA 32.478 -0.442 63.848 1 1354 +ATOM C C . ILE B 1 211 . 211 ILE B C 31.056 -0.746 64.326 1 1355 +ATOM O O . ILE B 1 211 . 211 ILE B O 30.806 -0.639 65.542 1 1356 +ATOM C CB . ILE B 1 211 . 211 ILE B CB 33.508 -1.444 64.411 1 1357 +ATOM C CG1 . ILE B 1 211 . 211 ILE B CG1 34.926 -1.118 63.931 1 1358 +ATOM C CG2 . ILE B 1 211 . 211 ILE B CG2 33.107 -2.876 64.074 1 1359 +ATOM C CD1 . ILE B 1 211 . 211 ILE B CD1 36.006 -1.976 64.55 1 1360 +ATOM N N . HIS B 1 212 . 212 HIS B N 30.164 -1.092 63.396 1 1361 +ATOM C CA . HIS B 1 212 . 212 HIS B CA 28.726 -1.359 63.662 1 1362 +ATOM C C . HIS B 1 212 . 212 HIS B C 28.548 -2.833 64.009 1 1363 +ATOM O O . HIS B 1 212 . 212 HIS B O 29.326 -3.676 63.566 1 1364 +ATOM C CB . HIS B 1 212 . 212 HIS B CB 27.873 -0.888 62.478 1 1365 +ATOM C CG . HIS B 1 212 . 212 HIS B CG 27.933 0.589 62.268 1 1366 +ATOM C CD2 . HIS B 1 212 . 212 HIS B CD2 28.045 1.609 63.148 1 1367 +ATOM N ND1 . HIS B 1 212 . 212 HIS B ND1 27.9 1.161 61.014 1 1368 +ATOM C CE1 . HIS B 1 212 . 212 HIS B CE1 27.978 2.473 61.129 1 1369 +ATOM N NE2 . HIS B 1 212 . 212 HIS B NE2 28.069 2.773 62.429 1 1370 +ATOM N N . PRO B 1 213 . 213 PRO B N 27.536 -3.179 64.837 1 1371 +ATOM C CA . PRO B 1 213 . 213 PRO B CA 27.338 -4.558 65.282 1 1372 +ATOM C C . PRO B 1 213 . 213 PRO B C 26.576 -5.398 64.241 1 1373 +ATOM O O . PRO B 1 213 . 213 PRO B O 25.401 -5.647 64.427 1 1374 +ATOM C CB . PRO B 1 213 . 213 PRO B CB 26.558 -4.339 66.589 1 1375 +ATOM C CG . PRO B 1 213 . 213 PRO B CG 25.687 -3.137 66.293 1 1376 +ATOM C CD . PRO B 1 213 . 213 PRO B CD 26.538 -2.256 65.401 1 1377 +ATOM N N . ILE B 1 214 . 214 ILE B N 27.278 -5.845 63.194 1 1378 +ATOM C CA . ILE B 1 214 . 214 ILE B CA 26.68 -6.396 61.937 1 1379 +ATOM C C . ILE B 1 214 . 214 ILE B C 25.988 -7.738 62.204 1 1380 +ATOM O O . ILE B 1 214 . 214 ILE B O 24.882 -7.927 61.664 1 1381 +ATOM C CB . ILE B 1 214 . 214 ILE B CB 27.741 -6.501 60.822 1 1382 +ATOM C CG1 . ILE B 1 214 . 214 ILE B CG1 28.217 -5.116 60.378 1 1383 +ATOM C CG2 . ILE B 1 214 . 214 ILE B CG2 27.23 -7.319 59.647 1 1384 +ATOM C CD1 . ILE B 1 214 . 214 ILE B CD1 27.117 -4.208 59.874 1 1385 +ATOM N N . ARG B 1 215 . 215 ARG B N 26.601 -8.638 62.979 1 1386 +ATOM C CA . ARG B 1 215 . 215 ARG B CA 26.015 -9.974 63.279 1 1387 +ATOM C C . ARG B 1 215 . 215 ARG B C 24.743 -9.791 64.116 1 1388 +ATOM O O . ARG B 1 215 . 215 ARG B O 23.779 -10.544 63.896 1 1389 +ATOM C CB . ARG B 1 215 . 215 ARG B CB 27.027 -10.889 63.982 1 1390 +ATOM C CG . ARG B 1 215 . 215 ARG B CG 26.477 -12.254 64.383 1 1391 +ATOM C CD . ARG B 1 215 . 215 ARG B CD 25.761 -12.987 63.259 1 1392 +ATOM N NE . ARG B 1 215 . 215 ARG B NE 25.039 -14.179 63.687 1 1393 +ATOM C CZ . ARG B 1 215 . 215 ARG B CZ 25.557 -15.402 63.811 1 1394 +ATOM N NH1 . ARG B 1 215 . 215 ARG B NH1 26.837 -15.626 63.56 1 1395 +ATOM N NH2 . ARG B 1 215 . 215 ARG B NH2 24.787 -16.406 64.195 1 1396 +ATOM N N . GLU B 1 216 . 216 GLU B N 24.748 -8.826 65.038 1 1397 +ATOM C CA . GLU B 1 216 . 216 GLU B CA 23.584 -8.485 65.897 1 1398 +ATOM C C . GLU B 1 216 . 216 GLU B C 22.443 -7.992 64.996 1 1399 +ATOM O O . GLU B 1 216 . 216 GLU B O 21.305 -8.46 65.179 1 1400 +ATOM C CB . GLU B 1 216 . 216 GLU B CB 23.987 -7.464 66.968 1 1401 +ATOM C CG . GLU B 1 216 . 216 GLU B CG 24.968 -8.01 68.005 1 1402 +ATOM C CD . GLU B 1 216 . 216 GLU B CD 26.459 -7.835 67.718 1 1403 +ATOM O OE1 . GLU B 1 216 . 216 GLU B OE1 26.876 -7.961 66.539 1 1404 +ATOM O OE2 . GLU B 1 216 . 216 GLU B OE2 27.219 -7.579 68.685 1 1405 +ATOM N N . ILE B 1 217 . 217 ILE B N 22.753 -7.124 64.029 1 1406 +ATOM C CA . ILE B 1 217 . 217 ILE B CA 21.781 -6.571 63.036 1 1407 +ATOM C C . ILE B 1 217 . 217 ILE B C 21.231 -7.716 62.169 1 1408 +ATOM O O . ILE B 1 217 . 217 ILE B O 20.0 -7.767 61.98 1 1409 +ATOM C CB . ILE B 1 217 . 217 ILE B CB 22.44 -5.459 62.194 1 1410 +ATOM C CG1 . ILE B 1 217 . 217 ILE B CG1 22.788 -4.24 63.055 1 1411 +ATOM C CG2 . ILE B 1 217 . 217 ILE B CG2 21.564 -5.076 61.009 1 1412 +ATOM C CD1 . ILE B 1 217 . 217 ILE B CD1 23.838 -3.338 62.456 1 1413 +ATOM N N . ALA B 1 218 . 218 ALA B N 22.107 -8.595 61.667 1 1414 +ATOM C CA . ALA B 1 218 . 218 ALA B CA 21.76 -9.762 60.823 1 1415 +ATOM C C . ALA B 1 218 . 218 ALA B C 20.75 -10.659 61.55 1 1416 +ATOM O O . ALA B 1 218 . 218 ALA B O 19.743 -11.041 60.924 1 1417 +ATOM C CB . ALA B 1 218 . 218 ALA B CB 23.007 -10.526 60.464 1 1418 +ATOM N N . ASP B 1 219 . 219 ASP B N 21.014 -10.984 62.819 1 1419 +ATOM C CA . ASP B 1 219 . 219 ASP B CA 20.166 -11.882 63.649 1 1420 +ATOM C C . ASP B 1 219 . 219 ASP B C 18.763 -11.271 63.792 1 1421 +ATOM O O . ASP B 1 219 . 219 ASP B O 17.785 -12.047 63.782 1 1422 +ATOM C CB . ASP B 1 219 . 219 ASP B CB 20.827 -12.181 65.0 1 1423 +ATOM C CG . ASP B 1 219 . 219 ASP B CG 21.972 -13.183 64.923 1 1424 +ATOM O OD1 . ASP B 1 219 . 219 ASP B OD1 22.215 -13.724 63.823 1 1425 +ATOM O OD2 . ASP B 1 219 . 219 ASP B OD2 22.618 -13.419 65.964 1 1426 +ATOM N N . VAL B 1 220 . 220 VAL B N 18.667 -9.941 63.885 1 1427 +ATOM C CA . VAL B 1 220 . 220 VAL B CA 17.376 -9.201 64.021 1 1428 +ATOM C C . VAL B 1 220 . 220 VAL B C 16.662 -9.182 62.66 1 1429 +ATOM O O . VAL B 1 220 . 220 VAL B O 15.46 -9.504 62.629 1 1430 +ATOM C CB . VAL B 1 220 . 220 VAL B CB 17.599 -7.785 64.586 1 1431 +ATOM C CG1 . VAL B 1 220 . 220 VAL B CG1 16.367 -6.905 64.448 1 1432 +ATOM C CG2 . VAL B 1 220 . 220 VAL B CG2 18.045 -7.843 66.038 1 1433 +ATOM N N . VAL B 1 221 . 221 VAL B N 17.371 -8.84 61.58 1 1434 +ATOM C CA . VAL B 1 221 . 221 VAL B CA 16.813 -8.806 60.193 1 1435 +ATOM C C . VAL B 1 221 . 221 VAL B C 16.235 -10.189 59.863 1 1436 +ATOM O O . VAL B 1 221 . 221 VAL B O 15.139 -10.247 59.271 1 1437 +ATOM C CB . VAL B 1 221 . 221 VAL B CB 17.866 -8.365 59.156 1 1438 +ATOM C CG1 . VAL B 1 221 . 221 VAL B CG1 17.375 -8.552 57.724 1 1439 +ATOM C CG2 . VAL B 1 221 . 221 VAL B CG2 18.299 -6.923 59.37 1 1440 +ATOM N N . HIS B 1 222 . 222 HIS B N 16.925 -11.258 60.27 1 1441 +ATOM C CA . HIS B 1 222 . 222 HIS B CA 16.598 -12.663 59.909 1 1442 +ATOM C C . HIS B 1 222 . 222 HIS B C 15.462 -13.226 60.776 1 1443 +ATOM O O . HIS B 1 222 . 222 HIS B O 15.113 -14.396 60.549 1 1444 +ATOM C CB . HIS B 1 222 . 222 HIS B CB 17.867 -13.527 59.947 1 1445 +ATOM C CG . HIS B 1 222 . 222 HIS B CG 18.826 -13.2 58.851 1 1446 +ATOM C CD2 . HIS B 1 222 . 222 HIS B CD2 18.62 -12.733 57.6 1 1447 +ATOM N ND1 . HIS B 1 222 . 222 HIS B ND1 20.197 -13.312 59.002 1 1448 +ATOM C CE1 . HIS B 1 222 . 222 HIS B CE1 20.79 -12.948 57.883 1 1449 +ATOM N NE2 . HIS B 1 222 . 222 HIS B NE2 19.845 -12.585 57.01 1 1450 +ATOM N N . THR B 1 223 . 223 THR B N 14.898 -12.443 61.709 1 1451 +ATOM C CA . THR B 1 223 . 223 THR B CA 13.623 -12.768 62.414 1 1452 +ATOM C C . THR B 1 223 . 223 THR B C 12.449 -12.561 61.446 1 1453 +ATOM O O . THR B 1 223 . 223 THR B O 11.376 -13.14 61.697 1 1454 +ATOM C CB . THR B 1 223 . 223 THR B CB 13.411 -11.946 63.697 1 1455 +ATOM C CG2 . THR B 1 223 . 223 THR B CG2 14.469 -12.193 64.749 1 1456 +ATOM O OG1 . THR B 1 223 . 223 THR B OG1 13.387 -10.555 63.378 1 1457 +ATOM N N . ILE B 1 224 . 224 ILE B N 12.642 -11.751 60.398 1 1458 +ATOM C CA . ILE B 1 224 . 224 ILE B CA 11.646 -11.51 59.309 1 1459 +ATOM C C . ILE B 1 224 . 224 ILE B C 11.975 -12.436 58.14 1 1460 +ATOM O O . ILE B 1 224 . 224 ILE B O 12.91 -12.171 57.383 1 1461 +ATOM C CB . ILE B 1 224 . 224 ILE B CB 11.637 -10.03 58.881 1 1462 +ATOM C CG1 . ILE B 1 224 . 224 ILE B CG1 11.5 -9.079 60.075 1 1463 +ATOM C CG2 . ILE B 1 224 . 224 ILE B CG2 10.559 -9.781 57.837 1 1464 +ATOM C CD1 . ILE B 1 224 . 224 ILE B CD1 10.273 -9.319 60.929 1 1465 +ATOM N N . PRO B 1 225 . 225 PRO B N 11.22 -13.544 57.941 1 1466 +ATOM C CA . PRO B 1 225 . 225 PRO B CA 11.498 -14.469 56.843 1 1467 +ATOM C C . PRO B 1 225 . 225 PRO B C 11.368 -13.756 55.489 1 1468 +ATOM O O . PRO B 1 225 . 225 PRO B O 10.369 -13.091 55.278 1 1469 +ATOM C CB . PRO B 1 225 . 225 PRO B CB 10.44 -15.577 56.98 1 1470 +ATOM C CG . PRO B 1 225 . 225 PRO B CG 9.899 -15.424 58.39 1 1471 +ATOM C CD . PRO B 1 225 . 225 PRO B CD 10.051 -13.957 58.733 1 1472 +ATOM N N . GLY B 1 226 . 226 GLY B N 12.38 -13.899 54.629 1 1473 +ATOM C CA . GLY B 1 226 . 226 GLY B CA 12.453 -13.255 53.303 1 1474 +ATOM C C . GLY B 1 226 . 226 GLY B C 13.327 -12.009 53.307 1 1475 +ATOM O O . GLY B 1 226 . 226 GLY B O 13.702 -11.563 52.207 1 1476 +ATOM N N . ALA B 1 227 . 227 ALA B N 13.645 -11.459 54.487 1 1477 +ATOM C CA . ALA B 1 227 . 227 ALA B CA 14.476 -10.24 54.648 1 1478 +ATOM C C . ALA B 1 227 . 227 ALA B C 15.946 -10.587 54.397 1 1479 +ATOM O O . ALA B 1 227 . 227 ALA B O 16.396 -11.637 54.898 1 1480 +ATOM C CB . ALA B 1 227 . 227 ALA B CB 14.289 -9.637 56.02 1 1481 +ATOM N N . MET B 1 228 . 228 MET B N 16.658 -9.73 53.656 1 1482 +ATOM C CA . MET B 1 228 . 228 MET B CA 18.115 -9.87 53.392 1 1483 +ATOM C C . MET B 1 228 . 228 MET B C 18.842 -8.593 53.844 1 1484 +ATOM O O . MET B 1 228 . 228 MET B O 18.294 -7.479 53.649 1 1485 +ATOM C CB . MET B 1 228 . 228 MET B CB 18.394 -10.168 51.913 1 1486 +ATOM C CG . MET B 1 228 . 228 MET B CG 18.031 -9.048 50.954 1 1487 +ATOM S SD . MET B 1 228 . 228 MET B SD 17.649 -9.661 49.281 1 1488 +ATOM C CE . MET B 1 228 . 228 MET B CE 15.952 -10.197 49.493 1 1489 +ATOM N N . LEU B 1 229 . 229 LEU B N 20.012 -8.765 54.471 1 1490 +ATOM C CA . LEU B 1 229 . 229 LEU B CA 20.893 -7.666 54.95 1 1491 +ATOM C C . LEU B 1 229 . 229 LEU B C 22.031 -7.465 53.945 1 1492 +ATOM O O . LEU B 1 229 . 229 LEU B O 22.76 -8.432 53.671 1 1493 +ATOM C CB . LEU B 1 229 . 229 LEU B CB 21.437 -8.013 56.34 1 1494 +ATOM C CG . LEU B 1 229 . 229 LEU B CG 22.447 -7.024 56.924 1 1495 +ATOM C CD1 . LEU B 1 229 . 229 LEU B CD1 21.808 -5.671 57.195 1 1496 +ATOM C CD2 . LEU B 1 229 . 229 LEU B CD2 23.061 -7.575 58.199 1 1497 +ATOM N N . ILE B 1 230 . 230 ILE B N 22.17 -6.249 53.422 1 1498 +ATOM C CA . ILE B 1 230 . 230 ILE B CA 23.252 -5.857 52.473 1 1499 +ATOM C C . ILE B 1 230 . 230 ILE B C 24.208 -4.925 53.223 1 1500 +ATOM O O . ILE B 1 230 . 230 ILE B O 23.754 -3.862 53.683 1 1501 +ATOM C CB . ILE B 1 230 . 230 ILE B CB 22.657 -5.209 51.208 1 1502 +ATOM C CG1 . ILE B 1 230 . 230 ILE B CG1 21.58 -6.101 50.581 1 1503 +ATOM C CG2 . ILE B 1 230 . 230 ILE B CG2 23.758 -4.87 50.216 1 1504 +ATOM C CD1 . ILE B 1 230 . 230 ILE B CD1 20.687 -5.399 49.575 1 1505 +ATOM N N . VAL B 1 231 . 231 VAL B N 25.475 -5.322 53.36 1 1506 +ATOM C CA . VAL B 1 231 . 231 VAL B CA 26.472 -4.591 54.195 1 1507 +ATOM C C . VAL B 1 231 . 231 VAL B C 27.582 -4.038 53.295 1 1508 +ATOM O O . VAL B 1 231 . 231 VAL B O 28.21 -4.823 52.547 1 1509 +ATOM C CB . VAL B 1 231 . 231 VAL B CB 27.034 -5.475 55.321 1 1510 +ATOM C CG1 . VAL B 1 231 . 231 VAL B CG1 28.054 -4.718 56.158 1 1511 +ATOM C CG2 . VAL B 1 231 . 231 VAL B CG2 25.925 -6.027 56.204 1 1512 +ATOM N N . ASP B 1 232 . 232 ASP B N 27.799 -2.725 53.378 1 1513 +ATOM C CA . ASP B 1 232 . 232 ASP B CA 28.915 -1.998 52.72 1 1514 +ATOM C C . ASP B 1 232 . 232 ASP B C 30.128 -2.075 53.657 1 1515 +ATOM O O . ASP B 1 232 . 232 ASP B O 30.097 -1.415 54.711 1 1516 +ATOM C CB . ASP B 1 232 . 232 ASP B CB 28.469 -0.573 52.375 1 1517 +ATOM C CG . ASP B 1 232 . 232 ASP B CG 29.568 0.343 51.869 1 1518 +ATOM O OD1 . ASP B 1 232 . 232 ASP B OD1 30.728 -0.113 51.786 1 1519 +ATOM O OD2 . ASP B 1 232 . 232 ASP B OD2 29.252 1.512 51.575 1 1520 +ATOM N N . GLY B 1 233 . 233 GLY B N 31.138 -2.873 53.294 1 1521 +ATOM C CA . GLY B 1 233 . 233 GLY B CA 32.32 -3.156 54.135 1 1522 +ATOM C C . GLY B 1 233 . 233 GLY B C 33.584 -2.493 53.613 1 1523 +ATOM O O . GLY B 1 233 . 233 GLY B O 34.678 -2.986 53.938 1 1524 +ATOM N N . VAL B 1 234 . 234 VAL B N 33.448 -1.406 52.85 1 1525 +ATOM C CA . VAL B 1 234 . 234 VAL B CA 34.581 -0.746 52.132 1 1526 +ATOM C C . VAL B 1 234 . 234 VAL B C 35.559 -0.159 53.161 1 1527 +ATOM O O . VAL B 1 234 . 234 VAL B O 36.778 -0.265 52.932 1 1528 +ATOM C CB . VAL B 1 234 . 234 VAL B CB 34.074 0.315 51.133 1 1529 +ATOM C CG1 . VAL B 1 234 . 234 VAL B CG1 35.205 1.172 50.594 1 1530 +ATOM C CG2 . VAL B 1 234 . 234 VAL B CG2 33.305 -0.317 49.98 1 1531 +ATOM N N . ALA B 1 235 . 235 ALA B N 35.05 0.409 54.26 1 1532 +ATOM C CA . ALA B 1 235 . 235 ALA B CA 35.843 1.093 55.315 1 1533 +ATOM C C . ALA B 1 235 . 235 ALA B C 36.237 0.124 56.442 1 1534 +ATOM O O . ALA B 1 235 . 235 ALA B O 36.855 0.591 57.421 1 1535 +ATOM C CB . ALA B 1 235 . 235 ALA B CB 35.061 2.268 55.855 1 1536 +ATOM N N . SER B 1 236 . 236 SER B N 35.917 -1.169 56.314 1 1537 +ATOM C CA . SER B 1 236 . 236 SER B CA 36.157 -2.213 57.349 1 1538 +ATOM C C . SER B 1 236 . 236 SER B C 37.262 -3.189 56.913 1 1539 +ATOM O O . SER B 1 236 . 236 SER B O 38.167 -3.463 57.729 1 1540 +ATOM C CB . SER B 1 236 . 236 SER B CB 34.884 -2.949 57.659 1 1541 +ATOM O OG . SER B 1 236 . 236 SER B OG 35.107 -3.93 58.661 1 1542 +ATOM N N . VAL B 1 237 . 237 VAL B N 37.185 -3.705 55.683 1 1543 +ATOM C CA . VAL B 1 237 . 237 VAL B CA 38.048 -4.82 55.18 1 1544 +ATOM C C . VAL B 1 237 . 237 VAL B C 39.523 -4.407 55.232 1 1545 +ATOM O O . VAL B 1 237 . 237 VAL B O 40.372 -5.239 55.552 1 1546 +ATOM C CB . VAL B 1 237 . 237 VAL B CB 37.603 -5.284 53.776 1 1547 +ATOM C CG1 . VAL B 1 237 . 237 VAL B CG1 38.553 -6.304 53.163 1 1548 +ATOM C CG2 . VAL B 1 237 . 237 VAL B CG2 36.193 -5.855 53.804 1 1549 +ATOM N N . PRO B 1 238 . 238 PRO B N 39.891 -3.136 54.929 1 1550 +ATOM C CA . PRO B 1 238 . 238 PRO B CA 41.277 -2.685 55.082 1 1551 +ATOM C C . PRO B 1 238 . 238 PRO B C 41.839 -2.826 56.505 1 1552 +ATOM O O . PRO B 1 238 . 238 PRO B O 42.994 -3.185 56.633 1 1553 +ATOM C CB . PRO B 1 238 . 238 PRO B CB 41.226 -1.193 54.71 1 1554 +ATOM C CG . PRO B 1 238 . 238 PRO B CG 40.008 -1.068 53.823 1 1555 +ATOM C CD . PRO B 1 238 . 238 PRO B CD 39.026 -2.083 54.369 1 1556 +ATOM N N . HIS B 1 239 . 239 HIS B N 41.011 -2.557 57.52 1 1557 +ATOM C CA . HIS B 1 239 . 239 HIS B CA 41.424 -2.334 58.932 1 1558 +ATOM C C . HIS B 1 239 . 239 HIS B C 41.247 -3.601 59.781 1 1559 +ATOM O O . HIS B 1 239 . 239 HIS B O 41.956 -3.719 60.796 1 1560 +ATOM C CB . HIS B 1 239 . 239 HIS B CB 40.658 -1.138 59.511 1 1561 +ATOM C CG . HIS B 1 239 . 239 HIS B CG 40.848 0.116 58.725 1 1562 +ATOM C CD2 . HIS B 1 239 . 239 HIS B CD2 41.841 1.032 58.727 1 1563 +ATOM N ND1 . HIS B 1 239 . 239 HIS B ND1 39.936 0.536 57.777 1 1564 +ATOM C CE1 . HIS B 1 239 . 239 HIS B CE1 40.357 1.66 57.234 1 1565 +ATOM N NE2 . HIS B 1 239 . 239 HIS B NE2 41.523 1.986 57.802 1 1566 +ATOM N N . ARG B 1 240 . 240 ARG B N 40.341 -4.507 59.404 1 1567 +ATOM C CA . ARG B 1 240 . 240 ARG B CA 39.967 -5.676 60.246 1 1568 +ATOM C C . ARG B 1 240 . 240 ARG B C 39.796 -6.926 59.388 1 1569 +ATOM O O . ARG B 1 240 . 240 ARG B O 39.316 -6.85 58.257 1 1570 +ATOM C CB . ARG B 1 240 . 240 ARG B CB 38.67 -5.39 61.01 1 1571 +ATOM C CG . ARG B 1 240 . 240 ARG B CG 38.753 -4.217 61.976 1 1572 +ATOM C CD . ARG B 1 240 . 240 ARG B CD 39.644 -4.47 63.179 1 1573 +ATOM N NE . ARG B 1 240 . 240 ARG B NE 39.641 -3.305 64.054 1 1574 +ATOM C CZ . ARG B 1 240 . 240 ARG B CZ 40.417 -2.232 63.913 1 1575 +ATOM N NH1 . ARG B 1 240 . 240 ARG B NH1 40.306 -1.228 64.766 1 1576 +ATOM N NH2 . ARG B 1 240 . 240 ARG B NH2 41.298 -2.153 62.933 1 1577 +ATOM N N . PRO B 1 241 . 241 PRO B N 40.153 -8.12 59.915 1 1578 +ATOM C CA . PRO B 1 241 . 241 PRO B CA 39.8 -9.381 59.263 1 1579 +ATOM C C . PRO B 1 241 . 241 PRO B C 38.274 -9.555 59.273 1 1580 +ATOM O O . PRO B 1 241 . 241 PRO B O 37.664 -9.266 60.288 1 1581 +ATOM C CB . PRO B 1 241 . 241 PRO B CB 40.498 -10.456 60.112 1 1582 +ATOM C CG . PRO B 1 241 . 241 PRO B CG 40.684 -9.804 61.47 1 1583 +ATOM C CD . PRO B 1 241 . 241 PRO B CD 40.877 -8.328 61.18 1 1584 +ATOM N N . VAL B 1 242 . 242 VAL B N 37.697 -10.003 58.154 1 1585 +ATOM C CA . VAL B 1 242 . 242 VAL B CA 36.225 -10.199 58.007 1 1586 +ATOM C C . VAL B 1 242 . 242 VAL B C 35.944 -11.686 57.772 1 1587 +ATOM O O . VAL B 1 242 . 242 VAL B O 36.688 -12.318 56.997 1 1588 +ATOM C CB . VAL B 1 242 . 242 VAL B CB 35.634 -9.314 56.893 1 1589 +ATOM C CG1 . VAL B 1 242 . 242 VAL B CG1 35.575 -7.858 57.322 1 1590 +ATOM C CG2 . VAL B 1 242 . 242 VAL B CG2 36.388 -9.449 55.58 1 1591 +ATOM N N . ASP B 1 243 . 243 ASP B N 34.921 -12.209 58.452 1 1592 +ATOM C CA . ASP B 1 243 . 243 ASP B CA 34.374 -13.581 58.286 1 1593 +ATOM C C . ASP B 1 243 . 243 ASP B C 32.874 -13.436 58.01 1 1594 +ATOM O O . ASP B 1 243 . 243 ASP B O 32.116 -13.279 58.983 1 1595 +ATOM C CB . ASP B 1 243 . 243 ASP B CB 34.679 -14.434 59.522 1 1596 +ATOM C CG . ASP B 1 243 . 243 ASP B CG 34.118 -15.848 59.493 1 1597 +ATOM O OD1 . ASP B 1 243 . 243 ASP B OD1 33.265 -16.13 58.626 1 1598 +ATOM O OD2 . ASP B 1 243 . 243 ASP B OD2 34.533 -16.659 60.354 1 1599 +ATOM N N . VAL B 1 244 . 244 VAL B N 32.471 -13.479 56.736 1 1600 +ATOM C CA . VAL B 1 244 . 244 VAL B CA 31.073 -13.189 56.29 1 1601 +ATOM C C . VAL B 1 244 . 244 VAL B C 30.117 -14.26 56.835 1 1602 +ATOM O O . VAL B 1 244 . 244 VAL B O 28.907 -13.98 56.881 1 1603 +ATOM C CB . VAL B 1 244 . 244 VAL B CB 30.95 -13.058 54.757 1 1604 +ATOM C CG1 . VAL B 1 244 . 244 VAL B CG1 31.786 -11.907 54.218 1 1605 +ATOM C CG2 . VAL B 1 244 . 244 VAL B CG2 31.279 -14.356 54.027 1 1606 +ATOM N N . LYS B 1 245 . 245 LYS B N 30.624 -15.437 57.218 1 1607 +ATOM C CA . LYS B 1 245 . 245 LYS B CA 29.81 -16.528 57.822 1 1608 +ATOM C C . LYS B 1 245 . 245 LYS B C 29.498 -16.172 59.28 1 1609 +ATOM O O . LYS B 1 245 . 245 LYS B O 28.346 -16.392 59.704 1 1610 +ATOM C CB . LYS B 1 245 . 245 LYS B CB 30.531 -17.877 57.719 1 1611 +ATOM C CG . LYS B 1 245 . 245 LYS B CG 30.941 -18.284 56.308 1 1612 +ATOM C CD . LYS B 1 245 . 245 LYS B CD 29.788 -18.38 55.323 1 1613 +ATOM C CE . LYS B 1 245 . 245 LYS B CE 30.26 -18.695 53.921 1 1614 +ATOM N NZ . LYS B 1 245 . 245 LYS B NZ 29.13 -18.982 53.007 1 1615 +ATOM N N . GLU B 1 246 . 246 GLU B N 30.481 -15.63 60.006 1 1616 +ATOM C CA . GLU B 1 246 . 246 GLU B CA 30.339 -15.175 61.417 1 1617 +ATOM C C . GLU B 1 246 . 246 GLU B C 29.431 -13.938 61.463 1 1618 +ATOM O O . GLU B 1 246 . 246 GLU B O 28.664 -13.812 62.435 1 1619 +ATOM C CB . GLU B 1 246 . 246 GLU B CB 31.713 -14.884 62.028 1 1620 +ATOM C CG . GLU B 1 246 . 246 GLU B CG 31.661 -14.422 63.479 1 1621 +ATOM C CD . GLU B 1 246 . 246 GLU B CD 31.08 -15.423 64.467 1 1622 +ATOM O OE1 . GLU B 1 246 . 246 GLU B OE1 31.047 -16.634 64.14 1 1623 +ATOM O OE2 . GLU B 1 246 . 246 GLU B OE2 30.666 -14.993 65.565 1 1624 +ATOM N N . LEU B 1 247 . 247 LEU B N 29.521 -13.059 60.46 1 1625 +ATOM C CA . LEU B 1 247 . 247 LEU B CA 28.633 -11.872 60.315 1 1626 +ATOM C C . LEU B 1 247 . 247 LEU B C 27.235 -12.334 59.887 1 1627 +ATOM O O . LEU B 1 247 . 247 LEU B O 26.269 -11.623 60.198 1 1628 +ATOM C CB . LEU B 1 247 . 247 LEU B CB 29.231 -10.897 59.296 1 1629 +ATOM C CG . LEU B 1 247 . 247 LEU B CG 30.59 -10.301 59.66 1 1630 +ATOM C CD1 . LEU B 1 247 . 247 LEU B CD1 31.06 -9.339 58.579 1 1631 +ATOM C CD2 . LEU B 1 247 . 247 LEU B CD2 30.543 -9.601 61.011 1 1632 +ATOM N N . ASP B 1 248 . 248 ASP B N 27.15 -13.48 59.201 1 1633 +ATOM C CA . ASP B 1 248 . 248 ASP B CA 25.886 -14.114 58.738 1 1634 +ATOM C C . ASP B 1 248 . 248 ASP B C 25.124 -13.121 57.85 1 1635 +ATOM O O . ASP B 1 248 . 248 ASP B O 23.887 -13.112 57.905 1 1636 +ATOM C CB . ASP B 1 248 . 248 ASP B CB 25.04 -14.592 59.924 1 1637 +ATOM C CG . ASP B 1 248 . 248 ASP B CG 24.014 -15.662 59.576 1 1638 +ATOM O OD1 . ASP B 1 248 . 248 ASP B OD1 24.226 -16.38 58.579 1 1639 +ATOM O OD2 . ASP B 1 248 . 248 ASP B OD2 23.007 -15.768 60.307 1 1640 +ATOM N N . VAL B 1 249 . 249 VAL B N 25.846 -12.318 57.065 1 1641 +ATOM C CA . VAL B 1 249 . 249 VAL B CA 25.269 -11.357 56.08 1 1642 +ATOM C C . VAL B 1 249 . 249 VAL B C 24.75 -12.145 54.873 1 1643 +ATOM O O . VAL B 1 249 . 249 VAL B O 25.304 -13.225 54.582 1 1644 +ATOM C CB . VAL B 1 249 . 249 VAL B CB 26.293 -10.292 55.642 1 1645 +ATOM C CG1 . VAL B 1 249 . 249 VAL B CG1 26.673 -9.374 56.794 1 1646 +ATOM C CG2 . VAL B 1 249 . 249 VAL B CG2 27.534 -10.91 55.016 1 1647 +ATOM N N . ASP B 1 250 . 250 ASP B N 23.728 -11.613 54.203 1 1648 +ATOM C CA . ASP B 1 250 . 250 ASP B CA 23.25 -12.107 52.885 1 1649 +ATOM C C . ASP B 1 250 . 250 ASP B C 24.228 -11.616 51.814 1 1650 +ATOM O O . ASP B 1 250 . 250 ASP B O 24.627 -12.431 50.958 1 1651 +ATOM C CB . ASP B 1 250 . 250 ASP B CB 21.816 -11.652 52.614 1 1652 +ATOM C CG . ASP B 1 250 . 250 ASP B CG 20.802 -12.326 53.521 1 1653 +ATOM O OD1 . ASP B 1 250 . 250 ASP B OD1 20.355 -13.434 53.171 1 1654 +ATOM O OD2 . ASP B 1 250 . 250 ASP B OD2 20.491 -11.751 54.585 1 1655 +ATOM N N . PHE B 1 251 . 251 PHE B N 24.606 -10.336 51.888 1 1656 +ATOM C CA . PHE B 1 251 . 251 PHE B CA 25.485 -9.643 50.912 1 1657 +ATOM C C . PHE B 1 251 . 251 PHE B C 26.482 -8.742 51.647 1 1658 +ATOM O O . PHE B 1 251 . 251 PHE B O 26.102 -8.085 52.631 1 1659 +ATOM C CB . PHE B 1 251 . 251 PHE B CB 24.653 -8.822 49.926 1 1660 +ATOM C CG . PHE B 1 251 . 251 PHE B CG 23.619 -9.609 49.16 1 1661 +ATOM C CD1 . PHE B 1 251 . 251 PHE B CD1 23.932 -10.192 47.942 1 1662 +ATOM C CD2 . PHE B 1 251 . 251 PHE B CD2 22.329 -9.755 49.649 1 1663 +ATOM C CE1 . PHE B 1 251 . 251 PHE B CE1 22.981 -10.911 47.235 1 1664 +ATOM C CE2 . PHE B 1 251 . 251 PHE B CE2 21.379 -10.472 48.938 1 1665 +ATOM C CZ . PHE B 1 251 . 251 PHE B CZ 21.704 -11.042 47.729 1 1666 +ATOM N N . TYR B 1 252 . 252 TYR B N 27.715 -8.708 51.14 1 1667 +ATOM C CA . TYR B 1 252 . 252 TYR B CA 28.86 -7.913 51.653 1 1668 +ATOM C C . TYR B 1 252 . 252 TYR B C 29.699 -7.456 50.455 1 1669 +ATOM O O . TYR B 1 252 . 252 TYR B O 30.029 -8.313 49.612 1 1670 +ATOM C CB . TYR B 1 252 . 252 TYR B CB 29.692 -8.766 52.614 1 1671 +ATOM C CG . TYR B 1 252 . 252 TYR B CG 30.611 -7.996 53.526 1 1672 +ATOM C CD1 . TYR B 1 252 . 252 TYR B CD1 31.889 -7.642 53.125 1 1673 +ATOM C CD2 . TYR B 1 252 . 252 TYR B CD2 30.21 -7.64 54.802 1 1674 +ATOM C CE1 . TYR B 1 252 . 252 TYR B CE1 32.74 -6.941 53.964 1 1675 +ATOM C CE2 . TYR B 1 252 . 252 TYR B CE2 31.048 -6.938 55.651 1 1676 +ATOM C CZ . TYR B 1 252 . 252 TYR B CZ 32.319 -6.589 55.233 1 1677 +ATOM O OH . TYR B 1 252 . 252 TYR B OH 33.142 -5.895 56.072 1 1678 +ATOM N N . CYS B 1 253 . 253 CYS B N 30.031 -6.164 50.374 1 1679 +ATOM C CA . CYS B 1 253 . 253 CYS B CA 30.818 -5.578 49.254 1 1680 +ATOM C C . CYS B 1 253 . 253 CYS B C 31.963 -4.713 49.792 1 1681 +ATOM O O . CYS B 1 253 . 253 CYS B O 31.753 -4.007 50.792 1 1682 +ATOM C CB . CYS B 1 253 . 253 CYS B CB 29.928 -4.755 48.334 1 1683 +ATOM S SG . CYS B 1 253 . 253 CYS B SG 30.635 -4.539 46.682 1 1684 +ATOM N N . PHE B 1 254 . 254 PHE B N 33.128 -4.766 49.141 1 1685 +ATOM C CA . PHE B 1 254 . 254 PHE B CA 34.312 -3.929 49.469 1 1686 +ATOM C C . PHE B 1 254 . 254 PHE B C 35.072 -3.575 48.186 1 1687 +ATOM O O . PHE B 1 254 . 254 PHE B O 34.852 -4.232 47.143 1 1688 +ATOM C CB . PHE B 1 254 . 254 PHE B CB 35.204 -4.627 50.5 1 1689 +ATOM C CG . PHE B 1 254 . 254 PHE B CG 35.927 -5.864 50.03 1 1690 +ATOM C CD1 . PHE B 1 254 . 254 PHE B CD1 35.334 -7.115 50.122 1 1691 +ATOM C CD2 . PHE B 1 254 . 254 PHE B CD2 37.224 -5.786 49.55 1 1692 +ATOM C CE1 . PHE B 1 254 . 254 PHE B CE1 36.016 -8.255 49.722 1 1693 +ATOM C CE2 . PHE B 1 254 . 254 PHE B CE2 37.905 -6.927 49.15 1 1694 +ATOM C CZ . PHE B 1 254 . 254 PHE B CZ 37.298 -8.159 49.232 1 1695 +ATOM N N . SER B 1 255 . 255 SER B N 35.929 -2.551 48.281 1 1696 +ATOM C CA . SER B 1 255 . 255 SER B CA 36.746 -1.983 47.177 1 1697 +ATOM C C . SER B 1 255 . 255 SER B C 38.233 -2.197 47.48 1 1698 +ATOM O O . SER B 1 255 . 255 SER B O 38.677 -1.815 48.579 1 1699 +ATOM C CB . SER B 1 255 . 255 SER B CB 36.443 -0.524 46.983 1 1700 +ATOM O OG . SER B 1 255 . 255 SER B OG 35.063 -0.324 46.733 1 1701 +ATOM N N . TRP B 1 256 . 256 TRP B N 38.972 -2.747 46.515 1 1702 +ATOM C CA . TRP B 1 256 . 256 TRP B CA 40.39 -3.168 46.663 1 1703 +ATOM C C . TRP B 1 256 . 256 TRP B C 41.326 -1.954 46.738 1 1704 +ATOM O O . TRP B 1 256 . 256 TRP B O 42.427 -2.114 47.302 1 1705 +ATOM C CB . TRP B 1 256 . 256 TRP B CB 40.785 -4.124 45.531 1 1706 +ATOM C CG . TRP B 1 256 . 256 TRP B CG 40.333 -5.529 45.778 1 1707 +ATOM C CD1 . TRP B 1 256 . 256 TRP B CD1 39.261 -6.168 45.226 1 1708 +ATOM C CD2 . TRP B 1 256 . 256 TRP B CD2 40.936 -6.466 46.687 1 1709 +ATOM C CE2 . TRP B 1 256 . 256 TRP B CE2 40.18 -7.655 46.614 1 1710 +ATOM C CE3 . TRP B 1 256 . 256 TRP B CE3 42.042 -6.419 47.544 1 1711 +ATOM N NE1 . TRP B 1 256 . 256 TRP B NE1 39.167 -7.441 45.719 1 1712 +ATOM C CZ2 . TRP B 1 256 . 256 TRP B CZ2 40.495 -8.784 47.371 1 1713 +ATOM C CZ3 . TRP B 1 256 . 256 TRP B CZ3 42.355 -7.536 48.288 1 1714 +ATOM C CH2 . TRP B 1 256 . 256 TRP B CH2 41.59 -8.702 48.201 1 1715 +ATOM N N . TYR B 1 257 . 257 TYR B N 40.911 -0.786 46.229 1 1716 +ATOM C CA . TYR B 1 257 . 257 TYR B CA 41.781 0.42 46.123 1 1717 +ATOM C C . TYR B 1 257 . 257 TYR B C 41.949 1.095 47.49 1 1718 +ATOM O O . TYR B 1 257 . 257 TYR B O 42.78 2.016 47.58 1 1719 +ATOM C CB . TYR B 1 257 . 257 TYR B CB 41.289 1.386 45.04 1 1720 +ATOM C CG . TYR B 1 257 . 257 TYR B CG 40.032 2.173 45.319 1 1721 +ATOM C CD1 . TYR B 1 257 . 257 TYR B CD1 38.786 1.682 44.961 1 1722 +ATOM C CD2 . TYR B 1 257 . 257 TYR B CD2 40.093 3.454 45.85 1 1723 +ATOM C CE1 . TYR B 1 257 . 257 TYR B CE1 37.63 2.418 45.171 1 1724 +ATOM C CE2 . TYR B 1 257 . 257 TYR B CE2 38.948 4.205 46.064 1 1725 +ATOM C CZ . TYR B 1 257 . 257 TYR B CZ 37.71 3.686 45.723 1 1726 +ATOM O OH . TYR B 1 257 . 257 TYR B OH 36.572 4.413 45.929 1 1727 +HETATM N N . LLP B 1 258 . 258 LLP B N 41.212 0.644 48.51 1 1728 +HETATM C CA . LLP B 1 258 . 258 LLP B CA 41.392 1.067 49.925 1 1729 +HETATM C C . LLP B 1 258 . 258 LLP B C 42.112 -0.06 50.687 1 1730 +HETATM O O . LLP B 1 258 . 258 LLP B O 42.303 0.075 51.909 1 1731 +HETATM C CB . LLP B 1 258 . 258 LLP B CB 40.025 1.493 50.478 1 1732 +HETATM C CG . LLP B 1 258 . 258 LLP B CG 39.362 2.618 49.684 1 1733 +HETATM C CD . LLP B 1 258 . 258 LLP B CD 37.974 3.03 50.161 1 1734 +HETATM C CE . LLP B 1 258 . 258 LLP B CE 37.278 4.006 49.22 1 1735 +HETATM N NZ . LLP B 1 258 . 258 LLP B NZ 36.112 4.63 49.855 1 1736 +HETATM P P . LLP B 1 258 . 258 LLP B P 33.579 2.508 44.982 1 1737 +HETATM C "C5'" . LLP B 1 258 . 258 LLP B "C5'" 32.96 3.87 47.172 1 1738 +HETATM C "C4'" . LLP B 1 258 . 258 LLP B "C4'" 35.029 4.791 49.174 1 1739 +HETATM C "C2'" . LLP B 1 258 . 258 LLP B "C2'" 31.967 3.244 52.814 1 1740 +HETATM N N1 . LLP B 1 258 . 258 LLP B N1 31.386 3.013 50.466 1 1741 +HETATM C C2 . LLP B 1 258 . 258 LLP B C2 32.276 3.426 51.363 1 1742 +HETATM C C3 . LLP B 1 258 . 258 LLP B C3 33.481 4.022 50.95 1 1743 +HETATM O O3 . LLP B 1 258 . 258 LLP B O3 34.363 4.419 51.895 1 1744 +HETATM C C4 . LLP B 1 258 . 258 LLP B C4 33.753 4.186 49.581 1 1745 +HETATM C C5 . LLP B 1 258 . 258 LLP B C5 32.788 3.738 48.659 1 1746 +HETATM C C6 . LLP B 1 258 . 258 LLP B C6 31.64 3.173 49.149 1 1747 +HETATM O OP1 . LLP B 1 258 . 258 LLP B OP1 32.207 2.413 44.344 1 1748 +HETATM O OP2 . LLP B 1 258 . 258 LLP B OP2 34.416 1.259 44.768 1 1749 +HETATM O OP3 . LLP B 1 258 . 258 LLP B OP3 34.315 3.784 44.632 1 1750 +HETATM O OP4 . LLP B 1 258 . 258 LLP B OP4 33.297 2.587 46.56 1 1751 +ATOM N N . LEU B 1 259 . 259 LEU B N 42.566 -1.089 49.958 1 1752 +ATOM C CA . LEU B 1 259 . 259 LEU B CA 43.206 -2.335 50.468 1 1753 +ATOM C C . LEU B 1 259 . 259 LEU B C 44.5 -2.618 49.696 1 1754 +ATOM O O . LEU B 1 259 . 259 LEU B O 44.769 -3.804 49.418 1 1755 +ATOM C CB . LEU B 1 259 . 259 LEU B CB 42.236 -3.505 50.267 1 1756 +ATOM C CG . LEU B 1 259 . 259 LEU B CG 41.524 -4.014 51.512 1 1757 +ATOM C CD1 . LEU B 1 259 . 259 LEU B CD1 40.032 -3.75 51.412 1 1758 +ATOM C CD2 . LEU B 1 259 . 259 LEU B CD2 41.801 -5.5 51.706 1 1759 +ATOM N N . PHE B 1 260 . 260 PHE B N 45.269 -1.588 49.338 1 1760 +ATOM C CA . PHE B 1 260 . 260 PHE B CA 46.627 -1.738 48.748 1 1761 +ATOM C C . PHE B 1 260 . 260 PHE B C 46.528 -2.438 47.384 1 1762 +ATOM O O . PHE B 1 260 . 260 PHE B O 47.504 -3.082 46.952 1 1763 +ATOM C CB . PHE B 1 260 . 260 PHE B CB 47.521 -2.495 49.733 1 1764 +ATOM C CG . PHE B 1 260 . 260 PHE B CG 47.324 -2.075 51.168 1 1765 +ATOM C CD1 . PHE B 1 260 . 260 PHE B CD1 47.802 -0.853 51.615 1 1766 +ATOM C CD2 . PHE B 1 260 . 260 PHE B CD2 46.633 -2.88 52.06 1 1767 +ATOM C CE1 . PHE B 1 260 . 260 PHE B CE1 47.614 -0.456 52.929 1 1768 +ATOM C CE2 . PHE B 1 260 . 260 PHE B CE2 46.438 -2.478 53.372 1 1769 +ATOM C CZ . PHE B 1 260 . 260 PHE B CZ 46.931 -1.268 53.804 1 1770 +ATOM N N . GLY B 1 261 . 261 GLY B N 45.384 -2.284 46.713 1 1771 +ATOM C CA . GLY B 1 261 . 261 GLY B CA 45.053 -2.974 45.453 1 1772 +ATOM C C . GLY B 1 261 . 261 GLY B C 44.486 -2.015 44.409 1 1773 +ATOM O O . GLY B 1 261 . 261 GLY B O 44.541 -0.798 44.577 1 1774 +ATOM N N . PRO B 1 262 . 262 PRO B N 43.93 -2.545 43.297 1 1775 +ATOM C CA . PRO B 1 262 . 262 PRO B CA 43.421 -1.711 42.209 1 1776 +ATOM C C . PRO B 1 262 . 262 PRO B C 42.002 -1.189 42.474 1 1777 +ATOM O O . PRO B 1 262 . 262 PRO B O 41.386 -1.617 43.436 1 1778 +ATOM C CB . PRO B 1 262 . 262 PRO B CB 43.416 -2.691 41.03 1 1779 +ATOM C CG . PRO B 1 262 . 262 PRO B CG 43.089 -4.024 41.674 1 1780 +ATOM C CD . PRO B 1 262 . 262 PRO B CD 43.765 -3.981 43.029 1 1781 +ATOM N N . HIS B 1 263 . 263 HIS B N 41.524 -0.286 41.615 1 1782 +ATOM C CA . HIS B 1 263 . 263 HIS B CA 40.11 0.173 41.567 1 1783 +ATOM C C . HIS B 1 263 . 263 HIS B C 39.257 -0.97 41.003 1 1784 +ATOM O O . HIS B 1 263 . 263 HIS B O 39.102 -1.058 39.772 1 1785 +ATOM C CB . HIS B 1 263 . 263 HIS B CB 39.991 1.487 40.784 1 1786 +ATOM C CG . HIS B 1 263 . 263 HIS B CG 40.728 2.622 41.415 1 1787 +ATOM C CD2 . HIS B 1 263 . 263 HIS B CD2 40.32 3.592 42.261 1 1788 +ATOM N ND1 . HIS B 1 263 . 263 HIS B ND1 42.073 2.845 41.192 1 1789 +ATOM C CE1 . HIS B 1 263 . 263 HIS B CE1 42.46 3.903 41.874 1 1790 +ATOM N NE2 . HIS B 1 263 . 263 HIS B NE2 41.404 4.381 42.539 1 1791 +ATOM N N . LEU B 1 264 . 264 LEU B N 38.754 -1.819 41.9 1 1792 +ATOM C CA . LEU B 1 264 . 264 LEU B CA 38.091 -3.116 41.607 1 1793 +ATOM C C . LEU B 1 264 . 264 LEU B C 37.276 -3.518 42.842 1 1794 +ATOM O O . LEU B 1 264 . 264 LEU B O 37.855 -3.535 43.95 1 1795 +ATOM C CB . LEU B 1 264 . 264 LEU B CB 39.176 -4.153 41.299 1 1796 +ATOM C CG . LEU B 1 264 . 264 LEU B CG 38.688 -5.543 40.894 1 1797 +ATOM C CD1 . LEU B 1 264 . 264 LEU B CD1 38.175 -5.54 39.462 1 1798 +ATOM C CD2 . LEU B 1 264 . 264 LEU B CD2 39.796 -6.574 41.059 1 1799 +ATOM N N . GLY B 1 265 . 265 GLY B N 35.984 -3.801 42.663 1 1800 +ATOM C CA . GLY B 1 265 . 265 GLY B CA 35.083 -4.237 43.744 1 1801 +ATOM C C . GLY B 1 265 . 265 GLY B C 35.066 -5.748 43.884 1 1802 +ATOM O O . GLY B 1 265 . 265 GLY B O 35.443 -6.445 42.917 1 1803 +ATOM N N . THR B 1 266 . 266 THR B N 34.66 -6.24 45.054 1 1804 +ATOM C CA . THR B 1 266 . 266 THR B CA 34.289 -7.659 45.287 1 1805 +ATOM C C . THR B 1 266 . 266 THR B C 32.986 -7.697 46.088 1 1806 +ATOM O O . THR B 1 266 . 266 THR B O 32.934 -7.056 47.158 1 1807 +ATOM C CB . THR B 1 266 . 266 THR B CB 35.415 -8.441 45.972 1 1808 +ATOM C CG2 . THR B 1 266 . 266 THR B CG2 35.014 -9.855 46.335 1 1809 +ATOM O OG1 . THR B 1 266 . 266 THR B OG1 36.52 -8.471 45.067 1 1810 +ATOM N N . LEU B 1 267 . 267 LEU B N 31.986 -8.414 45.569 1 1811 +ATOM C CA . LEU B 1 267 . 267 LEU B CA 30.665 -8.627 46.213 1 1812 +ATOM C C . LEU B 1 267 . 267 LEU B C 30.569 -10.086 46.658 1 1813 +ATOM O O . LEU B 1 267 . 267 LEU B O 30.898 -10.975 45.849 1 1814 +ATOM C CB . LEU B 1 267 . 267 LEU B CB 29.553 -8.286 45.216 1 1815 +ATOM C CG . LEU B 1 267 . 267 LEU B CG 28.127 -8.557 45.697 1 1816 +ATOM C CD1 . LEU B 1 267 . 267 LEU B CD1 27.759 -7.632 46.849 1 1817 +ATOM C CD2 . LEU B 1 267 . 267 LEU B CD2 27.135 -8.411 44.552 1 1818 +ATOM N N . TYR B 1 268 . 268 TYR B N 30.137 -10.309 47.899 1 1819 +ATOM C CA . TYR B 1 268 . 268 TYR B CA 29.785 -11.641 48.45 1 1820 +ATOM C C . TYR B 1 268 . 268 TYR B C 28.261 -11.78 48.416 1 1821 +ATOM O O . TYR B 1 268 . 268 TYR B O 27.567 -10.795 48.728 1 1822 +ATOM C CB . TYR B 1 268 . 268 TYR B CB 30.336 -11.814 49.868 1 1823 +ATOM C CG . TYR B 1 268 . 268 TYR B CG 29.684 -12.922 50.655 1 1824 +ATOM C CD1 . TYR B 1 268 . 268 TYR B CD1 29.948 -14.253 50.371 1 1825 +ATOM C CD2 . TYR B 1 268 . 268 TYR B CD2 28.791 -12.643 51.676 1 1826 +ATOM C CE1 . TYR B 1 268 . 268 TYR B CE1 29.349 -15.278 51.088 1 1827 +ATOM C CE2 . TYR B 1 268 . 268 TYR B CE2 28.183 -13.654 52.4 1 1828 +ATOM C CZ . TYR B 1 268 . 268 TYR B CZ 28.464 -14.976 52.109 1 1829 +ATOM O OH . TYR B 1 268 . 268 TYR B OH 27.864 -15.969 52.827 1 1830 +ATOM N N . ALA B 1 269 . 269 ALA B N 27.767 -12.956 48.026 1 1831 +ATOM C CA . ALA B 1 269 . 269 ALA B CA 26.338 -13.336 48.09 1 1832 +ATOM C C . ALA B 1 269 . 269 ALA B C 26.244 -14.752 48.655 1 1833 +ATOM O O . ALA B 1 269 . 269 ALA B O 26.814 -15.664 48.029 1 1834 +ATOM C CB . ALA B 1 269 . 269 ALA B CB 25.71 -13.244 46.722 1 1835 +ATOM N N . SER B 1 270 . 270 SER B N 25.579 -14.926 49.801 1 1836 +ATOM C CA . SER B 1 270 . 270 SER B CA 25.366 -16.252 50.437 1 1837 +ATOM C C . SER B 1 270 . 270 SER B C 24.607 -17.154 49.458 1 1838 +ATOM O O . SER B 1 270 . 270 SER B O 23.836 -16.631 48.637 1 1839 +ATOM C CB . SER B 1 270 . 270 SER B CB 24.657 -16.14 51.768 1 1840 +ATOM O OG . SER B 1 270 . 270 SER B OG 23.266 -15.909 51.595 1 1841 +ATOM N N . ARG B 1 271 . 271 ARG B N 24.841 -18.462 49.532 1 1842 +ATOM C CA . ARG B 1 271 . 271 ARG B CA 24.186 -19.471 48.665 1 1843 +ATOM C C . ARG B 1 271 . 271 ARG B C 22.661 -19.336 48.798 1 1844 +ATOM O O . ARG B 1 271 . 271 ARG B O 21.974 -19.41 47.762 1 1845 +ATOM C CB . ARG B 1 271 . 271 ARG B CB 24.707 -20.867 49.021 1 1846 +ATOM C CG . ARG B 1 271 . 271 ARG B CG 24.158 -21.995 48.157 1 1847 +ATOM C CD . ARG B 1 271 . 271 ARG B CD 24.045 -21.673 46.676 1 1848 +ATOM N NE . ARG B 1 271 . 271 ARG B NE 25.271 -21.177 46.061 1 1849 +ATOM C CZ . ARG B 1 271 . 271 ARG B CZ 25.37 -20.773 44.794 1 1850 +ATOM N NH1 . ARG B 1 271 . 271 ARG B NH1 24.309 -20.791 44.001 1 1851 +ATOM N NH2 . ARG B 1 271 . 271 ARG B NH2 26.529 -20.345 44.323 1 1852 +ATOM N N . LYS B 1 272 . 272 LYS B N 22.158 -19.118 50.016 1 1853 +ATOM C CA . LYS B 1 272 . 272 LYS B CA 20.708 -18.936 50.304 1 1854 +ATOM C C . LYS B 1 272 . 272 LYS B C 20.19 -17.688 49.574 1 1855 +ATOM O O . LYS B 1 272 . 272 LYS B O 19.096 -17.766 48.986 1 1856 +ATOM C CB . LYS B 1 272 . 272 LYS B CB 20.457 -18.828 51.812 1 1857 +ATOM C CG . LYS B 1 272 . 272 LYS B CG 19.009 -18.555 52.204 1 1858 +ATOM C CD . LYS B 1 272 . 272 LYS B CD 18.799 -18.349 53.687 1 1859 +ATOM C CE . LYS B 1 272 . 272 LYS B CE 17.37 -17.98 54.027 1 1860 +ATOM N NZ . LYS B 1 272 . 272 LYS B NZ 17.138 -17.984 55.49 1 1861 +ATOM N N . ALA B 1 273 . 273 ALA B N 20.938 -16.583 49.628 1 1862 +ATOM C CA . ALA B 1 273 . 273 ALA B CA 20.596 -15.296 48.979 1 1863 +ATOM C C . ALA B 1 273 . 273 ALA B C 20.504 -15.499 47.462 1 1864 +ATOM O O . ALA B 1 273 . 273 ALA B O 19.476 -15.118 46.865 1 1865 +ATOM C CB . ALA B 1 273 . 273 ALA B CB 21.614 -14.242 49.342 1 1866 +ATOM N N . GLN B 1 274 . 274 GLN B N 21.534 -16.11 46.874 1 1867 +ATOM C CA . GLN B 1 274 . 274 GLN B CA 21.624 -16.399 45.417 1 1868 +ATOM C C . GLN B 1 274 . 274 GLN B C 20.427 -17.248 44.971 1 1869 +ATOM O O . GLN B 1 274 . 274 GLN B O 19.774 -16.863 43.987 1 1870 +ATOM C CB . GLN B 1 274 . 274 GLN B CB 22.939 -17.11 45.097 1 1871 +ATOM C CG . GLN B 1 274 . 274 GLN B CG 24.156 -16.205 45.194 1 1872 +ATOM C CD . GLN B 1 274 . 274 GLN B CD 25.405 -16.88 44.682 1 1873 +ATOM N NE2 . GLN B 1 274 . 274 GLN B NE2 26.416 -16.961 45.532 1 1874 +ATOM O OE1 . GLN B 1 274 . 274 GLN B OE1 25.47 -17.326 43.54 1 1875 +ATOM N N . ASP B 1 275 . 275 ASP B N 20.153 -18.356 45.665 1 1876 +ATOM C CA . ASP B 1 275 . 275 ASP B CA 19.149 -19.374 45.243 1 1877 +ATOM C C . ASP B 1 275 . 275 ASP B C 17.726 -18.811 45.37 1 1878 +ATOM O O . ASP B 1 275 . 275 ASP B O 16.889 -19.167 44.524 1 1879 +ATOM C CB . ASP B 1 275 . 275 ASP B CB 19.3 -20.678 46.034 1 1880 +ATOM C CG . ASP B 1 275 . 275 ASP B CG 20.525 -21.5 45.661 1 1881 +ATOM O OD1 . ASP B 1 275 . 275 ASP B OD1 21.273 -21.074 44.753 1 1882 +ATOM O OD2 . ASP B 1 275 . 275 ASP B OD2 20.722 -22.562 46.284 1 1883 +ATOM N N . ARG B 1 276 . 276 ARG B N 17.463 -17.964 46.371 1 1884 +ATOM C CA . ARG B 1 276 . 276 ARG B CA 16.086 -17.545 46.75 1 1885 +ATOM C C . ARG B 1 276 . 276 ARG B C 15.728 -16.16 46.192 1 1886 +ATOM O O . ARG B 1 276 . 276 ARG B O 14.562 -16.002 45.778 1 1887 +ATOM C CB . ARG B 1 276 . 276 ARG B CB 15.934 -17.55 48.273 1 1888 +ATOM C CG . ARG B 1 276 . 276 ARG B CG 15.996 -18.94 48.886 1 1889 +ATOM C CD . ARG B 1 276 . 276 ARG B CD 15.385 -18.944 50.27 1 1890 +ATOM N NE . ARG B 1 276 . 276 ARG B NE 15.466 -20.245 50.918 1 1891 +ATOM C CZ . ARG B 1 276 . 276 ARG B CZ 14.931 -20.53 52.098 1 1892 +ATOM N NH1 . ARG B 1 276 . 276 ARG B NH1 14.266 -19.602 52.77 1 1893 +ATOM N NH2 . ARG B 1 276 . 276 ARG B NH2 15.06 -21.745 52.6 1 1894 +ATOM N N . TYR B 1 277 . 277 TYR B N 16.662 -15.198 46.203 1 1895 +ATOM C CA . TYR B 1 277 . 277 TYR B CA 16.376 -13.748 46.005 1 1896 +ATOM C C . TYR B 1 277 . 277 TYR B C 16.904 -13.22 44.663 1 1897 +ATOM O O . TYR B 1 277 . 277 TYR B O 16.398 -12.167 44.225 1 1898 +ATOM C CB . TYR B 1 277 . 277 TYR B CB 16.98 -12.904 47.133 1 1899 +ATOM C CG . TYR B 1 277 . 277 TYR B CG 16.732 -13.401 48.537 1 1900 +ATOM C CD1 . TYR B 1 277 . 277 TYR B CD1 15.495 -13.892 48.932 1 1901 +ATOM C CD2 . TYR B 1 277 . 277 TYR B CD2 17.74 -13.354 49.487 1 1902 +ATOM C CE1 . TYR B 1 277 . 277 TYR B CE1 15.276 -14.344 50.226 1 1903 +ATOM C CE2 . TYR B 1 277 . 277 TYR B CE2 17.537 -13.802 50.781 1 1904 +ATOM C CZ . TYR B 1 277 . 277 TYR B CZ 16.301 -14.297 51.155 1 1905 +ATOM O OH . TYR B 1 277 . 277 TYR B OH 16.114 -14.74 52.435 1 1906 +ATOM N N . MET B 1 278 . 278 MET B N 17.884 -13.884 44.036 1 1907 +ATOM C CA . MET B 1 278 . 278 MET B CA 18.637 -13.317 42.882 1 1908 +ATOM C C . MET B 1 278 . 278 MET B C 18.153 -13.922 41.557 1 1909 +ATOM O O . MET B 1 278 . 278 MET B O 17.886 -15.139 41.512 1 1910 +ATOM C CB . MET B 1 278 . 278 MET B CB 20.14 -13.57 43.024 1 1911 +ATOM C CG . MET B 1 278 . 278 MET B CG 20.793 -12.759 44.129 1 1912 +ATOM S SD . MET B 1 278 . 278 MET B SD 22.6 -12.87 44.075 1 1913 +ATOM C CE . MET B 1 278 . 278 MET B CE 22.96 -11.884 42.622 1 1914 +ATOM N N . THR B 1 279 . 279 THR B N 18.055 -13.079 40.522 1 1915 +ATOM C CA . THR B 1 279 . 279 THR B CA 17.876 -13.457 39.093 1 1916 +ATOM C C . THR B 1 279 . 279 THR B C 19.119 -13.011 38.313 1 1917 +ATOM O O . THR B 1 279 . 279 THR B O 19.626 -11.901 38.58 1 1918 +ATOM C CB . THR B 1 279 . 279 THR B CB 16.601 -12.838 38.505 1 1919 +ATOM C CG2 . THR B 1 279 . 279 THR B CG2 16.335 -13.262 37.077 1 1920 +ATOM O OG1 . THR B 1 279 . 279 THR B OG1 15.487 -13.221 39.312 1 1921 +ATOM N N . SER B 1 280 . 280 SER B N 19.595 -13.849 37.392 1 1922 +ATOM C CA . SER B 1 280 . 280 SER B CA 20.717 -13.545 36.465 1 1923 +ATOM C C . SER B 1 280 . 280 SER B C 20.397 -12.291 35.642 1 1924 +ATOM O O . SER B 1 280 . 280 SER B O 19.272 -12.207 35.121 1 1925 +ATOM C CB . SER B 1 280 . 280 SER B CB 21.0 -14.711 35.559 1 1926 +ATOM O OG . SER B 1 280 . 280 SER B OG 22.007 -14.371 34.622 1 1927 +ATOM N N . ILE B 1 281 . 281 ILE B N 21.36 -11.371 35.527 1 1928 +ATOM C CA . ILE B 1 281 . 281 ILE B CA 21.309 -10.202 34.599 1 1929 +ATOM C C . ILE B 1 281 . 281 ILE B C 22.196 -10.502 33.382 1 1930 +ATOM O O . ILE B 1 281 . 281 ILE B O 22.343 -9.608 32.534 1 1931 +ATOM C CB . ILE B 1 281 . 281 ILE B CB 21.716 -8.895 35.315 1 1932 +ATOM C CG1 . ILE B 1 281 . 281 ILE B CG1 23.172 -8.9 35.789 1 1933 +ATOM C CG2 . ILE B 1 281 . 281 ILE B CG2 20.759 -8.598 36.459 1 1934 +ATOM C CD1 . ILE B 1 281 . 281 ILE B CD1 23.685 -7.538 36.194 1 1935 +ATOM N N . ASN B 1 282 . 282 ASN B N 22.749 -11.718 33.299 1 1936 +ATOM C CA . ASN B 1 282 . 282 ASN B CA 23.747 -12.136 32.278 1 1937 +ATOM C C . ASN B 1 282 . 282 ASN B C 23.007 -12.723 31.069 1 1938 +ATOM O O . ASN B 1 282 . 282 ASN B O 21.766 -12.827 31.135 1 1939 +ATOM C CB . ASN B 1 282 . 282 ASN B CB 24.753 -13.12 32.882 1 1940 +ATOM C CG . ASN B 1 282 . 282 ASN B CG 26.109 -13.103 32.208 1 1941 +ATOM N ND2 . ASN B 1 282 . 282 ASN B ND2 27.091 -13.728 32.834 1 1942 +ATOM O OD1 . ASN B 1 282 . 282 ASN B OD1 26.27 -12.541 31.128 1 1943 +ATOM N N . HIS B 1 283 . 283 HIS B N 23.737 -13.091 30.009 1 1944 +ATOM C CA . HIS B 1 283 . 283 HIS B CA 23.183 -13.691 28.764 1 1945 +ATOM C C . HIS B 1 283 . 283 HIS B C 22.393 -14.955 29.12 1 1946 +ATOM O O . HIS B 1 283 . 283 HIS B O 22.757 -15.617 30.109 1 1947 +ATOM C CB . HIS B 1 283 . 283 HIS B CB 24.293 -13.993 27.749 1 1948 +ATOM C CG . HIS B 1 283 . 283 HIS B CG 25.239 -12.861 27.528 1 1949 +ATOM C CD2 . HIS B 1 283 . 283 HIS B CD2 26.585 -12.799 27.594 1 1950 +ATOM N ND1 . HIS B 1 283 . 283 HIS B ND1 24.807 -11.594 27.201 1 1951 +ATOM C CE1 . HIS B 1 283 . 283 HIS B CE1 25.847 -10.798 27.078 1 1952 +ATOM N NE2 . HIS B 1 283 . 283 HIS B NE2 26.951 -11.514 27.308 1 1953 +ATOM N N . TYR B 1 284 . 284 TYR B N 21.364 -15.281 28.333 1 1954 +ATOM C CA . TYR B 1 284 . 284 TYR B CA 20.43 -16.411 28.587 1 1955 +ATOM C C . TYR B 1 284 . 284 TYR B C 21.196 -17.746 28.621 1 1956 +ATOM O O . TYR B 1 284 . 284 TYR B O 20.751 -18.65 29.361 1 1957 +ATOM C CB . TYR B 1 284 . 284 TYR B CB 19.293 -16.424 27.559 1 1958 +ATOM C CG . TYR B 1 284 . 284 TYR B CG 19.673 -16.886 26.174 1 1959 +ATOM C CD1 . TYR B 1 284 . 284 TYR B CD1 19.714 -18.236 25.856 1 1960 +ATOM C CD2 . TYR B 1 284 . 284 TYR B CD2 19.983 -15.977 25.174 1 1961 +ATOM C CE1 . TYR B 1 284 . 284 TYR B CE1 20.068 -18.672 24.589 1 1962 +ATOM C CE2 . TYR B 1 284 . 284 TYR B CE2 20.339 -16.396 23.903 1 1963 +ATOM C CZ . TYR B 1 284 . 284 TYR B CZ 20.379 -17.748 23.607 1 1964 +ATOM O OH . TYR B 1 284 . 284 TYR B OH 20.72 -18.172 22.354 1 1965 +ATOM N N . PHE B 1 285 . 285 PHE B N 22.304 -17.859 27.872 1 1966 +ATOM C CA . PHE B 1 285 . 285 PHE B CA 23.077 -19.118 27.667 1 1967 +ATOM C C . PHE B 1 285 . 285 PHE B C 24.09 -19.347 28.801 1 1968 +ATOM O O . PHE B 1 285 . 285 PHE B O 24.617 -20.472 28.881 1 1969 +ATOM C CB . PHE B 1 285 . 285 PHE B CB 23.765 -19.131 26.294 1 1970 +ATOM C CG . PHE B 1 285 . 285 PHE B CG 24.621 -17.931 25.968 1 1971 +ATOM C CD1 . PHE B 1 285 . 285 PHE B CD1 25.901 -17.804 26.487 1 1972 +ATOM C CD2 . PHE B 1 285 . 285 PHE B CD2 24.15 -16.931 25.13 1 1973 +ATOM C CE1 . PHE B 1 285 . 285 PHE B CE1 26.686 -16.702 26.184 1 1974 +ATOM C CE2 . PHE B 1 285 . 285 PHE B CE2 24.937 -15.83 24.826 1 1975 +ATOM C CZ . PHE B 1 285 . 285 PHE B CZ 26.202 -15.716 25.355 1 1976 +ATOM N N . VAL B 1 286 . 286 VAL B N 24.354 -18.339 29.644 1 1977 +ATOM C CA . VAL B 1 286 . 286 VAL B CA 25.356 -18.418 30.753 1 1978 +ATOM C C . VAL B 1 286 . 286 VAL B C 24.689 -19.06 31.976 1 1979 +ATOM O O . VAL B 1 286 . 286 VAL B O 23.596 -18.598 32.37 1 1980 +ATOM C CB . VAL B 1 286 . 286 VAL B CB 25.955 -17.04 31.101 1 1981 +ATOM C CG1 . VAL B 1 286 . 286 VAL B CG1 26.99 -17.139 32.212 1 1982 +ATOM C CG2 . VAL B 1 286 . 286 VAL B CG2 26.556 -16.357 29.881 1 1983 +ATOM N N . SER B 1 287 . 287 SER B N 25.328 -20.09 32.542 1 1984 +ATOM C CA . SER B 1 287 . 287 SER B CA 24.889 -20.809 33.769 1 1985 +ATOM C C . SER B 1 287 . 287 SER B C 24.862 -19.819 34.939 1 1986 +ATOM O O . SER B 1 287 . 287 SER B O 25.878 -19.132 35.147 1 1987 +ATOM C CB . SER B 1 287 . 287 SER B CB 25.784 -21.995 34.055 1 1988 +ATOM O OG . SER B 1 287 . 287 SER B OG 25.347 -22.702 35.209 1 1989 +ATOM N N . SER B 1 288 . 288 SER B N 23.73 -19.734 35.645 1 1990 +ATOM C CA . SER B 1 288 . 288 SER B CA 23.431 -18.692 36.665 1 1991 +ATOM C C . SER B 1 288 . 288 SER B C 23.645 -19.241 38.081 1 1992 +ATOM O O . SER B 1 288 . 288 SER B O 23.24 -18.554 39.038 1 1993 +ATOM C CB . SER B 1 288 . 288 SER B CB 22.029 -18.164 36.489 1 1994 +ATOM O OG . SER B 1 288 . 288 SER B OG 21.075 -19.201 36.665 1 1995 +ATOM N N . SER B 1 289 . 289 SER B N 24.266 -20.419 38.205 1 1996 +ATOM C CA . SER B 1 289 . 289 SER B CA 24.52 -21.127 39.488 1 1997 +ATOM C C . SER B 1 289 . 289 SER B C 25.488 -20.318 40.364 1 1998 +ATOM O O . SER B 1 289 . 289 SER B O 25.178 -20.136 41.553 1 1999 +ATOM C CB . SER B 1 289 . 289 SER B CB 25.03 -22.524 39.24 1 2000 +ATOM O OG . SER B 1 289 . 289 SER B OG 25.198 -23.221 40.464 1 2001 +ATOM N N . SER B 1 290 . 290 SER B N 26.604 -19.847 39.797 1 2002 +ATOM C CA . SER B 1 290 . 290 SER B CA 27.638 -19.027 40.489 1 2003 +ATOM C C . SER B 1 290 . 290 SER B C 27.213 -17.553 40.517 1 2004 +ATOM O O . SER B 1 290 . 290 SER B O 26.341 -17.172 39.714 1 2005 +ATOM C CB . SER B 1 290 . 290 SER B CB 28.99 -19.187 39.836 1 2006 +ATOM O OG . SER B 1 290 . 290 SER B OG 29.008 -18.589 38.548 1 2007 +ATOM N N . LEU B 1 291 . 291 LEU B N 27.821 -16.756 41.401 1 2008 +ATOM C CA . LEU B 1 291 . 291 LEU B CA 27.586 -15.289 41.497 1 2009 +ATOM C C . LEU B 1 291 . 291 LEU B C 28.128 -14.6 40.237 1 2010 +ATOM O O . LEU B 1 291 . 291 LEU B O 27.48 -13.646 39.772 1 2011 +ATOM C CB . LEU B 1 291 . 291 LEU B CB 28.25 -14.736 42.761 1 2012 +ATOM C CG . LEU B 1 291 . 291 LEU B CG 27.949 -13.269 43.07 1 2013 +ATOM C CD1 . LEU B 1 291 . 291 LEU B CD1 26.449 -13.018 43.149 1 2014 +ATOM C CD2 . LEU B 1 291 . 291 LEU B CD2 28.627 -12.836 44.361 1 2015 +ATOM N N . ASP B 1 292 . 292 ASP B N 29.27 -15.058 39.713 1 2016 +ATOM C CA . ASP B 1 292 . 292 ASP B CA 29.847 -14.592 38.42 1 2017 +ATOM C C . ASP B 1 292 . 292 ASP B C 28.822 -14.825 37.303 1 2018 +ATOM O O . ASP B 1 292 . 292 ASP B O 28.581 -13.893 36.518 1 2019 +ATOM C CB . ASP B 1 292 . 292 ASP B CB 31.171 -15.292 38.096 1 2020 +ATOM C CG . ASP B 1 292 . 292 ASP B CG 32.34 -14.83 38.95 1 2021 +ATOM O OD1 . ASP B 1 292 . 292 ASP B OD1 32.754 -15.606 39.837 1 2022 +ATOM O OD2 . ASP B 1 292 . 292 ASP B OD2 32.833 -13.705 38.718 1 2023 +ATOM N N . GLY B 1 293 . 293 GLY B N 28.234 -16.025 37.251 1 2024 +ATOM C CA . GLY B 1 293 . 293 GLY B CA 27.183 -16.397 36.285 1 2025 +ATOM C C . GLY B 1 293 . 293 GLY B C 26.042 -15.392 36.27 1 2026 +ATOM O O . GLY B 1 293 . 293 GLY B O 25.567 -15.059 35.171 1 2027 +ATOM N N . LYS B 1 294 . 294 LYS B N 25.625 -14.916 37.447 1 2028 +ATOM C CA . LYS B 1 294 . 294 LYS B CA 24.471 -13.994 37.623 1 2029 +ATOM C C . LYS B 1 294 . 294 LYS B C 24.842 -12.565 37.202 1 2030 +ATOM O O . LYS B 1 294 . 294 LYS B O 24.018 -11.947 36.507 1 2031 +ATOM C CB . LYS B 1 294 . 294 LYS B CB 23.986 -14.001 39.078 1 2032 +ATOM C CG . LYS B 1 294 . 294 LYS B CG 23.334 -15.295 39.544 1 2033 +ATOM C CD . LYS B 1 294 . 294 LYS B CD 22.934 -15.27 41.007 1 2034 +ATOM C CE . LYS B 1 294 . 294 LYS B CE 22.265 -16.547 41.469 1 2035 +ATOM N NZ . LYS B 1 294 . 294 LYS B NZ 23.235 -17.66 41.604 1 2036 +ATOM N N . LEU B 1 295 . 295 LEU B N 26.012 -12.056 37.613 1 2037 +ATOM C CA . LEU B 1 295 . 295 LEU B CA 26.301 -10.591 37.659 1 2038 +ATOM C C . LEU B 1 295 . 295 LEU B C 27.44 -10.165 36.717 1 2039 +ATOM O O . LEU B 1 295 . 295 LEU B O 27.513 -8.957 36.421 1 2040 +ATOM C CB . LEU B 1 295 . 295 LEU B CB 26.635 -10.205 39.104 1 2041 +ATOM C CG . LEU B 1 295 . 295 LEU B CG 25.474 -10.272 40.093 1 2042 +ATOM C CD1 . LEU B 1 295 . 295 LEU B CD1 25.933 -9.845 41.477 1 2043 +ATOM C CD2 . LEU B 1 295 . 295 LEU B CD2 24.306 -9.412 39.63 1 2044 +ATOM N N . ALA B 1 296 . 296 ALA B N 28.312 -11.079 36.278 1 2045 +ATOM C CA . ALA B 1 296 . 296 ALA B CA 29.479 -10.753 35.423 1 2046 +ATOM C C . ALA B 1 296 . 296 ALA B C 28.994 -10.299 34.041 1 2047 +ATOM O O . ALA B 1 296 . 296 ALA B O 28.496 -11.153 33.283 1 2048 +ATOM C CB . ALA B 1 296 . 296 ALA B CB 30.41 -11.934 35.316 1 2049 +ATOM N N . LEU B 1 297 . 297 LEU B N 29.12 -9.0 33.75 1 2050 +ATOM C CA . LEU B 1 297 . 297 LEU B CA 28.748 -8.375 32.454 1 2051 +ATOM C C . LEU B 1 297 . 297 LEU B C 30.0 -8.258 31.578 1 2052 +ATOM O O . LEU B 1 297 . 297 LEU B O 30.837 -7.373 31.856 1 2053 +ATOM C CB . LEU B 1 297 . 297 LEU B CB 28.122 -6.997 32.705 1 2054 +ATOM C CG . LEU B 1 297 . 297 LEU B CG 26.775 -6.986 33.43 1 2055 +ATOM C CD1 . LEU B 1 297 . 297 LEU B CD1 26.187 -5.585 33.44 1 2056 +ATOM C CD2 . LEU B 1 297 . 297 LEU B CD2 25.789 -7.961 32.805 1 2057 +ATOM N N . GLY B 1 298 . 298 GLY B N 30.114 -9.128 30.569 1 2058 +ATOM C CA . GLY B 1 298 . 298 GLY B CA 31.227 -9.148 29.6 1 2059 +ATOM C C . GLY B 1 298 . 298 GLY B C 32.486 -9.763 30.189 1 2060 +ATOM O O . GLY B 1 298 . 298 GLY B O 32.407 -10.348 31.292 1 2061 +ATOM N N . MET B 1 299 . 299 MET B N 33.609 -9.622 29.48 1 2062 +ATOM C CA . MET B 1 299 . 299 MET B CA 34.922 -10.217 29.849 1 2063 +ATOM C C . MET B 1 299 . 299 MET B C 35.425 -9.555 31.133 1 2064 +ATOM O O . MET B 1 299 . 299 MET B O 35.521 -8.332 31.211 1 2065 +ATOM C CB . MET B 1 299 . 299 MET B CB 35.945 -10.033 28.722 1 2066 +ATOM C CG . MET B 1 299 . 299 MET B CG 37.051 -11.08 28.715 1 2067 +ATOM S SD . MET B 1 299 . 299 MET B SD 36.459 -12.751 28.318 1 2068 +ATOM C CE . MET B 1 299 . 299 MET B CE 37.961 -13.462 27.643 1 2069 +ATOM N N . PRO B 1 300 . 300 PRO B N 35.721 -10.34 32.196 1 2070 +ATOM C CA . PRO B 1 300 . 300 PRO B CA 36.273 -9.791 33.434 1 2071 +ATOM C C . PRO B 1 300 . 300 PRO B C 37.679 -9.208 33.233 1 2072 +ATOM O O . PRO B 1 300 . 300 PRO B O 38.375 -9.652 32.327 1 2073 +ATOM C CB . PRO B 1 300 . 300 PRO B CB 36.352 -10.994 34.388 1 2074 +ATOM C CG . PRO B 1 300 . 300 PRO B CG 35.446 -12.042 33.772 1 2075 +ATOM C CD . PRO B 1 300 . 300 PRO B CD 35.515 -11.795 32.28 1 2076 +ATOM N N . SER B 1 301 . 301 SER B N 38.057 -8.243 34.077 1 2077 +ATOM C CA . SER B 1 301 . 301 SER B CA 39.41 -7.63 34.106 1 2078 +ATOM C C . SER B 1 301 . 301 SER B C 40.352 -8.554 34.881 1 2079 +ATOM O O . SER B 1 301 . 301 SER B O 40.63 -8.278 36.064 1 2080 +ATOM C CB . SER B 1 301 . 301 SER B CB 39.386 -6.24 34.681 1 2081 +ATOM O OG . SER B 1 301 . 301 SER B OG 40.693 -5.685 34.687 1 2082 +ATOM N N . PHE B 1 302 . 302 PHE B N 40.815 -9.619 34.223 1 2083 +ATOM C CA . PHE B 1 302 . 302 PHE B CA 41.677 -10.674 34.814 1 2084 +ATOM C C . PHE B 1 302 . 302 PHE B C 42.941 -10.032 35.396 1 2085 +ATOM O O . PHE B 1 302 . 302 PHE B O 43.38 -10.471 36.477 1 2086 +ATOM C CB . PHE B 1 302 . 302 PHE B CB 42.037 -11.734 33.77 1 2087 +ATOM C CG . PHE B 1 302 . 302 PHE B CG 40.861 -12.37 33.071 1 2088 +ATOM C CD1 . PHE B 1 302 . 302 PHE B CD1 40.018 -13.242 33.744 1 2089 +ATOM C CD2 . PHE B 1 302 . 302 PHE B CD2 40.609 -12.11 31.733 1 2090 +ATOM C CE1 . PHE B 1 302 . 302 PHE B CE1 38.944 -13.834 33.096 1 2091 +ATOM C CE2 . PHE B 1 302 . 302 PHE B CE2 39.536 -12.703 31.086 1 2092 +ATOM C CZ . PHE B 1 302 . 302 PHE B CZ 38.706 -13.565 31.767 1 2093 +ATOM N N . GLU B 1 303 . 303 GLU B N 43.491 -9.029 34.698 1 2094 +ATOM C CA . GLU B 1 303 . 303 GLU B CA 44.753 -8.332 35.07 1 2095 +ATOM C C . GLU B 1 303 . 303 GLU B C 44.608 -7.693 36.457 1 2096 +ATOM O O . GLU B 1 303 . 303 GLU B O 45.581 -7.763 37.227 1 2097 +ATOM C CB . GLU B 1 303 . 303 GLU B CB 45.18 -7.31 34.007 1 2098 +ATOM C CG . GLU B 1 303 . 303 GLU B CG 44.149 -6.24 33.684 1 2099 +ATOM C CD . GLU B 1 303 . 303 GLU B CD 43.163 -6.593 32.583 1 2100 +ATOM O OE1 . GLU B 1 303 . 303 GLU B OE1 43.15 -7.766 32.152 1 2101 +ATOM O OE2 . GLU B 1 303 . 303 GLU B OE2 42.405 -5.693 32.161 1 2102 +ATOM N N . LEU B 1 304 . 304 LEU B N 43.441 -7.122 36.778 1 2103 +ATOM C CA . LEU B 1 304 . 304 LEU B CA 43.185 -6.45 38.083 1 2104 +ATOM C C . LEU B 1 304 . 304 LEU B C 42.911 -7.503 39.168 1 2105 +ATOM O O . LEU B 1 304 . 304 LEU B O 43.43 -7.333 40.285 1 2106 +ATOM C CB . LEU B 1 304 . 304 LEU B CB 42.019 -5.464 37.947 1 2107 +ATOM C CG . LEU B 1 304 . 304 LEU B CG 42.288 -4.201 37.125 1 2108 +ATOM C CD1 . LEU B 1 304 . 304 LEU B CD1 41.185 -3.176 37.346 1 2109 +ATOM C CD2 . LEU B 1 304 . 304 LEU B CD2 43.643 -3.588 37.453 1 2110 +ATOM N N . GLN B 1 305 . 305 GLN B N 42.148 -8.556 38.849 1 2111 +ATOM C CA . GLN B 1 305 . 305 GLN B CA 41.791 -9.651 39.797 1 2112 +ATOM C C . GLN B 1 305 . 305 GLN B C 43.06 -10.327 40.339 1 2113 +ATOM O O . GLN B 1 305 . 305 GLN B O 43.097 -10.64 41.547 1 2114 +ATOM C CB . GLN B 1 305 . 305 GLN B CB 40.912 -10.704 39.12 1 2115 +ATOM C CG . GLN B 1 305 . 305 GLN B CG 39.534 -10.199 38.722 1 2116 +ATOM C CD . GLN B 1 305 . 305 GLN B CD 38.636 -11.318 38.251 1 2117 +ATOM N NE2 . GLN B 1 305 . 305 GLN B NE2 37.591 -10.964 37.52 1 2118 +ATOM O OE1 . GLN B 1 305 . 305 GLN B OE1 38.865 -12.489 38.546 1 2119 +ATOM N N . LEU B 1 306 . 306 LEU B N 44.066 -10.541 39.486 1 2120 +ATOM C CA . LEU B 1 306 . 306 LEU B CA 45.303 -11.285 39.846 1 2121 +ATOM C C . LEU B 1 306 . 306 LEU B C 46.223 -10.428 40.73 1 2122 +ATOM O O . LEU B 1 306 . 306 LEU B O 47.254 -10.966 41.179 1 2123 +ATOM C CB . LEU B 1 306 . 306 LEU B CB 46.007 -11.742 38.564 1 2124 +ATOM C CG . LEU B 1 306 . 306 LEU B CG 45.333 -12.911 37.846 1 2125 +ATOM C CD1 . LEU B 1 306 . 306 LEU B CD1 45.913 -13.097 36.458 1 2126 +ATOM C CD2 . LEU B 1 306 . 306 LEU B CD2 45.463 -14.196 38.651 1 2127 +ATOM N N . MET B 1 307 . 307 MET B N 45.862 -9.163 40.985 1 2128 +ATOM C CA . MET B 1 307 . 307 MET B CA 46.575 -8.258 41.928 1 2129 +ATOM C C . MET B 1 307 . 307 MET B C 46.188 -8.587 43.376 1 2130 +ATOM O O . MET B 1 307 . 307 MET B O 46.923 -8.159 44.288 1 2131 +ATOM C CB . MET B 1 307 . 307 MET B CB 46.237 -6.788 41.656 1 2132 +ATOM C CG . MET B 1 307 . 307 MET B CG 46.794 -6.278 40.345 1 2133 +ATOM S SD . MET B 1 307 . 307 MET B SD 46.491 -4.512 40.055 1 2134 +ATOM C CE . MET B 1 307 . 307 MET B CE 47.43 -3.782 41.396 1 2135 +ATOM N N . CYS B 1 308 . 308 CYS B N 45.086 -9.315 43.584 1 2136 +ATOM C CA . CYS B 1 308 . 308 CYS B CA 44.376 -9.397 44.89 1 2137 +ATOM C C . CYS B 1 308 . 308 CYS B C 44.959 -10.494 45.789 1 2138 +ATOM O O . CYS B 1 308 . 308 CYS B O 45.035 -10.248 47.002 1 2139 +ATOM C CB . CYS B 1 308 . 308 CYS B CB 42.88 -9.592 44.68 1 2140 +ATOM S SG . CYS B 1 308 . 308 CYS B SG 42.125 -8.188 43.821 1 2141 +ATOM N N . SER B 1 309 . 309 SER B N 45.341 -11.654 45.24 1 2142 +ATOM C CA . SER B 1 309 . 309 SER B CA 45.853 -12.805 46.035 1 2143 +ATOM C C . SER B 1 309 . 309 SER B C 47.184 -12.446 46.694 1 2144 +ATOM O O . SER B 1 309 . 309 SER B O 47.407 -12.82 47.842 1 2145 +ATOM C CB . SER B 1 309 . 309 SER B CB 45.939 -14.088 45.234 1 2146 +ATOM O OG . SER B 1 309 . 309 SER B OG 46.675 -13.92 44.036 1 2147 +ATOM N N . PRO B 1 310 . 310 PRO B N 48.111 -11.726 46.015 1 2148 +ATOM C CA . PRO B 1 310 . 310 PRO B CA 49.305 -11.194 46.678 1 2149 +ATOM C C . PRO B 1 310 . 310 PRO B C 49.027 -10.341 47.927 1 2150 +ATOM O O . PRO B 1 310 . 310 PRO B O 49.818 -10.419 48.846 1 2151 +ATOM C CB . PRO B 1 310 . 310 PRO B CB 49.955 -10.32 45.593 1 2152 +ATOM C CG . PRO B 1 310 . 310 PRO B CG 49.532 -10.975 44.303 1 2153 +ATOM C CD . PRO B 1 310 . 310 PRO B CD 48.12 -11.449 44.569 1 2154 +ATOM N N . ILE B 1 311 . 311 ILE B N 47.937 -9.563 47.928 1 2155 +ATOM C CA . ILE B 1 311 . 311 ILE B CA 47.551 -8.643 49.045 1 2156 +ATOM C C . ILE B 1 311 . 311 ILE B C 47.013 -9.471 50.219 1 2157 +ATOM O O . ILE B 1 311 . 311 ILE B O 47.369 -9.161 51.372 1 2158 +ATOM C CB . ILE B 1 311 . 311 ILE B CB 46.521 -7.586 48.592 1 2159 +ATOM C CG1 . ILE B 1 311 . 311 ILE B CG1 46.994 -6.797 47.368 1 2160 +ATOM C CG2 . ILE B 1 311 . 311 ILE B CG2 46.172 -6.658 49.748 1 2161 +ATOM C CD1 . ILE B 1 311 . 311 ILE B CD1 45.882 -6.088 46.637 1 2162 +ATOM N N . VAL B 1 312 . 312 VAL B N 46.166 -10.464 49.932 1 2163 +ATOM C CA . VAL B 1 312 . 312 VAL B CA 45.613 -11.422 50.936 1 2164 +ATOM C C . VAL B 1 312 . 312 VAL B C 46.789 -12.097 51.658 1 2165 +ATOM O O . VAL B 1 312 . 312 VAL B O 46.785 -12.126 52.903 1 2166 +ATOM C CB . VAL B 1 312 . 312 VAL B CB 44.688 -12.457 50.265 1 2167 +ATOM C CG1 . VAL B 1 312 . 312 VAL B CG1 44.306 -13.589 51.212 1 2168 +ATOM C CG2 . VAL B 1 312 . 312 VAL B CG2 43.442 -11.8 49.683 1 2169 +ATOM N N . SER B 1 313 . 313 SER B N 47.751 -12.618 50.893 1 2170 +ATOM C CA . SER B 1 313 . 313 SER B CA 48.984 -13.278 51.393 1 2171 +ATOM C C . SER B 1 313 . 313 SER B C 49.791 -12.286 52.24 1 2172 +ATOM O O . SER B 1 313 . 313 SER B O 50.258 -12.682 53.332 1 2173 +ATOM C CB . SER B 1 313 . 313 SER B CB 49.803 -13.824 50.252 1 2174 +ATOM O OG . SER B 1 313 . 313 SER B OG 51.062 -14.287 50.714 1 2175 +ATOM N N . TYR B 1 314 . 314 TYR B N 49.938 -11.048 51.754 1 2176 +ATOM C CA . TYR B 1 314 . 314 TYR B CA 50.668 -9.951 52.443 1 2177 +ATOM C C . TYR B 1 314 . 314 TYR B C 50.012 -9.667 53.803 1 2178 +ATOM O O . TYR B 1 314 . 314 TYR B O 50.698 -9.788 54.833 1 2179 +ATOM C CB . TYR B 1 314 . 314 TYR B CB 50.722 -8.688 51.578 1 2180 +ATOM C CG . TYR B 1 314 . 314 TYR B CG 51.233 -7.481 52.321 1 2181 +ATOM C CD1 . TYR B 1 314 . 314 TYR B CD1 52.564 -7.388 52.696 1 2182 +ATOM C CD2 . TYR B 1 314 . 314 TYR B CD2 50.38 -6.458 52.698 1 2183 +ATOM C CE1 . TYR B 1 314 . 314 TYR B CE1 53.042 -6.294 53.399 1 2184 +ATOM C CE2 . TYR B 1 314 . 314 TYR B CE2 50.841 -5.358 53.403 1 2185 +ATOM C CZ . TYR B 1 314 . 314 TYR B CZ 52.176 -5.276 53.757 1 2186 +ATOM O OH . TYR B 1 314 . 314 TYR B OH 52.641 -4.199 54.455 1 2187 +ATOM N N . LEU B 1 315 . 315 LEU B N 48.725 -9.308 53.806 1 2188 +ATOM C CA . LEU B 1 315 . 315 LEU B CA 47.986 -8.878 55.026 1 2189 +ATOM C C . LEU B 1 315 . 315 LEU B C 47.959 -10.014 56.055 1 2190 +ATOM O O . LEU B 1 315 . 315 LEU B O 48.217 -9.73 57.238 1 2191 +ATOM C CB . LEU B 1 315 . 315 LEU B CB 46.564 -8.448 54.648 1 2192 +ATOM C CG . LEU B 1 315 . 315 LEU B CG 46.455 -7.173 53.811 1 2193 +ATOM C CD1 . LEU B 1 315 . 315 LEU B CD1 45.033 -6.984 53.305 1 2194 +ATOM C CD2 . LEU B 1 315 . 315 LEU B CD2 46.909 -5.95 54.594 1 2195 +ATOM N N . GLN B 1 316 . 316 GLN B N 47.677 -11.245 55.619 1 2196 +ATOM C CA . GLN B 1 316 . 316 GLN B CA 47.443 -12.41 56.517 1 2197 +ATOM C C . GLN B 1 316 . 316 GLN B C 48.771 -12.99 57.027 1 2198 +ATOM O O . GLN B 1 316 . 316 GLN B O 48.825 -13.308 58.231 1 2199 +ATOM C CB . GLN B 1 316 . 316 GLN B CB 46.604 -13.477 55.809 1 2200 +ATOM C CG . GLN B 1 316 . 316 GLN B CG 45.159 -13.05 55.586 1 2201 +ATOM C CD . GLN B 1 316 . 316 GLN B CD 44.279 -14.176 55.097 1 2202 +ATOM N NE2 . GLN B 1 316 . 316 GLN B NE2 42.981 -14.037 55.313 1 2203 +ATOM O OE1 . GLN B 1 316 . 316 GLN B OE1 44.751 -15.162 54.531 1 2204 +ATOM N N . ASP B 1 317 . 317 ASP B N 49.793 -13.126 56.168 1 2205 +ATOM C CA . ASP B 1 317 . 317 ASP B CA 51.014 -13.932 56.458 1 2206 +ATOM C C . ASP B 1 317 . 317 ASP B C 52.251 -13.041 56.648 1 2207 +ATOM O O . ASP B 1 317 . 317 ASP B O 52.986 -13.279 57.631 1 2208 +ATOM C CB . ASP B 1 317 . 317 ASP B CB 51.255 -14.985 55.372 1 2209 +ATOM C CG . ASP B 1 317 . 317 ASP B CG 50.094 -15.95 55.183 1 2210 +ATOM O OD1 . ASP B 1 317 . 317 ASP B OD1 49.318 -16.135 56.146 1 2211 +ATOM O OD2 . ASP B 1 317 . 317 ASP B OD2 49.97 -16.504 54.071 1 2212 +ATOM N N . THR B 1 318 . 318 THR B N 52.503 -12.084 55.747 1 2213 +ATOM C CA . THR B 1 318 . 318 THR B CA 53.726 -11.231 55.761 1 2214 +ATOM C C . THR B 1 318 . 318 THR B C 53.71 -10.34 57.01 1 2215 +ATOM O O . THR B 1 318 . 318 THR B O 54.672 -10.43 57.8 1 2216 +ATOM C CB . THR B 1 318 . 318 THR B CB 53.878 -10.415 54.468 1 2217 +ATOM C CG2 . THR B 1 318 . 318 THR B CG2 55.165 -9.62 54.419 1 2218 +ATOM O OG1 . THR B 1 318 . 318 THR B OG1 53.839 -11.311 53.356 1 2219 +ATOM N N . VAL B 1 319 . 319 VAL B N 52.663 -9.524 57.187 1 2220 +ATOM C CA . VAL B 1 319 . 319 VAL B CA 52.51 -8.604 58.357 1 2221 +ATOM C C . VAL B 1 319 . 319 VAL B C 51.638 -9.283 59.421 1 2222 +ATOM O O . VAL B 1 319 . 319 VAL B O 52.059 -9.307 60.595 1 2223 +ATOM C CB . VAL B 1 319 . 319 VAL B CB 51.93 -7.237 57.947 1 2224 +ATOM C CG1 . VAL B 1 319 . 319 VAL B CG1 51.867 -6.278 59.126 1 2225 +ATOM C CG2 . VAL B 1 319 . 319 VAL B CG2 52.712 -6.616 56.8 1 2226 +ATOM N N . GLY B 1 320 . 320 GLY B N 50.471 -9.802 59.025 1 2227 +ATOM C CA . GLY B 1 320 . 320 GLY B CA 49.484 -10.415 59.937 1 2228 +ATOM C C . GLY B 1 320 . 320 GLY B C 48.58 -9.367 60.564 1 2229 +ATOM O O . GLY B 1 320 . 320 GLY B O 49.035 -8.22 60.74 1 2230 +ATOM N N . TRP B 1 321 . 321 TRP B N 47.344 -9.748 60.9 1 2231 +ATOM C CA . TRP B 1 321 . 321 TRP B CA 46.286 -8.826 61.395 1 2232 +ATOM C C . TRP B 1 321 . 321 TRP B C 46.618 -8.321 62.804 1 2233 +ATOM O O . TRP B 1 321 . 321 TRP B O 46.262 -7.166 63.098 1 2234 +ATOM C CB . TRP B 1 321 . 321 TRP B CB 44.905 -9.491 61.338 1 2235 +ATOM C CG . TRP B 1 321 . 321 TRP B CG 44.328 -9.528 59.955 1 2236 +ATOM C CD1 . TRP B 1 321 . 321 TRP B CD1 44.114 -10.63 59.18 1 2237 +ATOM C CD2 . TRP B 1 321 . 321 TRP B CD2 43.919 -8.397 59.164 1 2238 +ATOM C CE2 . TRP B 1 321 . 321 TRP B CE2 43.459 -8.903 57.929 1 2239 +ATOM C CE3 . TRP B 1 321 . 321 TRP B CE3 43.889 -7.015 59.379 1 2240 +ATOM N NE1 . TRP B 1 321 . 321 TRP B NE1 43.589 -10.266 57.969 1 2241 +ATOM C CZ2 . TRP B 1 321 . 321 TRP B CZ2 42.973 -8.072 56.92 1 2242 +ATOM C CZ3 . TRP B 1 321 . 321 TRP B CZ3 43.411 -6.194 58.381 1 2243 +ATOM C CH2 . TRP B 1 321 . 321 TRP B CH2 42.96 -6.718 57.168 1 2244 +ATOM N N . ASP B 1 322 . 322 ASP B N 47.271 -9.141 63.636 1 2245 +ATOM C CA . ASP B 1 322 . 322 ASP B CA 47.663 -8.764 65.021 1 2246 +ATOM C C . ASP B 1 322 . 322 ASP B C 48.49 -7.476 64.97 1 2247 +ATOM O O . ASP B 1 322 . 322 ASP B O 48.176 -6.537 65.723 1 2248 +ATOM C CB . ASP B 1 322 . 322 ASP B CB 48.457 -9.869 65.722 1 2249 +ATOM C CG . ASP B 1 322 . 322 ASP B CG 48.737 -9.553 67.181 1 2250 +ATOM O OD1 . ASP B 1 322 . 322 ASP B OD1 47.767 -9.488 67.958 1 2251 +ATOM O OD2 . ASP B 1 322 . 322 ASP B OD2 49.914 -9.343 67.52 1 2252 +ATOM N N . ARG B 1 323 . 323 ARG B N 49.491 -7.445 64.089 1 2253 +ATOM C CA . ARG B 1 323 . 323 ARG B CA 50.462 -6.331 63.93 1 2254 +ATOM C C . ARG B 1 323 . 323 ARG B C 49.774 -5.147 63.235 1 2255 +ATOM O O . ARG B 1 323 . 323 ARG B O 50.074 -3.996 63.601 1 2256 +ATOM C CB . ARG B 1 323 . 323 ARG B CB 51.679 -6.841 63.149 1 2257 +ATOM C CG . ARG B 1 323 . 323 ARG B CG 53.015 -6.255 63.586 1 2258 +ATOM C CD . ARG B 1 323 . 323 ARG B CD 54.091 -7.32 63.713 1 2259 +ATOM N NE . ARG B 1 323 . 323 ARG B NE 54.172 -8.199 62.551 1 2260 +ATOM C CZ . ARG B 1 323 . 323 ARG B CZ 54.799 -9.373 62.524 1 2261 +ATOM N NH1 . ARG B 1 323 . 323 ARG B NH1 55.422 -9.828 63.6 1 2262 +ATOM N NH2 . ARG B 1 323 . 323 ARG B NH2 54.8 -10.091 61.415 1 2263 +ATOM N N . ILE B 1 324 . 324 ILE B N 48.883 -5.424 62.275 1 2264 +ATOM C CA . ILE B 1 324 . 324 ILE B CA 48.135 -4.393 61.491 1 2265 +ATOM C C . ILE B 1 324 . 324 ILE B C 47.246 -3.581 62.446 1 2266 +ATOM O O . ILE B 1 324 . 324 ILE B O 47.38 -2.339 62.472 1 2267 +ATOM C CB . ILE B 1 324 . 324 ILE B CB 47.329 -5.048 60.349 1 2268 +ATOM C CG1 . ILE B 1 324 . 324 ILE B CG1 48.251 -5.488 59.208 1 2269 +ATOM C CG2 . ILE B 1 324 . 324 ILE B CG2 46.225 -4.126 59.851 1 2270 +ATOM C CD1 . ILE B 1 324 . 324 ILE B CD1 47.598 -6.4 58.197 1 2271 +ATOM N N . VAL B 1 325 . 325 VAL B N 46.383 -4.261 63.206 1 2272 +ATOM C CA . VAL B 1 325 . 325 VAL B CA 45.37 -3.628 64.106 1 2273 +ATOM C C . VAL B 1 325 . 325 VAL B C 46.103 -2.847 65.205 1 2274 +ATOM O O . VAL B 1 325 . 325 VAL B O 45.772 -1.662 65.404 1 2275 +ATOM C CB . VAL B 1 325 . 325 VAL B CB 44.405 -4.679 64.69 1 2276 +ATOM C CG1 . VAL B 1 325 . 325 VAL B CG1 43.493 -4.092 65.757 1 2277 +ATOM C CG2 . VAL B 1 325 . 325 VAL B CG2 43.58 -5.346 63.598 1 2278 +ATOM N N . ARG B 1 326 . 326 ARG B N 47.059 -3.488 65.885 1 2279 +ATOM C CA . ARG B 1 326 . 326 ARG B CA 47.844 -2.883 66.995 1 2280 +ATOM C C . ARG B 1 326 . 326 ARG B C 48.569 -1.626 66.498 1 2281 +ATOM O O . ARG B 1 326 . 326 ARG B O 48.565 -0.623 67.231 1 2282 +ATOM C CB . ARG B 1 326 . 326 ARG B CB 48.838 -3.895 67.571 1 2283 +ATOM C CG . ARG B 1 326 . 326 ARG B CG 48.207 -4.955 68.463 1 2284 +ATOM C CD . ARG B 1 326 . 326 ARG B CD 49.195 -6.056 68.8 1 2285 +ATOM N NE . ARG B 1 326 . 326 ARG B NE 50.403 -5.529 69.426 1 2286 +ATOM C CZ . ARG B 1 326 . 326 ARG B CZ 51.578 -6.152 69.475 1 2287 +ATOM N NH1 . ARG B 1 326 . 326 ARG B NH1 51.73 -7.349 68.93 1 2288 +ATOM N NH2 . ARG B 1 326 . 326 ARG B NH2 52.607 -5.568 70.071 1 2289 +ATOM N N . GLN B 1 327 . 327 GLN B N 49.166 -1.676 65.304 1 2290 +ATOM C CA . GLN B 1 327 . 327 GLN B CA 49.877 -0.521 64.693 1 2291 +ATOM C C . GLN B 1 327 . 327 GLN B C 48.878 0.627 64.501 1 2292 +ATOM O O . GLN B 1 327 . 327 GLN B O 49.136 1.726 65.02 1 2293 +ATOM C CB . GLN B 1 327 . 327 GLN B CB 50.545 -0.933 63.378 1 2294 +ATOM C CG . GLN B 1 327 . 327 GLN B CG 51.205 0.216 62.622 1 2295 +ATOM C CD . GLN B 1 327 . 327 GLN B CD 52.334 0.87 63.38 1 2296 +ATOM N NE2 . GLN B 1 327 . 327 GLN B NE2 52.697 2.073 62.964 1 2297 +ATOM O OE1 . GLN B 1 327 . 327 GLN B OE1 52.89 0.302 64.317 1 2298 +ATOM N N . GLU B 1 328 . 328 GLU B N 47.769 0.362 63.809 1 2299 +ATOM C CA . GLU B 1 328 . 328 GLU B CA 46.712 1.362 63.491 1 2300 +ATOM C C . GLU B 1 328 . 328 GLU B C 46.112 1.934 64.784 1 2301 +ATOM O O . GLU B 1 328 . 328 GLU B O 45.818 3.141 64.801 1 2302 +ATOM C CB . GLU B 1 328 . 328 GLU B CB 45.654 0.729 62.587 1 2303 +ATOM C CG . GLU B 1 328 . 328 GLU B CG 46.176 0.485 61.181 1 2304 +ATOM C CD . GLU B 1 328 . 328 GLU B CD 45.202 -0.127 60.192 1 2305 +ATOM O OE1 . GLU B 1 328 . 328 GLU B OE1 44.006 -0.245 60.522 1 2306 +ATOM O OE2 . GLU B 1 328 . 328 GLU B OE2 45.651 -0.486 59.087 1 2307 +ATOM N N . THR B 1 329 . 329 THR B N 45.958 1.105 65.822 1 2308 +ATOM C CA . THR B 1 329 . 329 THR B CA 45.379 1.478 67.142 1 2309 +ATOM C C . THR B 1 329 . 329 THR B C 46.263 2.541 67.811 1 2310 +ATOM O O . THR B 1 329 . 329 THR B O 45.711 3.548 68.29 1 2311 +ATOM C CB . THR B 1 329 . 329 THR B CB 45.182 0.224 68.006 1 2312 +ATOM C CG2 . THR B 1 329 . 329 THR B CG2 44.687 0.515 69.407 1 2313 +ATOM O OG1 . THR B 1 329 . 329 THR B OG1 44.246 -0.604 67.312 1 2314 +ATOM N N . VAL B 1 330 . 330 VAL B N 47.582 2.326 67.829 1 2315 +ATOM C CA . VAL B 1 330 . 330 VAL B CA 48.585 3.253 68.44 1 2316 +ATOM C C . VAL B 1 330 . 330 VAL B C 48.509 4.616 67.739 1 2317 +ATOM O O . VAL B 1 330 . 330 VAL B O 48.572 5.637 68.446 1 2318 +ATOM C CB . VAL B 1 330 . 330 VAL B CB 50.009 2.668 68.375 1 2319 +ATOM C CG1 . VAL B 1 330 . 330 VAL B CG1 51.057 3.686 68.797 1 2320 +ATOM C CG2 . VAL B 1 330 . 330 VAL B CG2 50.138 1.406 69.214 1 2321 +ATOM N N . LEU B 1 331 . 331 LEU B N 48.378 4.625 66.405 1 2322 +ATOM C CA . LEU B 1 331 . 331 LEU B CA 48.339 5.859 65.569 1 2323 +ATOM C C . LEU B 1 331 . 331 LEU B C 47.093 6.68 65.925 1 2324 +ATOM O O . LEU B 1 331 . 331 LEU B O 47.215 7.912 66.048 1 2325 +ATOM C CB . LEU B 1 331 . 331 LEU B CB 48.318 5.491 64.081 1 2326 +ATOM C CG . LEU B 1 331 . 331 LEU B CG 49.484 4.654 63.562 1 2327 +ATOM C CD1 . LEU B 1 331 . 331 LEU B CD1 49.288 4.345 62.089 1 2328 +ATOM C CD2 . LEU B 1 331 . 331 LEU B CD2 50.817 5.348 63.787 1 2329 +ATOM N N . VAL B 1 332 . 332 VAL B N 45.94 6.019 66.064 1 2330 +ATOM C CA . VAL B 1 332 . 332 VAL B CA 44.647 6.671 66.432 1 2331 +ATOM C C . VAL B 1 332 . 332 VAL B C 44.769 7.221 67.858 1 2332 +ATOM O O . VAL B 1 332 . 332 VAL B O 44.279 8.342 68.094 1 2333 +ATOM C CB . VAL B 1 332 . 332 VAL B CB 43.45 5.708 66.292 1 2334 +ATOM C CG1 . VAL B 1 332 . 332 VAL B CG1 42.147 6.345 66.757 1 2335 +ATOM C CG2 . VAL B 1 332 . 332 VAL B CG2 43.299 5.203 64.866 1 2336 +ATOM N N . THR B 1 333 . 333 THR B N 45.41 6.472 68.762 1 2337 +ATOM C CA . THR B 1 333 . 333 THR B CA 45.648 6.882 70.173 1 2338 +ATOM C C . THR B 1 333 . 333 THR B C 46.404 8.216 70.191 1 2339 +ATOM O O . THR B 1 333 . 333 THR B O 46.023 9.079 71.001 1 2340 +ATOM C CB . THR B 1 333 . 333 THR B CB 46.395 5.803 70.97 1 2341 +ATOM C CG2 . THR B 1 333 . 333 THR B CG2 46.572 6.162 72.43 1 2342 +ATOM O OG1 . THR B 1 333 . 333 THR B OG1 45.664 4.58 70.879 1 2343 +ATOM N N . ILE B 1 334 . 334 ILE B N 47.416 8.381 69.328 1 2344 +ATOM C CA . ILE B 1 334 . 334 ILE B CA 48.279 9.603 69.283 1 2345 +ATOM C C . ILE B 1 334 . 334 ILE B C 47.44 10.785 68.779 1 2346 +ATOM O O . ILE B 1 334 . 334 ILE B O 47.519 11.858 69.396 1 2347 +ATOM C CB . ILE B 1 334 . 334 ILE B CB 49.551 9.401 68.427 1 2348 +ATOM C CG1 . ILE B 1 334 . 334 ILE B CG1 50.379 8.19 68.87 1 2349 +ATOM C CG2 . ILE B 1 334 . 334 ILE B CG2 50.399 10.667 68.409 1 2350 +ATOM C CD1 . ILE B 1 334 . 334 ILE B CD1 50.875 8.262 70.289 1 2351 +ATOM N N . LEU B 1 335 . 335 LEU B N 46.669 10.597 67.704 1 2352 +ATOM C CA . LEU B 1 335 . 335 LEU B CA 45.876 11.683 67.066 1 2353 +ATOM C C . LEU B 1 335 . 335 LEU B C 44.764 12.141 68.017 1 2354 +ATOM O O . LEU B 1 335 . 335 LEU B O 44.657 13.355 68.243 1 2355 +ATOM C CB . LEU B 1 335 . 335 LEU B CB 45.297 11.195 65.734 1 2356 +ATOM C CG . LEU B 1 335 . 335 LEU B CG 44.458 12.215 64.959 1 2357 +ATOM C CD1 . LEU B 1 335 . 335 LEU B CD1 45.238 13.5 64.703 1 2358 +ATOM C CD2 . LEU B 1 335 . 335 LEU B CD2 43.975 11.624 63.645 1 2359 +ATOM N N . LEU B 1 336 . 336 LEU B N 43.97 11.207 68.547 1 2360 +ATOM C CA . LEU B 1 336 . 336 LEU B CA 42.837 11.511 69.463 1 2361 +ATOM C C . LEU B 1 336 . 336 LEU B C 43.375 12.145 70.75 1 2362 +ATOM O O . LEU B 1 336 . 336 LEU B O 42.768 13.134 71.212 1 2363 +ATOM C CB . LEU B 1 336 . 336 LEU B CB 42.05 10.233 69.77 1 2364 +ATOM C CG . LEU B 1 336 . 336 LEU B CG 41.12 9.738 68.664 1 2365 +ATOM C CD1 . LEU B 1 336 . 336 LEU B CD1 40.374 8.495 69.121 1 2366 +ATOM C CD2 . LEU B 1 336 . 336 LEU B CD2 40.135 10.82 68.235 1 2367 +ATOM N N . GLU B 1 337 . 337 GLU B N 44.465 11.599 71.3 1 2368 +ATOM C CA . GLU B 1 337 . 337 GLU B CA 45.156 12.145 72.499 1 2369 +ATOM C C . GLU B 1 337 . 337 GLU B C 45.349 13.656 72.317 1 2370 +ATOM O O . GLU B 1 337 . 337 GLU B O 45.009 14.407 73.25 1 2371 +ATOM C CB . GLU B 1 337 . 337 GLU B CB 46.502 11.453 72.722 1 2372 +ATOM N N . TYR B 1 338 . 338 TYR B N 45.856 14.08 71.153 1 2373 +ATOM C CA . TYR B 1 338 . 338 TYR B CA 46.074 15.51 70.811 1 2374 +ATOM C C . TYR B 1 338 . 338 TYR B C 44.728 16.227 70.633 1 2375 +ATOM O O . TYR B 1 338 . 338 TYR B O 44.516 17.261 71.292 1 2376 +ATOM C CB . TYR B 1 338 . 338 TYR B CB 46.919 15.683 69.545 1 2377 +ATOM C CG . TYR B 1 338 . 338 TYR B CG 46.893 17.097 69.024 1 2378 +ATOM C CD1 . TYR B 1 338 . 338 TYR B CD1 47.447 18.132 69.761 1 2379 +ATOM C CD2 . TYR B 1 338 . 338 TYR B CD2 46.264 17.415 67.83 1 2380 +ATOM C CE1 . TYR B 1 338 . 338 TYR B CE1 47.403 19.442 69.312 1 2381 +ATOM C CE2 . TYR B 1 338 . 338 TYR B CE2 46.212 18.72 67.366 1 2382 +ATOM C CZ . TYR B 1 338 . 338 TYR B CZ 46.785 19.736 68.11 1 2383 +ATOM O OH . TYR B 1 338 . 338 TYR B OH 46.737 21.026 67.673 1 2384 +ATOM N N . LEU B 1 339 . 339 LEU B N 43.858 15.7 69.762 1 2385 +ATOM C CA . LEU B 1 339 . 339 LEU B CA 42.581 16.355 69.353 1 2386 +ATOM C C . LEU B 1 339 . 339 LEU B C 41.702 16.62 70.583 1 2387 +ATOM O O . LEU B 1 339 . 339 LEU B O 41.171 17.743 70.688 1 2388 +ATOM C CB . LEU B 1 339 . 339 LEU B CB 41.852 15.481 68.325 1 2389 +ATOM C CG . LEU B 1 339 . 339 LEU B CG 42.497 15.408 66.939 1 2390 +ATOM C CD1 . LEU B 1 339 . 339 LEU B CD1 41.712 14.477 66.023 1 2391 +ATOM C CD2 . LEU B 1 339 . 339 LEU B CD2 42.621 16.79 66.31 1 2392 +ATOM N N . LEU B 1 340 . 340 LEU B N 41.569 15.639 71.48 1 2393 +ATOM C CA . LEU B 1 340 . 340 LEU B CA 40.7 15.736 72.685 1 2394 +ATOM C C . LEU B 1 340 . 340 LEU B C 41.401 16.534 73.794 1 2395 +ATOM O O . LEU B 1 340 . 340 LEU B O 40.699 16.927 74.74 1 2396 +ATOM C CB . LEU B 1 340 . 340 LEU B CB 40.324 14.326 73.156 1 2397 +ATOM C CG . LEU B 1 340 . 340 LEU B CG 39.481 13.518 72.169 1 2398 +ATOM C CD1 . LEU B 1 340 . 340 LEU B CD1 39.38 12.063 72.602 1 2399 +ATOM C CD2 . LEU B 1 340 . 340 LEU B CD2 38.094 14.125 72.007 1 2400 +ATOM N N . SER B 1 341 . 341 SER B N 42.714 16.778 73.675 1 2401 +ATOM C CA . SER B 1 341 . 341 SER B CA 43.516 17.608 74.617 1 2402 +ATOM C C . SER B 1 341 . 341 SER B C 43.072 19.075 74.539 1 2403 +ATOM O O . SER B 1 341 . 341 SER B O 43.304 19.813 75.518 1 2404 +ATOM C CB . SER B 1 341 . 341 SER B CB 45.006 17.479 74.363 1 2405 +ATOM O OG . SER B 1 341 . 341 SER B OG 45.421 18.292 73.274 1 2406 +ATOM N N . LYS B 1 342 . 342 LYS B N 42.471 19.481 73.415 1 2407 +ATOM C CA . LYS B 1 342 . 342 LYS B CA 41.964 20.86 73.183 1 2408 +ATOM C C . LYS B 1 342 . 342 LYS B C 40.486 20.79 72.806 1 2409 +ATOM O O . LYS B 1 342 . 342 LYS B O 40.136 20.96 71.64 1 2410 +ATOM C CB . LYS B 1 342 . 342 LYS B CB 42.809 21.552 72.106 1 2411 +ATOM C CG . LYS B 1 342 . 342 LYS B CG 44.294 21.665 72.43 1 2412 +ATOM C CD . LYS B 1 342 . 342 LYS B CD 45.028 22.701 71.602 1 2413 +ATOM C CE . LYS B 1 342 . 342 LYS B CE 46.43 22.979 72.108 1 2414 +ATOM N NZ . LYS B 1 342 . 342 LYS B NZ 46.978 24.244 71.561 1 2415 +ATOM N N . PRO B 1 343 . 343 PRO B N 39.571 20.553 73.777 1 2416 +ATOM C CA . PRO B 1 343 . 343 PRO B CA 38.142 20.431 73.48 1 2417 +ATOM C C . PRO B 1 343 . 343 PRO B C 37.528 21.731 72.933 1 2418 +ATOM O O . PRO B 1 343 . 343 PRO B O 36.584 21.649 72.174 1 2419 +ATOM C CB . PRO B 1 343 . 343 PRO B CB 37.497 20.072 74.831 1 2420 +ATOM C CG . PRO B 1 343 . 343 PRO B CG 38.652 19.617 75.703 1 2421 +ATOM C CD . PRO B 1 343 . 343 PRO B CD 39.85 20.402 75.213 1 2422 +ATOM N N . SER B 1 344 . 344 SER B N 38.083 22.884 73.324 1 2423 +ATOM C CA . SER B 1 344 . 344 SER B CA 37.655 24.244 72.899 1 2424 +ATOM C C . SER B 1 344 . 344 SER B C 37.947 24.477 71.407 1 2425 +ATOM O O . SER B 1 344 . 344 SER B O 37.29 25.358 70.814 1 2426 +ATOM C CB . SER B 1 344 . 344 SER B CB 38.328 25.293 73.747 1 2427 +ATOM O OG . SER B 1 344 . 344 SER B OG 39.734 25.102 73.753 1 2428 +ATOM N N . VAL B 1 345 . 345 VAL B N 38.902 23.732 70.835 1 2429 +ATOM C CA . VAL B 1 345 . 345 VAL B CA 39.48 23.958 69.475 1 2430 +ATOM C C . VAL B 1 345 . 345 VAL B C 38.955 22.897 68.496 1 2431 +ATOM O O . VAL B 1 345 . 345 VAL B O 38.553 23.282 67.378 1 2432 +ATOM C CB . VAL B 1 345 . 345 VAL B CB 41.021 23.958 69.536 1 2433 +ATOM C CG1 . VAL B 1 345 . 345 VAL B CG1 41.655 23.793 68.163 1 2434 +ATOM C CG2 . VAL B 1 345 . 345 VAL B CG2 41.556 25.206 70.225 1 2435 +ATOM N N . TYR B 1 346 . 346 TYR B N 38.98 21.616 68.885 1 2436 +ATOM C CA . TYR B 1 346 . 346 TYR B CA 38.612 20.462 68.021 1 2437 +ATOM C C . TYR B 1 346 . 346 TYR B C 37.304 19.829 68.508 1 2438 +ATOM O O . TYR B 1 346 . 346 TYR B O 37.223 19.44 69.688 1 2439 +ATOM C CB . TYR B 1 346 . 346 TYR B CB 39.74 19.426 67.989 1 2440 +ATOM C CG . TYR B 1 346 . 346 TYR B CG 41.045 19.943 67.437 1 2441 +ATOM C CD1 . TYR B 1 346 . 346 TYR B CD1 41.166 20.299 66.103 1 2442 +ATOM C CD2 . TYR B 1 346 . 346 TYR B CD2 42.159 20.085 68.248 1 2443 +ATOM C CE1 . TYR B 1 346 . 346 TYR B CE1 42.36 20.782 65.589 1 2444 +ATOM C CE2 . TYR B 1 346 . 346 TYR B CE2 43.36 20.566 67.751 1 2445 +ATOM C CZ . TYR B 1 346 . 346 TYR B CZ 43.462 20.916 66.416 1 2446 +ATOM O OH . TYR B 1 346 . 346 TYR B OH 44.643 21.39 65.923 1 2447 +ATOM N N . ARG B 1 347 . 347 ARG B N 36.318 19.748 67.609 1 2448 +ATOM C CA . ARG B 1 347 . 347 ARG B CA 35.068 18.955 67.754 1 2449 +ATOM C C . ARG B 1 347 . 347 ARG B C 35.261 17.636 66.995 1 2450 +ATOM O O . ARG B 1 347 . 347 ARG B O 35.295 17.678 65.749 1 2451 +ATOM C CB . ARG B 1 347 . 347 ARG B CB 33.883 19.76 67.212 1 2452 +ATOM C CG . ARG B 1 347 . 347 ARG B CG 32.533 19.066 67.325 1 2453 +ATOM C CD . ARG B 1 347 . 347 ARG B CD 31.477 19.786 66.508 1 2454 +ATOM N NE . ARG B 1 347 . 347 ARG B NE 30.196 19.089 66.511 1 2455 +ATOM C CZ . ARG B 1 347 . 347 ARG B CZ 29.192 19.322 65.665 1 2456 +ATOM N NH1 . ARG B 1 347 . 347 ARG B NH1 29.303 20.246 64.724 1 2457 +ATOM N NH2 . ARG B 1 347 . 347 ARG B NH2 28.073 18.621 65.761 1 2458 +ATOM N N . VAL B 1 348 . 348 VAL B N 35.426 16.525 67.721 1 2459 +ATOM C CA . VAL B 1 348 . 348 VAL B CA 35.731 15.174 67.162 1 2460 +ATOM C C . VAL B 1 348 . 348 VAL B C 34.413 14.408 67.006 1 2461 +ATOM O O . VAL B 1 348 . 348 VAL B O 33.661 14.321 68.003 1 2462 +ATOM C CB . VAL B 1 348 . 348 VAL B CB 36.728 14.407 68.054 1 2463 +ATOM C CG1 . VAL B 1 348 . 348 VAL B CG1 37.031 13.017 67.514 1 2464 +ATOM C CG2 . VAL B 1 348 . 348 VAL B CG2 38.018 15.188 68.254 1 2465 +ATOM N N . PHE B 1 349 . 349 PHE B N 34.151 13.888 65.801 1 2466 +ATOM C CA . PHE B 1 349 . 349 PHE B CA 32.978 13.037 65.466 1 2467 +ATOM C C . PHE B 1 349 . 349 PHE B C 33.364 11.567 65.655 1 2468 +ATOM O O . PHE B 1 349 . 349 PHE B O 34.557 11.233 65.513 1 2469 +ATOM C CB . PHE B 1 349 . 349 PHE B CB 32.499 13.309 64.037 1 2470 +ATOM C CG . PHE B 1 349 . 349 PHE B CG 32.178 14.755 63.754 1 2471 +ATOM C CD1 . PHE B 1 349 . 349 PHE B CD1 30.956 15.295 64.123 1 2472 +ATOM C CD2 . PHE B 1 349 . 349 PHE B CD2 33.106 15.582 63.141 1 2473 +ATOM C CE1 . PHE B 1 349 . 349 PHE B CE1 30.666 16.628 63.875 1 2474 +ATOM C CE2 . PHE B 1 349 . 349 PHE B CE2 32.814 16.916 62.894 1 2475 +ATOM C CZ . PHE B 1 349 . 349 PHE B CZ 31.594 17.435 63.259 1 2476 +ATOM N N . GLY B 1 350 . 350 GLY B N 32.383 10.724 65.98 1 2477 +ATOM C CA . GLY B 1 350 . 350 GLY B CA 32.572 9.278 66.196 1 2478 +ATOM C C . GLY B 1 350 . 350 GLY B C 33.023 8.964 67.614 1 2479 +ATOM O O . GLY B 1 350 . 350 GLY B O 32.768 9.785 68.525 1 2480 +ATOM N N . ARG B 1 351 . 351 ARG B N 33.676 7.811 67.789 1 2481 +ATOM C CA . ARG B 1 351 . 351 ARG B CA 34.114 7.273 69.103 1 2482 +ATOM C C . ARG B 1 351 . 351 ARG B C 35.397 7.984 69.546 1 2483 +ATOM O O . ARG B 1 351 . 351 ARG B O 36.403 7.894 68.818 1 2484 +ATOM C CB . ARG B 1 351 . 351 ARG B CB 34.341 5.761 69.01 1 2485 +ATOM C CG . ARG B 1 351 . 351 ARG B CG 34.549 5.08 70.357 1 2486 +ATOM C CD . ARG B 1 351 . 351 ARG B CD 33.384 5.284 71.309 1 2487 +ATOM N NE . ARG B 1 351 . 351 ARG B NE 32.102 5.202 70.621 1 2488 +ATOM C CZ . ARG B 1 351 . 351 ARG B CZ 31.497 4.074 70.255 1 2489 +ATOM N NH1 . ARG B 1 351 . 351 ARG B NH1 30.333 4.128 69.628 1 2490 +ATOM N NH2 . ARG B 1 351 . 351 ARG B NH2 32.05 2.898 70.507 1 2491 +ATOM N N . ARG B 1 352 . 352 ARG B N 35.363 8.631 70.714 1 2492 +ATOM C CA . ARG B 1 352 . 352 ARG B CA 36.488 9.433 71.27 1 2493 +ATOM C C . ARG B 1 352 . 352 ARG B C 37.392 8.534 72.124 1 2494 +ATOM O O . ARG B 1 352 . 352 ARG B O 37.811 8.97 73.209 1 2495 +ATOM C CB . ARG B 1 352 . 352 ARG B CB 35.929 10.629 72.046 1 2496 +ATOM C CG . ARG B 1 352 . 352 ARG B CG 35.196 11.627 71.162 1 2497 +ATOM C CD . ARG B 1 352 . 352 ARG B CD 34.511 12.707 71.967 1 2498 +ATOM N NE . ARG B 1 352 . 352 ARG B NE 33.45 12.146 72.788 1 2499 +ATOM C CZ . ARG B 1 352 . 352 ARG B CZ 32.815 12.797 73.757 1 2500 +ATOM N NH1 . ARG B 1 352 . 352 ARG B NH1 33.131 14.051 74.041 1 2501 +ATOM N NH2 . ARG B 1 352 . 352 ARG B NH2 31.863 12.186 74.44 1 2502 +ATOM N N . ASN B 1 353 . 353 ASN B N 37.665 7.322 71.635 1 2503 +ATOM C CA . ASN B 1 353 . 353 ASN B CA 38.729 6.405 72.12 1 2504 +ATOM C C . ASN B 1 353 . 353 ASN B C 39.259 5.634 70.906 1 2505 +ATOM O O . ASN B 1 353 . 353 ASN B O 38.627 5.719 69.833 1 2506 +ATOM C CB . ASN B 1 353 . 353 ASN B CB 38.232 5.488 73.244 1 2507 +ATOM C CG . ASN B 1 353 . 353 ASN B CG 37.26 4.429 72.766 1 2508 +ATOM N ND2 . ASN B 1 353 . 353 ASN B ND2 36.024 4.482 73.24 1 2509 +ATOM O OD1 . ASN B 1 353 . 353 ASN B OD1 37.628 3.557 71.98 1 2510 +ATOM N N . SER B 1 354 . 354 SER B N 40.363 4.904 71.08 1 2511 +ATOM C CA . SER B 1 354 . 354 SER B CA 41.134 4.225 70.005 1 2512 +ATOM C C . SER B 1 354 . 354 SER B C 40.908 2.708 70.038 1 2513 +ATOM O O . SER B 1 354 . 354 SER B O 41.616 2.0 69.301 1 2514 +ATOM C CB . SER B 1 354 . 354 SER B CB 42.594 4.553 70.142 1 2515 +ATOM O OG . SER B 1 354 . 354 SER B OG 43.097 4.107 71.396 1 2516 +ATOM N N . ASP B 1 355 . 355 ASP B N 39.972 2.223 70.862 1 2517 +ATOM C CA . ASP B 1 355 . 355 ASP B CA 39.762 0.77 71.113 1 2518 +ATOM C C . ASP B 1 355 . 355 ASP B C 39.442 0.07 69.793 1 2519 +ATOM O O . ASP B 1 355 . 355 ASP B O 38.515 0.465 69.089 1 2520 +ATOM C CB . ASP B 1 355 . 355 ASP B CB 38.677 0.533 72.167 1 2521 +ATOM C CG . ASP B 1 355 . 355 ASP B CG 39.12 0.833 73.59 1 2522 +ATOM O OD1 . ASP B 1 355 . 355 ASP B OD1 40.144 1.524 73.76 1 2523 +ATOM O OD2 . ASP B 1 355 . 355 ASP B OD2 38.445 0.363 74.517 1 2524 +ATOM N N . PRO B 1 356 . 356 PRO B N 40.2 -0.992 69.426 1 2525 +ATOM C CA . PRO B 1 356 . 356 PRO B CA 40.089 -1.61 68.102 1 2526 +ATOM C C . PRO B 1 356 . 356 PRO B C 38.76 -2.334 67.81 1 2527 +ATOM O O . PRO B 1 356 . 356 PRO B O 38.408 -2.437 66.649 1 2528 +ATOM C CB . PRO B 1 356 . 356 PRO B CB 41.26 -2.611 68.071 1 2529 +ATOM C CG . PRO B 1 356 . 356 PRO B CG 41.532 -2.923 69.529 1 2530 +ATOM C CD . PRO B 1 356 . 356 PRO B CD 41.23 -1.633 70.261 1 2531 +ATOM N N . SER B 1 357 . 357 SER B N 38.061 -2.816 68.841 1 2532 +ATOM C CA . SER B 1 357 . 357 SER B CA 36.741 -3.491 68.709 1 2533 +ATOM C C . SER B 1 357 . 357 SER B C 35.65 -2.444 68.441 1 2534 +ATOM O O . SER B 1 357 . 357 SER B O 34.583 -2.828 67.928 1 2535 +ATOM C CB . SER B 1 357 . 357 SER B CB 36.415 -4.327 69.928 1 2536 +ATOM O OG . SER B 1 357 . 357 SER B OG 35.831 -3.535 70.957 1 2537 +ATOM N N . GLN B 1 358 . 358 GLN B N 35.921 -1.176 68.77 1 2538 +ATOM C CA . GLN B 1 358 . 358 GLN B CA 34.927 -0.07 68.78 1 2539 +ATOM C C . GLN B 1 358 . 358 GLN B C 35.037 0.777 67.509 1 2540 +ATOM O O . GLN B 1 358 . 358 GLN B O 33.974 1.191 66.999 1 2541 +ATOM C CB . GLN B 1 358 . 358 GLN B CB 35.136 0.804 70.017 1 2542 +ATOM C CG . GLN B 1 358 . 358 GLN B CG 34.92 0.06 71.324 1 2543 +ATOM C CD . GLN B 1 358 . 358 GLN B CD 33.517 -0.484 71.422 1 2544 +ATOM N NE2 . GLN B 1 358 . 358 GLN B NE2 33.388 -1.799 71.33 1 2545 +ATOM O OE1 . GLN B 1 358 . 358 GLN B OE1 32.558 0.267 71.577 1 2546 +ATOM N N . ARG B 1 359 . 359 ARG B N 36.258 1.051 67.033 1 2547 +ATOM C CA . ARG B 1 359 . 359 ARG B CA 36.496 2.002 65.914 1 2548 +ATOM C C . ARG B 1 359 . 359 ARG B C 37.79 1.648 65.165 1 2549 +ATOM O O . ARG B 1 359 . 359 ARG B O 38.741 1.142 65.8 1 2550 +ATOM C CB . ARG B 1 359 . 359 ARG B CB 36.514 3.434 66.46 1 2551 +ATOM C CG . ARG B 1 359 . 359 ARG B CG 37.705 3.763 67.35 1 2552 +ATOM C CD . ARG B 1 359 . 359 ARG B CD 38.646 4.776 66.722 1 2553 +ATOM N NE . ARG B 1 359 . 359 ARG B NE 38.101 6.129 66.814 1 2554 +ATOM C CZ . ARG B 1 359 . 359 ARG B CZ 37.669 6.875 65.798 1 2555 +ATOM N NH1 . ARG B 1 359 . 359 ARG B NH1 37.713 6.43 64.552 1 2556 +ATOM N NH2 . ARG B 1 359 . 359 ARG B NH2 37.195 8.086 66.038 1 2557 +ATOM N N . VAL B 1 360 . 360 VAL B N 37.803 1.908 63.852 1 2558 +ATOM C CA . VAL B 1 360 . 360 VAL B CA 38.983 1.744 62.948 1 2559 +ATOM C C . VAL B 1 360 . 360 VAL B C 39.699 3.095 62.837 1 2560 +ATOM O O . VAL B 1 360 . 360 VAL B O 39.184 4.086 63.394 1 2561 +ATOM C CB . VAL B 1 360 . 360 VAL B CB 38.57 1.204 61.564 1 2562 +ATOM C CG1 . VAL B 1 360 . 360 VAL B CG1 37.819 -0.115 61.679 1 2563 +ATOM C CG2 . VAL B 1 360 . 360 VAL B CG2 37.755 2.213 60.768 1 2564 +ATOM N N . ALA B 1 361 . 361 ALA B N 40.826 3.138 62.12 1 2565 +ATOM C CA . ALA B 1 361 . 361 ALA B CA 41.715 4.318 62.016 1 2566 +ATOM C C . ALA B 1 361 . 361 ALA B C 41.209 5.28 60.934 1 2567 +ATOM O O . ALA B 1 361 . 361 ALA B O 42.006 5.656 60.049 1 2568 +ATOM C CB . ALA B 1 361 . 361 ALA B CB 43.13 3.868 61.751 1 2569 +ATOM N N . ILE B 1 362 . 362 ILE B N 39.925 5.646 61.008 1 2570 +ATOM C CA . ILE B 1 362 . 362 ILE B CA 39.297 6.77 60.252 1 2571 +ATOM C C . ILE B 1 362 . 362 ILE B C 38.764 7.757 61.294 1 2572 +ATOM O O . ILE B 1 362 . 362 ILE B O 37.77 7.422 61.968 1 2573 +ATOM C CB . ILE B 1 362 . 362 ILE B CB 38.187 6.274 59.299 1 2574 +ATOM C CG1 . ILE B 1 362 . 362 ILE B CG1 38.692 5.187 58.343 1 2575 +ATOM C CG2 . ILE B 1 362 . 362 ILE B CG2 37.566 7.446 58.546 1 2576 +ATOM C CD1 . ILE B 1 362 . 362 ILE B CD1 37.616 4.574 57.472 1 2577 +ATOM N N . VAL B 1 363 . 363 VAL B N 39.432 8.902 61.452 1 2578 +ATOM C CA . VAL B 1 363 . 363 VAL B CA 39.054 9.961 62.431 1 2579 +ATOM C C . VAL B 1 363 . 363 VAL B C 38.548 11.177 61.651 1 2580 +ATOM O O . VAL B 1 363 . 363 VAL B O 39.322 11.722 60.841 1 2581 +ATOM C CB . VAL B 1 363 . 363 VAL B CB 40.234 10.321 63.354 1 2582 +ATOM C CG1 . VAL B 1 363 . 363 VAL B CG1 39.875 11.441 64.318 1 2583 +ATOM C CG2 . VAL B 1 363 . 363 VAL B CG2 40.742 9.106 64.116 1 2584 +ATOM N N . THR B 1 364 . 364 THR B N 37.289 11.563 61.876 1 2585 +ATOM C CA . THR B 1 364 . 364 THR B CA 36.692 12.83 61.382 1 2586 +ATOM C C . THR B 1 364 . 364 THR B C 36.52 13.802 62.55 1 2587 +ATOM O O . THR B 1 364 . 364 THR B O 36.204 13.351 63.675 1 2588 +ATOM C CB . THR B 1 364 . 364 THR B CB 35.355 12.615 60.664 1 2589 +ATOM C CG2 . THR B 1 364 . 364 THR B CG2 35.519 11.965 59.307 1 2590 +ATOM O OG1 . THR B 1 364 . 364 THR B OG1 34.519 11.82 61.504 1 2591 +ATOM N N . PHE B 1 365 . 365 PHE B N 36.73 15.088 62.264 1 2592 +ATOM C CA . PHE B 1 365 . 365 PHE B CA 36.628 16.226 63.212 1 2593 +ATOM C C . PHE B 1 365 . 365 PHE B C 36.45 17.514 62.402 1 2594 +ATOM O O . PHE B 1 365 . 365 PHE B O 36.614 17.483 61.167 1 2595 +ATOM C CB . PHE B 1 365 . 365 PHE B CB 37.873 16.296 64.099 1 2596 +ATOM C CG . PHE B 1 365 . 365 PHE B CG 39.158 16.596 63.364 1 2597 +ATOM C CD1 . PHE B 1 365 . 365 PHE B CD1 39.877 15.583 62.745 1 2598 +ATOM C CD2 . PHE B 1 365 . 365 PHE B CD2 39.653 17.89 63.298 1 2599 +ATOM C CE1 . PHE B 1 365 . 365 PHE B CE1 41.06 15.86 62.077 1 2600 +ATOM C CE2 . PHE B 1 365 . 365 PHE B CE2 40.834 18.164 62.628 1 2601 +ATOM C CZ . PHE B 1 365 . 365 PHE B CZ 41.536 17.149 62.02 1 2602 +ATOM N N . GLU B 1 366 . 366 GLU B N 36.115 18.613 63.077 1 2603 +ATOM C CA . GLU B 1 366 . 366 GLU B CA 36.244 19.98 62.511 1 2604 +ATOM C C . GLU B 1 366 . 366 GLU B C 36.993 20.852 63.523 1 2605 +ATOM O O . GLU B 1 366 . 366 GLU B O 37.073 20.466 64.71 1 2606 +ATOM C CB . GLU B 1 366 . 366 GLU B CB 34.88 20.556 62.125 1 2607 +ATOM C CG . GLU B 1 366 . 366 GLU B CG 33.959 20.848 63.296 1 2608 +ATOM C CD . GLU B 1 366 . 366 GLU B CD 32.689 21.589 62.912 1 2609 +ATOM O OE1 . GLU B 1 366 . 366 GLU B OE1 32.699 22.29 61.874 1 2610 +ATOM O OE2 . GLU B 1 366 . 366 GLU B OE2 31.691 21.468 63.649 1 2611 +ATOM N N . VAL B 1 367 . 367 VAL B N 37.552 21.961 63.038 1 2612 +ATOM C CA . VAL B 1 367 . 367 VAL B CA 38.181 23.031 63.863 1 2613 +ATOM C C . VAL B 1 367 . 367 VAL B C 37.116 24.11 64.089 1 2614 +ATOM O O . VAL B 1 367 . 367 VAL B O 36.64 24.684 63.088 1 2615 +ATOM C CB . VAL B 1 367 . 367 VAL B CB 39.446 23.586 63.182 1 2616 +ATOM C CG1 . VAL B 1 367 . 367 VAL B CG1 40.183 24.571 64.079 1 2617 +ATOM C CG2 . VAL B 1 367 . 367 VAL B CG2 40.376 22.464 62.74 1 2618 +ATOM N N . VAL B 1 368 . 368 VAL B N 36.744 24.349 65.351 1 2619 +ATOM C CA . VAL B 1 368 . 368 VAL B CA 35.658 25.295 65.751 1 2620 +ATOM C C . VAL B 1 368 . 368 VAL B C 35.982 26.675 65.161 1 2621 +ATOM O O . VAL B 1 368 . 368 VAL B O 37.12 27.148 65.356 1 2622 +ATOM C CB . VAL B 1 368 . 368 VAL B CB 35.485 25.358 67.284 1 2623 +ATOM C CG1 . VAL B 1 368 . 368 VAL B CG1 34.419 26.365 67.695 1 2624 +ATOM C CG2 . VAL B 1 368 . 368 VAL B CG2 35.173 23.993 67.883 1 2625 +ATOM N N . GLY B 1 369 . 369 GLY B N 35.025 27.267 64.434 1 2626 +ATOM C CA . GLY B 1 369 . 369 GLY B CA 35.086 28.654 63.93 1 2627 +ATOM C C . GLY B 1 369 . 369 GLY B C 35.991 28.815 62.717 1 2628 +ATOM O O . GLY B 1 369 . 369 GLY B O 36.287 29.973 62.364 1 2629 +ATOM N N . ARG B 1 370 . 370 ARG B N 36.405 27.71 62.086 1 2630 +ATOM C CA . ARG B 1 370 . 370 ARG B CA 37.31 27.704 60.904 1 2631 +ATOM C C . ARG B 1 370 . 370 ARG B C 36.732 26.777 59.827 1 2632 +ATOM O O . ARG B 1 370 . 370 ARG B O 36.057 25.791 60.191 1 2633 +ATOM C CB . ARG B 1 370 . 370 ARG B CB 38.719 27.267 61.321 1 2634 +ATOM N N . SER B 1 371 . 371 SER B N 36.985 27.098 58.554 1 2635 +ATOM C CA . SER B 1 371 . 371 SER B CA 36.621 26.28 57.365 1 2636 +ATOM C C . SER B 1 371 . 371 SER B C 37.434 24.978 57.363 1 2637 +ATOM O O . SER B 1 371 . 371 SER B O 38.673 25.056 57.485 1 2638 +ATOM C CB . SER B 1 371 . 371 SER B CB 36.833 27.057 56.087 1 2639 +ATOM O OG . SER B 1 371 . 371 SER B OG 36.951 26.183 54.973 1 2640 +ATOM N N . SER B 1 372 . 372 SER B N 36.762 23.83 57.22 1 2641 +ATOM C CA . SER B 1 372 . 372 SER B CA 37.388 22.483 57.129 1 2642 +ATOM C C . SER B 1 372 . 372 SER B C 38.229 22.38 55.849 1 2643 +ATOM O O . SER B 1 372 . 372 SER B O 39.258 21.676 55.875 1 2644 +ATOM C CB . SER B 1 372 . 372 SER B CB 36.35 21.39 57.199 1 2645 +ATOM O OG . SER B 1 372 . 372 SER B OG 35.839 21.258 58.519 1 2646 +ATOM N N . GLY B 1 373 . 373 GLY B N 37.805 23.063 54.779 1 2647 +ATOM C CA . GLY B 1 373 . 373 GLY B CA 38.528 23.149 53.494 1 2648 +ATOM C C . GLY B 1 373 . 373 GLY B C 39.796 23.979 53.605 1 2649 +ATOM O O . GLY B 1 373 . 373 GLY B O 40.786 23.626 52.937 1 2650 +ATOM N N . ASP B 1 374 . 374 ASP B N 39.766 25.049 54.407 1 2651 +ATOM C CA . ASP B 1 374 . 374 ASP B CA 40.925 25.951 54.661 1 2652 +ATOM C C . ASP B 1 374 . 374 ASP B C 42.023 25.161 55.386 1 2653 +ATOM O O . ASP B 1 374 . 374 ASP B O 43.203 25.322 55.017 1 2654 +ATOM C CB . ASP B 1 374 . 374 ASP B CB 40.494 27.189 55.455 1 2655 +ATOM C CG . ASP B 1 374 . 374 ASP B CG 41.54 28.289 55.543 1 2656 +ATOM O OD1 . ASP B 1 374 . 374 ASP B OD1 42.604 28.151 54.906 1 2657 +ATOM O OD2 . ASP B 1 374 . 374 ASP B OD2 41.278 29.286 56.246 1 2658 +ATOM N N . VAL B 1 375 . 375 VAL B N 41.642 24.331 56.363 1 2659 +ATOM C CA . VAL B 1 375 . 375 VAL B CA 42.573 23.505 57.192 1 2660 +ATOM C C . VAL B 1 375 . 375 VAL B C 43.289 22.496 56.282 1 2661 +ATOM O O . VAL B 1 375 . 375 VAL B O 44.535 22.505 56.268 1 2662 +ATOM C CB . VAL B 1 375 . 375 VAL B CB 41.831 22.813 58.355 1 2663 +ATOM C CG1 . VAL B 1 375 . 375 VAL B CG1 42.704 21.79 59.069 1 2664 +ATOM C CG2 . VAL B 1 375 . 375 VAL B CG2 41.288 23.826 59.352 1 2665 +ATOM N N . ALA B 1 376 . 376 ALA B N 42.527 21.68 55.543 1 2666 +ATOM C CA . ALA B 1 376 . 376 ALA B CA 43.03 20.609 54.645 1 2667 +ATOM C C . ALA B 1 376 . 376 ALA B C 43.926 21.206 53.551 1 2668 +ATOM O O . ALA B 1 376 . 376 ALA B O 44.988 20.613 53.268 1 2669 +ATOM C CB . ALA B 1 376 . 376 ALA B CB 41.871 19.843 54.052 1 2670 +ATOM N N . MET B 1 377 . 377 MET B N 43.506 22.328 52.956 1 2671 +ATOM C CA . MET B 1 377 . 377 MET B CA 44.269 23.081 51.922 1 2672 +ATOM C C . MET B 1 377 . 377 MET B C 45.675 23.389 52.451 1 2673 +ATOM O O . MET B 1 377 . 377 MET B O 46.651 23.089 51.738 1 2674 +ATOM C CB . MET B 1 377 . 377 MET B CB 43.559 24.394 51.572 1 2675 +ATOM C CG . MET B 1 377 . 377 MET B CG 44.356 25.319 50.655 1 2676 +ATOM S SD . MET B 1 377 . 377 MET B SD 44.009 25.062 48.898 1 2677 +ATOM C CE . MET B 1 377 . 377 MET B CE 42.339 25.708 48.796 1 2678 +ATOM N N . ARG B 1 378 . 378 ARG B N 45.761 23.961 53.656 1 2679 +ATOM C CA . ARG B 1 378 . 378 ARG B CA 47.025 24.446 54.272 1 2680 +ATOM C C . ARG B 1 378 . 378 ARG B C 47.968 23.268 54.548 1 2681 +ATOM O O . ARG B 1 378 . 378 ARG B O 49.182 23.43 54.318 1 2682 +ATOM C CB . ARG B 1 378 . 378 ARG B CB 46.721 25.256 55.536 1 2683 +ATOM C CG . ARG B 1 378 . 378 ARG B CG 46.267 26.677 55.239 1 2684 +ATOM C CD . ARG B 1 378 . 378 ARG B CD 45.642 27.354 56.442 1 2685 +ATOM N NE . ARG B 1 378 . 378 ARG B NE 45.423 28.779 56.222 1 2686 +ATOM C CZ . ARG B 1 378 . 378 ARG B CZ 44.776 29.588 57.06 1 2687 +ATOM N NH1 . ARG B 1 378 . 378 ARG B NH1 44.267 29.118 58.189 1 2688 +ATOM N NH2 . ARG B 1 378 . 378 ARG B NH2 44.637 30.869 56.762 1 2689 +ATOM N N . VAL B 1 379 . 379 VAL B N 47.44 22.132 55.012 1 2690 +ATOM C CA . VAL B 1 379 . 379 VAL B CA 48.247 20.903 55.283 1 2691 +ATOM C C . VAL B 1 379 . 379 VAL B C 48.759 20.359 53.941 1 2692 +ATOM O O . VAL B 1 379 . 379 VAL B O 49.965 20.059 53.855 1 2693 +ATOM C CB . VAL B 1 379 . 379 VAL B CB 47.448 19.839 56.064 1 2694 +ATOM C CG1 . VAL B 1 379 . 379 VAL B CG1 48.252 18.564 56.276 1 2695 +ATOM C CG2 . VAL B 1 379 . 379 VAL B CG2 46.95 20.371 57.399 1 2696 +ATOM N N . ASN B 1 380 . 380 ASN B N 47.877 20.265 52.938 1 2697 +ATOM C CA . ASN B 1 380 . 380 ASN B CA 48.144 19.625 51.62 1 2698 +ATOM C C . ASN B 1 380 . 380 ASN B C 49.072 20.505 50.769 1 2699 +ATOM O O . ASN B 1 380 . 380 ASN B O 49.917 19.928 50.057 1 2700 +ATOM C CB . ASN B 1 380 . 380 ASN B CB 46.843 19.288 50.884 1 2701 +ATOM C CG . ASN B 1 380 . 380 ASN B CG 46.081 18.145 51.524 1 2702 +ATOM N ND2 . ASN B 1 380 . 380 ASN B ND2 44.888 17.868 51.025 1 2703 +ATOM O OD1 . ASN B 1 380 . 380 ASN B OD1 46.562 17.514 52.464 1 2704 +ATOM N N . THR B 1 381 . 381 THR B N 48.926 21.835 50.827 1 2705 +ATOM C CA . THR B 1 381 . 381 THR B CA 49.812 22.806 50.123 1 2706 +ATOM C C . THR B 1 381 . 381 THR B C 51.235 22.701 50.688 1 2707 +ATOM O O . THR B 1 381 . 381 THR B O 52.18 22.713 49.879 1 2708 +ATOM C CB . THR B 1 381 . 381 THR B CB 49.283 24.245 50.203 1 2709 +ATOM C CG2 . THR B 1 381 . 381 THR B CG2 48.101 24.497 49.292 1 2710 +ATOM O OG1 . THR B 1 381 . 381 THR B OG1 48.921 24.515 51.558 1 2711 +ATOM N N . ARG B 1 382 . 382 ARG B N 51.38 22.601 52.015 1 2712 +ATOM C CA . ARG B 1 382 . 382 ARG B CA 52.684 22.342 52.691 1 2713 +ATOM C C . ARG B 1 382 . 382 ARG B C 53.237 21.002 52.187 1 2714 +ATOM O O . ARG B 1 382 . 382 ARG B O 54.46 20.919 51.947 1 2715 +ATOM C CB . ARG B 1 382 . 382 ARG B CB 52.524 22.357 54.215 1 2716 +ATOM N N . ASN B 1 383 . 383 ASN B N 52.357 20.002 52.043 1 2717 +ATOM C CA . ASN B 1 383 . 383 ASN B CA 52.594 18.729 51.305 1 2718 +ATOM C C . ASN B 1 383 . 383 ASN B C 53.502 17.793 52.119 1 2719 +ATOM O O . ASN B 1 383 . 383 ASN B O 54.105 16.884 51.516 1 2720 +ATOM C CB . ASN B 1 383 . 383 ASN B CB 53.154 18.995 49.902 1 2721 +ATOM C CG . ASN B 1 383 . 383 ASN B CG 52.888 17.872 48.921 1 2722 +ATOM N ND2 . ASN B 1 383 . 383 ASN B ND2 53.779 17.697 47.957 1 2723 +ATOM O OD1 . ASN B 1 383 . 383 ASN B OD1 51.883 17.171 49.025 1 2724 +ATOM N N . ARG B 1 384 . 384 ARG B N 53.582 17.99 53.439 1 2725 +ATOM C CA . ARG B 1 384 . 384 ARG B CA 54.251 17.053 54.38 1 2726 +ATOM C C . ARG B 1 384 . 384 ARG B C 53.277 15.909 54.692 1 2727 +ATOM O O . ARG B 1 384 . 384 ARG B O 53.746 14.784 54.956 1 2728 +ATOM C CB . ARG B 1 384 . 384 ARG B CB 54.7 17.788 55.647 1 2729 +ATOM N N . PHE B 1 385 . 385 PHE B N 51.971 16.194 54.651 1 2730 +ATOM C CA . PHE B 1 385 . 385 PHE B CA 50.865 15.23 54.9 1 2731 +ATOM C C . PHE B 1 385 . 385 PHE B C 49.75 15.453 53.871 1 2732 +ATOM O O . PHE B 1 385 . 385 PHE B O 49.475 16.622 53.527 1 2733 +ATOM C CB . PHE B 1 385 . 385 PHE B CB 50.34 15.381 56.331 1 2734 +ATOM C CG . PHE B 1 385 . 385 PHE B CG 51.417 15.365 57.384 1 2735 +ATOM C CD1 . PHE B 1 385 . 385 PHE B CD1 51.863 14.169 57.925 1 2736 +ATOM C CD2 . PHE B 1 385 . 385 PHE B CD2 52.008 16.545 57.813 1 2737 +ATOM C CE1 . PHE B 1 385 . 385 PHE B CE1 52.869 14.153 58.879 1 2738 +ATOM C CE2 . PHE B 1 385 . 385 PHE B CE2 53.014 16.528 58.768 1 2739 +ATOM C CZ . PHE B 1 385 . 385 PHE B CZ 53.442 15.333 59.299 1 2740 +ATOM N N . ARG B 1 386 . 386 ARG B N 49.147 14.362 53.387 1 2741 +ATOM C CA . ARG B 1 386 . 386 ARG B CA 47.965 14.369 52.481 1 2742 +ATOM C C . ARG B 1 386 . 386 ARG B C 46.729 13.965 53.298 1 2743 +ATOM O O . ARG B 1 386 . 386 ARG B O 46.639 12.789 53.693 1 2744 +ATOM C CB . ARG B 1 386 . 386 ARG B CB 48.199 13.432 51.289 1 2745 +ATOM C CG . ARG B 1 386 . 386 ARG B CG 47.137 13.502 50.198 1 2746 +ATOM C CD . ARG B 1 386 . 386 ARG B CD 47.207 14.746 49.327 1 2747 +ATOM N NE . ARG B 1 386 . 386 ARG B NE 48.531 14.97 48.754 1 2748 +ATOM C CZ . ARG B 1 386 . 386 ARG B CZ 49.025 14.354 47.681 1 2749 +ATOM N NH1 . ARG B 1 386 . 386 ARG B NH1 48.311 13.45 47.03 1 2750 +ATOM N NH2 . ARG B 1 386 . 386 ARG B NH2 50.245 14.644 47.262 1 2751 +ATOM N N . ILE B 1 387 . 387 ILE B N 45.839 14.922 53.571 1 2752 +ATOM C CA . ILE B 1 387 . 387 ILE B CA 44.519 14.694 54.231 1 2753 +ATOM C C . ILE B 1 387 . 387 ILE B C 43.424 15.171 53.271 1 2754 +ATOM O O . ILE B 1 387 . 387 ILE B O 43.757 15.528 52.127 1 2755 +ATOM C CB . ILE B 1 387 . 387 ILE B CB 44.461 15.39 55.608 1 2756 +ATOM C CG1 . ILE B 1 387 . 387 ILE B CG1 44.481 16.919 55.495 1 2757 +ATOM C CG2 . ILE B 1 387 . 387 ILE B CG2 45.58 14.874 56.501 1 2758 +ATOM C CD1 . ILE B 1 387 . 387 ILE B CD1 44.246 17.641 56.807 1 2759 +ATOM N N . THR B 1 388 . 388 THR B N 42.164 15.167 53.71 1 2760 +ATOM C CA . THR B 1 388 . 388 THR B CA 41.009 15.604 52.882 1 2761 +ATOM C C . THR B 1 388 . 388 THR B C 39.971 16.304 53.763 1 2762 +ATOM O O . THR B 1 388 . 388 THR B O 40.097 16.248 55.006 1 2763 +ATOM C CB . THR B 1 388 . 388 THR B CB 40.426 14.424 52.094 1 2764 +ATOM C CG2 . THR B 1 388 . 388 THR B CG2 39.612 13.47 52.943 1 2765 +ATOM O OG1 . THR B 1 388 . 388 THR B OG1 39.625 14.976 51.049 1 2766 +ATOM N N . SER B 1 389 . 389 SER B N 39.0 16.949 53.114 1 2767 +ATOM C CA . SER B 1 389 . 389 SER B CA 37.861 17.664 53.74 1 2768 +ATOM C C . SER B 1 389 . 389 SER B C 36.628 17.536 52.841 1 2769 +ATOM O O . SER B 1 389 . 389 SER B O 36.793 17.158 51.664 1 2770 +ATOM C CB . SER B 1 389 . 389 SER B CB 38.202 19.108 53.979 1 2771 +ATOM O OG . SER B 1 389 . 389 SER B OG 38.401 19.777 52.745 1 2772 +ATOM N N . GLY B 1 390 . 390 GLY B N 35.448 17.859 53.375 1 2773 +ATOM C CA . GLY B 1 390 . 390 GLY B CA 34.199 17.972 52.601 1 2774 +ATOM C C . GLY B 1 390 . 390 GLY B C 33.138 17.0 53.077 1 2775 +ATOM O O . GLY B 1 390 . 390 GLY B O 33.091 16.712 54.289 1 2776 +ATOM N N . THR B 1 391 . 391 THR B N 32.313 16.528 52.139 1 2777 +ATOM C CA . THR B 1 391 . 391 THR B CA 31.167 15.61 52.359 1 2778 +ATOM C C . THR B 1 391 . 391 THR B C 31.623 14.163 52.142 1 2779 +ATOM O O . THR B 1 391 . 391 THR B O 31.007 13.256 52.74 1 2780 +ATOM C CB . THR B 1 391 . 391 THR B CB 30.005 15.991 51.435 1 2781 +ATOM C CG2 . THR B 1 391 . 391 THR B CG2 28.717 15.277 51.771 1 2782 +ATOM O OG1 . THR B 1 391 . 391 THR B OG1 29.815 17.401 51.541 1 2783 +ATOM N N . LEU B 1 392 . 392 LEU B N 32.637 13.963 51.295 1 2784 +ATOM C CA . LEU B 1 392 . 392 LEU B CA 33.329 12.665 51.069 1 2785 +ATOM C C . LEU B 1 392 . 392 LEU B C 32.327 11.621 50.556 1 2786 +ATOM O O . LEU B 1 392 . 392 LEU B O 32.335 10.482 51.068 1 2787 +ATOM C CB . LEU B 1 392 . 392 LEU B CB 34.006 12.23 52.375 1 2788 +ATOM C CG . LEU B 1 392 . 392 LEU B CG 34.98 13.248 52.969 1 2789 +ATOM C CD1 . LEU B 1 392 . 392 LEU B CD1 35.447 12.821 54.348 1 2790 +ATOM C CD2 . LEU B 1 392 . 392 LEU B CD2 36.173 13.462 52.047 1 2791 +ATOM N N . MET B 1 393 . 393 MET B N 31.502 12.011 49.578 1 2792 +ATOM C CA . MET B 1 393 . 393 MET B CA 30.557 11.126 48.844 1 2793 +ATOM C C . MET B 1 393 . 393 MET B C 29.573 10.481 49.833 1 2794 +ATOM O O . MET B 1 393 . 393 MET B O 29.082 9.369 49.548 1 2795 +ATOM C CB . MET B 1 393 . 393 MET B CB 31.329 10.038 48.087 1 2796 +ATOM C CG . MET B 1 393 . 393 MET B CG 32.475 10.575 47.241 1 2797 +ATOM S SD . MET B 1 393 . 393 MET B SD 31.957 11.08 45.583 1 2798 +ATOM C CE . MET B 1 393 . 393 MET B CE 31.636 9.479 44.843 1 2799 +ATOM N N . ALA B 1 394 . 394 ALA B N 29.286 11.164 50.946 1 2800 +ATOM C CA . ALA B 1 394 . 394 ALA B CA 28.406 10.679 52.034 1 2801 +ATOM C C . ALA B 1 394 . 394 ALA B C 27.639 11.856 52.635 1 2802 +ATOM O O . ALA B 1 394 . 394 ALA B O 27.861 12.219 53.789 1 2803 +ATOM C CB . ALA B 1 394 . 394 ALA B CB 29.235 9.941 53.058 1 2804 +ATOM N N . PRO B 1 395 . 395 PRO B N 26.714 12.486 51.869 1 2805 +ATOM C CA . PRO B 1 395 . 395 PRO B CA 25.966 13.654 52.341 1 2806 +ATOM C C . PRO B 1 395 . 395 PRO B C 25.005 13.378 53.506 1 2807 +ATOM O O . PRO B 1 395 . 395 PRO B O 24.773 14.281 54.284 1 2808 +ATOM C CB . PRO B 1 395 . 395 PRO B CB 25.128 14.097 51.128 1 2809 +ATOM C CG . PRO B 1 395 . 395 PRO B CG 25.747 13.39 49.944 1 2810 +ATOM C CD . PRO B 1 395 . 395 PRO B CD 26.334 12.111 50.499 1 2811 +ATOM N N . ARG B 1 396 . 396 ARG B N 24.443 12.169 53.575 1 2812 +ATOM C CA . ARG B 1 396 . 396 ARG B CA 23.431 11.793 54.598 1 2813 +ATOM C C . ARG B 1 396 . 396 ARG B C 24.105 11.732 55.969 1 2814 +ATOM O O . ARG B 1 396 . 396 ARG B O 23.704 12.46 56.869 1 2815 +ATOM C CB . ARG B 1 396 . 396 ARG B CB 22.693 10.518 54.171 1 2816 +ATOM C CG . ARG B 1 396 . 396 ARG B CG 21.821 10.721 52.937 1 2817 +ATOM C CD . ARG B 1 396 . 396 ARG B CD 21.455 9.448 52.194 1 2818 +ATOM N NE . ARG B 1 396 . 396 ARG B NE 21.266 9.709 50.767 1 2819 +ATOM C CZ . ARG B 1 396 . 396 ARG B CZ 20.189 10.278 50.222 1 2820 +ATOM N NH1 . ARG B 1 396 . 396 ARG B NH1 19.161 10.644 50.971 1 2821 +ATOM N NH2 . ARG B 1 396 . 396 ARG B NH2 20.147 10.48 48.916 1 2822 +ATOM N N . PRO B 1 397 . 397 PRO B N 25.167 10.921 56.189 1 2823 +ATOM C CA . PRO B 1 397 . 397 PRO B CA 25.844 10.914 57.485 1 2824 +ATOM C C . PRO B 1 397 . 397 PRO B C 26.462 12.277 57.843 1 2825 +ATOM O O . PRO B 1 397 . 397 PRO B O 26.36 12.67 58.987 1 2826 +ATOM C CB . PRO B 1 397 . 397 PRO B CB 26.932 9.836 57.35 1 2827 +ATOM C CG . PRO B 1 397 . 397 PRO B CG 27.106 9.64 55.857 1 2828 +ATOM C CD . PRO B 1 397 . 397 PRO B CD 25.766 9.973 55.237 1 2829 +ATOM N N . THR B 1 398 . 398 THR B N 27.062 12.973 56.871 1 2830 +ATOM C CA . THR B 1 398 . 398 THR B CA 27.758 14.271 57.086 1 2831 +ATOM C C . THR B 1 398 . 398 THR B C 26.748 15.339 57.524 1 2832 +ATOM O O . THR B 1 398 . 398 THR B O 26.956 15.922 58.601 1 2833 +ATOM C CB . THR B 1 398 . 398 THR B CB 28.551 14.717 55.848 1 2834 +ATOM C CG2 . THR B 1 398 . 398 THR B CG2 29.213 16.068 56.018 1 2835 +ATOM O OG1 . THR B 1 398 . 398 THR B OG1 29.566 13.748 55.583 1 2836 +ATOM N N . TRP B 1 399 . 399 TRP B N 25.696 15.578 56.735 1 2837 +ATOM C CA . TRP B 1 399 . 399 TRP B CA 24.825 16.781 56.848 1 2838 +ATOM C C . TRP B 1 399 . 399 TRP B C 23.527 16.494 57.614 1 2839 +ATOM O O . TRP B 1 399 . 399 TRP B O 22.943 17.473 58.112 1 2840 +ATOM C CB . TRP B 1 399 . 399 TRP B CB 24.54 17.365 55.46 1 2841 +ATOM C CG . TRP B 1 399 . 399 TRP B CG 25.776 17.873 54.786 1 2842 +ATOM C CD1 . TRP B 1 399 . 399 TRP B CD1 26.371 17.365 53.668 1 2843 +ATOM C CD2 . TRP B 1 399 . 399 TRP B CD2 26.598 18.974 55.217 1 2844 +ATOM C CE2 . TRP B 1 399 . 399 TRP B CE2 27.661 19.077 54.294 1 2845 +ATOM C CE3 . TRP B 1 399 . 399 TRP B CE3 26.537 19.886 56.279 1 2846 +ATOM N NE1 . TRP B 1 399 . 399 TRP B NE1 27.494 18.087 53.362 1 2847 +ATOM C CZ2 . TRP B 1 399 . 399 TRP B CZ2 28.65 20.054 54.405 1 2848 +ATOM C CZ3 . TRP B 1 399 . 399 TRP B CZ3 27.515 20.851 56.388 1 2849 +ATOM C CH2 . TRP B 1 399 . 399 TRP B CH2 28.555 20.934 55.46 1 2850 +ATOM N N . ASP B 1 400 . 400 ASP B N 23.082 15.232 57.698 1 2851 +ATOM C CA . ASP B 1 400 . 400 ASP B CA 21.804 14.848 58.363 1 2852 +ATOM C C . ASP B 1 400 . 400 ASP B C 22.06 14.29 59.769 1 2853 +ATOM O O . ASP B 1 400 . 400 ASP B O 21.133 14.373 60.593 1 2854 +ATOM C CB . ASP B 1 400 . 400 ASP B CB 21.013 13.828 57.539 1 2855 +ATOM C CG . ASP B 1 400 . 400 ASP B CG 20.706 14.29 56.126 1 2856 +ATOM O OD1 . ASP B 1 400 . 400 ASP B OD1 20.709 15.515 55.897 1 2857 +ATOM O OD2 . ASP B 1 400 . 400 ASP B OD2 20.477 13.422 55.266 1 2858 +ATOM N N . VAL B 1 401 . 401 VAL B N 23.245 13.726 60.029 1 2859 +ATOM C CA . VAL B 1 401 . 401 VAL B CA 23.597 13.086 61.335 1 2860 +ATOM C C . VAL B 1 401 . 401 VAL B C 24.667 13.936 62.04 1 2861 +ATOM O O . VAL B 1 401 . 401 VAL B O 24.33 14.574 63.053 1 2862 +ATOM C CB . VAL B 1 401 . 401 VAL B CB 24.038 11.62 61.143 1 2863 +ATOM C CG1 . VAL B 1 401 . 401 VAL B CG1 24.412 10.961 62.464 1 2864 +ATOM C CG2 . VAL B 1 401 . 401 VAL B CG2 22.97 10.802 60.425 1 2865 +ATOM N N . LEU B 1 402 . 402 LEU B N 25.897 13.967 61.514 1 2866 +ATOM C CA . LEU B 1 402 . 402 LEU B CA 27.06 14.639 62.163 1 2867 +ATOM C C . LEU B 1 402 . 402 LEU B C 26.812 16.151 62.26 1 2868 +ATOM O O . LEU B 1 402 . 402 LEU B O 27.107 16.718 63.333 1 2869 +ATOM C CB . LEU B 1 402 . 402 LEU B CB 28.342 14.333 61.379 1 2870 +ATOM C CG . LEU B 1 402 . 402 LEU B CG 28.796 12.872 61.384 1 2871 +ATOM C CD1 . LEU B 1 402 . 402 LEU B CD1 30.124 12.722 60.661 1 2872 +ATOM C CD2 . LEU B 1 402 . 402 LEU B CD2 28.897 12.321 62.8 1 2873 +ATOM N N . LYS B 1 403 . 403 LYS B N 26.291 16.766 61.19 1 2874 +ATOM C CA . LYS B 1 403 . 403 LYS B CA 25.911 18.207 61.12 1 2875 +ATOM C C . LYS B 1 403 . 403 LYS B C 27.088 19.073 61.568 1 2876 +ATOM O O . LYS B 1 403 . 403 LYS B O 27.054 19.67 62.644 1 2877 +ATOM C CB . LYS B 1 403 . 403 LYS B CB 24.664 18.482 61.97 1 2878 +ATOM C CG . LYS B 1 403 . 403 LYS B CG 23.349 18.002 61.369 1 2879 +ATOM C CD . LYS B 1 403 . 403 LYS B CD 22.15 18.226 62.271 1 2880 +ATOM C CE . LYS B 1 403 . 403 LYS B CE 20.835 17.83 61.63 1 2881 +ATOM N NZ . LYS B 1 403 . 403 LYS B NZ 20.416 18.792 60.582 1 2882 +ATOM N N . PRO B 1 404 . 404 PRO B N 28.177 19.161 60.77 1 2883 +ATOM C CA . PRO B 1 404 . 404 PRO B CA 29.281 20.067 61.082 1 2884 +ATOM C C . PRO B 1 404 . 404 PRO B C 28.846 21.527 60.896 1 2885 +ATOM O O . PRO B 1 404 . 404 PRO B O 28.027 21.784 60.03 1 2886 +ATOM C CB . PRO B 1 404 . 404 PRO B CB 30.381 19.667 60.089 1 2887 +ATOM C CG . PRO B 1 404 . 404 PRO B CG 29.629 19.057 58.923 1 2888 +ATOM C CD . PRO B 1 404 . 404 PRO B CD 28.409 18.398 59.534 1 2889 +ATOM N N . LYS B 1 405 . 405 LYS B N 29.388 22.43 61.717 1 2890 +ATOM C CA . LYS B 1 405 . 405 LYS B CA 29.075 23.885 61.692 1 2891 +ATOM C C . LYS B 1 405 . 405 LYS B C 29.763 24.539 60.485 1 2892 +ATOM O O . LYS B 1 405 . 405 LYS B O 29.225 25.537 59.972 1 2893 +ATOM C CB . LYS B 1 405 . 405 LYS B CB 29.468 24.527 63.027 1 2894 +ATOM C CG . LYS B 1 405 . 405 LYS B CG 28.617 24.077 64.208 1 2895 +ATOM C CD . LYS B 1 405 . 405 LYS B CD 28.903 24.813 65.501 1 2896 +ATOM C CE . LYS B 1 405 . 405 LYS B CE 27.954 24.423 66.616 1 2897 +ATOM N NZ . LYS B 1 405 . 405 LYS B NZ 28.288 25.112 67.886 1 2898 +ATOM N N . SER B 1 406 . 406 SER B N 30.896 23.984 60.043 1 2899 +ATOM C CA . SER B 1 406 . 406 SER B CA 31.652 24.415 58.836 1 2900 +ATOM C C . SER B 1 406 . 406 SER B C 30.855 24.075 57.568 1 2901 +ATOM O O . SER B 1 406 . 406 SER B O 30.469 22.898 57.415 1 2902 +ATOM C CB . SER B 1 406 . 406 SER B CB 33.022 23.781 58.809 1 2903 +ATOM O OG . SER B 1 406 . 406 SER B OG 33.708 24.096 57.606 1 2904 +ATOM N N . SER B 1 407 . 407 SER B N 30.644 25.068 56.694 1 2905 +ATOM C CA . SER B 1 407 . 407 SER B CA 29.945 24.948 55.385 1 2906 +ATOM C C . SER B 1 407 . 407 SER B C 30.74 24.044 54.436 1 2907 +ATOM O O . SER B 1 407 . 407 SER B O 30.136 23.527 53.476 1 2908 +ATOM C CB . SER B 1 407 . 407 SER B CB 29.724 26.302 54.76 1 2909 +ATOM O OG . SER B 1 407 . 407 SER B OG 29.139 27.199 55.691 1 2910 +ATOM N N . ASP B 1 408 . 408 ASP B N 32.045 23.89 54.689 1 2911 +ATOM C CA . ASP B 1 408 . 408 ASP B CA 32.981 23.054 53.889 1 2912 +ATOM C C . ASP B 1 408 . 408 ASP B C 32.985 21.614 54.42 1 2913 +ATOM O O . ASP B 1 408 . 408 ASP B O 33.773 20.807 53.898 1 2914 +ATOM C CB . ASP B 1 408 . 408 ASP B CB 34.386 23.662 53.899 1 2915 +ATOM C CG . ASP B 1 408 . 408 ASP B CG 34.471 24.979 53.147 1 2916 +ATOM O OD1 . ASP B 1 408 . 408 ASP B OD1 34.627 26.028 53.811 1 2917 +ATOM O OD2 . ASP B 1 408 . 408 ASP B OD2 34.375 24.944 51.902 1 2918 +ATOM N N . GLY B 1 409 . 409 GLY B N 32.152 21.309 55.42 1 2919 +ATOM C CA . GLY B 1 409 . 409 GLY B CA 31.964 19.946 55.953 1 2920 +ATOM C C . GLY B 1 409 . 409 GLY B C 33.054 19.558 56.937 1 2921 +ATOM O O . GLY B 1 409 . 409 GLY B O 33.37 20.373 57.82 1 2922 +ATOM N N . LEU B 1 410 . 410 LEU B N 33.608 18.352 56.782 1 2923 +ATOM C CA . LEU B 1 410 . 410 LEU B CA 34.464 17.666 57.789 1 2924 +ATOM C C . LEU B 1 410 . 410 LEU B C 35.934 17.727 57.368 1 2925 +ATOM O O . LEU B 1 410 . 410 LEU B O 36.199 17.891 56.163 1 2926 +ATOM C CB . LEU B 1 410 . 410 LEU B CB 34.023 16.203 57.905 1 2927 +ATOM C CG . LEU B 1 410 . 410 LEU B CG 32.544 15.973 58.204 1 2928 +ATOM C CD1 . LEU B 1 410 . 410 LEU B CD1 32.204 14.492 58.141 1 2929 +ATOM C CD2 . LEU B 1 410 . 410 LEU B CD2 32.17 16.547 59.558 1 2930 +ATOM N N . VAL B 1 411 . 411 VAL B N 36.839 17.587 58.341 1 2931 +ATOM C CA . VAL B 1 411 . 411 VAL B CA 38.269 17.214 58.127 1 2932 +ATOM C C . VAL B 1 411 . 411 VAL B C 38.389 15.719 58.432 1 2933 +ATOM O O . VAL B 1 411 . 411 VAL B O 37.855 15.299 59.479 1 2934 +ATOM C CB . VAL B 1 411 . 411 VAL B CB 39.224 18.04 59.011 1 2935 +ATOM C CG1 . VAL B 1 411 . 411 VAL B CG1 40.678 17.789 58.638 1 2936 +ATOM C CG2 . VAL B 1 411 . 411 VAL B CG2 38.906 19.527 58.959 1 2937 +ATOM N N . ARG B 1 412 . 412 ARG B N 39.034 14.946 57.553 1 2938 +ATOM C CA . ARG B 1 412 . 412 ARG B CA 39.233 13.485 57.752 1 2939 +ATOM C C . ARG B 1 412 . 412 ARG B C 40.719 13.135 57.635 1 2940 +ATOM O O . ARG B 1 412 . 412 ARG B O 41.34 13.464 56.602 1 2941 +ATOM C CB . ARG B 1 412 . 412 ARG B CB 38.406 12.656 56.765 1 2942 +ATOM C CG . ARG B 1 412 . 412 ARG B CG 38.33 11.181 57.137 1 2943 +ATOM C CD . ARG B 1 412 . 412 ARG B CD 37.587 10.347 56.113 1 2944 +ATOM N NE . ARG B 1 412 . 412 ARG B NE 38.295 10.291 54.841 1 2945 +ATOM C CZ . ARG B 1 412 . 412 ARG B CZ 37.863 9.659 53.754 1 2946 +ATOM N NH1 . ARG B 1 412 . 412 ARG B NH1 36.708 9.013 53.766 1 2947 +ATOM N NH2 . ARG B 1 412 . 412 ARG B NH2 38.596 9.674 52.654 1 2948 +ATOM N N . VAL B 1 413 . 413 VAL B N 41.244 12.487 58.677 1 2949 +ATOM C CA . VAL B 1 413 . 413 VAL B CA 42.584 11.835 58.715 1 2950 +ATOM C C . VAL B 1 413 . 413 VAL B C 42.335 10.327 58.815 1 2951 +ATOM O O . VAL B 1 413 . 413 VAL B O 41.603 9.913 59.736 1 2952 +ATOM C CB . VAL B 1 413 . 413 VAL B CB 43.427 12.377 59.886 1 2953 +ATOM C CG1 . VAL B 1 413 . 413 VAL B CG1 44.82 11.766 59.93 1 2954 +ATOM C CG2 . VAL B 1 413 . 413 VAL B CG2 43.516 13.895 59.841 1 2955 +ATOM N N . SER B 1 414 . 414 SER B N 42.873 9.549 57.873 1 2956 +ATOM C CA . SER B 1 414 . 414 SER B CA 42.657 8.082 57.765 1 2957 +ATOM C C . SER B 1 414 . 414 SER B C 44.006 7.374 57.605 1 2958 +ATOM O O . SER B 1 414 . 414 SER B O 44.715 7.66 56.619 1 2959 +ATOM C CB . SER B 1 414 . 414 SER B CB 41.714 7.752 56.635 1 2960 +ATOM O OG . SER B 1 414 . 414 SER B OG 42.094 8.422 55.44 1 2961 +ATOM N N . PHE B 1 415 . 415 PHE B N 44.336 6.486 58.544 1 2962 +ATOM C CA . PHE B 1 415 . 415 PHE B CA 45.618 5.74 58.598 1 2963 +ATOM C C . PHE B 1 415 . 415 PHE B C 45.404 4.302 58.115 1 2964 +ATOM O O . PHE B 1 415 . 415 PHE B O 44.272 3.788 58.201 1 2965 +ATOM C CB . PHE B 1 415 . 415 PHE B CB 46.174 5.709 60.024 1 2966 +ATOM C CG . PHE B 1 415 . 415 PHE B CG 46.331 7.049 60.7 1 2967 +ATOM C CD1 . PHE B 1 415 . 415 PHE B CD1 47.053 8.068 60.103 1 2968 +ATOM C CD2 . PHE B 1 415 . 415 PHE B CD2 45.798 7.274 61.962 1 2969 +ATOM C CE1 . PHE B 1 415 . 415 PHE B CE1 47.214 9.29 60.74 1 2970 +ATOM C CE2 . PHE B 1 415 . 415 PHE B CE2 45.958 8.497 62.595 1 2971 +ATOM C CZ . PHE B 1 415 . 415 PHE B CZ 46.664 9.505 61.981 1 2972 +ATOM N N . VAL B 1 416 . 416 VAL B N 46.479 3.673 57.639 1 2973 +ATOM C CA . VAL B 1 416 . 416 VAL B CA 46.61 2.192 57.493 1 2974 +ATOM C C . VAL B 1 416 . 416 VAL B C 47.937 1.777 58.14 1 2975 +ATOM O O . VAL B 1 416 . 416 VAL B O 48.669 2.675 58.617 1 2976 +ATOM C CB . VAL B 1 416 . 416 VAL B CB 46.513 1.73 56.026 1 2977 +ATOM C CG1 . VAL B 1 416 . 416 VAL B CG1 45.085 1.788 55.509 1 2978 +ATOM C CG2 . VAL B 1 416 . 416 VAL B CG2 47.451 2.505 55.115 1 2979 +ATOM N N . HIS B 1 417 . 417 HIS B N 48.238 0.475 58.14 1 2980 +ATOM C CA . HIS B 1 417 . 417 HIS B CA 49.331 -0.149 58.935 1 2981 +ATOM C C . HIS B 1 417 . 417 HIS B C 50.717 0.351 58.503 1 2982 +ATOM O O . HIS B 1 417 . 417 HIS B O 51.646 0.196 59.316 1 2983 +ATOM C CB . HIS B 1 417 . 417 HIS B CB 49.234 -1.68 58.881 1 2984 +ATOM C CG . HIS B 1 417 . 417 HIS B CG 49.585 -2.267 57.557 1 2985 +ATOM C CD2 . HIS B 1 417 . 417 HIS B CD2 50.773 -2.636 57.03 1 2986 +ATOM N ND1 . HIS B 1 417 . 417 HIS B ND1 48.63 -2.546 56.602 1 2987 +ATOM C CE1 . HIS B 1 417 . 417 HIS B CE1 49.213 -3.062 55.54 1 2988 +ATOM N NE2 . HIS B 1 417 . 417 HIS B NE2 50.53 -3.128 55.777 1 2989 +ATOM N N . TYR B 1 418 . 418 TYR B N 50.875 0.916 57.298 1 2990 +ATOM C CA . TYR B 1 418 . 418 TYR B CA 52.19 1.408 56.8 1 2991 +ATOM C C . TYR B 1 418 . 418 TYR B C 52.46 2.841 57.287 1 2992 +ATOM O O . TYR B 1 418 . 418 TYR B O 53.565 3.35 56.999 1 2993 +ATOM C CB . TYR B 1 418 . 418 TYR B CB 52.337 1.245 55.28 1 2994 +ATOM C CG . TYR B 1 418 . 418 TYR B CG 51.375 1.977 54.372 1 2995 +ATOM C CD1 . TYR B 1 418 . 418 TYR B CD1 51.034 3.309 54.563 1 2996 +ATOM C CD2 . TYR B 1 418 . 418 TYR B CD2 50.867 1.343 53.247 1 2997 +ATOM C CE1 . TYR B 1 418 . 418 TYR B CE1 50.173 3.967 53.698 1 2998 +ATOM C CE2 . TYR B 1 418 . 418 TYR B CE2 50.008 1.987 52.372 1 2999 +ATOM C CZ . TYR B 1 418 . 418 TYR B CZ 49.656 3.306 52.597 1 3000 +ATOM O OH . TYR B 1 418 . 418 TYR B OH 48.81 3.933 51.727 1 3001 +ATOM N N . ASN B 1 419 . 419 ASN B N 51.515 3.468 58.001 1 3002 +ATOM C CA . ASN B 1 419 . 419 ASN B CA 51.709 4.794 58.652 1 3003 +ATOM C C . ASN B 1 419 . 419 ASN B C 52.453 4.596 59.979 1 3004 +ATOM O O . ASN B 1 419 . 419 ASN B O 52.251 3.546 60.621 1 3005 +ATOM C CB . ASN B 1 419 . 419 ASN B CB 50.387 5.537 58.865 1 3006 +ATOM C CG . ASN B 1 419 . 419 ASN B CG 49.739 5.989 57.573 1 3007 +ATOM N ND2 . ASN B 1 419 . 419 ASN B ND2 50.484 6.703 56.742 1 3008 +ATOM O OD1 . ASN B 1 419 . 419 ASN B OD1 48.572 5.692 57.325 1 3009 +ATOM N N . THR B 1 420 . 420 THR B N 53.267 5.582 60.374 1 3010 +ATOM C CA . THR B 1 420 . 420 THR B CA 54.232 5.509 61.505 1 3011 +ATOM C C . THR B 1 420 . 420 THR B C 53.792 6.448 62.636 1 3012 +ATOM O O . THR B 1 420 . 420 THR B O 53.052 7.403 62.356 1 3013 +ATOM C CB . THR B 1 420 . 420 THR B CB 55.648 5.855 61.028 1 3014 +ATOM C CG2 . THR B 1 420 . 420 THR B CG2 56.071 5.075 59.802 1 3015 +ATOM O OG1 . THR B 1 420 . 420 THR B OG1 55.683 7.253 60.742 1 3016 +ATOM N N . VAL B 1 421 . 421 VAL B N 54.253 6.195 63.864 1 3017 +ATOM C CA . VAL B 1 421 . 421 VAL B CA 53.958 7.042 65.06 1 3018 +ATOM C C . VAL B 1 421 . 421 VAL B C 54.629 8.411 64.88 1 3019 +ATOM O O . VAL B 1 421 . 421 VAL B O 54.022 9.417 65.293 1 3020 +ATOM C CB . VAL B 1 421 . 421 VAL B CB 54.377 6.364 66.382 1 3021 +ATOM C CG1 . VAL B 1 421 . 421 VAL B CG1 53.591 5.085 66.628 1 3022 +ATOM C CG2 . VAL B 1 421 . 421 VAL B CG2 55.875 6.099 66.471 1 3023 +ATOM N N . GLU B 1 422 . 422 GLU B N 55.819 8.446 64.268 1 3024 +ATOM C CA . GLU B 1 422 . 422 GLU B CA 56.613 9.686 64.035 1 3025 +ATOM C C . GLU B 1 422 . 422 GLU B C 55.819 10.632 63.123 1 3026 +ATOM O O . GLU B 1 422 . 422 GLU B O 55.823 11.852 63.39 1 3027 +ATOM C CB . GLU B 1 422 . 422 GLU B CB 57.978 9.356 63.424 1 3028 +ATOM C CG . GLU B 1 422 . 422 GLU B CG 58.896 8.568 64.347 1 3029 +ATOM C CD . GLU B 1 422 . 422 GLU B CD 58.725 7.056 64.325 1 3030 +ATOM O OE1 . GLU B 1 422 . 422 GLU B OE1 57.945 6.559 63.49 1 3031 +ATOM O OE2 . GLU B 1 422 . 422 GLU B OE2 59.371 6.377 65.149 1 3032 +ATOM N N . GLU B 1 423 . 423 GLU B N 55.158 10.085 62.097 1 3033 +ATOM C CA . GLU B 1 423 . 423 GLU B CA 54.313 10.844 61.133 1 3034 +ATOM C C . GLU B 1 423 . 423 GLU B C 53.149 11.524 61.867 1 3035 +ATOM O O . GLU B 1 423 . 423 GLU B O 52.955 12.738 61.666 1 3036 +ATOM C CB . GLU B 1 423 . 423 GLU B CB 53.773 9.923 60.039 1 3037 +ATOM C CG . GLU B 1 423 . 423 GLU B CG 54.75 9.69 58.904 1 3038 +ATOM C CD . GLU B 1 423 . 423 GLU B CD 54.215 8.797 57.799 1 3039 +ATOM O OE1 . GLU B 1 423 . 423 GLU B OE1 53.312 7.971 58.079 1 3040 +ATOM O OE2 . GLU B 1 423 . 423 GLU B OE2 54.707 8.918 56.661 1 3041 +ATOM N N . VAL B 1 424 . 424 VAL B N 52.412 10.769 62.687 1 3042 +ATOM C CA . VAL B 1 424 . 424 VAL B CA 51.187 11.246 63.399 1 3043 +ATOM C C . VAL B 1 424 . 424 VAL B C 51.581 12.369 64.368 1 3044 +ATOM O O . VAL B 1 424 . 424 VAL B O 50.872 13.395 64.397 1 3045 +ATOM C CB . VAL B 1 424 . 424 VAL B CB 50.467 10.096 64.129 1 3046 +ATOM C CG1 . VAL B 1 424 . 424 VAL B CG1 49.254 10.591 64.903 1 3047 +ATOM C CG2 . VAL B 1 424 . 424 VAL B CG2 50.068 8.98 63.175 1 3048 +ATOM N N . ARG B 1 425 . 425 ARG B N 52.663 12.175 65.131 1 3049 +ATOM C CA . ARG B 1 425 . 425 ARG B CA 53.196 13.166 66.105 1 3050 +ATOM C C . ARG B 1 425 . 425 ARG B C 53.583 14.455 65.37 1 3051 +ATOM O O . ARG B 1 425 . 425 ARG B O 53.255 15.545 65.88 1 3052 +ATOM C CB . ARG B 1 425 . 425 ARG B CB 54.392 12.588 66.871 1 3053 +ATOM C CG . ARG B 1 425 . 425 ARG B CG 54.011 11.58 67.946 1 3054 +ATOM C CD . ARG B 1 425 . 425 ARG B CD 55.173 11.221 68.852 1 3055 +ATOM N NE . ARG B 1 425 . 425 ARG B NE 54.714 10.561 70.071 1 3056 +ATOM C CZ . ARG B 1 425 . 425 ARG B CZ 54.675 9.244 70.274 1 3057 +ATOM N NH1 . ARG B 1 425 . 425 ARG B NH1 55.081 8.397 69.34 1 3058 +ATOM N NH2 . ARG B 1 425 . 425 ARG B NH2 54.23 8.775 71.428 1 3059 +ATOM N N . ALA B 1 426 . 426 ALA B N 54.252 14.335 64.22 1 3060 +ATOM C CA . ALA B 1 426 . 426 ALA B CA 54.687 15.473 63.376 1 3061 +ATOM C C . ALA B 1 426 . 426 ALA B C 53.46 16.214 62.826 1 3062 +ATOM O O . ALA B 1 426 . 426 ALA B O 53.468 17.458 62.853 1 3063 +ATOM C CB . ALA B 1 426 . 426 ALA B CB 55.59 14.989 62.266 1 3064 +ATOM N N . PHE B 1 427 . 427 PHE B N 52.447 15.476 62.356 1 3065 +ATOM C CA . PHE B 1 427 . 427 PHE B CA 51.163 16.025 61.842 1 3066 +ATOM C C . PHE B 1 427 . 427 PHE B C 50.471 16.837 62.944 1 3067 +ATOM O O . PHE B 1 427 . 427 PHE B O 49.979 17.944 62.646 1 3068 +ATOM C CB . PHE B 1 427 . 427 PHE B CB 50.244 14.911 61.328 1 3069 +ATOM C CG . PHE B 1 427 . 427 PHE B CG 48.865 15.378 60.929 1 3070 +ATOM C CD1 . PHE B 1 427 . 427 PHE B CD1 48.658 16.031 59.724 1 3071 +ATOM C CD2 . PHE B 1 427 . 427 PHE B CD2 47.778 15.187 61.768 1 3072 +ATOM C CE1 . PHE B 1 427 . 427 PHE B CE1 47.393 16.472 59.364 1 3073 +ATOM C CE2 . PHE B 1 427 . 427 PHE B CE2 46.514 15.629 61.405 1 3074 +ATOM C CZ . PHE B 1 427 . 427 PHE B CZ 46.323 16.271 60.204 1 3075 +ATOM N N . CYS B 1 428 . 428 CYS B N 50.436 16.299 64.168 1 3076 +ATOM C CA . CYS B 1 428 . 428 CYS B CA 49.806 16.931 65.361 1 3077 +ATOM C C . CYS B 1 428 . 428 CYS B C 50.488 18.271 65.668 1 3078 +ATOM O O . CYS B 1 428 . 428 CYS B O 49.78 19.195 66.112 1 3079 +ATOM C CB . CYS B 1 428 . 428 CYS B CB 49.852 16.01 66.575 1 3080 +ATOM S SG . CYS B 1 428 . 428 CYS B SG 48.71 14.605 66.454 1 3081 +ATOM N N . SER B 1 429 . 429 SER B N 51.801 18.373 65.429 1 3082 +ATOM C CA . SER B 1 429 . 429 SER B CA 52.602 19.612 65.618 1 3083 +ATOM C C . SER B 1 429 . 429 SER B C 52.235 20.636 64.537 1 3084 +ATOM O O . SER B 1 429 . 429 SER B O 52.008 21.808 64.897 1 3085 +ATOM C CB . SER B 1 429 . 429 SER B CB 54.084 19.326 65.615 1 3086 +ATOM O OG . SER B 1 429 . 429 SER B OG 54.414 18.338 66.579 1 3087 +ATOM N N . GLU B 1 430 . 430 GLU B N 52.183 20.205 63.271 1 3088 +ATOM C CA . GLU B 1 430 . 430 GLU B CA 51.865 21.068 62.098 1 3089 +ATOM C C . GLU B 1 430 . 430 GLU B C 50.433 21.599 62.222 1 3090 +ATOM O O . GLU B 1 430 . 430 GLU B O 50.241 22.818 62.04 1 3091 +ATOM C CB . GLU B 1 430 . 430 GLU B CB 52.043 20.307 60.779 1 3092 +ATOM C CG . GLU B 1 430 . 430 GLU B CG 51.55 21.083 59.563 1 3093 +ATOM C CD . GLU B 1 430 . 430 GLU B CD 52.238 20.782 58.24 1 3094 +ATOM O OE1 . GLU B 1 430 . 430 GLU B OE1 53.098 19.878 58.204 1 3095 +ATOM O OE2 . GLU B 1 430 . 430 GLU B OE2 51.915 21.464 57.244 1 3096 +ATOM N N . LEU B 1 431 . 431 LEU B N 49.474 20.711 62.505 1 3097 +ATOM C CA . LEU B 1 431 . 431 LEU B CA 48.031 21.049 62.646 1 3098 +ATOM C C . LEU B 1 431 . 431 LEU B C 47.869 22.11 63.742 1 3099 +ATOM O O . LEU B 1 431 . 431 LEU B O 47.131 23.082 63.506 1 3100 +ATOM C CB . LEU B 1 431 . 431 LEU B CB 47.238 19.775 62.965 1 3101 +ATOM C CG . LEU B 1 431 . 431 LEU B CG 45.715 19.907 62.933 1 3102 +ATOM C CD1 . LEU B 1 431 . 431 LEU B CD1 45.227 20.376 61.571 1 3103 +ATOM C CD2 . LEU B 1 431 . 431 LEU B CD2 45.052 18.59 63.305 1 3104 +ATOM N N . ASP B 1 432 . 432 ASP B N 48.559 21.939 64.876 1 3105 +ATOM C CA . ASP B 1 432 . 432 ASP B CA 48.559 22.89 66.022 1 3106 +ATOM C C . ASP B 1 432 . 432 ASP B C 48.941 24.291 65.525 1 3107 +ATOM O O . ASP B 1 432 . 432 ASP B O 48.16 25.232 65.774 1 3108 +ATOM C CB . ASP B 1 432 . 432 ASP B CB 49.502 22.419 67.135 1 3109 +ATOM C CG . ASP B 1 432 . 432 ASP B CG 49.259 23.084 68.478 1 3110 +ATOM O OD1 . ASP B 1 432 . 432 ASP B OD1 48.105 23.05 68.948 1 3111 +ATOM O OD2 . ASP B 1 432 . 432 ASP B OD2 50.227 23.627 69.046 1 3112 +ATOM N N . GLU B 1 433 . 433 GLU B N 50.083 24.411 64.838 1 3113 +ATOM C CA . GLU B 1 433 . 433 GLU B CA 50.637 25.689 64.305 1 3114 +ATOM C C . GLU B 1 433 . 433 GLU B C 49.598 26.381 63.413 1 3115 +ATOM O O . GLU B 1 433 . 433 GLU B O 49.38 27.594 63.602 1 3116 +ATOM C CB . GLU B 1 433 . 433 GLU B CB 51.928 25.433 63.523 1 3117 +ATOM N N . ILE B 1 434 . 434 ILE B N 48.986 25.636 62.485 1 3118 +ATOM C CA . ILE B 1 434 . 434 ILE B CA 47.972 26.151 61.513 1 3119 +ATOM C C . ILE B 1 434 . 434 ILE B C 46.798 26.757 62.296 1 3120 +ATOM O O . ILE B 1 434 . 434 ILE B O 46.395 27.888 61.966 1 3121 +ATOM C CB . ILE B 1 434 . 434 ILE B CB 47.518 25.041 60.538 1 3122 +ATOM C CG1 . ILE B 1 434 . 434 ILE B CG1 48.643 24.647 59.575 1 3123 +ATOM C CG2 . ILE B 1 434 . 434 ILE B CG2 46.258 25.453 59.786 1 3124 +ATOM C CD1 . ILE B 1 434 . 434 ILE B CD1 48.366 23.4 58.763 1 3125 +ATOM N N . VAL B 1 435 . 435 VAL B N 46.302 26.035 63.307 1 3126 +ATOM C CA . VAL B 1 435 . 435 VAL B CA 45.067 26.37 64.081 1 3127 +ATOM C C . VAL B 1 435 . 435 VAL B C 45.353 27.52 65.058 1 3128 +ATOM O O . VAL B 1 435 . 435 VAL B O 44.528 28.454 65.114 1 3129 +ATOM C CB . VAL B 1 435 . 435 VAL B CB 44.52 25.125 64.809 1 3130 +ATOM C CG1 . VAL B 1 435 . 435 VAL B CG1 43.416 25.475 65.795 1 3131 +ATOM C CG2 . VAL B 1 435 . 435 VAL B CG2 44.038 24.071 63.821 1 3132 +ATOM N N . THR B 1 436 . 436 THR B N 46.459 27.449 65.807 1 3133 +ATOM C CA . THR B 1 436 . 436 THR B CA 46.838 28.433 66.861 1 3134 +ATOM C C . THR B 1 436 . 436 THR B C 47.144 29.798 66.226 1 3135 +ATOM O O . THR B 1 436 . 436 THR B O 46.699 30.819 66.792 1 3136 +ATOM C CB . THR B 1 436 . 436 THR B CB 48.018 27.928 67.703 1 3137 +ATOM C CG2 . THR B 1 436 . 436 THR B CG2 47.638 26.792 68.628 1 3138 +ATOM O OG1 . THR B 1 436 . 436 THR B OG1 49.054 27.503 66.816 1 3139 +ATOM N N . ARG B 1 437 . 437 ARG B N 47.871 29.813 65.102 1 3140 +ATOM C CA . ARG B 1 437 . 437 ARG B CA 48.247 31.046 64.356 1 3141 +ATOM C C . ARG B 1 437 . 437 ARG B C 46.991 31.665 63.731 1 3142 +ATOM O O . ARG B 1 437 . 437 ARG B O 46.641 32.808 64.023 1 3143 +ATOM C CB . ARG B 1 437 . 437 ARG B CB 49.292 30.731 63.28 1 3144 +ATOM N N . PRO C 2 32 . 32 PRO C N 54.839 -14.198 -4.949 1 3145 +ATOM C CA . PRO C 2 32 . 32 PRO C CA 54.42 -14.598 -3.596 1 3146 +ATOM C C . PRO C 2 32 . 32 PRO C C 54.257 -13.37 -2.684 1 3147 +ATOM O O . PRO C 2 32 . 32 PRO C O 55.179 -12.578 -2.615 1 3148 +ATOM C CB . PRO C 2 32 . 32 PRO C CB 55.534 -15.524 -3.075 1 3149 +ATOM C CG . PRO C 2 32 . 32 PRO C CG 56.286 -15.947 -4.316 1 3150 +ATOM C CD . PRO C 2 32 . 32 PRO C CD 56.146 -14.78 -5.27 1 3151 +ATOM N N . LEU C 2 33 . 33 LEU C N 53.1 -13.238 -2.023 1 3152 +ATOM C CA . LEU C 2 33 . 33 LEU C CA 52.769 -12.096 -1.125 1 3153 +ATOM C C . LEU C 2 33 . 33 LEU C C 53.687 -12.126 0.103 1 3154 +ATOM O O . LEU C 2 33 . 33 LEU C O 53.841 -13.21 0.702 1 3155 +ATOM C CB . LEU C 2 33 . 33 LEU C CB 51.295 -12.169 -0.709 1 3156 +ATOM C CG . LEU C 2 33 . 33 LEU C CG 50.275 -11.835 -1.798 1 3157 +ATOM C CD1 . LEU C 2 33 . 33 LEU C CD1 48.857 -12.045 -1.29 1 3158 +ATOM C CD2 . LEU C 2 33 . 33 LEU C CD2 50.452 -10.408 -2.3 1 3159 +ATOM N N . GLU C 2 34 . 34 GLU C N 54.265 -10.972 0.454 1 3160 +ATOM C CA . GLU C 2 34 . 34 GLU C CA 55.207 -10.797 1.592 1 3161 +ATOM C C . GLU C 2 34 . 34 GLU C C 54.619 -9.792 2.591 1 3162 +ATOM O O . GLU C 2 34 . 34 GLU C O 53.646 -9.094 2.234 1 3163 +ATOM C CB . GLU C 2 34 . 34 GLU C CB 56.569 -10.328 1.077 1 3164 +ATOM N N . ASN C 2 35 . 35 ASN C N 55.197 -9.733 3.797 1 3165 +ATOM C CA . ASN C 2 35 . 35 ASN C CA 54.833 -8.771 4.872 1 3166 +ATOM C C . ASN C 2 35 . 35 ASN C C 55.071 -7.341 4.371 1 3167 +ATOM O O . ASN C 2 35 . 35 ASN C O 56.08 -7.12 3.676 1 3168 +ATOM C CB . ASN C 2 35 . 35 ASN C CB 55.626 -9.023 6.159 1 3169 +ATOM C CG . ASN C 2 35 . 35 ASN C CG 55.302 -10.354 6.805 1 3170 +ATOM N ND2 . ASN C 2 35 . 35 ASN C ND2 56.321 -11.152 7.085 1 3171 +ATOM O OD1 . ASN C 2 35 . 35 ASN C OD1 54.14 -10.658 7.057 1 3172 +ATOM N N . VAL C 2 36 . 36 VAL C N 54.169 -6.416 4.717 1 3173 +ATOM C CA . VAL C 2 36 . 36 VAL C CA 54.262 -4.961 4.388 1 3174 +ATOM C C . VAL C 2 36 . 36 VAL C C 54.871 -4.213 5.585 1 3175 +ATOM O O . VAL C 2 36 . 36 VAL C O 55.509 -3.163 5.357 1 3176 +ATOM C CB . VAL C 2 36 . 36 VAL C CB 52.883 -4.388 3.999 1 3177 +ATOM C CG1 . VAL C 2 36 . 36 VAL C CG1 52.986 -2.958 3.486 1 3178 +ATOM C CG2 . VAL C 2 36 . 36 VAL C CG2 52.174 -5.264 2.975 1 3179 +ATOM N N . ILE C 2 37 . 37 ILE C N 54.681 -4.73 6.806 1 3180 +ATOM C CA . ILE C 2 37 . 37 ILE C CA 55.24 -4.157 8.07 1 3181 +ATOM C C . ILE C 2 37 . 37 ILE C C 56.644 -4.721 8.314 1 3182 +ATOM O O . ILE C 2 37 . 37 ILE C O 56.98 -5.763 7.717 1 3183 +ATOM C CB . ILE C 2 37 . 37 ILE C CB 54.325 -4.445 9.281 1 3184 +ATOM C CG1 . ILE C 2 37 . 37 ILE C CG1 54.118 -5.946 9.505 1 3185 +ATOM C CG2 . ILE C 2 37 . 37 ILE C CG2 53.003 -3.708 9.146 1 3186 +ATOM C CD1 . ILE C 2 37 . 37 ILE C CD1 54.261 -6.38 10.943 1 3187 +ATOM N N . ASP C 2 38 . 38 ASP C N 57.412 -4.054 9.183 1 3188 +ATOM C CA . ASP C 2 38 . 38 ASP C CA 58.685 -4.56 9.761 1 3189 +ATOM C C . ASP C 2 38 . 38 ASP C C 58.329 -5.487 10.928 1 3190 +ATOM O O . ASP C 2 38 . 38 ASP C O 58.063 -4.97 12.031 1 3191 +ATOM C CB . ASP C 2 38 . 38 ASP C CB 59.598 -3.407 10.188 1 3192 +ATOM C CG . ASP C 2 38 . 38 ASP C CG 60.989 -3.835 10.631 1 3193 +ATOM O OD1 . ASP C 2 38 . 38 ASP C OD1 61.231 -5.057 10.73 1 3194 +ATOM O OD2 . ASP C 2 38 . 38 ASP C OD2 61.825 -2.939 10.87 1 3195 +ATOM N N . VAL C 2 39 . 39 VAL C N 58.311 -6.8 10.674 1 3196 +ATOM C CA . VAL C 2 39 . 39 VAL C CA 57.835 -7.855 11.62 1 3197 +ATOM C C . VAL C 2 39 . 39 VAL C C 58.558 -7.69 12.961 1 3198 +ATOM O O . VAL C 2 39 . 39 VAL C O 57.866 -7.605 13.991 1 3199 +ATOM C CB . VAL C 2 39 . 39 VAL C CB 58.037 -9.268 11.035 1 3200 +ATOM C CG1 . VAL C 2 39 . 39 VAL C CG1 57.889 -10.354 12.094 1 3201 +ATOM C CG2 . VAL C 2 39 . 39 VAL C CG2 57.088 -9.527 9.875 1 3202 +ATOM N N . GLU C 2 40 . 40 GLU C N 59.893 -7.641 12.937 1 3203 +ATOM C CA . GLU C 2 40 . 40 GLU C CA 60.762 -7.577 14.146 1 3204 +ATOM C C . GLU C 2 40 . 40 GLU C C 60.413 -6.332 14.972 1 3205 +ATOM O O . GLU C 2 40 . 40 GLU C O 60.245 -6.472 16.199 1 3206 +ATOM C CB . GLU C 2 40 . 40 GLU C CB 62.238 -7.576 13.74 1 3207 +ATOM N N . SER C 2 41 . 41 SER C N 60.299 -5.171 14.318 1 3208 +ATOM C CA . SER C 2 41 . 41 SER C CA 60.028 -3.845 14.938 1 3209 +ATOM C C . SER C 2 41 . 41 SER C C 58.662 -3.844 15.642 1 3210 +ATOM O O . SER C 2 41 . 41 SER C O 58.576 -3.297 16.759 1 3211 +ATOM C CB . SER C 2 41 . 41 SER C CB 60.119 -2.746 13.906 1 3212 +ATOM O OG . SER C 2 41 . 41 SER C OG 60.08 -1.465 14.516 1 3213 +ATOM N N . VAL C 2 42 . 42 VAL C N 57.642 -4.434 15.012 1 3214 +ATOM C CA . VAL C 2 42 . 42 VAL C CA 56.243 -4.517 15.534 1 3215 +ATOM C C . VAL C 2 42 . 42 VAL C C 56.201 -5.506 16.707 1 3216 +ATOM O O . VAL C 2 42 . 42 VAL C O 55.633 -5.148 17.756 1 3217 +ATOM C CB . VAL C 2 42 . 42 VAL C CB 55.252 -4.908 14.418 1 3218 +ATOM C CG1 . VAL C 2 42 . 42 VAL C CG1 53.919 -5.407 14.962 1 3219 +ATOM C CG2 . VAL C 2 42 . 42 VAL C CG2 55.033 -3.755 13.452 1 3220 +ATOM N N . ARG C 2 43 . 43 ARG C N 56.787 -6.695 16.531 1 3221 +ATOM C CA . ARG C 2 43 . 43 ARG C CA 56.722 -7.822 17.503 1 3222 +ATOM C C . ARG C 2 43 . 43 ARG C C 57.597 -7.54 18.733 1 3223 +ATOM O O . ARG C 2 43 . 43 ARG C O 57.454 -8.285 19.725 1 3224 +ATOM C CB . ARG C 2 43 . 43 ARG C CB 57.128 -9.133 16.824 1 3225 +ATOM C CG . ARG C 2 43 . 43 ARG C CG 56.121 -9.616 15.79 1 3226 +ATOM C CD . ARG C 2 43 . 43 ARG C CD 56.247 -11.092 15.468 1 3227 +ATOM N NE . ARG C 2 43 . 43 ARG C NE 54.942 -11.622 15.103 1 3228 +ATOM C CZ . ARG C 2 43 . 43 ARG C CZ 54.56 -12.891 15.214 1 3229 +ATOM N NH1 . ARG C 2 43 . 43 ARG C NH1 55.384 -13.815 15.682 1 3230 +ATOM N NH2 . ARG C 2 43 . 43 ARG C NH2 53.336 -13.228 14.854 1 3231 +ATOM N N . SER C 2 44 . 44 SER C N 58.46 -6.517 18.683 1 3232 +ATOM C CA . SER C 2 44 . 44 SER C CA 59.289 -6.05 19.829 1 3233 +ATOM C C . SER C 2 44 . 44 SER C C 58.391 -5.46 20.928 1 3234 +ATOM O O . SER C 2 44 . 44 SER C O 58.771 -5.562 22.115 1 3235 +ATOM C CB . SER C 2 44 . 44 SER C CB 60.34 -5.059 19.385 1 3236 +ATOM O OG . SER C 2 44 . 44 SER C OG 59.763 -3.792 19.101 1 3237 +ATOM N N . HIS C 2 45 . 45 HIS C N 57.248 -4.876 20.546 1 3238 +ATOM C CA . HIS C 2 45 . 45 HIS C CA 56.247 -4.264 21.466 1 3239 +ATOM C C . HIS C 2 45 . 45 HIS C C 55.335 -5.339 22.079 1 3240 +ATOM O O . HIS C 2 45 . 45 HIS C O 54.508 -4.971 22.933 1 3241 +ATOM C CB . HIS C 2 45 . 45 HIS C CB 55.408 -3.202 20.736 1 3242 +ATOM C CG . HIS C 2 45 . 45 HIS C CG 56.198 -2.087 20.135 1 3243 +ATOM C CD2 . HIS C 2 45 . 45 HIS C CD2 56.673 -0.941 20.667 1 3244 +ATOM N ND1 . HIS C 2 45 . 45 HIS C ND1 56.565 -2.074 18.802 1 3245 +ATOM C CE1 . HIS C 2 45 . 45 HIS C CE1 57.24 -0.971 18.546 1 3246 +ATOM N NE2 . HIS C 2 45 . 45 HIS C NE2 57.321 -0.259 19.676 1 3247 +ATOM N N . PHE C 2 46 . 46 PHE C N 55.465 -6.602 21.652 1 3248 +ATOM C CA . PHE C 2 46 . 46 PHE C CA 54.577 -7.738 22.022 1 3249 +ATOM C C . PHE C 2 46 . 46 PHE C C 55.428 -8.974 22.316 1 3250 +ATOM O O . PHE C 2 46 . 46 PHE C O 55.397 -9.947 21.566 1 3251 +ATOM C CB . PHE C 2 46 . 46 PHE C CB 53.572 -8.003 20.897 1 3252 +ATOM C CG . PHE C 2 46 . 46 PHE C CG 52.747 -6.81 20.481 1 3253 +ATOM C CD1 . PHE C 2 46 . 46 PHE C CD1 51.764 -6.29 21.31 1 3254 +ATOM C CD2 . PHE C 2 46 . 46 PHE C CD2 52.955 -6.203 19.254 1 3255 +ATOM C CE1 . PHE C 2 46 . 46 PHE C CE1 51.009 -5.195 20.92 1 3256 +ATOM C CE2 . PHE C 2 46 . 46 PHE C CE2 52.199 -5.107 18.865 1 3257 +ATOM C CZ . PHE C 2 46 . 46 PHE C CZ 51.227 -4.605 19.697 1 3258 +ATOM N N . PRO C 2 47 . 47 PRO C N 56.201 -8.979 23.427 1 3259 +ATOM C CA . PRO C 2 47 . 47 PRO C CA 57.278 -9.954 23.627 1 3260 +ATOM C C . PRO C 2 47 . 47 PRO C C 56.909 -11.448 23.586 1 3261 +ATOM O O . PRO C 2 47 . 47 PRO C O 57.768 -12.228 23.208 1 3262 +ATOM C CB . PRO C 2 47 . 47 PRO C CB 57.803 -9.617 25.034 1 3263 +ATOM C CG . PRO C 2 47 . 47 PRO C CG 57.431 -8.163 25.237 1 3264 +ATOM C CD . PRO C 2 47 . 47 PRO C CD 56.1 -8.016 24.535 1 3265 +ATOM N N . VAL C 2 48 . 48 VAL C N 55.679 -11.818 23.964 1 3266 +ATOM C CA . VAL C 2 48 . 48 VAL C CA 55.232 -13.241 24.109 1 3267 +ATOM C C . VAL C 2 48 . 48 VAL C C 55.079 -13.897 22.725 1 3268 +ATOM O O . VAL C 2 48 . 48 VAL C O 55.007 -15.143 22.673 1 3269 +ATOM C CB . VAL C 2 48 . 48 VAL C CB 53.931 -13.354 24.93 1 3270 +ATOM C CG1 . VAL C 2 48 . 48 VAL C CG1 52.707 -12.914 24.139 1 3271 +ATOM C CG2 . VAL C 2 48 . 48 VAL C CG2 53.733 -14.76 25.482 1 3272 +ATOM N N . LEU C 2 49 . 49 LEU C N 55.035 -13.104 21.648 1 3273 +ATOM C CA . LEU C 2 49 . 49 LEU C CA 54.909 -13.598 20.248 1 3274 +ATOM C C . LEU C 2 49 . 49 LEU C C 56.225 -14.235 19.776 1 3275 +ATOM O O . LEU C 2 49 . 49 LEU C O 56.216 -14.837 18.687 1 3276 +ATOM C CB . LEU C 2 49 . 49 LEU C CB 54.506 -12.437 19.333 1 3277 +ATOM C CG . LEU C 2 49 . 49 LEU C CG 53.15 -11.801 19.633 1 3278 +ATOM C CD1 . LEU C 2 49 . 49 LEU C CD1 52.878 -10.644 18.687 1 3279 +ATOM C CD2 . LEU C 2 49 . 49 LEU C CD2 52.032 -12.83 19.547 1 3280 +ATOM N N . GLY C 2 50 . 50 GLY C N 57.309 -14.103 20.551 1 3281 +ATOM C CA . GLY C 2 50 . 50 GLY C CA 58.595 -14.786 20.303 1 3282 +ATOM C C . GLY C 2 50 . 50 GLY C C 58.549 -16.265 20.664 1 3283 +ATOM O O . GLY C 2 50 . 50 GLY C O 59.518 -16.976 20.327 1 3284 +ATOM N N . GLY C 2 51 . 51 GLY C N 57.472 -16.714 21.325 1 3285 +ATOM C CA . GLY C 2 51 . 51 GLY C CA 57.281 -18.099 21.801 1 3286 +ATOM C C . GLY C 2 51 . 51 GLY C C 56.881 -19.053 20.684 1 3287 +ATOM O O . GLY C 2 51 . 51 GLY C O 57.032 -18.683 19.501 1 3288 +ATOM N N . GLU C 2 52 . 52 GLU C N 56.387 -20.239 21.06 1 3289 +ATOM C CA . GLU C 2 52 . 52 GLU C CA 56.099 -21.386 20.153 1 3290 +ATOM C C . GLU C 2 52 . 52 GLU C C 54.717 -21.226 19.503 1 3291 +ATOM O O . GLU C 2 52 . 52 GLU C O 54.527 -21.781 18.404 1 3292 +ATOM C CB . GLU C 2 52 . 52 GLU C CB 56.167 -22.704 20.929 1 3293 +ATOM N N . THR C 2 53 . 53 THR C N 53.792 -20.512 20.157 1 3294 +ATOM C CA . THR C 2 53 . 53 THR C CA 52.372 -20.379 19.731 1 3295 +ATOM C C . THR C 2 53 . 53 THR C C 52.154 -19.037 19.021 1 3296 +ATOM O O . THR C 2 53 . 53 THR C O 52.533 -17.993 19.59 1 3297 +ATOM C CB . THR C 2 53 . 53 THR C CB 51.41 -20.522 20.917 1 3298 +ATOM C CG2 . THR C 2 53 . 53 THR C CG2 49.975 -20.714 20.475 1 3299 +ATOM O OG1 . THR C 2 53 . 53 THR C OG1 51.824 -21.632 21.716 1 3300 +ATOM N N . ALA C 2 54 . 54 ALA C N 51.551 -19.077 17.83 1 3301 +ATOM C CA . ALA C 2 54 . 54 ALA C CA 51.087 -17.893 17.072 1 3302 +ATOM C C . ALA C 2 54 . 54 ALA C C 49.672 -17.525 17.533 1 3303 +ATOM O O . ALA C 2 54 . 54 ALA C O 48.836 -18.443 17.693 1 3304 +ATOM C CB . ALA C 2 54 . 54 ALA C CB 51.135 -18.168 15.59 1 3305 +ATOM N N . ALA C 2 55 . 55 ALA C N 49.425 -16.227 17.736 1 3306 +ATOM C CA . ALA C 2 55 . 55 ALA C CA 48.153 -15.662 18.244 1 3307 +ATOM C C . ALA C 2 55 . 55 ALA C C 47.181 -15.425 17.08 1 3308 +ATOM O O . ALA C 2 55 . 55 ALA C O 47.293 -14.372 16.422 1 3309 +ATOM C CB . ALA C 2 55 . 55 ALA C CB 48.44 -14.386 18.998 1 3310 +ATOM N N . PHE C 2 56 . 56 PHE C N 46.26 -16.368 16.844 1 3311 +ATOM C CA . PHE C 2 56 . 56 PHE C CA 45.267 -16.35 15.735 1 3312 +ATOM C C . PHE C 2 56 . 56 PHE C C 43.839 -16.314 16.301 1 3313 +ATOM O O . PHE C 2 56 . 56 PHE C O 42.906 -16.706 15.565 1 3314 +ATOM C CB . PHE C 2 56 . 56 PHE C CB 45.473 -17.563 14.819 1 3315 +ATOM C CG . PHE C 2 56 . 56 PHE C CG 46.489 -17.401 13.715 1 3316 +ATOM C CD1 . PHE C 2 56 . 56 PHE C CD1 47.454 -16.404 13.751 1 3317 +ATOM C CD2 . PHE C 2 56 . 56 PHE C CD2 46.5 -18.284 12.645 1 3318 +ATOM C CE1 . PHE C 2 56 . 56 PHE C CE1 48.385 -16.278 12.732 1 3319 +ATOM C CE2 . PHE C 2 56 . 56 PHE C CE2 47.438 -18.162 11.631 1 3320 +ATOM C CZ . PHE C 2 56 . 56 PHE C CZ 48.378 -17.159 11.676 1 3321 +ATOM N N . ASN C 2 57 . 57 ASN C N 43.672 -15.846 17.547 1 3322 +ATOM C CA . ASN C 2 57 . 57 ASN C CA 42.356 -15.674 18.224 1 3323 +ATOM C C . ASN C 2 57 . 57 ASN C C 42.297 -14.276 18.858 1 3324 +ATOM O O . ASN C 2 57 . 57 ASN C O 41.924 -14.164 20.052 1 3325 +ATOM C CB . ASN C 2 57 . 57 ASN C CB 42.091 -16.785 19.248 1 3326 +ATOM C CG . ASN C 2 57 . 57 ASN C CG 40.645 -16.835 19.7 1 3327 +ATOM N ND2 . ASN C 2 57 . 57 ASN C ND2 40.42 -17.162 20.961 1 3328 +ATOM O OD1 . ASN C 2 57 . 57 ASN C OD1 39.734 -16.585 18.915 1 3329 +ATOM N N . ASN C 2 58 . 58 ASN C N 42.63 -13.249 18.073 1 3330 +ATOM C CA . ASN C 2 58 . 58 ASN C CA 42.743 -11.835 18.522 1 3331 +ATOM C C . ASN C 2 58 . 58 ASN C C 41.346 -11.232 18.716 1 3332 +ATOM O O . ASN C 2 58 . 58 ASN C O 41.227 -10.272 19.501 1 3333 +ATOM C CB . ASN C 2 58 . 58 ASN C CB 43.59 -11.021 17.544 1 3334 +ATOM C CG . ASN C 2 58 . 58 ASN C CG 44.994 -11.575 17.424 1 3335 +ATOM N ND2 . ASN C 2 58 . 58 ASN C ND2 45.301 -12.195 16.296 1 3336 +ATOM O OD1 . ASN C 2 58 . 58 ASN C OD1 45.79 -11.455 18.353 1 3337 +ATOM N N . ALA C 2 59 . 59 ALA C N 40.331 -11.782 18.043 1 3338 +ATOM C CA . ALA C 2 59 . 59 ALA C CA 38.916 -11.353 18.147 1 3339 +ATOM C C . ALA C 2 59 . 59 ALA C C 38.349 -11.705 19.533 1 3340 +ATOM O O . ALA C 2 59 . 59 ALA C O 37.306 -11.129 19.894 1 3341 +ATOM C CB . ALA C 2 59 . 59 ALA C CB 38.103 -11.971 17.035 1 3342 +ATOM N N . SER C 2 60 . 60 SER C N 39.0 -12.612 20.274 1 3343 +ATOM C CA . SER C 2 60 . 60 SER C CA 38.673 -12.958 21.684 1 3344 +ATOM C C . SER C 2 60 . 60 SER C C 39.613 -12.234 22.658 1 3345 +ATOM O O . SER C 2 60 . 60 SER C O 39.274 -12.168 23.852 1 3346 +ATOM C CB . SER C 2 60 . 60 SER C CB 38.702 -14.449 21.911 1 3347 +ATOM O OG . SER C 2 60 . 60 SER C OG 37.511 -15.06 21.433 1 3348 +ATOM N N . GLY C 2 61 . 61 GLY C N 40.757 -11.731 22.19 1 3349 +ATOM C CA . GLY C 2 61 . 61 GLY C CA 41.699 -10.959 23.024 1 3350 +ATOM C C . GLY C 2 61 . 61 GLY C C 43.095 -10.922 22.432 1 3351 +ATOM O O . GLY C 2 61 . 61 GLY C O 43.608 -11.997 22.06 1 3352 +ATOM N N . THR C 2 62 . 62 THR C N 43.691 -9.729 22.364 1 3353 +ATOM C CA . THR C 2 62 . 62 THR C CA 45.049 -9.468 21.816 1 3354 +ATOM C C . THR C 2 62 . 62 THR C C 46.094 -9.59 22.929 1 3355 +ATOM O O . THR C 2 62 . 62 THR C O 45.706 -9.554 24.105 1 3356 +ATOM C CB . THR C 2 62 . 62 THR C CB 45.127 -8.071 21.188 1 3357 +ATOM C CG2 . THR C 2 62 . 62 THR C CG2 44.528 -8.016 19.8 1 3358 +ATOM O OG1 . THR C 2 62 . 62 THR C OG1 44.435 -7.167 22.052 1 3359 +ATOM N N . VAL C 2 63 . 63 VAL C N 47.372 -9.705 22.563 1 3360 +ATOM C CA . VAL C 2 63 . 63 VAL C CA 48.517 -9.546 23.51 1 3361 +ATOM C C . VAL C 2 63 . 63 VAL C C 48.631 -8.045 23.818 1 3362 +ATOM O O . VAL C 2 63 . 63 VAL C O 48.408 -7.231 22.893 1 3363 +ATOM C CB . VAL C 2 63 . 63 VAL C CB 49.832 -10.138 22.956 1 3364 +ATOM C CG1 . VAL C 2 63 . 63 VAL C CG1 49.641 -11.52 22.351 1 3365 +ATOM C CG2 . VAL C 2 63 . 63 VAL C CG2 50.493 -9.224 21.944 1 3366 +ATOM N N . VAL C 2 64 . 64 VAL C N 48.933 -7.689 25.069 1 3367 +ATOM C CA . VAL C 2 64 . 64 VAL C CA 48.915 -6.274 25.549 1 3368 +ATOM C C . VAL C 2 64 . 64 VAL C C 50.18 -5.573 25.044 1 3369 +ATOM O O . VAL C 2 64 . 64 VAL C O 51.247 -6.214 25.04 1 3370 +ATOM C CB . VAL C 2 64 . 64 VAL C CB 48.792 -6.182 27.084 1 3371 +ATOM C CG1 . VAL C 2 64 . 64 VAL C CG1 48.66 -4.738 27.546 1 3372 +ATOM C CG2 . VAL C 2 64 . 64 VAL C CG2 47.633 -7.014 27.618 1 3373 +ATOM N N . LEU C 2 65 . 65 LEU C N 50.058 -4.307 24.636 1 3374 +ATOM C CA . LEU C 2 65 . 65 LEU C CA 51.203 -3.463 24.198 1 3375 +ATOM C C . LEU C 2 65 . 65 LEU C C 52.171 -3.289 25.373 1 3376 +ATOM O O . LEU C 2 65 . 65 LEU C O 51.694 -3.024 26.486 1 3377 +ATOM C CB . LEU C 2 65 . 65 LEU C CB 50.684 -2.107 23.71 1 3378 +ATOM C CG . LEU C 2 65 . 65 LEU C CG 51.731 -1.205 23.057 1 3379 +ATOM C CD1 . LEU C 2 65 . 65 LEU C CD1 52.11 -1.723 21.68 1 3380 +ATOM C CD2 . LEU C 2 65 . 65 LEU C CD2 51.235 0.23 22.968 1 3381 +ATOM N N . LYS C 2 66 . 66 LYS C N 53.476 -3.436 25.124 1 3382 +ATOM C CA . LYS C 2 66 . 66 LYS C CA 54.557 -3.288 26.138 1 3383 +ATOM C C . LYS C 2 66 . 66 LYS C C 54.455 -1.906 26.798 1 3384 +ATOM O O . LYS C 2 66 . 66 LYS C O 54.529 -1.851 28.038 1 3385 +ATOM C CB . LYS C 2 66 . 66 LYS C CB 55.935 -3.496 25.497 1 3386 +ATOM C CG . LYS C 2 66 . 66 LYS C CG 57.124 -3.269 26.423 1 3387 +ATOM C CD . LYS C 2 66 . 66 LYS C CD 58.461 -3.606 25.791 1 3388 +ATOM C CE . LYS C 2 66 . 66 LYS C CE 59.641 -3.237 26.667 1 3389 +ATOM N NZ . LYS C 2 66 . 66 LYS C NZ 59.674 -4.042 27.912 1 3390 +ATOM N N . GLU C 2 67 . 67 GLU C N 54.289 -0.844 26.001 1 3391 +ATOM C CA . GLU C 2 67 . 67 GLU C CA 54.238 0.566 26.481 1 3392 +ATOM C C . GLU C 2 67 . 67 GLU C C 53.025 0.766 27.401 1 3393 +ATOM O O . GLU C 2 67 . 67 GLU C O 53.134 1.568 28.35 1 3394 +ATOM C CB . GLU C 2 67 . 67 GLU C CB 54.198 1.549 25.308 1 3395 +ATOM C CG . GLU C 2 67 . 67 GLU C CG 55.563 1.825 24.697 1 3396 +ATOM C CD . GLU C 2 67 . 67 GLU C CD 56.106 0.738 23.782 1 3397 +ATOM O OE1 . GLU C 2 67 . 67 GLU C OE1 55.332 -0.169 23.409 1 3398 +ATOM O OE2 . GLU C 2 67 . 67 GLU C OE2 57.305 0.806 23.436 1 3399 +ATOM N N . ALA C 2 68 . 68 ALA C N 51.916 0.07 27.129 1 3400 +ATOM C CA . ALA C 2 68 . 68 ALA C CA 50.67 0.118 27.932 1 3401 +ATOM C C . ALA C 2 68 . 68 ALA C C 50.951 -0.381 29.355 1 3402 +ATOM O O . ALA C 2 68 . 68 ALA C O 50.511 0.289 30.306 1 3403 +ATOM C CB . ALA C 2 68 . 68 ALA C CB 49.581 -0.688 27.265 1 3404 +ATOM N N . ILE C 2 69 . 69 ILE C N 51.665 -1.505 29.489 1 3405 +ATOM C CA . ILE C 2 69 . 69 ILE C CA 51.969 -2.161 30.798 1 3406 +ATOM C C . ILE C 2 69 . 69 ILE C C 52.902 -1.247 31.603 1 3407 +ATOM O O . ILE C 2 69 . 69 ILE C O 52.681 -1.103 32.817 1 3408 +ATOM C CB . ILE C 2 69 . 69 ILE C CB 52.567 -3.57 30.594 1 3409 +ATOM C CG1 . ILE C 2 69 . 69 ILE C CG1 51.597 -4.495 29.851 1 3410 +ATOM C CG2 . ILE C 2 69 . 69 ILE C CG2 53.006 -4.173 31.924 1 3411 +ATOM C CD1 . ILE C 2 69 . 69 ILE C CD1 52.254 -5.709 29.239 1 3412 +ATOM N N . GLU C 2 70 . 70 GLU C N 53.897 -0.65 30.944 1 3413 +ATOM C CA . GLU C 2 70 . 70 GLU C CA 54.873 0.285 31.566 1 3414 +ATOM C C . GLU C 2 70 . 70 GLU C C 54.125 1.514 32.096 1 3415 +ATOM O O . GLU C 2 70 . 70 GLU C O 54.449 1.963 33.202 1 3416 +ATOM C CB . GLU C 2 70 . 70 GLU C CB 55.959 0.68 30.561 1 3417 +ATOM C CG . GLU C 2 70 . 70 GLU C CG 56.854 -0.478 30.151 1 3418 +ATOM C CD . GLU C 2 70 . 70 GLU C CD 57.861 -0.171 29.053 1 3419 +ATOM O OE1 . GLU C 2 70 . 70 GLU C OE1 57.508 0.565 28.103 1 3420 +ATOM O OE2 . GLU C 2 70 . 70 GLU C OE2 59.004 -0.672 29.151 1 3421 +ATOM N N . SER C 2 71 . 71 SER C N 53.149 2.021 31.339 1 3422 +ATOM C CA . SER C 2 71 . 71 SER C CA 52.367 3.238 31.683 1 3423 +ATOM C C . SER C 2 71 . 71 SER C C 51.588 3.014 32.988 1 3424 +ATOM O O . SER C 2 71 . 71 SER C O 51.621 3.916 33.846 1 3425 +ATOM C CB . SER C 2 71 . 71 SER C CB 51.458 3.636 30.549 1 3426 +ATOM O OG . SER C 2 71 . 71 SER C OG 50.9 4.922 30.773 1 3427 +ATOM N N . THR C 2 72 . 72 THR C N 50.926 1.858 33.135 1 3428 +ATOM C CA . THR C 2 72 . 72 THR C CA 50.081 1.518 34.316 1 3429 +ATOM C C . THR C 2 72 . 72 THR C C 50.976 1.163 35.511 1 3430 +ATOM O O . THR C 2 72 . 72 THR C O 50.603 1.538 36.636 1 3431 +ATOM C CB . THR C 2 72 . 72 THR C CB 49.052 0.422 34.0 1 3432 +ATOM C CG2 . THR C 2 72 . 72 THR C CG2 49.65 -0.956 33.806 1 3433 +ATOM O OG1 . THR C 2 72 . 72 THR C OG1 48.115 0.387 35.077 1 3434 +ATOM N N . SER C 2 73 . 73 SER C N 52.097 0.471 35.277 1 3435 +ATOM C CA . SER C 2 73 . 73 SER C CA 53.116 0.133 36.307 1 3436 +ATOM C C . SER C 2 73 . 73 SER C C 53.634 1.42 36.961 1 3437 +ATOM O O . SER C 2 73 . 73 SER C O 53.688 1.466 38.203 1 3438 +ATOM C CB . SER C 2 73 . 73 SER C CB 54.25 -0.677 35.726 1 3439 +ATOM O OG . SER C 2 73 . 73 SER C OG 53.81 -1.978 35.369 1 3440 +ATOM N N . ASN C 2 74 . 74 ASN C N 53.986 2.42 36.145 1 3441 +ATOM C CA . ASN C 2 74 . 74 ASN C CA 54.51 3.737 36.598 1 3442 +ATOM C C . ASN C 2 74 . 74 ASN C C 53.438 4.458 37.42 1 3443 +ATOM O O . ASN C 2 74 . 74 ASN C O 53.791 5.05 38.452 1 3444 +ATOM C CB . ASN C 2 74 . 74 ASN C CB 54.96 4.617 35.429 1 3445 +ATOM C CG . ASN C 2 74 . 74 ASN C CG 56.12 4.03 34.653 1 3446 +ATOM N ND2 . ASN C 2 74 . 74 ASN C ND2 56.337 4.522 33.444 1 3447 +ATOM O OD1 . ASN C 2 74 . 74 ASN C OD1 56.81 3.134 35.136 1 3448 +ATOM N N . PHE C 2 75 . 75 PHE C N 52.179 4.412 36.978 1 3449 +ATOM C CA . PHE C 2 75 . 75 PHE C CA 51.039 5.068 37.669 1 3450 +ATOM C C . PHE C 2 75 . 75 PHE C C 50.813 4.421 39.043 1 3451 +ATOM O O . PHE C 2 75 . 75 PHE C O 50.59 5.167 40.014 1 3452 +ATOM C CB . PHE C 2 75 . 75 PHE C CB 49.763 5.016 36.827 1 3453 +ATOM C CG . PHE C 2 75 . 75 PHE C CG 48.561 5.597 37.527 1 3454 +ATOM C CD1 . PHE C 2 75 . 75 PHE C CD1 48.361 6.969 37.568 1 3455 +ATOM C CD2 . PHE C 2 75 . 75 PHE C CD2 47.651 4.778 38.178 1 3456 +ATOM C CE1 . PHE C 2 75 . 75 PHE C CE1 47.262 7.505 38.221 1 3457 +ATOM C CE2 . PHE C 2 75 . 75 PHE C CE2 46.554 5.316 38.834 1 3458 +ATOM C CZ . PHE C 2 75 . 75 PHE C CZ 46.363 6.678 38.855 1 3459 +ATOM N N . MET C 2 76 . 76 MET C N 50.872 3.086 39.123 1 3460 +ATOM C CA . MET C 2 76 . 76 MET C CA 50.489 2.313 40.339 1 3461 +ATOM C C . MET C 2 76 . 76 MET C C 51.633 2.31 41.364 1 3462 +ATOM O O . MET C 2 76 . 76 MET C O 51.35 2.029 42.538 1 3463 +ATOM C CB . MET C 2 76 . 76 MET C CB 50.089 0.877 39.983 1 3464 +ATOM C CG . MET C 2 76 . 76 MET C CG 48.696 0.787 39.383 1 3465 +ATOM S SD . MET C 2 76 . 76 MET C SD 48.098 -0.912 39.202 1 3466 +ATOM C CE . MET C 2 76 . 76 MET C CE 49.243 -1.542 37.976 1 3467 +ATOM N N . TYR C 2 77 . 77 TYR C N 52.865 2.622 40.947 1 3468 +ATOM C CA . TYR C 2 77 . 77 TYR C CA 54.032 2.835 41.848 1 3469 +ATOM C C . TYR C 2 77 . 77 TYR C C 54.055 4.289 42.345 1 3470 +ATOM O O . TYR C 2 77 . 77 TYR C O 54.738 4.548 43.351 1 3471 +ATOM C CB . TYR C 2 77 . 77 TYR C CB 55.352 2.479 41.153 1 3472 +ATOM C CG . TYR C 2 77 . 77 TYR C CG 55.715 1.013 41.169 1 3473 +ATOM C CD1 . TYR C 2 77 . 77 TYR C CD1 55.858 0.321 42.361 1 3474 +ATOM C CD2 . TYR C 2 77 . 77 TYR C CD2 55.945 0.317 39.991 1 3475 +ATOM C CE1 . TYR C 2 77 . 77 TYR C CE1 56.199 -1.022 42.384 1 3476 +ATOM C CE2 . TYR C 2 77 . 77 TYR C CE2 56.287 -1.027 39.995 1 3477 +ATOM C CZ . TYR C 2 77 . 77 TYR C CZ 56.416 -1.701 41.197 1 3478 +ATOM O OH . TYR C 2 77 . 77 TYR C OH 56.754 -3.024 41.207 1 3479 +ATOM N N . SER C 2 78 . 78 SER C N 53.347 5.201 41.666 1 3480 +ATOM C CA . SER C 2 78 . 78 SER C CA 53.276 6.652 41.992 1 3481 +ATOM C C . SER C 2 78 . 78 SER C C 52.337 6.88 43.181 1 3482 +ATOM O O . SER C 2 78 . 78 SER C O 51.672 5.915 43.614 1 3483 +ATOM C CB . SER C 2 78 . 78 SER C CB 52.836 7.468 40.8 1 3484 +ATOM O OG . SER C 2 78 . 78 SER C OG 53.74 7.321 39.718 1 3485 +ATOM N N . PHE C 2 79 . 79 PHE C N 52.28 8.118 43.68 1 3486 +ATOM C CA . PHE C 2 79 . 79 PHE C CA 51.422 8.518 44.825 1 3487 +ATOM C C . PHE C 2 79 . 79 PHE C C 49.961 8.384 44.403 1 3488 +ATOM O O . PHE C 2 79 . 79 PHE C O 49.594 8.802 43.305 1 3489 +ATOM C CB . PHE C 2 79 . 79 PHE C CB 51.772 9.926 45.317 1 3490 +ATOM C CG . PHE C 2 79 . 79 PHE C CG 51.658 10.09 46.812 1 3491 +ATOM C CD1 . PHE C 2 79 . 79 PHE C CD1 52.57 9.481 47.66 1 3492 +ATOM C CD2 . PHE C 2 79 . 79 PHE C CD2 50.63 10.832 47.373 1 3493 +ATOM C CE1 . PHE C 2 79 . 79 PHE C CE1 52.464 9.617 49.035 1 3494 +ATOM C CE2 . PHE C 2 79 . 79 PHE C CE2 50.524 10.967 48.749 1 3495 +ATOM C CZ . PHE C 2 79 . 79 PHE C CZ 51.442 10.362 49.577 1 3496 +ATOM N N . PRO C 2 80 . 80 PRO C N 49.09 7.792 45.254 1 3497 +ATOM C CA . PRO C 2 80 . 80 PRO C CA 47.743 7.401 44.836 1 3498 +ATOM C C . PRO C 2 80 . 80 PRO C C 46.792 8.582 44.579 1 3499 +ATOM O O . PRO C 2 80 . 80 PRO C O 47.013 9.648 45.128 1 3500 +ATOM C CB . PRO C 2 80 . 80 PRO C CB 47.232 6.566 46.024 1 3501 +ATOM C CG . PRO C 2 80 . 80 PRO C CG 47.988 7.111 47.216 1 3502 +ATOM C CD . PRO C 2 80 . 80 PRO C CD 49.353 7.472 46.667 1 3503 +ATOM N N . PHE C 2 81 . 81 PHE C N 45.772 8.355 43.745 1 3504 +ATOM C CA . PHE C 2 81 . 81 PHE C CA 44.576 9.228 43.598 1 3505 +ATOM C C . PHE C 2 81 . 81 PHE C C 43.706 9.046 44.844 1 3506 +ATOM O O . PHE C 2 81 . 81 PHE C O 43.64 7.944 45.389 1 3507 +ATOM C CB . PHE C 2 81 . 81 PHE C CB 43.833 8.91 42.296 1 3508 +ATOM C CG . PHE C 2 81 . 81 PHE C CG 44.347 9.625 41.069 1 3509 +ATOM C CD1 . PHE C 2 81 . 81 PHE C CD1 45.691 9.944 40.927 1 3510 +ATOM C CD2 . PHE C 2 81 . 81 PHE C CD2 43.481 9.969 40.041 1 3511 +ATOM C CE1 . PHE C 2 81 . 81 PHE C CE1 46.151 10.601 39.795 1 3512 +ATOM C CE2 . PHE C 2 81 . 81 PHE C CE2 43.942 10.623 38.909 1 3513 +ATOM C CZ . PHE C 2 81 . 81 PHE C CZ 45.276 10.938 38.787 1 3514 +ATOM N N . PRO C 2 82 . 82 PRO C N 43.004 10.092 45.343 1 3515 +ATOM C CA . PRO C 2 82 . 82 PRO C CA 42.82 11.356 44.626 1 3516 +ATOM C C . PRO C 2 82 . 82 PRO C C 44.021 12.295 44.728 1 3517 +ATOM O O . PRO C 2 82 . 82 PRO C O 44.62 12.427 45.795 1 3518 +ATOM C CB . PRO C 2 82 . 82 PRO C CB 41.594 11.957 45.332 1 3519 +ATOM C CG . PRO C 2 82 . 82 PRO C CG 41.724 11.481 46.764 1 3520 +ATOM C CD . PRO C 2 82 . 82 PRO C CD 42.371 10.113 46.671 1 3521 +ATOM N N . PRO C 2 83 . 83 PRO C N 44.403 12.979 43.623 1 3522 +ATOM C CA . PRO C 2 83 . 83 PRO C CA 45.598 13.827 43.609 1 3523 +ATOM C C . PRO C 2 83 . 83 PRO C C 45.416 15.149 44.375 1 3524 +ATOM O O . PRO C 2 83 . 83 PRO C O 44.306 15.654 44.422 1 3525 +ATOM C CB . PRO C 2 83 . 83 PRO C CB 45.827 14.076 42.109 1 3526 +ATOM C CG . PRO C 2 83 . 83 PRO C CG 44.442 13.999 41.499 1 3527 +ATOM C CD . PRO C 2 83 . 83 PRO C CD 43.697 12.975 42.331 1 3528 +ATOM N N . GLY C 2 84 . 84 GLY C N 46.51 15.672 44.945 1 3529 +ATOM C CA . GLY C 2 84 . 84 GLY C CA 46.539 16.913 45.746 1 3530 +ATOM C C . GLY C 2 84 . 84 GLY C C 46.64 18.164 44.885 1 3531 +ATOM O O . GLY C 2 84 . 84 GLY C O 46.197 18.118 43.72 1 3532 +ATOM N N . VAL C 2 85 . 85 VAL C N 47.196 19.244 45.45 1 3533 +ATOM C CA . VAL C 2 85 . 85 VAL C CA 47.328 20.593 44.815 1 3534 +ATOM C C . VAL C 2 85 . 85 VAL C C 48.816 20.925 44.604 1 3535 +ATOM O O . VAL C 2 85 . 85 VAL C O 49.106 22.032 44.113 1 3536 +ATOM C CB . VAL C 2 85 . 85 VAL C CB 46.608 21.662 45.665 1 3537 +ATOM C CG1 . VAL C 2 85 . 85 VAL C CG1 46.798 23.076 45.127 1 3538 +ATOM C CG2 . VAL C 2 85 . 85 VAL C CG2 45.124 21.351 45.807 1 3539 +ATOM N N . ASP C 2 86 . 86 ASP C N 49.728 19.999 44.924 1 3540 +ATOM C CA . ASP C 2 86 . 86 ASP C CA 51.194 20.175 44.725 1 3541 +ATOM C C . ASP C 2 86 . 86 ASP C C 51.528 20.022 43.235 1 3542 +ATOM O O . ASP C 2 86 . 86 ASP C O 50.723 19.411 42.503 1 3543 +ATOM C CB . ASP C 2 86 . 86 ASP C CB 52.016 19.212 45.588 1 3544 +ATOM C CG . ASP C 2 86 . 86 ASP C CG 51.608 17.754 45.471 1 3545 +ATOM O OD1 . ASP C 2 86 . 86 ASP C OD1 50.45 17.446 45.812 1 3546 +ATOM O OD2 . ASP C 2 86 . 86 ASP C OD2 52.453 16.941 45.043 1 3547 +ATOM N N . ALA C 2 87 . 87 ALA C N 52.68 20.555 42.814 1 3548 +ATOM C CA . ALA C 2 87 . 87 ALA C CA 53.147 20.613 41.407 1 3549 +ATOM C C . ALA C 2 87 . 87 ALA C C 53.245 19.204 40.807 1 3550 +ATOM O O . ALA C 2 87 . 87 ALA C O 52.934 19.058 39.61 1 3551 +ATOM C CB . ALA C 2 87 . 87 ALA C CB 54.474 21.328 41.337 1 3552 +ATOM N N . LYS C 2 88 . 88 LYS C N 53.674 18.217 41.602 1 3553 +ATOM C CA . LYS C 2 88 . 88 LYS C CA 53.807 16.794 41.181 1 3554 +ATOM C C . LYS C 2 88 . 88 LYS C C 52.412 16.186 40.985 1 3555 +ATOM O O . LYS C 2 88 . 88 LYS C O 52.237 15.411 40.02 1 3556 +ATOM C CB . LYS C 2 88 . 88 LYS C CB 54.609 15.998 42.215 1 3557 +ATOM C CG . LYS C 2 88 . 88 LYS C CG 55.0 14.59 41.785 1 3558 +ATOM C CD . LYS C 2 88 . 88 LYS C CD 55.779 13.83 42.84 1 3559 +ATOM C CE . LYS C 2 88 . 88 LYS C CE 56.197 12.448 42.383 1 3560 +ATOM N NZ . LYS C 2 88 . 88 LYS C NZ 56.874 11.695 43.466 1 3561 +ATOM N N . SER C 2 89 . 89 SER C N 51.466 16.516 41.872 1 3562 +ATOM C CA . SER C 2 89 . 89 SER C CA 50.05 16.064 41.818 1 3563 +ATOM C C . SER C 2 89 . 89 SER C C 49.346 16.691 40.606 1 3564 +ATOM O O . SER C 2 89 . 89 SER C O 48.567 15.973 39.95 1 3565 +ATOM C CB . SER C 2 89 . 89 SER C CB 49.319 16.369 43.103 1 3566 +ATOM O OG . SER C 2 89 . 89 SER C OG 49.766 15.523 44.153 1 3567 +ATOM N N . MET C 2 90 . 90 MET C N 49.618 17.97 40.316 1 3568 +ATOM C CA . MET C 2 90 . 90 MET C CA 49.038 18.696 39.152 1 3569 +ATOM C C . MET C 2 90 . 90 MET C C 49.536 18.056 37.848 1 3570 +ATOM O O . MET C 2 90 . 90 MET C O 48.764 18.053 36.87 1 3571 +ATOM C CB . MET C 2 90 . 90 MET C CB 49.414 20.182 39.17 1 3572 +ATOM C CG . MET C 2 90 . 90 MET C CG 48.595 21.002 40.151 1 3573 +ATOM S SD . MET C 2 90 . 90 MET C SD 48.85 22.785 39.955 1 3574 +ATOM C CE . MET C 2 90 . 90 MET C CE 47.685 23.418 41.161 1 3575 +ATOM N N . GLU C 2 91 . 91 GLU C N 50.774 17.543 37.849 1 3576 +ATOM C CA . GLU C 2 91 . 91 GLU C CA 51.403 16.85 36.687 1 3577 +ATOM C C . GLU C 2 91 . 91 GLU C C 50.7 15.509 36.435 1 3578 +ATOM O O . GLU C 2 91 . 91 GLU C O 50.642 15.085 35.261 1 3579 +ATOM C CB . GLU C 2 91 . 91 GLU C CB 52.901 16.629 36.917 1 3580 +ATOM C CG . GLU C 2 91 . 91 GLU C CG 53.753 17.843 36.589 1 3581 +ATOM C CD . GLU C 2 91 . 91 GLU C CD 55.218 17.724 36.976 1 3582 +ATOM O OE1 . GLU C 2 91 . 91 GLU C OE1 55.674 16.592 37.236 1 3583 +ATOM O OE2 . GLU C 2 91 . 91 GLU C OE2 55.898 18.768 37.022 1 3584 +ATOM N N . ALA C 2 92 . 92 ALA C N 50.204 14.864 37.496 1 3585 +ATOM C CA . ALA C 2 92 . 92 ALA C CA 49.468 13.577 37.444 1 3586 +ATOM C C . ALA C 2 92 . 92 ALA C C 48.039 13.805 36.929 1 3587 +ATOM O O . ALA C 2 92 . 92 ALA C O 47.521 12.9 36.251 1 3588 +ATOM C CB . ALA C 2 92 . 92 ALA C CB 49.47 12.918 38.803 1 3589 +ATOM N N . ILE C 2 93 . 93 ILE C N 47.432 14.959 37.242 1 3590 +ATOM C CA . ILE C 2 93 . 93 ILE C CA 46.082 15.372 36.741 1 3591 +ATOM C C . ILE C 2 93 . 93 ILE C C 46.183 15.636 35.23 1 3592 +ATOM O O . ILE C 2 93 . 93 ILE C O 45.298 15.16 34.486 1 3593 +ATOM C CB . ILE C 2 93 . 93 ILE C CB 45.541 16.602 37.505 1 3594 +ATOM C CG1 . ILE C 2 93 . 93 ILE C CG1 45.362 16.321 39.001 1 3595 +ATOM C CG2 . ILE C 2 93 . 93 ILE C CG2 44.244 17.103 36.881 1 3596 +ATOM C CD1 . ILE C 2 93 . 93 ILE C CD1 45.095 17.555 39.838 1 3597 +ATOM N N . THR C 2 94 . 94 THR C N 47.216 16.375 34.808 1 3598 +ATOM C CA . THR C 2 94 . 94 THR C CA 47.511 16.733 33.392 1 3599 +ATOM C C . THR C 2 94 . 94 THR C C 47.693 15.453 32.565 1 3600 +ATOM O O . THR C 2 94 . 94 THR C O 47.191 15.415 31.426 1 3601 +ATOM C CB . THR C 2 94 . 94 THR C CB 48.743 17.644 33.299 1 3602 +ATOM C CG2 . THR C 2 94 . 94 THR C CG2 49.051 18.099 31.889 1 3603 +ATOM O OG1 . THR C 2 94 . 94 THR C OG1 48.508 18.785 34.124 1 3604 +ATOM N N . ALA C 2 95 . 95 ALA C N 48.387 14.453 33.123 1 3605 +ATOM C CA . ALA C 2 95 . 95 ALA C CA 48.646 13.134 32.497 1 3606 +ATOM C C . ALA C 2 95 . 95 ALA C C 47.32 12.391 32.294 1 3607 +ATOM O O . ALA C 2 95 . 95 ALA C O 47.096 11.877 31.173 1 3608 +ATOM C CB . ALA C 2 95 . 95 ALA C CB 49.602 12.333 33.35 1 3609 +ATOM N N . TYR C 2 96 . 96 TYR C N 46.485 12.345 33.339 1 3610 +ATOM C CA . TYR C 2 96 . 96 TYR C CA 45.133 11.721 33.342 1 3611 +ATOM C C . TYR C 2 96 . 96 TYR C C 44.243 12.413 32.298 1 3612 +ATOM O O . TYR C 2 96 . 96 TYR C O 43.638 11.702 31.471 1 3613 +ATOM C CB . TYR C 2 96 . 96 TYR C CB 44.522 11.764 34.747 1 3614 +ATOM C CG . TYR C 2 96 . 96 TYR C CG 43.063 11.389 34.809 1 3615 +ATOM C CD1 . TYR C 2 96 . 96 TYR C CD1 42.65 10.084 34.595 1 3616 +ATOM C CD2 . TYR C 2 96 . 96 TYR C CD2 42.088 12.341 35.072 1 3617 +ATOM C CE1 . TYR C 2 96 . 96 TYR C CE1 41.31 9.734 34.639 1 3618 +ATOM C CE2 . TYR C 2 96 . 96 TYR C CE2 40.744 12.007 35.123 1 3619 +ATOM C CZ . TYR C 2 96 . 96 TYR C CZ 40.352 10.697 34.908 1 3620 +ATOM O OH . TYR C 2 96 . 96 TYR C OH 39.031 10.356 34.956 1 3621 +ATOM N N . THR C 2 97 . 97 THR C N 44.172 13.749 32.337 1 3622 +ATOM C CA . THR C 2 97 . 97 THR C CA 43.387 14.597 31.396 1 3623 +ATOM C C . THR C 2 97 . 97 THR C C 43.936 14.429 29.973 1 3624 +ATOM O O . THR C 2 97 . 97 THR C O 43.117 14.36 29.032 1 3625 +ATOM C CB . THR C 2 97 . 97 THR C CB 43.402 16.071 31.821 1 3626 +ATOM C CG2 . THR C 2 97 . 97 THR C CG2 42.527 16.952 30.956 1 3627 +ATOM O OG1 . THR C 2 97 . 97 THR C OG1 42.955 16.143 33.174 1 3628 +ATOM N N . GLY C 2 98 . 98 GLY C N 45.267 14.381 29.832 1 3629 +ATOM C CA . GLY C 2 98 . 98 GLY C CA 45.968 14.213 28.542 1 3630 +ATOM C C . GLY C 2 98 . 98 GLY C C 45.685 12.86 27.911 1 3631 +ATOM O O . GLY C 2 98 . 98 GLY C O 45.563 12.801 26.676 1 3632 +ATOM N N . ASN C 2 99 . 99 ASN C N 45.59 11.809 28.731 1 3633 +ATOM C CA . ASN C 2 99 . 99 ASN C CA 45.298 10.417 28.295 1 3634 +ATOM C C . ASN C 2 99 . 99 ASN C C 43.873 10.348 27.73 1 3635 +ATOM O O . ASN C 2 99 . 99 ASN C O 43.702 9.739 26.652 1 3636 +ATOM C CB . ASN C 2 99 . 99 ASN C CB 45.507 9.417 29.436 1 3637 +ATOM C CG . ASN C 2 99 . 99 ASN C CG 45.017 8.023 29.103 1 3638 +ATOM N ND2 . ASN C 2 99 . 99 ASN C ND2 43.838 7.669 29.592 1 3639 +ATOM O OD1 . ASN C 2 99 . 99 ASN C OD1 45.705 7.268 28.417 1 3640 +ATOM N N . LYS C 2 100 . 100 LYS C N 42.896 10.941 28.431 1 3641 +ATOM C CA . LYS C 2 100 . 100 LYS C CA 41.464 10.984 28.014 1 3642 +ATOM C C . LYS C 2 100 . 100 LYS C C 41.336 11.754 26.691 1 3643 +ATOM O O . LYS C 2 100 . 100 LYS C O 40.602 11.276 25.805 1 3644 +ATOM C CB . LYS C 2 100 . 100 LYS C CB 40.594 11.625 29.101 1 3645 +ATOM C CG . LYS C 2 100 . 100 LYS C CG 39.093 11.482 28.885 1 3646 +ATOM C CD . LYS C 2 100 . 100 LYS C CD 38.268 11.823 30.106 1 3647 +ATOM C CE . LYS C 2 100 . 100 LYS C CE 36.813 11.426 29.966 1 3648 +ATOM N NZ . LYS C 2 100 . 100 LYS C NZ 36.626 9.958 30.064 1 3649 +ATOM N N . GLY C 2 101 . 101 GLY C N 42.025 12.896 26.576 1 3650 +ATOM C CA . GLY C 2 101 . 101 GLY C CA 42.047 13.757 25.376 1 3651 +ATOM C C . GLY C 2 101 . 101 GLY C C 42.552 13.019 24.146 1 3652 +ATOM O O . GLY C 2 101 . 101 GLY C O 41.992 13.241 23.058 1 3653 +ATOM N N . LYS C 2 102 . 102 LYS C N 43.569 12.169 24.313 1 3654 +ATOM C CA . LYS C 2 102 . 102 LYS C CA 44.198 11.384 23.217 1 3655 +ATOM C C . LYS C 2 102 . 102 LYS C C 43.26 10.246 22.791 1 3656 +ATOM O O . LYS C 2 102 . 102 LYS C O 43.106 10.052 21.567 1 3657 +ATOM C CB . LYS C 2 102 . 102 LYS C CB 45.578 10.874 23.644 1 3658 +ATOM C CG . LYS C 2 102 . 102 LYS C CG 46.667 11.94 23.646 1 3659 +ATOM C CD . LYS C 2 102 . 102 LYS C CD 47.974 11.489 24.258 1 3660 +ATOM C CE . LYS C 2 102 . 102 LYS C CE 49.049 12.558 24.219 1 3661 +ATOM N NZ . LYS C 2 102 . 102 LYS C NZ 48.766 13.663 25.165 1 3662 +ATOM N N . VAL C 2 103 . 103 VAL C N 42.654 9.531 23.748 1 3663 +ATOM C CA . VAL C 2 103 . 103 VAL C CA 41.665 8.444 23.467 1 3664 +ATOM C C . VAL C 2 103 . 103 VAL C C 40.426 9.06 22.801 1 3665 +ATOM O O . VAL C 2 103 . 103 VAL C O 39.85 8.401 21.919 1 3666 +ATOM C CB . VAL C 2 103 . 103 VAL C CB 41.289 7.646 24.732 1 3667 +ATOM C CG1 . VAL C 2 103 . 103 VAL C CG1 40.085 6.743 24.496 1 3668 +ATOM C CG2 . VAL C 2 103 . 103 VAL C CG2 42.464 6.835 25.259 1 3669 +ATOM N N . ALA C 2 104 . 104 ALA C N 40.025 10.27 23.204 1 3670 +ATOM C CA . ALA C 2 104 . 104 ALA C CA 38.89 11.006 22.597 1 3671 +ATOM C C . ALA C 2 104 . 104 ALA C C 39.201 11.282 21.12 1 3672 +ATOM O O . ALA C 2 104 . 104 ALA C O 38.367 10.919 20.266 1 3673 +ATOM C CB . ALA C 2 104 . 104 ALA C CB 38.615 12.277 23.364 1 3674 +ATOM N N . ALA C 2 105 . 105 ALA C N 40.373 11.862 20.838 1 3675 +ATOM C CA . ALA C 2 105 . 105 ALA C CA 40.858 12.222 19.482 1 3676 +ATOM C C . ALA C 2 105 . 105 ALA C C 41.036 10.967 18.614 1 3677 +ATOM O O . ALA C 2 105 . 105 ALA C O 40.901 11.09 17.389 1 3678 +ATOM C CB . ALA C 2 105 . 105 ALA C CB 42.147 13.002 19.587 1 3679 +ATOM N N . PHE C 2 106 . 106 PHE C N 41.327 9.813 19.226 1 3680 +ATOM C CA . PHE C 2 106 . 106 PHE C CA 41.485 8.489 18.56 1 3681 +ATOM C C . PHE C 2 106 . 106 PHE C C 40.252 8.167 17.701 1 3682 +ATOM O O . PHE C 2 106 . 106 PHE C O 40.428 7.577 16.609 1 3683 +ATOM C CB . PHE C 2 106 . 106 PHE C CB 41.726 7.397 19.606 1 3684 +ATOM C CG . PHE C 2 106 . 106 PHE C CG 41.842 5.995 19.062 1 3685 +ATOM C CD1 . PHE C 2 106 . 106 PHE C CD1 42.809 5.676 18.118 1 3686 +ATOM C CD2 . PHE C 2 106 . 106 PHE C CD2 41.003 4.985 19.515 1 3687 +ATOM C CE1 . PHE C 2 106 . 106 PHE C CE1 42.92 4.384 17.629 1 3688 +ATOM C CE2 . PHE C 2 106 . 106 PHE C CE2 41.118 3.693 19.024 1 3689 +ATOM C CZ . PHE C 2 106 . 106 PHE C CZ 42.073 3.396 18.079 1 3690 +ATOM N N . ILE C 2 107 . 107 ILE C N 39.055 8.533 18.177 1 3691 +ATOM C CA . ILE C 2 107 . 107 ILE C CA 37.75 8.293 17.486 1 3692 +ATOM C C . ILE C 2 107 . 107 ILE C C 37.141 9.632 17.039 1 3693 +ATOM O O . ILE C 2 107 . 107 ILE C O 35.986 9.624 16.576 1 3694 +ATOM C CB . ILE C 2 107 . 107 ILE C CB 36.79 7.486 18.387 1 3695 +ATOM C CG1 . ILE C 2 107 . 107 ILE C CG1 36.461 8.214 19.694 1 3696 +ATOM C CG2 . ILE C 2 107 . 107 ILE C CG2 37.352 6.095 18.644 1 3697 +ATOM C CD1 . ILE C 2 107 . 107 ILE C CD1 35.297 7.619 20.453 1 3698 +ATOM N N . ASN C 2 108 . 108 ASN C N 37.901 10.727 17.146 1 3699 +ATOM C CA . ASN C 2 108 . 108 ASN C CA 37.495 12.108 16.766 1 3700 +ATOM C C . ASN C 2 108 . 108 ASN C C 36.28 12.532 17.6 1 3701 +ATOM O O . ASN C 2 108 . 108 ASN C O 35.347 13.133 17.036 1 3702 +ATOM C CB . ASN C 2 108 . 108 ASN C CB 37.241 12.234 15.259 1 3703 +ATOM C CG . ASN C 2 108 . 108 ASN C CG 37.24 13.669 14.767 1 3704 +ATOM N ND2 . ASN C 2 108 . 108 ASN C ND2 36.367 13.975 13.818 1 3705 +ATOM O OD1 . ASN C 2 108 . 108 ASN C OD1 38.026 14.496 15.227 1 3706 +ATOM N N . ALA C 2 109 . 109 ALA C N 36.31 12.238 18.902 1 3707 +ATOM C CA . ALA C 2 109 . 109 ALA C CA 35.295 12.656 19.897 1 3708 +ATOM C C . ALA C 2 109 . 109 ALA C C 35.889 13.745 20.794 1 3709 +ATOM O O . ALA C 2 109 . 109 ALA C O 37.135 13.828 20.888 1 3710 +ATOM C CB . ALA C 2 109 . 109 ALA C CB 34.837 11.467 20.707 1 3711 +ATOM N N . LEU C 2 110 . 110 LEU C N 35.024 14.559 21.407 1 3712 +ATOM C CA . LEU C 2 110 . 110 LEU C CA 35.402 15.508 22.489 1 3713 +ATOM C C . LEU C 2 110 . 110 LEU C C 35.59 14.714 23.778 1 3714 +ATOM O O . LEU C 2 110 . 110 LEU C O 34.94 13.687 23.971 1 3715 +ATOM C CB . LEU C 2 110 . 110 LEU C CB 34.323 16.587 22.644 1 3716 +ATOM C CG . LEU C 2 110 . 110 LEU C CG 34.554 17.876 21.856 1 3717 +ATOM C CD1 . LEU C 2 110 . 110 LEU C CD1 34.974 17.586 20.422 1 3718 +ATOM C CD2 . LEU C 2 110 . 110 LEU C CD2 33.312 18.756 21.883 1 3719 +ATOM N N . PRO C 2 111 . 111 PRO C N 36.482 15.161 24.693 1 3720 +ATOM C CA . PRO C 2 111 . 111 PRO C CA 36.757 14.425 25.93 1 3721 +ATOM C C . PRO C 2 111 . 111 PRO C C 35.512 14.197 26.807 1 3722 +ATOM O O . PRO C 2 111 . 111 PRO C O 35.432 13.148 27.422 1 3723 +ATOM C CB . PRO C 2 111 . 111 PRO C CB 37.799 15.291 26.664 1 3724 +ATOM C CG . PRO C 2 111 . 111 PRO C CG 37.692 16.661 26.019 1 3725 +ATOM C CD . PRO C 2 111 . 111 PRO C CD 37.27 16.399 24.589 1 3726 +ATOM N N . ASP C 2 112 . 112 ASP C N 34.574 15.152 26.823 1 3727 +ATOM C CA . ASP C 2 112 . 112 ASP C CA 33.311 15.081 27.613 1 3728 +ATOM C C . ASP C 2 112 . 112 ASP C C 32.256 14.232 26.879 1 3729 +ATOM O O . ASP C 2 112 . 112 ASP C O 31.121 14.143 27.392 1 3730 +ATOM C CB . ASP C 2 112 . 112 ASP C CB 32.77 16.479 27.931 1 3731 +ATOM C CG . ASP C 2 112 . 112 ASP C CG 32.456 17.317 26.704 1 3732 +ATOM O OD1 . ASP C 2 112 . 112 ASP C OD1 33.407 17.699 25.992 1 3733 +ATOM O OD2 . ASP C 2 112 . 112 ASP C OD2 31.267 17.58 26.47 1 3734 +ATOM N N . GLU C 2 113 . 113 GLU C N 32.605 13.632 25.736 1 3735 +ATOM C CA . GLU C 2 113 . 113 GLU C CA 31.702 12.77 24.927 1 3736 +ATOM C C . GLU C 2 113 . 113 GLU C C 32.11 11.294 25.067 1 3737 +ATOM O O . GLU C 2 113 . 113 GLU C O 31.433 10.45 24.451 1 3738 +ATOM C CB . GLU C 2 113 . 113 GLU C CB 31.709 13.217 23.461 1 3739 +ATOM C CG . GLU C 2 113 . 113 GLU C CG 31.062 14.575 23.229 1 3740 +ATOM C CD . GLU C 2 113 . 113 GLU C CD 31.254 15.158 21.835 1 3741 +ATOM O OE1 . GLU C 2 113 . 113 GLU C OE1 31.952 14.526 21.011 1 3742 +ATOM O OE2 . GLU C 2 113 . 113 GLU C OE2 30.712 16.252 21.572 1 3743 +ATOM N N . ILE C 2 114 . 114 ILE C N 33.146 10.978 25.855 1 3744 +ATOM C CA . ILE C 2 114 . 114 ILE C CA 33.594 9.57 26.091 1 3745 +ATOM C C . ILE C 2 114 . 114 ILE C C 33.671 9.287 27.598 1 3746 +ATOM O O . ILE C 2 114 . 114 ILE C O 33.918 10.226 28.375 1 3747 +ATOM C CB . ILE C 2 114 . 114 ILE C CB 34.928 9.265 25.376 1 3748 +ATOM C CG1 . ILE C 2 114 . 114 ILE C CG1 36.078 10.151 25.863 1 3749 +ATOM C CG2 . ILE C 2 114 . 114 ILE C CG2 34.758 9.352 23.868 1 3750 +ATOM C CD1 . ILE C 2 114 . 114 ILE C CD1 37.436 9.504 25.728 1 3751 +ATOM N N . THR C 2 115 . 115 THR C N 33.452 8.027 27.981 1 3752 +ATOM C CA . THR C 2 115 . 115 THR C CA 33.59 7.513 29.369 1 3753 +ATOM C C . THR C 2 115 . 115 THR C C 34.182 6.1 29.321 1 3754 +ATOM O O . THR C 2 115 . 115 THR C O 33.856 5.356 28.376 1 3755 +ATOM C CB . THR C 2 115 . 115 THR C CB 32.247 7.552 30.112 1 3756 +ATOM C CG2 . THR C 2 115 . 115 THR C CG2 31.258 6.516 29.621 1 3757 +ATOM O OG1 . THR C 2 115 . 115 THR C OG1 32.507 7.357 31.504 1 3758 +ATOM N N . PHE C 2 116 . 116 PHE C N 35.016 5.755 30.306 1 3759 +ATOM C CA . PHE C 2 116 . 116 PHE C CA 35.617 4.408 30.487 1 3760 +ATOM C C . PHE C 2 116 . 116 PHE C C 34.739 3.601 31.447 1 3761 +ATOM O O . PHE C 2 116 . 116 PHE C O 34.294 4.165 32.453 1 3762 +ATOM C CB . PHE C 2 116 . 116 PHE C CB 37.05 4.519 31.012 1 3763 +ATOM C CG . PHE C 2 116 . 116 PHE C CG 38.036 5.143 30.055 1 3764 +ATOM C CD1 . PHE C 2 116 . 116 PHE C CD1 38.703 4.368 29.117 1 3765 +ATOM C CD2 . PHE C 2 116 . 116 PHE C CD2 38.313 6.501 30.103 1 3766 +ATOM C CE1 . PHE C 2 116 . 116 PHE C CE1 39.617 4.94 28.244 1 3767 +ATOM C CE2 . PHE C 2 116 . 116 PHE C CE2 39.225 7.072 29.229 1 3768 +ATOM C CZ . PHE C 2 116 . 116 PHE C CZ 39.878 6.29 28.303 1 3769 +ATOM N N . GLY C 2 117 . 117 GLY C N 34.493 2.327 31.13 1 3770 +ATOM C CA . GLY C 2 117 . 117 GLY C CA 33.654 1.412 31.929 1 3771 +ATOM C C . GLY C 2 117 . 117 GLY C C 34.278 0.033 32.058 1 3772 +ATOM O O . GLY C 2 117 . 117 GLY C O 35.334 -0.205 31.436 1 3773 +ATOM N N . GLN C 2 118 . 118 GLN C N 33.631 -0.844 32.829 1 3774 +ATOM C CA . GLN C 2 118 . 118 GLN C CA 34.055 -2.243 33.112 1 3775 +ATOM C C . GLN C 2 118 . 118 GLN C C 34.264 -3.013 31.803 1 3776 +ATOM O O . GLN C 2 118 . 118 GLN C O 35.229 -3.793 31.735 1 3777 +ATOM C CB . GLN C 2 118 . 118 GLN C CB 33.001 -2.915 33.999 1 3778 +ATOM C CG . GLN C 2 118 . 118 GLN C CG 32.999 -4.438 33.966 1 3779 +ATOM C CD . GLN C 2 118 . 118 GLN C CD 31.851 -5.011 34.763 1 3780 +ATOM N NE2 . GLN C 2 118 . 118 GLN C NE2 31.665 -6.318 34.662 1 3781 +ATOM O OE1 . GLN C 2 118 . 118 GLN C OE1 31.132 -4.299 35.465 1 3782 +ATOM N N . SER C 2 119 . 119 SER C N 33.376 -2.808 30.825 1 3783 +ATOM C CA . SER C 2 119 . 119 SER C CA 33.284 -3.577 29.557 1 3784 +ATOM C C . SER C 2 119 . 119 SER C C 32.186 -2.97 28.676 1 3785 +ATOM O O . SER C 2 119 . 119 SER C O 31.386 -2.165 29.203 1 3786 +ATOM C CB . SER C 2 119 . 119 SER C CB 33.004 -5.034 29.83 1 3787 +ATOM O OG . SER C 2 119 . 119 SER C OG 31.759 -5.192 30.5 1 3788 +ATOM N N . THR C 2 120 . 120 THR C N 32.139 -3.354 27.396 1 3789 +ATOM C CA . THR C 2 120 . 120 THR C CA 31.073 -2.954 26.436 1 3790 +ATOM C C . THR C 2 120 . 120 THR C C 29.716 -3.457 26.957 1 3791 +ATOM O O . THR C 2 120 . 120 THR C O 28.766 -2.655 26.981 1 3792 +ATOM C CB . THR C 2 120 . 120 THR C CB 31.403 -3.43 25.013 1 3793 +ATOM C CG2 . THR C 2 120 . 120 THR C CG2 30.238 -3.313 24.057 1 3794 +ATOM O OG1 . THR C 2 120 . 120 THR C OG1 32.482 -2.634 24.521 1 3795 +ATOM N N . THR C 2 121 . 121 THR C N 29.637 -4.724 27.378 1 3796 +ATOM C CA . THR C 2 121 . 121 THR C CA 28.409 -5.368 27.928 1 3797 +ATOM C C . THR C 2 121 . 121 THR C C 27.829 -4.51 29.062 1 3798 +ATOM O O . THR C 2 121 . 121 THR C O 26.606 -4.286 29.055 1 3799 +ATOM C CB . THR C 2 121 . 121 THR C CB 28.689 -6.794 28.423 1 3800 +ATOM C CG2 . THR C 2 121 . 121 THR C CG2 27.449 -7.503 28.926 1 3801 +ATOM O OG1 . THR C 2 121 . 121 THR C OG1 29.253 -7.554 27.353 1 3802 +ATOM N N . ALA C 2 122 . 122 ALA C N 28.674 -4.046 29.989 1 3803 +ATOM C CA . ALA C 2 122 . 122 ALA C CA 28.281 -3.213 31.15 1 3804 +ATOM C C . ALA C 2 122 . 122 ALA C C 27.828 -1.828 30.674 1 3805 +ATOM O O . ALA C 2 122 . 122 ALA C O 26.732 -1.398 31.08 1 3806 +ATOM C CB . ALA C 2 122 . 122 ALA C CB 29.419 -3.109 32.133 1 3807 +ATOM N N . LEU C 2 123 . 123 LEU C N 28.64 -1.162 29.849 1 3808 +ATOM C CA . LEU C 2 123 . 123 LEU C CA 28.389 0.223 29.363 1 3809 +ATOM C C . LEU C 2 123 . 123 LEU C C 27.073 0.287 28.576 1 3810 +ATOM O O . LEU C 2 123 . 123 LEU C O 26.388 1.325 28.667 1 3811 +ATOM C CB . LEU C 2 123 . 123 LEU C CB 29.567 0.684 28.497 1 3812 +ATOM C CG . LEU C 2 123 . 123 LEU C CG 30.789 1.175 29.27 1 3813 +ATOM C CD1 . LEU C 2 123 . 123 LEU C CD1 31.989 1.325 28.346 1 3814 +ATOM C CD2 . LEU C 2 123 . 123 LEU C CD2 30.49 2.489 29.978 1 3815 +ATOM N N . PHE C 2 124 . 124 PHE C N 26.742 -0.76 27.812 1 3816 +ATOM C CA . PHE C 2 124 . 124 PHE C CA 25.456 -0.863 27.072 1 3817 +ATOM C C . PHE C 2 124 . 124 PHE C C 24.306 -0.997 28.078 1 3818 +ATOM O O . PHE C 2 124 . 124 PHE C O 23.248 -0.383 27.855 1 3819 +ATOM C CB . PHE C 2 124 . 124 PHE C CB 25.468 -2.019 26.066 1 3820 +ATOM C CG . PHE C 2 124 . 124 PHE C CG 26.139 -1.735 24.744 1 3821 +ATOM C CD1 . PHE C 2 124 . 124 PHE C CD1 26.223 -0.447 24.23 1 3822 +ATOM C CD2 . PHE C 2 124 . 124 PHE C CD2 26.654 -2.776 23.984 1 3823 +ATOM C CE1 . PHE C 2 124 . 124 PHE C CE1 26.832 -0.205 23.007 1 3824 +ATOM C CE2 . PHE C 2 124 . 124 PHE C CE2 27.258 -2.531 22.758 1 3825 +ATOM C CZ . PHE C 2 124 . 124 PHE C CZ 27.348 -1.247 22.272 1 3826 +ATOM N N . ARG C 2 125 . 125 ARG C N 24.507 -1.76 29.158 1 3827 +ATOM C CA . ARG C 2 125 . 125 ARG C CA 23.476 -1.943 30.215 1 3828 +ATOM C C . ARG C 2 125 . 125 ARG C C 23.249 -0.61 30.937 1 3829 +ATOM O O . ARG C 2 125 . 125 ARG C O 22.071 -0.249 31.132 1 3830 +ATOM C CB . ARG C 2 125 . 125 ARG C CB 23.869 -3.029 31.22 1 3831 +ATOM C CG . ARG C 2 125 . 125 ARG C CG 22.684 -3.59 31.994 1 3832 +ATOM C CD . ARG C 2 125 . 125 ARG C CD 21.738 -4.367 31.094 1 3833 +ATOM N NE . ARG C 2 125 . 125 ARG C NE 22.458 -5.428 30.392 1 3834 +ATOM C CZ . ARG C 2 125 . 125 ARG C CZ 22.637 -6.666 30.851 1 3835 +ATOM N NH1 . ARG C 2 125 . 125 ARG C NH1 23.317 -7.541 30.131 1 3836 +ATOM N NH2 . ARG C 2 125 . 125 ARG C NH2 22.133 -7.031 32.021 1 3837 +ATOM N N . LEU C 2 126 . 126 LEU C N 24.329 0.093 31.295 1 3838 +ATOM C CA . LEU C 2 126 . 126 LEU C CA 24.279 1.405 32.0 1 3839 +ATOM C C . LEU C 2 126 . 126 LEU C C 23.673 2.475 31.081 1 3840 +ATOM O O . LEU C 2 126 . 126 LEU C O 22.97 3.361 31.608 1 3841 +ATOM C CB . LEU C 2 126 . 126 LEU C CB 25.687 1.804 32.454 1 3842 +ATOM C CG . LEU C 2 126 . 126 LEU C CG 26.36 0.884 33.474 1 3843 +ATOM C CD1 . LEU C 2 126 . 126 LEU C CD1 27.621 1.534 34.022 1 3844 +ATOM C CD2 . LEU C 2 126 . 126 LEU C CD2 25.416 0.517 34.613 1 3845 +ATOM N N . LEU C 2 127 . 127 LEU C N 23.924 2.399 29.769 1 3846 +ATOM C CA . LEU C 2 127 . 127 LEU C CA 23.341 3.332 28.765 1 3847 +ATOM C C . LEU C 2 127 . 127 LEU C C 21.816 3.166 28.736 1 3848 +ATOM O O . LEU C 2 127 . 127 LEU C O 21.113 4.192 28.734 1 3849 +ATOM C CB . LEU C 2 127 . 127 LEU C CB 23.947 3.073 27.382 1 3850 +ATOM C CG . LEU C 2 127 . 127 LEU C CG 23.359 3.913 26.247 1 3851 +ATOM C CD1 . LEU C 2 127 . 127 LEU C CD1 23.466 5.402 26.549 1 3852 +ATOM C CD2 . LEU C 2 127 . 127 LEU C CD2 24.034 3.589 24.924 1 3853 +ATOM N N . GLY C 2 128 . 128 GLY C N 21.333 1.921 28.71 1 3854 +ATOM C CA . GLY C 2 128 . 128 GLY C CA 19.898 1.586 28.806 1 3855 +ATOM C C . GLY C 2 128 . 128 GLY C C 19.238 2.211 30.03 1 3856 +ATOM O O . GLY C 2 128 . 128 GLY C O 18.114 2.731 29.876 1 3857 +ATOM N N . LEU C 2 129 . 129 LEU C N 19.899 2.171 31.197 1 3858 +ATOM C CA . LEU C 2 129 . 129 LEU C CA 19.389 2.767 32.467 1 3859 +ATOM C C . LEU C 2 129 . 129 LEU C C 19.291 4.29 32.317 1 3860 +ATOM O O . LEU C 2 129 . 129 LEU C O 18.326 4.865 32.849 1 3861 +ATOM C CB . LEU C 2 129 . 129 LEU C CB 20.306 2.408 33.644 1 3862 +ATOM C CG . LEU C 2 129 . 129 LEU C CG 20.425 0.923 33.989 1 3863 +ATOM C CD1 . LEU C 2 129 . 129 LEU C CD1 21.358 0.721 35.171 1 3864 +ATOM C CD2 . LEU C 2 129 . 129 LEU C CD2 19.067 0.305 34.285 1 3865 +ATOM N N . SER C 2 130 . 130 SER C N 20.259 4.909 31.635 1 3866 +ATOM C CA . SER C 2 130 . 130 SER C CA 20.322 6.374 31.385 1 3867 +ATOM C C . SER C 2 130 . 130 SER C C 19.208 6.799 30.415 1 3868 +ATOM O O . SER C 2 130 . 130 SER C O 18.627 7.884 30.628 1 3869 +ATOM C CB . SER C 2 130 . 130 SER C CB 21.691 6.775 30.877 1 3870 +ATOM O OG . SER C 2 130 . 130 SER C OG 21.783 8.181 30.697 1 3871 +ATOM N N . LEU C 2 131 . 131 LEU C N 18.923 5.977 29.396 1 3872 +ATOM C CA . LEU C 2 131 . 131 LEU C CA 17.923 6.259 28.328 1 3873 +ATOM C C . LEU C 2 131 . 131 LEU C C 16.503 5.97 28.831 1 3874 +ATOM O O . LEU C 2 131 . 131 LEU C O 15.577 6.679 28.389 1 3875 +ATOM C CB . LEU C 2 131 . 131 LEU C CB 18.231 5.406 27.09 1 3876 +ATOM C CG . LEU C 2 131 . 131 LEU C CG 19.533 5.728 26.354 1 3877 +ATOM C CD1 . LEU C 2 131 . 131 LEU C CD1 19.772 4.733 25.234 1 3878 +ATOM C CD2 . LEU C 2 131 . 131 LEU C CD2 19.53 7.146 25.806 1 3879 +ATOM N N . LYS C 2 132 . 132 LYS C N 16.341 4.976 29.714 1 3880 +ATOM C CA . LYS C 2 132 . 132 LYS C CA 15.024 4.405 30.114 1 3881 +ATOM C C . LYS C 2 132 . 132 LYS C C 14.055 5.494 30.583 1 3882 +ATOM O O . LYS C 2 132 . 132 LYS C O 12.901 5.494 30.163 1 3883 +ATOM C CB . LYS C 2 132 . 132 LYS C CB 15.207 3.331 31.19 1 3884 +ATOM C CG . LYS C 2 132 . 132 LYS C CG 13.921 2.658 31.65 1 3885 +ATOM C CD . LYS C 2 132 . 132 LYS C CD 14.155 1.411 32.464 1 3886 +ATOM C CE . LYS C 2 132 . 132 LYS C CE 14.738 1.694 33.833 1 3887 +ATOM N NZ . LYS C 2 132 . 132 LYS C NZ 15.09 0.438 34.539 1 3888 +ATOM N N . PRO C 2 133 . 133 PRO C N 14.449 6.434 31.477 1 3889 +ATOM C CA . PRO C 2 133 . 133 PRO C CA 13.497 7.388 32.052 1 3890 +ATOM C C . PRO C 2 133 . 133 PRO C C 12.69 8.196 31.021 1 3891 +ATOM O O . PRO C 2 133 . 133 PRO C O 11.561 8.523 31.319 1 3892 +ATOM C CB . PRO C 2 133 . 133 PRO C CB 14.386 8.321 32.893 1 3893 +ATOM C CG . PRO C 2 133 . 133 PRO C CG 15.569 7.455 33.264 1 3894 +ATOM C CD . PRO C 2 133 . 133 PRO C CD 15.803 6.606 32.03 1 3895 +ATOM N N . MET C 2 134 . 134 MET C N 13.269 8.481 29.848 1 3896 +ATOM C CA . MET C 2 134 . 134 MET C CA 12.638 9.32 28.79 1 3897 +ATOM C C . MET C 2 134 . 134 MET C C 12.059 8.436 27.675 1 3898 +ATOM O O . MET C 2 134 . 134 MET C O 11.779 8.978 26.585 1 3899 +ATOM C CB . MET C 2 134 . 134 MET C CB 13.652 10.307 28.202 1 3900 +ATOM C CG . MET C 2 134 . 134 MET C CG 13.996 11.433 29.151 1 3901 +ATOM S SD . MET C 2 134 . 134 MET C SD 15.269 12.541 28.497 1 3902 +ATOM C CE . MET C 2 134 . 134 MET C CE 15.484 13.639 29.898 1 3903 +ATOM N N . LEU C 2 135 . 135 LEU C N 11.867 7.14 27.94 1 3904 +ATOM C CA . LEU C 2 135 . 135 LEU C CA 11.24 6.172 27.001 1 3905 +ATOM C C . LEU C 2 135 . 135 LEU C C 9.996 5.583 27.671 1 3906 +ATOM O O . LEU C 2 135 . 135 LEU C O 9.803 5.825 28.881 1 3907 +ATOM C CB . LEU C 2 135 . 135 LEU C CB 12.246 5.072 26.645 1 3908 +ATOM C CG . LEU C 2 135 . 135 LEU C CG 13.514 5.529 25.922 1 3909 +ATOM C CD1 . LEU C 2 135 . 135 LEU C CD1 14.487 4.373 25.757 1 3910 +ATOM C CD2 . LEU C 2 135 . 135 LEU C CD2 13.192 6.142 24.566 1 3911 +ATOM N N . ASN C 2 136 . 136 ASN C N 9.19 4.841 26.907 1 3912 +ATOM C CA . ASN C 2 136 . 136 ASN C CA 7.921 4.229 27.379 1 3913 +ATOM C C . ASN C 2 136 . 136 ASN C C 7.524 3.1 26.422 1 3914 +ATOM O O . ASN C 2 136 . 136 ASN C O 8.244 2.892 25.428 1 3915 +ATOM C CB . ASN C 2 136 . 136 ASN C CB 6.825 5.29 27.513 1 3916 +ATOM C CG . ASN C 2 136 . 136 ASN C CG 6.567 6.037 26.221 1 3917 +ATOM N ND2 . ASN C 2 136 . 136 ASN C ND2 6.498 7.355 26.3 1 3918 +ATOM O OD1 . ASN C 2 136 . 136 ASN C OD1 6.43 5.431 25.162 1 3919 +ATOM N N . ASN C 2 137 . 137 ASN C N 6.396 2.437 26.695 1 3920 +ATOM C CA . ASN C 2 137 . 137 ASN C CA 5.883 1.267 25.929 1 3921 +ATOM C C . ASN C 2 137 . 137 ASN C C 5.319 1.698 24.565 1 3922 +ATOM O O . ASN C 2 137 . 137 ASN C O 4.938 0.8 23.798 1 3923 +ATOM C CB . ASN C 2 137 . 137 ASN C CB 4.835 0.474 26.722 1 3924 +ATOM C CG . ASN C 2 137 . 137 ASN C CG 3.756 1.33 27.353 1 3925 +ATOM N ND2 . ASN C 2 137 . 137 ASN C ND2 2.84 0.693 28.062 1 3926 +ATOM O OD1 . ASN C 2 137 . 137 ASN C OD1 3.755 2.554 27.23 1 3927 +ATOM N N . ASP C 2 138 . 138 ASP C N 5.256 3.001 24.269 1 3928 +ATOM C CA . ASP C 2 138 . 138 ASP C CA 4.811 3.533 22.95 1 3929 +ATOM C C . ASP C 2 138 . 138 ASP C C 6.009 3.694 21.997 1 3930 +ATOM O O . ASP C 2 138 . 138 ASP C O 5.781 3.825 20.778 1 3931 +ATOM C CB . ASP C 2 138 . 138 ASP C CB 4.059 4.855 23.124 1 3932 +ATOM C CG . ASP C 2 138 . 138 ASP C CG 3.18 5.239 21.946 1 3933 +ATOM O OD1 . ASP C 2 138 . 138 ASP C OD1 2.566 4.337 21.354 1 3934 +ATOM O OD2 . ASP C 2 138 . 138 ASP C OD2 3.111 6.445 21.64 1 3935 +ATOM N N . CYS C 2 139 . 139 CYS C N 7.235 3.695 22.538 1 3936 +ATOM C CA . CYS C 2 139 . 139 CYS C CA 8.513 3.806 21.783 1 3937 +ATOM C C . CYS C 2 139 . 139 CYS C C 8.807 2.474 21.083 1 3938 +ATOM O O . CYS C 2 139 . 139 CYS C O 8.198 1.458 21.468 1 3939 +ATOM C CB . CYS C 2 139 . 139 CYS C CB 9.668 4.179 22.706 1 3940 +ATOM S SG . CYS C 2 139 . 139 CYS C SG 9.379 5.704 23.642 1 3941 +ATOM N N . GLU C 2 140 . 140 GLU C N 9.693 2.488 20.082 1 3942 +ATOM C CA . GLU C 2 140 . 140 GLU C CA 10.058 1.277 19.3 1 3943 +ATOM C C . GLU C 2 140 . 140 GLU C C 11.58 1.11 19.294 1 3944 +ATOM O O . GLU C 2 140 . 140 GLU C O 12.292 2.124 19.151 1 3945 +ATOM C CB . GLU C 2 140 . 140 GLU C CB 9.472 1.36 17.891 1 3946 +ATOM C CG . GLU C 2 140 . 140 GLU C CG 7.959 1.226 17.874 1 3947 +ATOM C CD . GLU C 2 140 . 140 GLU C CD 7.321 1.239 16.497 1 3948 +ATOM O OE1 . GLU C 2 140 . 140 GLU C OE1 8.048 1.451 15.51 1 3949 +ATOM O OE2 . GLU C 2 140 . 140 GLU C OE2 6.099 1.041 16.418 1 3950 +ATOM N N . ILE C 2 141 . 141 ILE C N 12.039 -0.131 19.479 1 3951 +ATOM C CA . ILE C 2 141 . 141 ILE C CA 13.479 -0.517 19.489 1 3952 +ATOM C C . ILE C 2 141 . 141 ILE C C 13.706 -1.533 18.366 1 3953 +ATOM O O . ILE C 2 141 . 141 ILE C O 12.983 -2.553 18.324 1 3954 +ATOM C CB . ILE C 2 141 . 141 ILE C CB 13.906 -1.049 20.872 1 3955 +ATOM C CG1 . ILE C 2 141 . 141 ILE C CG1 13.682 0.008 21.958 1 3956 +ATOM C CG2 . ILE C 2 141 . 141 ILE C CG2 15.349 -1.539 20.833 1 3957 +ATOM C CD1 . ILE C 2 141 . 141 ILE C CD1 14.13 -0.398 23.348 1 3958 +ATOM N N . VAL C 2 142 . 142 VAL C N 14.66 -1.231 17.483 1 3959 +ATOM C CA . VAL C 2 142 . 142 VAL C CA 15.061 -2.072 16.319 1 3960 +ATOM C C . VAL C 2 142 . 142 VAL C C 16.471 -2.597 16.598 1 3961 +ATOM O O . VAL C 2 142 . 142 VAL C O 17.384 -1.766 16.775 1 3962 +ATOM C CB . VAL C 2 142 . 142 VAL C CB 14.996 -1.27 15.003 1 3963 +ATOM C CG1 . VAL C 2 142 . 142 VAL C CG1 15.412 -2.102 13.797 1 3964 +ATOM C CG2 . VAL C 2 142 . 142 VAL C CG2 13.617 -0.666 14.782 1 3965 +ATOM N N . CYS C 2 143 . 143 CYS C N 16.63 -3.922 16.658 1 3966 +ATOM C CA . CYS C 2 143 . 143 CYS C CA 17.926 -4.606 16.915 1 3967 +ATOM C C . CYS C 2 143 . 143 CYS C C 18.278 -5.519 15.739 1 3968 +ATOM O O . CYS C 2 143 . 143 CYS C O 17.382 -6.236 15.258 1 3969 +ATOM C CB . CYS C 2 143 . 143 CYS C CB 17.872 -5.437 18.191 1 3970 +ATOM S SG . CYS C 2 143 . 143 CYS C SG 17.657 -4.439 19.683 1 3971 +ATOM N N . SER C 2 144 . 144 SER C N 19.539 -5.488 15.307 1 3972 +ATOM C CA . SER C 2 144 . 144 SER C CA 20.116 -6.424 14.308 1 3973 +ATOM C C . SER C 2 144 . 144 SER C C 20.203 -7.829 14.916 1 3974 +ATOM O O . SER C 2 144 . 144 SER C O 20.595 -7.931 16.097 1 3975 +ATOM C CB . SER C 2 144 . 144 SER C CB 21.464 -5.947 13.849 1 3976 +ATOM O OG . SER C 2 144 . 144 SER C OG 22.157 -6.981 13.17 1 3977 +ATOM N N . THR C 2 145 . 145 THR C N 19.856 -8.863 14.141 1 3978 +ATOM C CA . THR C 2 145 . 145 THR C CA 20.025 -10.293 14.524 1 3979 +ATOM C C . THR C 2 145 . 145 THR C C 21.494 -10.717 14.353 1 3980 +ATOM O O . THR C 2 145 . 145 THR C O 21.791 -11.892 14.648 1 3981 +ATOM C CB . THR C 2 145 . 145 THR C CB 19.085 -11.223 13.742 1 3982 +ATOM C CG2 . THR C 2 145 . 145 THR C CG2 17.621 -10.89 13.928 1 3983 +ATOM O OG1 . THR C 2 145 . 145 THR C OG1 19.416 -11.155 12.355 1 3984 +ATOM N N . LEU C 2 146 . 146 LEU C N 22.383 -9.816 13.91 1 3985 +ATOM C CA . LEU C 2 146 . 146 LEU C CA 23.85 -10.072 13.839 1 3986 +ATOM C C . LEU C 2 146 . 146 LEU C C 24.533 -9.6 15.129 1 3987 +ATOM O O . LEU C 2 146 . 146 LEU C O 25.759 -9.775 15.239 1 3988 +ATOM C CB . LEU C 2 146 . 146 LEU C CB 24.456 -9.353 12.628 1 3989 +ATOM C CG . LEU C 2 146 . 146 LEU C CG 24.076 -9.903 11.254 1 3990 +ATOM C CD1 . LEU C 2 146 . 146 LEU C CD1 24.707 -9.058 10.157 1 3991 +ATOM C CD2 . LEU C 2 146 . 146 LEU C CD2 24.49 -11.36 11.105 1 3992 +HETATM N N . CSD C 2 147 . 147 CSD C N 23.781 -9.014 16.062 1 3993 +HETATM C CA . CSD C 2 147 . 147 CSD C CA 24.322 -8.391 17.297 1 3994 +HETATM C C . CSD C 2 147 . 147 CSD C C 24.925 -9.468 18.206 1 3995 +HETATM O O . CSD C 2 147 . 147 CSD C O 24.29 -10.529 18.386 1 3996 +HETATM C CB . CSD C 2 147 . 147 CSD C CB 23.258 -7.586 18.042 1 3997 +HETATM S SG . CSD C 2 147 . 147 CSD C SG 23.232 -5.881 17.423 1 3998 +HETATM O OD1 . CSD C 2 147 . 147 CSD C OD1 22.147 -5.203 18.17 1 3999 +HETATM O OD2 . CSD C 2 147 . 147 CSD C OD2 24.612 -5.168 17.81 1 4000 +ATOM N N . HIS C 2 148 . 148 HIS C N 26.121 -9.196 18.735 1 4001 +ATOM C CA . HIS C 2 148 . 148 HIS C CA 26.724 -9.889 19.903 1 4002 +ATOM C C . HIS C 2 148 . 148 HIS C C 25.717 -9.83 21.059 1 4003 +ATOM O O . HIS C 2 148 . 148 HIS C O 24.953 -8.849 21.118 1 4004 +ATOM C CB . HIS C 2 148 . 148 HIS C CB 28.064 -9.225 20.251 1 4005 +ATOM C CG . HIS C 2 148 . 148 HIS C CG 28.911 -9.988 21.213 1 4006 +ATOM C CD2 . HIS C 2 148 . 148 HIS C CD2 30.018 -10.739 21.023 1 4007 +ATOM N ND1 . HIS C 2 148 . 148 HIS C ND1 28.666 -9.995 22.573 1 4008 +ATOM C CE1 . HIS C 2 148 . 148 HIS C CE1 29.575 -10.733 23.179 1 4009 +ATOM N NE2 . HIS C 2 148 . 148 HIS C NE2 30.418 -11.198 22.249 1 4010 +ATOM N N . GLU C 2 149 . 149 GLU C N 25.706 -10.824 21.949 1 4011 +ATOM C CA . GLU C 2 149 . 149 GLU C CA 24.699 -10.897 23.045 1 4012 +ATOM C C . GLU C 2 149 . 149 GLU C C 24.949 -9.778 24.07 1 4013 +ATOM O O . GLU C 2 149 . 149 GLU C O 24.006 -9.451 24.802 1 4014 +ATOM C CB . GLU C 2 149 . 149 GLU C CB 24.673 -12.286 23.686 1 4015 +ATOM C CG . GLU C 2 149 . 149 GLU C CG 23.329 -12.62 24.316 1 4016 +ATOM C CD . GLU C 2 149 . 149 GLU C CD 22.173 -12.835 23.349 1 4017 +ATOM O OE1 . GLU C 2 149 . 149 GLU C OE1 22.367 -12.658 22.124 1 4018 +ATOM O OE2 . GLU C 2 149 . 149 GLU C OE2 21.074 -13.181 23.825 1 4019 +ATOM N N . ALA C 2 150 . 150 ALA C N 26.157 -9.201 24.105 1 4020 +ATOM C CA . ALA C 2 150 . 150 ALA C CA 26.499 -7.973 24.866 1 4021 +ATOM C C . ALA C 2 150 . 150 ALA C C 25.486 -6.866 24.549 1 4022 +ATOM O O . ALA C 2 150 . 150 ALA C O 25.034 -6.194 25.491 1 4023 +ATOM C CB . ALA C 2 150 . 150 ALA C CB 27.902 -7.529 24.542 1 4024 +ATOM N N . ALA C 2 151 . 151 ALA C N 25.161 -6.682 23.266 1 4025 +ATOM C CA . ALA C 2 151 . 151 ALA C CA 24.169 -5.697 22.775 1 4026 +ATOM C C . ALA C 2 151 . 151 ALA C C 22.756 -6.238 23.026 1 4027 +ATOM O O . ALA C 2 151 . 151 ALA C O 21.969 -5.542 23.701 1 4028 +ATOM C CB . ALA C 2 151 . 151 ALA C CB 24.398 -5.407 21.313 1 4029 +ATOM N N . ALA C 2 152 . 152 ALA C N 22.467 -7.442 22.517 1 4030 +ATOM C CA . ALA C 2 152 . 152 ALA C CA 21.138 -8.101 22.564 1 4031 +ATOM C C . ALA C 2 152 . 152 ALA C C 20.61 -8.135 24.003 1 4032 +ATOM O O . ALA C 2 152 . 152 ALA C O 19.456 -7.727 24.205 1 4033 +ATOM C CB . ALA C 2 152 . 152 ALA C CB 21.219 -9.492 21.984 1 4034 +ATOM N N . SER C 2 153 . 153 SER C N 21.42 -8.606 24.958 1 4035 +ATOM C CA . SER C 2 153 . 153 SER C CA 21.054 -8.709 26.397 1 4036 +ATOM C C . SER C 2 153 . 153 SER C C 20.62 -7.338 26.923 1 4037 +ATOM O O . SER C 2 153 . 153 SER C O 19.576 -7.276 27.599 1 4038 +ATOM C CB . SER C 2 153 . 153 SER C CB 22.176 -9.268 27.228 1 4039 +ATOM O OG . SER C 2 153 . 153 SER C OG 22.275 -10.672 27.061 1 4040 +ATOM N N . ALA C 2 154 . 154 ALA C N 21.398 -6.291 26.627 1 4041 +ATOM C CA . ALA C 2 154 . 154 ALA C CA 21.154 -4.906 27.088 1 4042 +ATOM C C . ALA C 2 154 . 154 ALA C C 19.768 -4.452 26.616 1 4043 +ATOM O O . ALA C 2 154 . 154 ALA C O 18.972 -4.022 27.467 1 4044 +ATOM C CB . ALA C 2 154 . 154 ALA C CB 22.243 -3.984 26.593 1 4045 +ATOM N N . TRP C 2 155 . 155 TRP C N 19.476 -4.59 25.319 1 4046 +ATOM C CA . TRP C 2 155 . 155 TRP C CA 18.255 -4.023 24.681 1 4047 +ATOM C C . TRP C 2 155 . 155 TRP C C 17.051 -4.944 24.911 1 4048 +ATOM O O . TRP C 2 155 . 155 TRP C O 15.92 -4.436 24.856 1 4049 +ATOM C CB . TRP C 2 155 . 155 TRP C CB 18.506 -3.728 23.196 1 4050 +ATOM C CG . TRP C 2 155 . 155 TRP C CG 19.817 -3.04 22.985 1 4051 +ATOM C CD1 . TRP C 2 155 . 155 TRP C CD1 20.821 -3.415 22.142 1 4052 +ATOM C CD2 . TRP C 2 155 . 155 TRP C CD2 20.299 -1.892 23.704 1 4053 +ATOM C CE2 . TRP C 2 155 . 155 TRP C CE2 21.595 -1.62 23.219 1 4054 +ATOM C CE3 . TRP C 2 155 . 155 TRP C CE3 19.762 -1.061 24.694 1 4055 +ATOM N NE1 . TRP C 2 155 . 155 TRP C NE1 21.884 -2.562 22.265 1 4056 +ATOM C CZ2 . TRP C 2 155 . 155 TRP C CZ2 22.356 -0.552 23.697 1 4057 +ATOM C CZ3 . TRP C 2 155 . 155 TRP C CZ3 20.512 -0.005 25.161 1 4058 +ATOM C CH2 . TRP C 2 155 . 155 TRP C CH2 21.794 0.243 24.669 1 4059 +ATOM N N . ILE C 2 156 . 156 ILE C N 17.28 -6.231 25.188 1 4060 +ATOM C CA . ILE C 2 156 . 156 ILE C CA 16.213 -7.168 25.655 1 4061 +ATOM C C . ILE C 2 156 . 156 ILE C C 15.787 -6.762 27.069 1 4062 +ATOM O O . ILE C 2 156 . 156 ILE C O 14.571 -6.712 27.325 1 4063 +ATOM C CB . ILE C 2 156 . 156 ILE C CB 16.671 -8.639 25.575 1 4064 +ATOM C CG1 . ILE C 2 156 . 156 ILE C CG1 16.661 -9.131 24.125 1 4065 +ATOM C CG2 . ILE C 2 156 . 156 ILE C CG2 15.822 -9.533 26.474 1 4066 +ATOM C CD1 . ILE C 2 156 . 156 ILE C CD1 17.49 -10.365 23.882 1 4067 +ATOM N N . HIS C 2 157 . 157 HIS C N 16.76 -6.485 27.947 1 4068 +ATOM C CA . HIS C 2 157 . 157 HIS C CA 16.552 -6.1 29.371 1 4069 +ATOM C C . HIS C 2 157 . 157 HIS C C 15.833 -4.749 29.443 1 4070 +ATOM O O . HIS C 2 157 . 157 HIS C O 14.886 -4.636 30.243 1 4071 +ATOM C CB . HIS C 2 157 . 157 HIS C CB 17.887 -6.081 30.127 1 4072 +ATOM C CG . HIS C 2 157 . 157 HIS C CG 17.826 -5.416 31.463 1 4073 +ATOM C CD2 . HIS C 2 157 . 157 HIS C CD2 18.213 -4.186 31.865 1 4074 +ATOM N ND1 . HIS C 2 157 . 157 HIS C ND1 17.329 -6.052 32.586 1 4075 +ATOM C CE1 . HIS C 2 157 . 157 HIS C CE1 17.408 -5.239 33.621 1 4076 +ATOM N NE2 . HIS C 2 157 . 157 HIS C NE2 17.949 -4.086 33.204 1 4077 +ATOM N N . LEU C 2 158 . 158 LEU C N 16.272 -3.773 28.641 1 4078 +ATOM C CA . LEU C 2 158 . 158 LEU C CA 15.651 -2.421 28.573 1 4079 +ATOM C C . LEU C 2 158 . 158 LEU C C 14.206 -2.559 28.077 1 4080 +ATOM O O . LEU C 2 158 . 158 LEU C O 13.316 -1.943 28.692 1 4081 +ATOM C CB . LEU C 2 158 . 158 LEU C CB 16.473 -1.515 27.651 1 4082 +ATOM C CG . LEU C 2 158 . 158 LEU C CG 15.893 -0.119 27.415 1 4083 +ATOM C CD1 . LEU C 2 158 . 158 LEU C CD1 15.763 0.646 28.722 1 4084 +ATOM C CD2 . LEU C 2 158 . 158 LEU C CD2 16.739 0.665 26.425 1 4085 +ATOM N N . SER C 2 159 . 159 SER C N 13.998 -3.347 27.016 1 4086 +ATOM C CA . SER C 2 159 . 159 SER C CA 12.679 -3.636 26.391 1 4087 +ATOM C C . SER C 2 159 . 159 SER C C 11.71 -4.207 27.433 1 4088 +ATOM O O . SER C 2 159 . 159 SER C O 10.589 -3.68 27.551 1 4089 +ATOM C CB . SER C 2 159 . 159 SER C CB 12.823 -4.576 25.219 1 4090 +ATOM O OG . SER C 2 159 . 159 SER C OG 13.397 -3.909 24.106 1 4091 +ATOM N N . ARG C 2 160 . 160 ARG C N 12.137 -5.247 28.153 1 4092 +ATOM C CA . ARG C 2 160 . 160 ARG C CA 11.324 -5.948 29.183 1 4093 +ATOM C C . ARG C 2 160 . 160 ARG C C 10.926 -4.961 30.289 1 4094 +ATOM O O . ARG C 2 160 . 160 ARG C O 9.781 -5.052 30.763 1 4095 +ATOM C CB . ARG C 2 160 . 160 ARG C CB 12.1 -7.142 29.746 1 4096 +ATOM C CG . ARG C 2 160 . 160 ARG C CG 11.292 -8.027 30.684 1 4097 +ATOM C CD . ARG C 2 160 . 160 ARG C CD 12.028 -9.321 30.983 1 4098 +ATOM N NE . ARG C 2 160 . 160 ARG C NE 11.281 -10.212 31.861 1 4099 +ATOM C CZ . ARG C 2 160 . 160 ARG C CZ 11.645 -11.457 32.174 1 4100 +ATOM N NH1 . ARG C 2 160 . 160 ARG C NH1 12.758 -11.979 31.682 1 4101 +ATOM N NH2 . ARG C 2 160 . 160 ARG C NH2 10.887 -12.179 32.983 1 4102 +ATOM N N . GLU C 2 161 . 161 GLU C N 11.828 -4.052 30.676 1 4103 +ATOM C CA . GLU C 2 161 . 161 GLU C CA 11.591 -3.062 31.764 1 4104 +ATOM C C . GLU C 2 161 . 161 GLU C C 10.564 -2.02 31.3 1 4105 +ATOM O O . GLU C 2 161 . 161 GLU C O 9.744 -1.598 32.138 1 4106 +ATOM C CB . GLU C 2 161 . 161 GLU C CB 12.908 -2.419 32.209 1 4107 +ATOM C CG . GLU C 2 161 . 161 GLU C CG 13.74 -3.327 33.099 1 4108 +ATOM C CD . GLU C 2 161 . 161 GLU C CD 15.078 -2.761 33.553 1 4109 +ATOM O OE1 . GLU C 2 161 . 161 GLU C OE1 15.711 -2.013 32.776 1 4110 +ATOM O OE2 . GLU C 2 161 . 161 GLU C OE2 15.484 -3.069 34.692 1 4111 +ATOM N N . LEU C 2 162 . 162 LEU C N 10.592 -1.645 30.018 1 4112 +ATOM C CA . LEU C 2 162 . 162 LEU C CA 9.683 -0.629 29.42 1 4113 +ATOM C C . LEU C 2 162 . 162 LEU C C 8.362 -1.274 28.971 1 4114 +ATOM O O . LEU C 2 162 . 162 LEU C O 7.399 -0.522 28.752 1 4115 +ATOM C CB . LEU C 2 162 . 162 LEU C CB 10.396 0.049 28.244 1 4116 +ATOM C CG . LEU C 2 162 . 162 LEU C CG 11.541 0.99 28.618 1 4117 +ATOM C CD1 . LEU C 2 162 . 162 LEU C CD1 12.374 1.341 27.397 1 4118 +ATOM C CD2 . LEU C 2 162 . 162 LEU C CD2 11.018 2.255 29.281 1 4119 +ATOM N N . GLY C 2 163 . 163 GLY C N 8.302 -2.603 28.849 1 4120 +ATOM C CA . GLY C 2 163 . 163 GLY C CA 7.138 -3.322 28.291 1 4121 +ATOM C C . GLY C 2 163 . 163 GLY C C 7.103 -3.225 26.771 1 4122 +ATOM O O . GLY C 2 163 . 163 GLY C O 5.993 -3.175 26.207 1 4123 +ATOM N N . ILE C 2 164 . 164 ILE C N 8.284 -3.189 26.144 1 4124 +ATOM C CA . ILE C 2 164 . 164 ILE C CA 8.494 -3.104 24.666 1 4125 +ATOM C C . ILE C 2 164 . 164 ILE C C 8.795 -4.509 24.132 1 4126 +ATOM O O . ILE C 2 164 . 164 ILE C O 9.445 -5.296 24.849 1 4127 +ATOM C CB . ILE C 2 164 . 164 ILE C CB 9.644 -2.128 24.333 1 4128 +ATOM C CG1 . ILE C 2 164 . 164 ILE C CG1 9.25 -0.675 24.606 1 4129 +ATOM C CG2 . ILE C 2 164 . 164 ILE C CG2 10.14 -2.309 22.905 1 4130 +ATOM C CD1 . ILE C 2 164 . 164 ILE C CD1 10.392 0.3 24.444 1 4131 +ATOM N N . THR C 2 165 . 165 THR C N 8.35 -4.786 22.906 1 4132 +ATOM C CA . THR C 2 165 . 165 THR C CA 8.745 -5.962 22.092 1 4133 +ATOM C C . THR C 2 165 . 165 THR C C 9.65 -5.445 20.973 1 4134 +ATOM O O . THR C 2 165 . 165 THR C O 9.234 -4.5 20.264 1 4135 +ATOM C CB . THR C 2 165 . 165 THR C CB 7.508 -6.716 21.587 1 4136 +ATOM C CG2 . THR C 2 165 . 165 THR C CG2 7.837 -7.899 20.706 1 4137 +ATOM O OG1 . THR C 2 165 . 165 THR C OG1 6.793 -7.166 22.738 1 4138 +ATOM N N . ILE C 2 166 . 166 ILE C N 10.849 -6.017 20.846 1 4139 +ATOM C CA . ILE C 2 166 . 166 ILE C CA 11.884 -5.559 19.875 1 4140 +ATOM C C . ILE C 2 166 . 166 ILE C C 11.404 -5.896 18.461 1 4141 +ATOM O O . ILE C 2 166 . 166 ILE C O 10.875 -7.004 18.259 1 4142 +ATOM C CB . ILE C 2 166 . 166 ILE C CB 13.263 -6.17 20.192 1 4143 +ATOM C CG1 . ILE C 2 166 . 166 ILE C CG1 13.803 -5.657 21.532 1 4144 +ATOM C CG2 . ILE C 2 166 . 166 ILE C CG2 14.241 -5.912 19.052 1 4145 +ATOM C CD1 . ILE C 2 166 . 166 ILE C CD1 15.065 -6.348 21.993 1 4146 +ATOM N N . LYS C 2 167 . 167 LYS C N 11.546 -4.939 17.542 1 4147 +ATOM C CA . LYS C 2 167 . 167 LYS C CA 11.389 -5.142 16.081 1 4148 +ATOM C C . LYS C 2 167 . 167 LYS C C 12.758 -5.555 15.531 1 4149 +ATOM O O . LYS C 2 167 . 167 LYS C O 13.583 -4.665 15.264 1 4150 +ATOM C CB . LYS C 2 167 . 167 LYS C CB 10.839 -3.873 15.427 1 4151 +ATOM C CG . LYS C 2 167 . 167 LYS C CG 9.506 -3.397 15.99 1 4152 +ATOM C CD . LYS C 2 167 . 167 LYS C CD 8.979 -2.141 15.332 1 4153 +ATOM C CE . LYS C 2 167 . 167 LYS C CE 8.548 -2.358 13.896 1 4154 +ATOM N NZ . LYS C 2 167 . 167 LYS C NZ 8.042 -1.106 13.285 1 4155 +ATOM N N . TRP C 2 168 . 168 TRP C N 12.997 -6.863 15.412 1 4156 +ATOM C CA . TRP C 2 168 . 168 TRP C CA 14.322 -7.431 15.054 1 4157 +ATOM C C . TRP C 2 168 . 168 TRP C C 14.619 -7.164 13.57 1 4158 +ATOM O O . TRP C 2 168 . 168 TRP C O 13.721 -7.346 12.728 1 4159 +ATOM C CB . TRP C 2 168 . 168 TRP C CB 14.39 -8.923 15.395 1 4160 +ATOM C CG . TRP C 2 168 . 168 TRP C CG 14.535 -9.235 16.853 1 4161 +ATOM C CD1 . TRP C 2 168 . 168 TRP C CD1 13.549 -9.657 17.697 1 4162 +ATOM C CD2 . TRP C 2 168 . 168 TRP C CD2 15.737 -9.172 17.642 1 4163 +ATOM C CE2 . TRP C 2 168 . 168 TRP C CE2 15.389 -9.565 18.953 1 4164 +ATOM C CE3 . TRP C 2 168 . 168 TRP C CE3 17.067 -8.821 17.376 1 4165 +ATOM N NE1 . TRP C 2 168 . 168 TRP C NE1 14.051 -9.857 18.954 1 4166 +ATOM C CZ2 . TRP C 2 168 . 168 TRP C CZ2 16.321 -9.612 19.989 1 4167 +ATOM C CZ3 . TRP C 2 168 . 168 TRP C CZ3 17.988 -8.868 18.401 1 4168 +ATOM C CH2 . TRP C 2 168 . 168 TRP C CH2 17.617 -9.255 19.69 1 4169 +ATOM N N . TRP C 2 169 . 169 TRP C N 15.851 -6.743 13.287 1 4170 +ATOM C CA . TRP C 2 169 . 169 TRP C CA 16.358 -6.335 11.954 1 4171 +ATOM C C . TRP C 2 169 . 169 TRP C C 17.228 -7.47 11.409 1 4172 +ATOM O O . TRP C 2 169 . 169 TRP C O 18.343 -7.659 11.934 1 4173 +ATOM C CB . TRP C 2 169 . 169 TRP C CB 17.118 -5.011 12.106 1 4174 +ATOM C CG . TRP C 2 169 . 169 TRP C CG 17.795 -4.481 10.881 1 4175 +ATOM C CD1 . TRP C 2 169 . 169 TRP C CD1 17.56 -4.807 9.575 1 4176 +ATOM C CD2 . TRP C 2 169 . 169 TRP C CD2 18.797 -3.448 10.863 1 4177 +ATOM C CE2 . TRP C 2 169 . 169 TRP C CE2 19.138 -3.226 9.511 1 4178 +ATOM C CE3 . TRP C 2 169 . 169 TRP C CE3 19.443 -2.703 11.856 1 4179 +ATOM N NE1 . TRP C 2 169 . 169 TRP C NE1 18.369 -4.069 8.751 1 4180 +ATOM C CZ2 . TRP C 2 169 . 169 TRP C CZ2 20.1 -2.29 9.135 1 4181 +ATOM C CZ3 . TRP C 2 169 . 169 TRP C CZ3 20.396 -1.78 11.483 1 4182 +ATOM C CH2 . TRP C 2 169 . 169 TRP C CH2 20.72 -1.581 10.141 1 4183 +ATOM N N . SER C 2 170 . 170 SER C N 16.708 -8.226 10.436 1 4184 +ATOM C CA . SER C 2 170 . 170 SER C CA 17.388 -9.389 9.812 1 4185 +ATOM C C . SER C 2 170 . 170 SER C C 18.331 -8.892 8.722 1 4186 +ATOM O O . SER C 2 170 . 170 SER C O 17.987 -7.984 7.965 1 4187 +ATOM C CB . SER C 2 170 . 170 SER C CB 16.4 -10.394 9.261 1 4188 +ATOM O OG . SER C 2 170 . 170 SER C OG 15.846 -11.194 10.295 1 4189 +ATOM N N . PRO C 2 171 . 171 PRO C N 19.537 -9.486 8.586 1 4190 +ATOM C CA . PRO C 2 171 . 171 PRO C CA 20.438 -9.134 7.492 1 4191 +ATOM C C . PRO C 2 171 . 171 PRO C C 19.937 -9.756 6.18 1 4192 +ATOM O O . PRO C 2 171 . 171 PRO C O 19.059 -10.598 6.227 1 4193 +ATOM C CB . PRO C 2 171 . 171 PRO C CB 21.775 -9.726 7.96 1 4194 +ATOM C CG . PRO C 2 171 . 171 PRO C CG 21.36 -10.962 8.729 1 4195 +ATOM C CD . PRO C 2 171 . 171 PRO C CD 20.086 -10.552 9.44 1 4196 +ATOM N N . THR C 2 172 . 172 THR C N 20.504 -9.319 5.054 1 4197 +ATOM C CA . THR C 2 172 . 172 THR C CA 20.18 -9.791 3.682 1 4198 +ATOM C C . THR C 2 172 . 172 THR C C 21.312 -10.698 3.186 1 4199 +ATOM O O . THR C 2 172 . 172 THR C O 22.488 -10.382 3.458 1 4200 +ATOM C CB . THR C 2 172 . 172 THR C CB 19.942 -8.599 2.744 1 4201 +ATOM C CG2 . THR C 2 172 . 172 THR C CG2 19.732 -8.991 1.297 1 4202 +ATOM O OG1 . THR C 2 172 . 172 THR C OG1 18.782 -7.912 3.214 1 4203 +ATOM N N . THR C 2 173 . 173 THR C N 20.954 -11.787 2.501 1 4204 +ATOM C CA . THR C 2 173 . 173 THR C CA 21.867 -12.647 1.704 1 4205 +ATOM C C . THR C 2 173 . 173 THR C C 21.452 -12.545 0.233 1 4206 +ATOM O O . THR C 2 173 . 173 THR C O 20.256 -12.281 -0.019 1 4207 +ATOM C CB . THR C 2 173 . 173 THR C CB 21.848 -14.102 2.192 1 4208 +ATOM C CG2 . THR C 2 173 . 173 THR C CG2 22.359 -14.259 3.608 1 4209 +ATOM O OG1 . THR C 2 173 . 173 THR C OG1 20.508 -14.586 2.108 1 4210 +ATOM N N . THR C 2 174 . 174 THR C N 22.403 -12.723 -0.689 1 4211 +ATOM C CA . THR C 2 174 . 174 THR C CA 22.161 -12.775 -2.157 1 4212 +ATOM C C . THR C 2 174 . 174 THR C C 22.157 -14.24 -2.589 1 4213 +ATOM O O . THR C 2 174 . 174 THR C O 22.786 -15.077 -1.946 1 4214 +ATOM C CB . THR C 2 174 . 174 THR C CB 23.19 -11.942 -2.935 1 4215 +ATOM C CG2 . THR C 2 174 . 174 THR C CG2 23.267 -10.505 -2.462 1 4216 +ATOM O OG1 . THR C 2 174 . 174 THR C OG1 24.474 -12.556 -2.816 1 4217 +ATOM N N . PRO C 2 175 . 175 PRO C N 21.436 -14.603 -3.675 1 4218 +ATOM C CA . PRO C 2 175 . 175 PRO C CA 21.449 -15.976 -4.186 1 4219 +ATOM C C . PRO C 2 175 . 175 PRO C C 22.849 -16.511 -4.541 1 4220 +ATOM O O . PRO C 2 175 . 175 PRO C O 23.057 -17.711 -4.433 1 4221 +ATOM C CB . PRO C 2 175 . 175 PRO C CB 20.582 -15.899 -5.456 1 4222 +ATOM C CG . PRO C 2 175 . 175 PRO C CG 19.692 -14.69 -5.235 1 4223 +ATOM C CD . PRO C 2 175 . 175 PRO C CD 20.545 -13.72 -4.445 1 4224 +ATOM N N . ASN C 2 176 . 176 ASN C N 23.763 -15.625 -4.959 1 4225 +ATOM C CA . ASN C 2 176 . 176 ASN C CA 25.159 -15.971 -5.345 1 4226 +ATOM C C . ASN C 2 176 . 176 ASN C C 26.006 -16.233 -4.09 1 4227 +ATOM O O . ASN C 2 176 . 176 ASN C O 26.965 -17.024 -4.201 1 4228 +ATOM C CB . ASN C 2 176 . 176 ASN C CB 25.793 -14.888 -6.227 1 4229 +ATOM C CG . ASN C 2 176 . 176 ASN C CG 25.255 -14.877 -7.645 1 4230 +ATOM N ND2 . ASN C 2 176 . 176 ASN C ND2 25.364 -13.742 -8.32 1 4231 +ATOM O OD1 . ASN C 2 176 . 176 ASN C OD1 24.748 -15.886 -8.133 1 4232 +ATOM N N . SER C 2 177 . 177 SER C N 25.668 -15.608 -2.952 1 4233 +ATOM C CA . SER C 2 177 . 177 SER C CA 26.439 -15.663 -1.677 1 4234 +ATOM C C . SER C 2 177 . 177 SER C C 25.508 -15.931 -0.493 1 4235 +ATOM O O . SER C 2 177 . 177 SER C O 25.225 -15.03 0.297 1 4236 +ATOM C CB . SER C 2 177 . 177 SER C CB 27.231 -14.393 -1.485 1 4237 +ATOM O OG . SER C 2 177 . 177 SER C OG 28.009 -14.102 -2.636 1 4238 +ATOM N N . PRO C 2 178 . 178 PRO C N 25.014 -17.181 -0.321 1 4239 +ATOM C CA . PRO C 2 178 . 178 PRO C CA 24.04 -17.5 0.728 1 4240 +ATOM C C . PRO C 2 178 . 178 PRO C C 24.57 -17.45 2.174 1 4241 +ATOM O O . PRO C 2 178 . 178 PRO C O 23.759 -17.293 3.075 1 4242 +ATOM C CB . PRO C 2 178 . 178 PRO C CB 23.6 -18.943 0.41 1 4243 +ATOM C CG . PRO C 2 178 . 178 PRO C CG 24.11 -19.218 -0.995 1 4244 +ATOM C CD . PRO C 2 178 . 178 PRO C CD 25.341 -18.352 -1.149 1 4245 +ATOM N N . ASP C 2 179 . 179 ASP C N 25.889 -17.598 2.366 1 4246 +ATOM C CA . ASP C 2 179 . 179 ASP C CA 26.565 -17.635 3.695 1 4247 +ATOM C C . ASP C 2 179 . 179 ASP C C 27.227 -16.278 3.986 1 4248 +ATOM O O . ASP C 2 179 . 179 ASP C O 28.088 -16.224 4.885 1 4249 +ATOM C CB . ASP C 2 179 . 179 ASP C CB 27.59 -18.773 3.749 1 4250 +ATOM C CG . ASP C 2 179 . 179 ASP C CG 26.99 -20.155 3.55 1 4251 +ATOM O OD1 . ASP C 2 179 . 179 ASP C OD1 25.831 -20.356 3.973 1 4252 +ATOM O OD2 . ASP C 2 179 . 179 ASP C OD2 27.687 -21.026 2.973 1 4253 +ATOM N N . ASP C 2 180 . 180 ASP C N 26.813 -15.221 3.278 1 4254 +ATOM C CA . ASP C 2 180 . 180 ASP C CA 27.424 -13.865 3.311 1 4255 +ATOM C C . ASP C 2 180 . 180 ASP C C 26.356 -12.861 3.742 1 4256 +ATOM O O . ASP C 2 180 . 180 ASP C O 25.834 -12.114 2.917 1 4257 +ATOM C CB . ASP C 2 180 . 180 ASP C CB 28.023 -13.529 1.942 1 4258 +ATOM C CG . ASP C 2 180 . 180 ASP C CG 28.767 -12.206 1.863 1 4259 +ATOM O OD1 . ASP C 2 180 . 180 ASP C OD1 28.864 -11.512 2.895 1 4260 +ATOM O OD2 . ASP C 2 180 . 180 ASP C OD2 29.251 -11.879 0.757 1 4261 +ATOM N N . PRO C 2 181 . 181 PRO C N 25.984 -12.821 5.042 1 4262 +ATOM C CA . PRO C 2 181 . 181 PRO C CA 24.924 -11.93 5.51 1 4263 +ATOM C C . PRO C 2 181 . 181 PRO C C 25.406 -10.477 5.638 1 4264 +ATOM O O . PRO C 2 181 . 181 PRO C O 26.492 -10.259 6.14 1 4265 +ATOM C CB . PRO C 2 181 . 181 PRO C CB 24.567 -12.527 6.875 1 4266 +ATOM C CG . PRO C 2 181 . 181 PRO C CG 25.878 -13.087 7.371 1 4267 +ATOM C CD . PRO C 2 181 . 181 PRO C CD 26.555 -13.63 6.131 1 4268 +ATOM N N . VAL C 2 182 . 182 VAL C N 24.594 -9.528 5.168 1 4269 +ATOM C CA . VAL C 2 182 . 182 VAL C CA 24.928 -8.074 5.167 1 4270 +ATOM C C . VAL C 2 182 . 182 VAL C C 23.725 -7.288 5.697 1 4271 +ATOM O O . VAL C 2 182 . 182 VAL C O 22.573 -7.717 5.493 1 4272 +ATOM C CB . VAL C 2 182 . 182 VAL C CB 25.371 -7.588 3.772 1 4273 +ATOM C CG1 . VAL C 2 182 . 182 VAL C CG1 24.235 -7.602 2.758 1 4274 +ATOM C CG2 . VAL C 2 182 . 182 VAL C CG2 26.015 -6.209 3.838 1 4275 +ATOM N N . LEU C 2 183 . 183 LEU C N 24.005 -6.181 6.379 1 4276 +ATOM C CA . LEU C 2 183 . 183 LEU C CA 22.981 -5.251 6.91 1 4277 +ATOM C C . LEU C 2 183 . 183 LEU C C 22.746 -4.188 5.837 1 4278 +ATOM O O . LEU C 2 183 . 183 LEU C O 23.728 -3.521 5.446 1 4279 +ATOM C CB . LEU C 2 183 . 183 LEU C CB 23.491 -4.652 8.226 1 4280 +ATOM C CG . LEU C 2 183 . 183 LEU C CG 22.581 -4.818 9.443 1 4281 +ATOM C CD1 . LEU C 2 183 . 183 LEU C CD1 21.974 -6.212 9.517 1 4282 +ATOM C CD2 . LEU C 2 183 . 183 LEU C CD2 23.346 -4.511 10.714 1 4283 +ATOM N N . THR C 2 184 . 184 THR C N 21.513 -4.089 5.338 1 4284 +ATOM C CA . THR C 2 184 . 184 THR C CA 21.108 -3.104 4.304 1 4285 +ATOM C C . THR C 2 184 . 184 THR C C 20.196 -2.052 4.941 1 4286 +ATOM O O . THR C 2 184 . 184 THR C O 19.442 -2.391 5.872 1 4287 +ATOM C CB . THR C 2 184 . 184 THR C CB 20.444 -3.782 3.098 1 4288 +ATOM C CG2 . THR C 2 184 . 184 THR C CG2 21.318 -4.852 2.478 1 4289 +ATOM O OG1 . THR C 2 184 . 184 THR C OG1 19.199 -4.347 3.507 1 4290 +ATOM N N . THR C 2 185 . 185 THR C N 20.295 -0.82 4.442 1 4291 +ATOM C CA . THR C 2 185 . 185 THR C CA 19.343 0.293 4.674 1 4292 +ATOM C C . THR C 2 185 . 185 THR C C 17.962 -0.109 4.131 1 4293 +ATOM O O . THR C 2 185 . 185 THR C O 16.953 0.333 4.71 1 4294 +ATOM C CB . THR C 2 185 . 185 THR C CB 19.869 1.578 4.02 1 4295 +ATOM C CG2 . THR C 2 185 . 185 THR C CG2 21.141 2.092 4.657 1 4296 +ATOM O OG1 . THR C 2 185 . 185 THR C OG1 20.114 1.302 2.642 1 4297 +ATOM N N . ASP C 2 186 . 186 ASP C N 17.932 -0.92 3.063 1 4298 +ATOM C CA . ASP C 2 186 . 186 ASP C CA 16.695 -1.384 2.374 1 4299 +ATOM C C . ASP C 2 186 . 186 ASP C C 15.836 -2.204 3.347 1 4300 +ATOM O O . ASP C 2 186 . 186 ASP C O 14.603 -2.07 3.292 1 4301 +ATOM C CB . ASP C 2 186 . 186 ASP C CB 16.99 -2.234 1.129 1 4302 +ATOM C CG . ASP C 2 186 . 186 ASP C CG 18.116 -1.74 0.23 1 4303 +ATOM O OD1 . ASP C 2 186 . 186 ASP C OD1 19.168 -1.326 0.767 1 4304 +ATOM O OD2 . ASP C 2 186 . 186 ASP C OD2 17.945 -1.8 -1.009 1 4305 +ATOM N N . SER C 2 187 . 187 SER C N 16.467 -3.026 4.194 1 4306 +ATOM C CA . SER C 2 187 . 187 SER C CA 15.793 -3.938 5.158 1 4307 +ATOM C C . SER C 2 187 . 187 SER C C 15.45 -3.201 6.46 1 4308 +ATOM O O . SER C 2 187 . 187 SER C O 14.532 -3.662 7.16 1 4309 +ATOM C CB . SER C 2 187 . 187 SER C CB 16.636 -5.152 5.43 1 4310 +ATOM O OG . SER C 2 187 . 187 SER C OG 17.938 -4.763 5.834 1 4311 +ATOM N N . LEU C 2 188 . 188 LEU C N 16.161 -2.114 6.778 1 4312 +ATOM C CA . LEU C 2 188 . 188 LEU C CA 15.925 -1.286 7.992 1 4313 +ATOM C C . LEU C 2 188 . 188 LEU C C 14.646 -0.456 7.821 1 4314 +ATOM O O . LEU C 2 188 . 188 LEU C O 13.821 -0.447 8.755 1 4315 +ATOM C CB . LEU C 2 188 . 188 LEU C CB 17.132 -0.372 8.232 1 4316 +ATOM C CG . LEU C 2 188 . 188 LEU C CG 16.99 0.616 9.39 1 4317 +ATOM C CD1 . LEU C 2 188 . 188 LEU C CD1 16.738 -0.108 10.702 1 4318 +ATOM C CD2 . LEU C 2 188 . 188 LEU C CD2 18.222 1.499 9.502 1 4319 +ATOM N N . LYS C 2 189 . 189 LYS C N 14.496 0.215 6.676 1 4320 +ATOM C CA . LYS C 2 189 . 189 LYS C CA 13.51 1.313 6.477 1 4321 +ATOM C C . LYS C 2 189 . 189 LYS C C 12.084 0.848 6.779 1 4322 +ATOM O O . LYS C 2 189 . 189 LYS C O 11.337 1.591 7.408 1 4323 +ATOM C CB . LYS C 2 189 . 189 LYS C CB 13.604 1.912 5.069 1 4324 +ATOM C CG . LYS C 2 189 . 189 LYS C CG 13.179 3.372 4.998 1 4325 +ATOM C CD . LYS C 2 189 . 189 LYS C CD 13.153 3.961 3.601 1 4326 +ATOM C CE . LYS C 2 189 . 189 LYS C CE 13.277 5.473 3.601 1 4327 +ATOM N NZ . LYS C 2 189 . 189 LYS C NZ 12.459 6.103 4.666 1 4328 +ATOM N N . PRO C 2 190 . 190 PRO C N 11.638 -0.353 6.334 1 4329 +ATOM C CA . PRO C 2 190 . 190 PRO C CA 10.288 -0.833 6.649 1 4330 +ATOM C C . PRO C 2 190 . 190 PRO C C 10.006 -0.976 8.157 1 4331 +ATOM O O . PRO C 2 190 . 190 PRO C O 8.879 -0.775 8.545 1 4332 +ATOM C CB . PRO C 2 190 . 190 PRO C CB 10.211 -2.212 5.97 1 4333 +ATOM C CG . PRO C 2 190 . 190 PRO C CG 11.276 -2.163 4.893 1 4334 +ATOM C CD . PRO C 2 190 . 190 PRO C CD 12.373 -1.296 5.475 1 4335 +ATOM N N . LEU C 2 191 . 191 LEU C N 11.024 -1.302 8.96 1 4336 +ATOM C CA . LEU C 2 191 . 191 LEU C CA 10.905 -1.474 10.436 1 4337 +ATOM C C . LEU C 2 191 . 191 LEU C C 10.818 -0.104 11.125 1 4338 +ATOM O O . LEU C 2 191 . 191 LEU C O 10.397 -0.063 12.298 1 4339 +ATOM C CB . LEU C 2 191 . 191 LEU C CB 12.106 -2.271 10.959 1 4340 +ATOM C CG . LEU C 2 191 . 191 LEU C CG 12.274 -3.678 10.387 1 4341 +ATOM C CD1 . LEU C 2 191 . 191 LEU C CD1 13.636 -4.245 10.755 1 4342 +ATOM C CD2 . LEU C 2 191 . 191 LEU C CD2 11.165 -4.604 10.863 1 4343 +ATOM N N . LEU C 2 192 . 192 LEU C N 11.204 0.973 10.439 1 4344 +ATOM C CA . LEU C 2 192 . 192 LEU C CA 11.18 2.346 11.006 1 4345 +ATOM C C . LEU C 2 192 . 192 LEU C C 9.745 2.879 11.012 1 4346 +ATOM O O . LEU C 2 192 . 192 LEU C O 8.961 2.548 10.109 1 4347 +ATOM C CB . LEU C 2 192 . 192 LEU C CB 12.109 3.264 10.208 1 4348 +ATOM C CG . LEU C 2 192 . 192 LEU C CG 13.599 2.939 10.295 1 4349 +ATOM C CD1 . LEU C 2 192 . 192 LEU C CD1 14.415 3.994 9.569 1 4350 +ATOM C CD2 . LEU C 2 192 . 192 LEU C CD2 14.062 2.83 11.735 1 4351 +ATOM N N . SER C 2 193 . 193 SER C N 9.419 3.655 12.042 1 4352 +ATOM C CA . SER C 2 193 . 193 SER C CA 8.133 4.376 12.198 1 4353 +ATOM C C . SER C 2 193 . 193 SER C C 8.429 5.694 12.899 1 4354 +ATOM O O . SER C 2 193 . 193 SER C O 9.528 5.871 13.415 1 4355 +ATOM C CB . SER C 2 193 . 193 SER C CB 7.13 3.545 12.958 1 4356 +ATOM O OG . SER C 2 193 . 193 SER C OG 7.306 3.694 14.357 1 4357 +ATOM N N . PRO C 2 194 . 194 PRO C N 7.47 6.647 12.937 1 4358 +ATOM C CA . PRO C 2 194 . 194 PRO C CA 7.643 7.877 13.713 1 4359 +ATOM C C . PRO C 2 194 . 194 PRO C C 7.918 7.639 15.211 1 4360 +ATOM O O . PRO C 2 194 . 194 PRO C O 8.388 8.551 15.862 1 4361 +ATOM C CB . PRO C 2 194 . 194 PRO C CB 6.302 8.605 13.52 1 4362 +ATOM C CG . PRO C 2 194 . 194 PRO C CG 5.771 8.061 12.205 1 4363 +ATOM C CD . PRO C 2 194 . 194 PRO C CD 6.192 6.608 12.206 1 4364 +ATOM N N . LYS C 2 195 . 195 LYS C N 7.622 6.439 15.722 1 4365 +ATOM C CA . LYS C 2 195 . 195 LYS C CA 7.787 6.077 17.156 1 4366 +ATOM C C . LYS C 2 195 . 195 LYS C C 9.115 5.343 17.393 1 4367 +ATOM O O . LYS C 2 195 . 195 LYS C O 9.404 5.065 18.568 1 4368 +ATOM C CB . LYS C 2 195 . 195 LYS C CB 6.59 5.245 17.625 1 4369 +ATOM C CG . LYS C 2 195 . 195 LYS C CG 5.271 6.006 17.648 1 4370 +ATOM C CD . LYS C 2 195 . 195 LYS C CD 4.088 5.163 18.057 1 4371 +ATOM C CE . LYS C 2 195 . 195 LYS C CE 2.813 5.971 18.145 1 4372 +ATOM N NZ . LYS C 2 195 . 195 LYS C NZ 1.697 5.169 18.698 1 4373 +ATOM N N . THR C 2 196 . 196 THR C N 9.898 5.057 16.344 1 4374 +ATOM C CA . THR C 2 196 . 196 THR C CA 11.276 4.501 16.462 1 4375 +ATOM C C . THR C 2 196 . 196 THR C C 12.119 5.436 17.336 1 4376 +ATOM O O . THR C 2 196 . 196 THR C O 12.144 6.646 17.042 1 4377 +ATOM C CB . THR C 2 196 . 196 THR C CB 11.958 4.318 15.099 1 4378 +ATOM C CG2 . THR C 2 196 . 196 THR C CG2 13.368 3.781 15.225 1 4379 +ATOM O OG1 . THR C 2 196 . 196 THR C OG1 11.18 3.418 14.31 1 4380 +ATOM N N . ARG C 2 197 . 197 ARG C N 12.781 4.896 18.363 1 4381 +ATOM C CA . ARG C 2 197 . 197 ARG C CA 13.639 5.681 19.291 1 4382 +ATOM C C . ARG C 2 197 . 197 ARG C C 15.049 5.076 19.378 1 4383 +ATOM O O . ARG C 2 197 . 197 ARG C O 15.999 5.865 19.474 1 4384 +ATOM C CB . ARG C 2 197 . 197 ARG C CB 12.956 5.819 20.657 1 4385 +ATOM C CG . ARG C 2 197 . 197 ARG C CG 11.831 6.847 20.686 1 4386 +ATOM C CD . ARG C 2 197 . 197 ARG C CD 12.264 8.231 20.228 1 4387 +ATOM N NE . ARG C 2 197 . 197 ARG C NE 11.173 9.201 20.236 1 4388 +ATOM C CZ . ARG C 2 197 . 197 ARG C CZ 10.344 9.443 19.219 1 4389 +ATOM N NH1 . ARG C 2 197 . 197 ARG C NH1 10.452 8.782 18.075 1 4390 +ATOM N NH2 . ARG C 2 197 . 197 ARG C NH2 9.391 10.348 19.358 1 4391 +ATOM N N . LEU C 2 198 . 198 LEU C N 15.187 3.747 19.345 1 4392 +ATOM C CA . LEU C 2 198 . 198 LEU C CA 16.501 3.049 19.347 1 4393 +ATOM C C . LEU C 2 198 . 198 LEU C C 16.617 2.134 18.124 1 4394 +ATOM O O . LEU C 2 198 . 198 LEU C O 15.717 1.284 17.926 1 4395 +ATOM C CB . LEU C 2 198 . 198 LEU C CB 16.638 2.231 20.633 1 4396 +ATOM C CG . LEU C 2 198 . 198 LEU C CG 17.283 2.947 21.817 1 4397 +ATOM C CD1 . LEU C 2 198 . 198 LEU C CD1 16.357 4.005 22.388 1 4398 +ATOM C CD2 . LEU C 2 198 . 198 LEU C CD2 17.669 1.944 22.892 1 4399 +ATOM N N . VAL C 2 199 . 199 VAL C N 17.696 2.305 17.352 1 4400 +ATOM C CA . VAL C 2 199 . 199 VAL C CA 18.212 1.31 16.367 1 4401 +ATOM C C . VAL C 2 199 . 199 VAL C C 19.606 0.883 16.837 1 4402 +ATOM O O . VAL C 2 199 . 199 VAL C O 20.416 1.778 17.135 1 4403 +ATOM C CB . VAL C 2 199 . 199 VAL C CB 18.244 1.877 14.935 1 4404 +ATOM C CG1 . VAL C 2 199 . 199 VAL C CG1 18.792 0.861 13.942 1 4405 +ATOM C CG2 . VAL C 2 199 . 199 VAL C CG2 16.874 2.37 14.502 1 4406 +ATOM N N . THR C 2 200 . 200 THR C N 19.864 -0.427 16.905 1 4407 +ATOM C CA . THR C 2 200 . 200 THR C CA 21.123 -1.022 17.434 1 4408 +ATOM C C . THR C 2 200 . 200 THR C C 21.688 -2.001 16.4 1 4409 +ATOM O O . THR C 2 200 . 200 THR C O 20.902 -2.782 15.831 1 4410 +ATOM C CB . THR C 2 200 . 200 THR C CB 20.898 -1.694 18.796 1 4411 +ATOM C CG2 . THR C 2 200 . 200 THR C CG2 20.1 -0.833 19.752 1 4412 +ATOM O OG1 . THR C 2 200 . 200 THR C OG1 20.22 -2.936 18.598 1 4413 +ATOM N N . CYS C 2 201 . 201 CYS C N 22.999 -1.942 16.159 1 4414 +ATOM C CA . CYS C 2 201 . 201 CYS C CA 23.733 -2.858 15.25 1 4415 +ATOM C C . CYS C 2 201 . 201 CYS C C 25.229 -2.814 15.583 1 4416 +ATOM O O . CYS C 2 201 . 201 CYS C O 25.615 -2.033 16.475 1 4417 +ATOM C CB . CYS C 2 201 . 201 CYS C CB 23.482 -2.499 13.787 1 4418 +ATOM S SG . CYS C 2 201 . 201 CYS C SG 24.222 -0.926 13.276 1 4419 +ATOM N N . ASN C 2 202 . 202 ASN C N 26.031 -3.634 14.901 1 4420 +ATOM C CA . ASN C 2 202 . 202 ASN C CA 27.51 -3.651 15.051 1 4421 +ATOM C C . ASN C 2 202 . 202 ASN C C 28.143 -3.112 13.761 1 4422 +ATOM O O . ASN C 2 202 . 202 ASN C O 27.569 -3.322 12.678 1 4423 +ATOM C CB . ASN C 2 202 . 202 ASN C CB 28.038 -5.031 15.465 1 4424 +ATOM C CG . ASN C 2 202 . 202 ASN C CG 27.445 -6.192 14.693 1 4425 +ATOM N ND2 . ASN C 2 202 . 202 ASN C ND2 27.273 -7.324 15.356 1 4426 +ATOM O OD1 . ASN C 2 202 . 202 ASN C OD1 27.156 -6.079 13.505 1 4427 +ATOM N N . HIS C 2 203 . 203 HIS C N 29.262 -2.396 13.891 1 4428 +ATOM C CA . HIS C 2 203 . 203 HIS C CA 30.134 -1.981 12.76 1 4429 +ATOM C C . HIS C 2 203 . 203 HIS C C 30.637 -3.248 12.055 1 4430 +ATOM O O . HIS C 2 203 . 203 HIS C O 30.6 -3.291 10.806 1 4431 +ATOM C CB . HIS C 2 203 . 203 HIS C CB 31.266 -1.072 13.258 1 4432 +ATOM C CG . HIS C 2 203 . 203 HIS C CG 31.831 -0.177 12.207 1 4433 +ATOM C CD2 . HIS C 2 203 . 203 HIS C CD2 31.75 -0.219 10.858 1 4434 +ATOM N ND1 . HIS C 2 203 . 203 HIS C ND1 32.578 0.942 12.517 1 4435 +ATOM C CE1 . HIS C 2 203 . 203 HIS C CE1 32.942 1.545 11.404 1 4436 +ATOM N NE2 . HIS C 2 203 . 203 HIS C NE2 32.446 0.854 10.372 1 4437 +ATOM N N . VAL C 2 204 . 204 VAL C N 31.046 -4.25 12.842 1 4438 +ATOM C CA . VAL C 2 204 . 204 VAL C CA 31.493 -5.599 12.378 1 4439 +ATOM C C . VAL C 2 204 . 204 VAL C C 30.966 -6.652 13.362 1 4440 +ATOM O O . VAL C 2 204 . 204 VAL C O 31.112 -6.434 14.579 1 4441 +ATOM C CB . VAL C 2 204 . 204 VAL C CB 33.03 -5.677 12.277 1 4442 +ATOM C CG1 . VAL C 2 204 . 204 VAL C CG1 33.502 -7.068 11.874 1 4443 +ATOM C CG2 . VAL C 2 204 . 204 VAL C CG2 33.597 -4.627 11.336 1 4444 +ATOM N N . SER C 2 205 . 205 SER C N 30.399 -7.752 12.856 1 4445 +ATOM C CA . SER C 2 205 . 205 SER C CA 30.055 -8.961 13.651 1 4446 +ATOM C C . SER C 2 205 . 205 SER C C 31.317 -9.798 13.877 1 4447 +ATOM O O . SER C 2 205 . 205 SER C O 32.05 -10.046 12.905 1 4448 +ATOM C CB . SER C 2 205 . 205 SER C CB 28.981 -9.792 13.006 1 4449 +ATOM O OG . SER C 2 205 . 205 SER C OG 28.825 -11.024 13.702 1 4450 +ATOM N N . ASN C 2 206 . 206 ASN C N 31.528 -10.239 15.117 1 4451 +ATOM C CA . ASN C 2 206 . 206 ASN C CA 32.724 -11.0 15.56 1 4452 +ATOM C C . ASN C 2 206 . 206 ASN C C 32.703 -12.427 14.998 1 4453 +ATOM O O . ASN C 2 206 . 206 ASN C O 33.703 -13.138 15.212 1 4454 +ATOM C CB . ASN C 2 206 . 206 ASN C CB 32.813 -11.041 17.088 1 4455 +ATOM C CG . ASN C 2 206 . 206 ASN C CG 31.622 -11.73 17.723 1 4456 +ATOM N ND2 . ASN C 2 206 . 206 ASN C ND2 31.874 -12.715 18.57 1 4457 +ATOM O OD1 . ASN C 2 206 . 206 ASN C OD1 30.477 -11.375 17.445 1 4458 +ATOM N N . VAL C 2 207 . 207 VAL C N 31.616 -12.847 14.335 1 4459 +ATOM C CA . VAL C 2 207 . 207 VAL C CA 31.482 -14.218 13.751 1 4460 +ATOM C C . VAL C 2 207 . 207 VAL C C 31.326 -14.143 12.222 1 4461 +ATOM O O . VAL C 2 207 . 207 VAL C O 31.065 -15.201 11.609 1 4462 +ATOM C CB . VAL C 2 207 . 207 VAL C CB 30.328 -15.003 14.411 1 4463 +ATOM C CG1 . VAL C 2 207 . 207 VAL C CG1 30.614 -15.286 15.88 1 4464 +ATOM C CG2 . VAL C 2 207 . 207 VAL C CG2 28.982 -14.312 14.255 1 4465 +ATOM N N . VAL C 2 208 . 208 VAL C N 31.508 -12.966 11.613 1 4466 +ATOM C CA . VAL C 2 208 . 208 VAL C CA 31.306 -12.764 10.144 1 4467 +ATOM C C . VAL C 2 208 . 208 VAL C C 32.52 -12.028 9.561 1 4468 +ATOM O O . VAL C 2 208 . 208 VAL C O 33.143 -12.571 8.629 1 4469 +ATOM C CB . VAL C 2 208 . 208 VAL C CB 29.987 -12.02 9.851 1 4470 +ATOM C CG1 . VAL C 2 208 . 208 VAL C CG1 29.733 -11.884 8.356 1 4471 +ATOM C CG2 . VAL C 2 208 . 208 VAL C CG2 28.799 -12.691 10.526 1 4472 +ATOM N N . GLY C 2 209 . 209 GLY C N 32.829 -10.835 10.078 1 4473 +ATOM C CA . GLY C 2 209 . 209 GLY C CA 33.994 -10.029 9.666 1 4474 +ATOM C C . GLY C 2 209 . 209 GLY C C 33.643 -8.978 8.626 1 4475 +ATOM O O . GLY C 2 209 . 209 GLY C O 34.537 -8.197 8.269 1 4476 +ATOM N N . THR C 2 210 . 210 THR C N 32.395 -8.944 8.15 1 4477 +ATOM C CA . THR C 2 210 . 210 THR C CA 31.931 -7.991 7.106 1 4478 +ATOM C C . THR C 2 210 . 210 THR C C 31.75 -6.603 7.735 1 4479 +ATOM O O . THR C 2 210 . 210 THR C O 31.174 -6.517 8.839 1 4480 +ATOM C CB . THR C 2 210 . 210 THR C CB 30.652 -8.489 6.42 1 4481 +ATOM C CG2 . THR C 2 210 . 210 THR C CG2 30.113 -7.518 5.392 1 4482 +ATOM O OG1 . THR C 2 210 . 210 THR C OG1 30.964 -9.734 5.795 1 4483 +ATOM N N . ILE C 2 211 . 211 ILE C N 32.228 -5.56 7.052 1 4484 +ATOM C CA . ILE C 2 211 . 211 ILE C CA 32.086 -4.14 7.492 1 4485 +ATOM C C . ILE C 2 211 . 211 ILE C C 30.692 -3.657 7.077 1 4486 +ATOM O O . ILE C 2 211 . 211 ILE C O 30.334 -3.817 5.896 1 4487 +ATOM C CB . ILE C 2 211 . 211 ILE C CB 33.215 -3.257 6.92 1 4488 +ATOM C CG1 . ILE C 2 211 . 211 ILE C CG1 34.586 -3.745 7.397 1 4489 +ATOM C CG2 . ILE C 2 211 . 211 ILE C CG2 32.982 -1.787 7.262 1 4490 +ATOM C CD1 . ILE C 2 211 . 211 ILE C CD1 35.758 -3.023 6.778 1 4491 +ATOM N N . HIS C 2 212 . 212 HIS C N 29.934 -3.105 8.028 1 4492 +ATOM C CA . HIS C 2 212 . 212 HIS C CA 28.547 -2.618 7.816 1 4493 +ATOM C C . HIS C 2 212 . 212 HIS C C 28.582 -1.145 7.422 1 4494 +ATOM O O . HIS C 2 212 . 212 HIS C O 29.513 -0.426 7.779 1 4495 +ATOM C CB . HIS C 2 212 . 212 HIS C CB 27.685 -2.914 9.049 1 4496 +ATOM C CG . HIS C 2 212 . 212 HIS C CG 27.392 -4.367 9.215 1 4497 +ATOM C CD2 . HIS C 2 212 . 212 HIS C CD2 27.117 -5.324 8.303 1 4498 +ATOM N ND1 . HIS C 2 212 . 212 HIS C ND1 27.377 -4.989 10.453 1 4499 +ATOM C CE1 . HIS C 2 212 . 212 HIS C CE1 27.094 -6.266 10.293 1 4500 +ATOM N NE2 . HIS C 2 212 . 212 HIS C NE2 26.929 -6.496 8.981 1 4501 +ATOM N N . PRO C 2 213 . 213 PRO C N 27.582 -0.67 6.642 1 4502 +ATOM C CA . PRO C 2 213 . 213 PRO C CA 27.532 0.722 6.199 1 4503 +ATOM C C . PRO C 2 213 . 213 PRO C C 26.879 1.618 7.262 1 4504 +ATOM O O . PRO C 2 213 . 213 PRO C O 25.734 1.987 7.097 1 4505 +ATOM C CB . PRO C 2 213 . 213 PRO C CB 26.692 0.603 4.918 1 4506 +ATOM C CG . PRO C 2 213 . 213 PRO C CG 25.689 -0.49 5.246 1 4507 +ATOM C CD . PRO C 2 213 . 213 PRO C CD 26.447 -1.459 6.131 1 4508 +ATOM N N . ILE C 2 214 . 214 ILE C N 27.63 1.953 8.316 1 4509 +ATOM C CA . ILE C 2 214 . 214 ILE C CA 27.113 2.631 9.544 1 4510 +ATOM C C . ILE C 2 214 . 214 ILE C C 26.687 4.066 9.207 1 4511 +ATOM O O . ILE C 2 214 . 214 ILE C O 25.632 4.496 9.719 1 4512 +ATOM C CB . ILE C 2 214 . 214 ILE C CB 28.158 2.578 10.68 1 4513 +ATOM C CG1 . ILE C 2 214 . 214 ILE C CG1 28.519 1.139 11.063 1 4514 +ATOM C CG2 . ILE C 2 214 . 214 ILE C CG2 27.688 3.374 11.886 1 4515 +ATOM C CD1 . ILE C 2 214 . 214 ILE C CD1 27.349 0.312 11.566 1 4516 +ATOM N N . ARG C 2 215 . 215 ARG C N 27.468 4.78 8.389 1 4517 +ATOM C CA . ARG C 2 215 . 215 ARG C CA 27.162 6.177 7.97 1 4518 +ATOM C C . ARG C 2 215 . 215 ARG C C 25.792 6.209 7.276 1 4519 +ATOM O O . ARG C 2 215 . 215 ARG C O 24.97 7.071 7.639 1 4520 +ATOM C CB . ARG C 2 215 . 215 ARG C CB 28.259 6.735 7.054 1 4521 +ATOM C CG . ARG C 2 215 . 215 ARG C CG 28.076 8.204 6.689 1 4522 +ATOM C CD . ARG C 2 215 . 215 ARG C CD 29.038 8.693 5.615 1 4523 +ATOM N NE . ARG C 2 215 . 215 ARG C NE 28.826 8.045 4.322 1 4524 +ATOM C CZ . ARG C 2 215 . 215 ARG C CZ 29.688 7.233 3.702 1 4525 +ATOM N NH1 . ARG C 2 215 . 215 ARG C NH1 30.868 6.951 4.235 1 4526 +ATOM N NH2 . ARG C 2 215 . 215 ARG C NH2 29.365 6.706 2.531 1 4527 +ATOM N N . GLU C 2 216 . 216 GLU C N 25.561 5.301 6.32 1 4528 +ATOM C CA . GLU C 2 216 . 216 GLU C CA 24.307 5.226 5.515 1 4529 +ATOM C C . GLU C 2 216 . 216 GLU C C 23.139 4.788 6.412 1 4530 +ATOM O O . GLU C 2 216 . 216 GLU C O 22.025 5.293 6.198 1 4531 +ATOM C CB . GLU C 2 216 . 216 GLU C CB 24.456 4.276 4.323 1 4532 +ATOM C CG . GLU C 2 216 . 216 GLU C CG 25.445 4.75 3.27 1 4533 +ATOM C CD . GLU C 2 216 . 216 GLU C CD 26.869 4.248 3.451 1 4534 +ATOM O OE1 . GLU C 2 216 . 216 GLU C OE1 27.291 4.058 4.616 1 4535 +ATOM O OE2 . GLU C 2 216 . 216 GLU C OE2 27.556 4.045 2.43 1 4536 +ATOM N N . ILE C 2 217 . 217 ILE C N 23.385 3.889 7.373 1 4537 +ATOM C CA . ILE C 2 217 . 217 ILE C CA 22.376 3.428 8.376 1 4538 +ATOM C C . ILE C 2 217 . 217 ILE C C 21.956 4.622 9.245 1 4539 +ATOM O O . ILE C 2 217 . 217 ILE C O 20.74 4.801 9.436 1 4540 +ATOM C CB . ILE C 2 217 . 217 ILE C CB 22.913 2.244 9.211 1 4541 +ATOM C CG1 . ILE C 2 217 . 217 ILE C CG1 22.999 0.967 8.37 1 4542 +ATOM C CG2 . ILE C 2 217 . 217 ILE C CG2 22.077 2.025 10.466 1 4543 +ATOM C CD1 . ILE C 2 217 . 217 ILE C CD1 23.933 -0.082 8.927 1 4544 +ATOM N N . ALA C 2 218 . 218 ALA C N 22.925 5.411 9.726 1 4545 +ATOM C CA . ALA C 2 218 . 218 ALA C CA 22.721 6.62 10.562 1 4546 +ATOM C C . ALA C 2 218 . 218 ALA C C 21.854 7.644 9.818 1 4547 +ATOM O O . ALA C 2 218 . 218 ALA C O 20.932 8.196 10.442 1 4548 +ATOM C CB . ALA C 2 218 . 218 ALA C CB 24.054 7.209 10.949 1 4549 +ATOM N N . ASP C 2 219 . 219 ASP C N 22.139 7.888 8.535 1 4550 +ATOM C CA . ASP C 2 219 . 219 ASP C CA 21.36 8.812 7.664 1 4551 +ATOM C C . ASP C 2 219 . 219 ASP C C 19.884 8.396 7.667 1 4552 +ATOM O O . ASP C 2 219 . 219 ASP C O 19.017 9.284 7.799 1 4553 +ATOM C CB . ASP C 2 219 . 219 ASP C CB 21.896 8.825 6.229 1 4554 +ATOM C CG . ASP C 2 219 . 219 ASP C CG 23.305 9.377 6.085 1 4555 +ATOM O OD1 . ASP C 2 219 . 219 ASP C OD1 23.692 10.245 6.894 1 4556 +ATOM O OD2 . ASP C 2 219 . 219 ASP C OD2 24.008 8.929 5.162 1 4557 +ATOM N N . VAL C 2 220 . 220 VAL C N 19.612 7.094 7.532 1 4558 +ATOM C CA . VAL C 2 220 . 220 VAL C CA 18.232 6.528 7.454 1 4559 +ATOM C C . VAL C 2 220 . 220 VAL C C 17.569 6.63 8.837 1 4560 +ATOM O O . VAL C 2 220 . 220 VAL C O 16.379 6.995 8.892 1 4561 +ATOM C CB . VAL C 2 220 . 220 VAL C CB 18.247 5.084 6.911 1 4562 +ATOM C CG1 . VAL C 2 220 . 220 VAL C CG1 16.869 4.445 6.941 1 4563 +ATOM C CG2 . VAL C 2 220 . 220 VAL C CG2 18.815 5.025 5.5 1 4564 +ATOM N N . VAL C 2 221 . 221 VAL C N 18.299 6.34 9.917 1 4565 +ATOM C CA . VAL C 2 221 . 221 VAL C CA 17.749 6.398 11.307 1 4566 +ATOM C C . VAL C 2 221 . 221 VAL C C 17.35 7.847 11.623 1 4567 +ATOM O O . VAL C 2 221 . 221 VAL C O 16.282 8.041 12.229 1 4568 +ATOM C CB . VAL C 2 221 . 221 VAL C CB 18.738 5.837 12.347 1 4569 +ATOM C CG1 . VAL C 2 221 . 221 VAL C CG1 18.268 6.094 13.773 1 4570 +ATOM C CG2 . VAL C 2 221 . 221 VAL C CG2 18.983 4.352 12.137 1 4571 +ATOM N N . HIS C 2 222 . 222 HIS C N 18.16 8.823 11.202 1 4572 +ATOM C CA . HIS C 2 222 . 222 HIS C CA 17.999 10.259 11.557 1 4573 +ATOM C C . HIS C 2 222 . 222 HIS C C 16.911 10.933 10.713 1 4574 +ATOM O O . HIS C 2 222 . 222 HIS C O 16.644 12.114 10.985 1 4575 +ATOM C CB . HIS C 2 222 . 222 HIS C CB 19.349 10.984 11.492 1 4576 +ATOM C CG . HIS C 2 222 . 222 HIS C CG 20.299 10.501 12.535 1 4577 +ATOM C CD2 . HIS C 2 222 . 222 HIS C CD2 20.085 10.057 13.792 1 4578 +ATOM N ND1 . HIS C 2 222 . 222 HIS C ND1 21.655 10.39 12.312 1 4579 +ATOM C CE1 . HIS C 2 222 . 222 HIS C CE1 22.235 9.92 13.397 1 4580 +ATOM N NE2 . HIS C 2 222 . 222 HIS C NE2 21.296 9.703 14.316 1 4581 +ATOM N N . THR C 2 223 . 223 THR C N 16.281 10.216 9.769 1 4582 +ATOM C CA . THR C 2 223 . 223 THR C CA 15.04 10.667 9.076 1 4583 +ATOM C C . THR C 2 223 . 223 THR C C 13.884 10.656 10.087 1 4584 +ATOM O O . THR C 2 223 . 223 THR C O 12.923 11.421 9.892 1 4585 +ATOM C CB . THR C 2 223 . 223 THR C CB 14.714 9.823 7.831 1 4586 +ATOM C CG2 . THR C 2 223 . 223 THR C CG2 15.802 9.862 6.778 1 4587 +ATOM O OG1 . THR C 2 223 . 223 THR C OG1 14.478 8.471 8.226 1 4588 +ATOM N N . ILE C 2 224 . 224 ILE C N 13.981 9.822 11.13 1 4589 +ATOM C CA . ILE C 2 224 . 224 ILE C CA 13.01 9.779 12.263 1 4590 +ATOM C C . ILE C 2 224 . 224 ILE C C 13.531 10.685 13.376 1 4591 +ATOM O O . ILE C 2 224 . 224 ILE C O 14.477 10.335 14.08 1 4592 +ATOM C CB . ILE C 2 224 . 224 ILE C CB 12.765 8.337 12.748 1 4593 +ATOM C CG1 . ILE C 2 224 . 224 ILE C CG1 12.422 7.388 11.592 1 4594 +ATOM C CG2 . ILE C 2 224 . 224 ILE C CG2 11.694 8.316 13.829 1 4595 +ATOM C CD1 . ILE C 2 224 . 224 ILE C CD1 11.259 7.843 10.729 1 4596 +ATOM N N . PRO C 2 225 . 225 PRO C N 12.953 11.894 13.55 1 4597 +ATOM C CA . PRO C 2 225 . 225 PRO C CA 13.334 12.774 14.655 1 4598 +ATOM C C . PRO C 2 225 . 225 PRO C C 13.077 12.111 16.021 1 4599 +ATOM O O . PRO C 2 225 . 225 PRO C O 12.039 11.494 16.203 1 4600 +ATOM C CB . PRO C 2 225 . 225 PRO C CB 12.477 14.033 14.444 1 4601 +ATOM C CG . PRO C 2 225 . 225 PRO C CG 11.35 13.58 13.539 1 4602 +ATOM C CD . PRO C 2 225 . 225 PRO C CD 11.933 12.49 12.675 1 4603 +ATOM N N . GLY C 2 226 . 226 GLY C N 14.049 12.226 16.932 1 4604 +ATOM C CA . GLY C 2 226 . 226 GLY C CA 14.037 11.573 18.255 1 4605 +ATOM C C . GLY C 2 226 . 226 GLY C C 14.76 10.234 18.255 1 4606 +ATOM O O . GLY C 2 226 . 226 GLY C O 15.146 9.79 19.347 1 4607 +ATOM N N . ALA C 2 227 . 227 ALA C N 14.945 9.609 17.087 1 4608 +ATOM C CA . ALA C 2 227 . 227 ALA C CA 15.577 8.274 16.932 1 4609 +ATOM C C . ALA C 2 227 . 227 ALA C C 17.098 8.389 17.095 1 4610 +ATOM O O . ALA C 2 227 . 227 ALA C O 17.7 9.316 16.511 1 4611 +ATOM C CB . ALA C 2 227 . 227 ALA C CB 15.219 7.674 15.592 1 4612 +ATOM N N . MET C 2 228 . 228 MET C N 17.697 7.464 17.848 1 4613 +ATOM C CA . MET C 2 228 . 228 MET C CA 19.165 7.415 18.082 1 4614 +ATOM C C . MET C 2 228 . 228 MET C C 19.7 6.059 17.607 1 4615 +ATOM O O . MET C 2 228 . 228 MET C O 18.975 5.049 17.747 1 4616 +ATOM C CB . MET C 2 228 . 228 MET C CB 19.512 7.661 19.557 1 4617 +ATOM C CG . MET C 2 228 . 228 MET C CG 19.017 6.597 20.519 1 4618 +ATOM S SD . MET C 2 228 . 228 MET C SD 18.737 7.231 22.204 1 4619 +ATOM C CE . MET C 2 228 . 228 MET C CE 17.142 8.022 21.998 1 4620 +ATOM N N . LEU C 2 229 . 229 LEU C N 20.904 6.059 17.027 1 4621 +ATOM C CA . LEU C 2 229 . 229 LEU C CA 21.62 4.842 16.565 1 4622 +ATOM C C . LEU C 2 229 . 229 LEU C C 22.729 4.508 17.567 1 4623 +ATOM O O . LEU C 2 229 . 229 LEU C O 23.546 5.399 17.864 1 4624 +ATOM C CB . LEU C 2 229 . 229 LEU C CB 22.186 5.08 15.159 1 4625 +ATOM C CG . LEU C 2 229 . 229 LEU C CG 23.025 3.937 14.584 1 4626 +ATOM C CD1 . LEU C 2 229 . 229 LEU C CD1 22.168 2.707 14.32 1 4627 +ATOM C CD2 . LEU C 2 229 . 229 LEU C CD2 23.736 4.37 13.31 1 4628 +ATOM N N . ILE C 2 230 . 230 ILE C N 22.745 3.265 18.054 1 4629 +ATOM C CA . ILE C 2 230 . 230 ILE C CA 23.758 2.721 19.006 1 4630 +ATOM C C . ILE C 2 230 . 230 ILE C C 24.571 1.653 18.271 1 4631 +ATOM O O . ILE C 2 230 . 230 ILE C O 23.958 0.657 17.82 1 4632 +ATOM C CB . ILE C 2 230 . 230 ILE C CB 23.07 2.151 20.26 1 4633 +ATOM C CG1 . ILE C 2 230 . 230 ILE C CG1 22.142 3.18 20.914 1 4634 +ATOM C CG2 . ILE C 2 230 . 230 ILE C CG2 24.107 1.621 21.237 1 4635 +ATOM C CD1 . ILE C 2 230 . 230 ILE C CD1 21.175 2.59 21.916 1 4636 +ATOM N N . VAL C 2 231 . 231 VAL C N 25.888 1.846 18.149 1 4637 +ATOM C CA . VAL C 2 231 . 231 VAL C CA 26.776 0.947 17.355 1 4638 +ATOM C C . VAL C 2 231 . 231 VAL C C 27.831 0.311 18.268 1 4639 +ATOM O O . VAL C 2 231 . 231 VAL C O 28.595 1.045 18.913 1 4640 +ATOM C CB . VAL C 2 231 . 231 VAL C CB 27.429 1.687 16.174 1 4641 +ATOM C CG1 . VAL C 2 231 . 231 VAL C CG1 28.318 0.759 15.356 1 4642 +ATOM C CG2 . VAL C 2 231 . 231 VAL C CG2 26.391 2.349 15.285 1 4643 +ATOM N N . ASP C 2 232 . 232 ASP C N 27.864 -1.023 18.274 1 4644 +ATOM C CA . ASP C 2 232 . 232 ASP C CA 28.937 -1.874 18.85 1 4645 +ATOM C C . ASP C 2 232 . 232 ASP C C 30.092 -1.938 17.838 1 4646 +ATOM O O . ASP C 2 232 . 232 ASP C O 29.937 -2.614 16.799 1 4647 +ATOM C CB . ASP C 2 232 . 232 ASP C CB 28.356 -3.246 19.205 1 4648 +ATOM C CG . ASP C 2 232 . 232 ASP C CG 29.361 -4.331 19.559 1 4649 +ATOM O OD1 . ASP C 2 232 . 232 ASP C OD1 30.581 -4.067 19.479 1 4650 +ATOM O OD2 . ASP C 2 232 . 232 ASP C OD2 28.908 -5.445 19.895 1 4651 +ATOM N N . GLY C 2 233 . 233 GLY C N 31.205 -1.257 18.129 1 4652 +ATOM C CA . GLY C 2 233 . 233 GLY C CA 32.411 -1.249 17.278 1 4653 +ATOM C C . GLY C 2 233 . 233 GLY C C 33.562 -2.024 17.898 1 4654 +ATOM O O . GLY C 2 233 . 233 GLY C O 34.712 -1.606 17.703 1 4655 +ATOM N N . VAL C 2 234 . 234 VAL C N 33.282 -3.118 18.608 1 4656 +ATOM C CA . VAL C 2 234 . 234 VAL C CA 34.322 -3.931 19.309 1 4657 +ATOM C C . VAL C 2 234 . 234 VAL C C 35.185 -4.662 18.265 1 4658 +ATOM O O . VAL C 2 234 . 234 VAL C O 36.402 -4.807 18.508 1 4659 +ATOM C CB . VAL C 2 234 . 234 VAL C CB 33.694 -4.897 20.335 1 4660 +ATOM C CG1 . VAL C 2 234 . 234 VAL C CG1 34.714 -5.875 20.895 1 4661 +ATOM C CG2 . VAL C 2 234 . 234 VAL C CG2 33.013 -4.149 21.473 1 4662 +ATOM N N . ALA C 2 235 . 235 ALA C N 34.596 -5.076 17.135 1 4663 +ATOM C CA . ALA C 2 235 . 235 ALA C CA 35.261 -5.881 16.08 1 4664 +ATOM C C . ALA C 2 235 . 235 ALA C C 35.68 -5.008 14.886 1 4665 +ATOM O O . ALA C 2 235 . 235 ALA C O 36.109 -5.588 13.868 1 4666 +ATOM C CB . ALA C 2 235 . 235 ALA C CB 34.347 -7.001 15.647 1 4667 +ATOM N N . SER C 2 236 . 236 SER C N 35.585 -3.678 15.002 1 4668 +ATOM C CA . SER C 2 236 . 236 SER C CA 35.989 -2.707 13.949 1 4669 +ATOM C C . SER C 2 236 . 236 SER C C 37.217 -1.893 14.383 1 4670 +ATOM O O . SER C 2 236 . 236 SER C O 38.166 -1.79 13.578 1 4671 +ATOM C CB . SER C 2 236 . 236 SER C CB 34.843 -1.803 13.593 1 4672 +ATOM O OG . SER C 2 236 . 236 SER C OG 35.186 -0.978 12.489 1 4673 +ATOM N N . VAL C 2 237 . 237 VAL C N 37.204 -1.352 15.608 1 4674 +ATOM C CA . VAL C 2 237 . 237 VAL C CA 38.183 -0.333 16.102 1 4675 +ATOM C C . VAL C 2 237 . 237 VAL C C 39.606 -0.899 16.104 1 4676 +ATOM O O . VAL C 2 237 . 237 VAL C O 40.546 -0.153 15.849 1 4677 +ATOM C CB . VAL C 2 237 . 237 VAL C CB 37.776 0.228 17.48 1 4678 +ATOM C CG1 . VAL C 2 237 . 237 VAL C CG1 38.838 1.149 18.068 1 4679 +ATOM C CG2 . VAL C 2 237 . 237 VAL C CG2 36.448 0.963 17.411 1 4680 +ATOM N N . PRO C 2 238 . 238 PRO C N 39.842 -2.2 16.4 1 4681 +ATOM C CA . PRO C 2 238 . 238 PRO C CA 41.183 -2.777 16.27 1 4682 +ATOM C C . PRO C 2 238 . 238 PRO C C 41.745 -2.828 14.837 1 4683 +ATOM O O . PRO C 2 238 . 238 PRO C O 42.952 -2.833 14.7 1 4684 +ATOM C CB . PRO C 2 238 . 238 PRO C CB 41.022 -4.219 16.785 1 4685 +ATOM C CG . PRO C 2 238 . 238 PRO C CG 39.78 -4.172 17.646 1 4686 +ATOM C CD . PRO C 2 238 . 238 PRO C CD 38.881 -3.164 16.957 1 4687 +ATOM N N . HIS C 2 239 . 239 HIS C N 40.875 -2.869 13.82 1 4688 +ATOM C CA . HIS C 2 239 . 239 HIS C CA 41.228 -3.211 12.416 1 4689 +ATOM C C . HIS C 2 239 . 239 HIS C C 41.215 -1.979 11.502 1 4690 +ATOM O O . HIS C 2 239 . 239 HIS C O 41.928 -2.011 10.476 1 4691 +ATOM C CB . HIS C 2 239 . 239 HIS C CB 40.276 -4.285 11.881 1 4692 +ATOM C CG . HIS C 2 239 . 239 HIS C CG 40.257 -5.535 12.69 1 4693 +ATOM C CD2 . HIS C 2 239 . 239 HIS C CD2 39.349 -6.012 13.565 1 4694 +ATOM N ND1 . HIS C 2 239 . 239 HIS C ND1 41.268 -6.472 12.631 1 4695 +ATOM C CE1 . HIS C 2 239 . 239 HIS C CE1 40.982 -7.47 13.441 1 4696 +ATOM N NE2 . HIS C 2 239 . 239 HIS C NE2 39.812 -7.21 14.027 1 4697 +ATOM N N . ARG C 2 240 . 240 ARG C N 40.419 -0.955 11.823 1 4698 +ATOM C CA . ARG C 2 240 . 240 ARG C CA 40.188 0.218 10.934 1 4699 +ATOM C C . ARG C 2 240 . 240 ARG C C 40.154 1.503 11.755 1 4700 +ATOM O O . ARG C 2 240 . 240 ARG C O 39.696 1.496 12.898 1 4701 +ATOM C CB . ARG C 2 240 . 240 ARG C CB 38.861 0.08 10.177 1 4702 +ATOM C CG . ARG C 2 240 . 240 ARG C CG 38.783 -1.112 9.233 1 4703 +ATOM C CD . ARG C 2 240 . 240 ARG C CD 39.668 -0.974 8.007 1 4704 +ATOM N NE . ARG C 2 240 . 240 ARG C NE 39.532 -2.144 7.148 1 4705 +ATOM C CZ . ARG C 2 240 . 240 ARG C CZ 40.218 -3.282 7.273 1 4706 +ATOM N NH1 . ARG C 2 240 . 240 ARG C NH1 41.126 -3.432 8.224 1 4707 +ATOM N NH2 . ARG C 2 240 . 240 ARG C NH2 39.988 -4.275 6.432 1 4708 +ATOM N N . PRO C 2 241 . 241 PRO C N 40.602 2.646 11.186 1 4709 +ATOM C CA . PRO C 2 241 . 241 PRO C CA 40.42 3.949 11.826 1 4710 +ATOM C C . PRO C 2 241 . 241 PRO C C 38.925 4.295 11.859 1 4711 +ATOM O O . PRO C 2 241 . 241 PRO C O 38.234 3.952 10.922 1 4712 +ATOM C CB . PRO C 2 241 . 241 PRO C CB 41.193 4.923 10.926 1 4713 +ATOM C CG . PRO C 2 241 . 241 PRO C CG 41.184 4.244 9.57 1 4714 +ATOM C CD . PRO C 2 241 . 241 PRO C CD 41.259 2.76 9.875 1 4715 +ATOM N N . VAL C 2 242 . 242 VAL C N 38.468 4.943 12.932 1 4716 +ATOM C CA . VAL C 2 242 . 242 VAL C CA 37.034 5.3 13.141 1 4717 +ATOM C C . VAL C 2 242 . 242 VAL C C 36.94 6.789 13.493 1 4718 +ATOM O O . VAL C 2 242 . 242 VAL C O 37.767 7.266 14.3 1 4719 +ATOM C CB . VAL C 2 242 . 242 VAL C CB 36.383 4.405 14.214 1 4720 +ATOM C CG1 . VAL C 2 242 . 242 VAL C CG1 34.955 4.832 14.52 1 4721 +ATOM C CG2 . VAL C 2 242 . 242 VAL C CG2 36.423 2.939 13.808 1 4722 +ATOM N N . ASP C 2 243 . 243 ASP C N 35.977 7.477 12.871 1 4723 +ATOM C CA . ASP C 2 243 . 243 ASP C CA 35.632 8.908 13.089 1 4724 +ATOM C C . ASP C 2 243 . 243 ASP C C 34.12 8.975 13.332 1 4725 +ATOM O O . ASP C 2 243 . 243 ASP C O 33.362 8.853 12.352 1 4726 +ATOM C CB . ASP C 2 243 . 243 ASP C CB 36.099 9.756 11.898 1 4727 +ATOM C CG . ASP C 2 243 . 243 ASP C CG 35.691 11.22 11.94 1 4728 +ATOM O OD1 . ASP C 2 243 . 243 ASP C OD1 34.885 11.587 12.815 1 4729 +ATOM O OD2 . ASP C 2 243 . 243 ASP C OD2 36.177 11.982 11.082 1 4730 +ATOM N N . VAL C 2 244 . 244 VAL C N 33.71 9.132 14.595 1 4731 +ATOM C CA . VAL C 2 244 . 244 VAL C CA 32.287 9.047 15.048 1 4732 +ATOM C C . VAL C 2 244 . 244 VAL C C 31.487 10.23 14.484 1 4733 +ATOM O O . VAL C 2 244 . 244 VAL C O 30.255 10.093 14.366 1 4734 +ATOM C CB . VAL C 2 244 . 244 VAL C CB 32.169 8.963 16.584 1 4735 +ATOM C CG1 . VAL C 2 244 . 244 VAL C CG1 32.746 7.662 17.119 1 4736 +ATOM C CG2 . VAL C 2 244 . 244 VAL C CG2 32.799 10.158 17.289 1 4737 +ATOM N N . LYS C 2 245 . 245 LYS C N 32.152 11.341 14.155 1 4738 +ATOM C CA . LYS C 2 245 . 245 LYS C CA 31.513 12.532 13.531 1 4739 +ATOM C C . LYS C 2 245 . 245 LYS C C 31.241 12.251 12.048 1 4740 +ATOM O O . LYS C 2 245 . 245 LYS C O 30.194 12.708 11.551 1 4741 +ATOM C CB . LYS C 2 245 . 245 LYS C CB 32.383 13.782 13.706 1 4742 +ATOM C CG . LYS C 2 245 . 245 LYS C CG 32.814 14.071 15.138 1 4743 +ATOM C CD . LYS C 2 245 . 245 LYS C CD 31.664 14.187 16.11 1 4744 +ATOM C CE . LYS C 2 245 . 245 LYS C CE 32.117 14.393 17.538 1 4745 +ATOM N NZ . LYS C 2 245 . 245 LYS C NZ 30.958 14.599 18.434 1 4746 +ATOM N N . GLU C 2 246 . 246 GLU C N 32.138 11.524 11.373 1 4747 +ATOM C CA . GLU C 2 246 . 246 GLU C CA 31.974 11.122 9.946 1 4748 +ATOM C C . GLU C 2 246 . 246 GLU C C 30.849 10.082 9.844 1 4749 +ATOM O O . GLU C 2 246 . 246 GLU C O 30.091 10.143 8.86 1 4750 +ATOM C CB . GLU C 2 246 . 246 GLU C CB 33.293 10.595 9.37 1 4751 +ATOM C CG . GLU C 2 246 . 246 GLU C CG 33.217 10.205 7.898 1 4752 +ATOM C CD . GLU C 2 246 . 246 GLU C CD 32.888 8.747 7.593 1 4753 +ATOM O OE1 . GLU C 2 246 . 246 GLU C OE1 33.034 7.892 8.495 1 4754 +ATOM O OE2 . GLU C 2 246 . 246 GLU C OE2 32.485 8.465 6.445 1 4755 +ATOM N N . LEU C 2 247 . 247 LEU C N 30.754 9.164 10.813 1 4756 +ATOM C CA . LEU C 2 247 . 247 LEU C CA 29.666 8.15 10.901 1 4757 +ATOM C C . LEU C 2 247 . 247 LEU C C 28.361 8.836 11.323 1 4758 +ATOM O O . LEU C 2 247 . 247 LEU C O 27.291 8.34 10.931 1 4759 +ATOM C CB . LEU C 2 247 . 247 LEU C CB 30.044 7.064 11.914 1 4760 +ATOM C CG . LEU C 2 247 . 247 LEU C CG 31.256 6.203 11.563 1 4761 +ATOM C CD1 . LEU C 2 247 . 247 LEU C CD1 31.601 5.275 12.718 1 4762 +ATOM C CD2 . LEU C 2 247 . 247 LEU C CD2 31.013 5.406 10.292 1 4763 +ATOM N N . ASP C 2 248 . 248 ASP C N 28.46 9.918 12.104 1 4764 +ATOM C CA . ASP C 2 248 . 248 ASP C CA 27.313 10.694 12.646 1 4765 +ATOM C C . ASP C 2 248 . 248 ASP C C 26.5 9.786 13.581 1 4766 +ATOM O O . ASP C 2 248 . 248 ASP C O 25.266 9.911 13.6 1 4767 +ATOM C CB . ASP C 2 248 . 248 ASP C CB 26.468 11.282 11.511 1 4768 +ATOM C CG . ASP C 2 248 . 248 ASP C CG 25.614 12.476 11.909 1 4769 +ATOM O OD1 . ASP C 2 248 . 248 ASP C OD1 25.951 13.132 12.914 1 4770 +ATOM O OD2 . ASP C 2 248 . 248 ASP C OD2 24.621 12.744 11.2 1 4771 +ATOM N N . VAL C 2 249 . 249 VAL C N 27.173 8.907 14.332 1 4772 +ATOM C CA . VAL C 2 249 . 249 VAL C CA 26.529 7.967 15.299 1 4773 +ATOM C C . VAL C 2 249 . 249 VAL C C 26.181 8.734 16.58 1 4774 +ATOM O O . VAL C 2 249 . 249 VAL C O 26.932 9.654 16.952 1 4775 +ATOM C CB . VAL C 2 249 . 249 VAL C CB 27.408 6.736 15.603 1 4776 +ATOM C CG1 . VAL C 2 249 . 249 VAL C CG1 27.398 5.748 14.447 1 4777 +ATOM C CG2 . VAL C 2 249 . 249 VAL C CG2 28.834 7.109 15.986 1 4778 +ATOM N N . ASP C 2 250 . 250 ASP C N 25.069 8.368 17.221 1 4779 +ATOM C CA . ASP C 2 250 . 250 ASP C CA 24.66 8.902 18.546 1 4780 +ATOM C C . ASP C 2 250 . 250 ASP C C 25.548 8.273 19.62 1 4781 +ATOM O O . ASP C 2 250 . 250 ASP C O 25.979 9.0 20.527 1 4782 +ATOM C CB . ASP C 2 250 . 250 ASP C CB 23.186 8.619 18.834 1 4783 +ATOM C CG . ASP C 2 250 . 250 ASP C CG 22.246 9.285 17.85 1 4784 +ATOM O OD1 . ASP C 2 250 . 250 ASP C OD1 21.865 10.447 18.097 1 4785 +ATOM O OD2 . ASP C 2 250 . 250 ASP C OD2 21.929 8.641 16.83 1 4786 +ATOM N N . PHE C 2 251 . 251 PHE C N 25.798 6.966 19.508 1 4787 +ATOM C CA . PHE C 2 251 . 251 PHE C CA 26.525 6.148 20.511 1 4788 +ATOM C C . PHE C 2 251 . 251 PHE C C 27.4 5.113 19.794 1 4789 +ATOM O O . PHE C 2 251 . 251 PHE C O 26.916 4.456 18.848 1 4790 +ATOM C CB . PHE C 2 251 . 251 PHE C CB 25.539 5.467 21.464 1 4791 +ATOM C CG . PHE C 2 251 . 251 PHE C CG 24.587 6.39 22.186 1 4792 +ATOM C CD1 . PHE C 2 251 . 251 PHE C CD1 24.94 6.975 23.394 1 4793 +ATOM C CD2 . PHE C 2 251 . 251 PHE C CD2 23.325 6.65 21.673 1 4794 +ATOM C CE1 . PHE C 2 251 . 251 PHE C CE1 24.058 7.811 24.065 1 4795 +ATOM C CE2 . PHE C 2 251 . 251 PHE C CE2 22.445 7.487 22.345 1 4796 +ATOM C CZ . PHE C 2 251 . 251 PHE C CZ 22.81 8.06 23.542 1 4797 +ATOM N N . TYR C 2 252 . 252 TYR C N 28.65 4.979 20.244 1 4798 +ATOM C CA . TYR C 2 252 . 252 TYR C CA 29.661 4.021 19.725 1 4799 +ATOM C C . TYR C 2 252 . 252 TYR C C 30.447 3.471 20.919 1 4800 +ATOM O O . TYR C 2 252 . 252 TYR C O 30.907 4.277 21.744 1 4801 +ATOM C CB . TYR C 2 252 . 252 TYR C CB 30.565 4.716 18.7 1 4802 +ATOM C CG . TYR C 2 252 . 252 TYR C CG 31.338 3.809 17.774 1 4803 +ATOM C CD1 . TYR C 2 252 . 252 TYR C CD1 32.561 3.28 18.152 1 4804 +ATOM C CD2 . TYR C 2 252 . 252 TYR C CD2 30.874 3.508 16.505 1 4805 +ATOM C CE1 . TYR C 2 252 . 252 TYR C CE1 33.291 2.462 17.305 1 4806 +ATOM C CE2 . TYR C 2 252 . 252 TYR C CE2 31.588 2.685 15.647 1 4807 +ATOM C CZ . TYR C 2 252 . 252 TYR C CZ 32.803 2.159 16.047 1 4808 +ATOM O OH . TYR C 2 252 . 252 TYR C OH 33.527 1.352 15.214 1 4809 +ATOM N N . CYS C 2 253 . 253 CYS C N 30.57 2.145 21.018 1 4810 +ATOM C CA . CYS C 2 253 . 253 CYS C CA 31.258 1.449 22.137 1 4811 +ATOM C C . CYS C 2 253 . 253 CYS C C 32.316 0.488 21.578 1 4812 +ATOM O O . CYS C 2 253 . 253 CYS C O 32.079 -0.094 20.493 1 4813 +ATOM C CB . CYS C 2 253 . 253 CYS C CB 30.256 0.704 23.011 1 4814 +ATOM S SG . CYS C 2 253 . 253 CYS C SG 30.852 0.446 24.702 1 4815 +ATOM N N . PHE C 2 254 . 254 PHE C N 33.449 0.354 22.273 1 4816 +ATOM C CA . PHE C 2 254 . 254 PHE C CA 34.542 -0.592 21.921 1 4817 +ATOM C C . PHE C 2 254 . 254 PHE C C 35.275 -1.038 23.193 1 4818 +ATOM O O . PHE C 2 254 . 254 PHE C O 35.147 -0.37 24.239 1 4819 +ATOM C CB . PHE C 2 254 . 254 PHE C CB 35.462 0.016 20.856 1 4820 +ATOM C CG . PHE C 2 254 . 254 PHE C CG 36.246 1.24 21.258 1 4821 +ATOM C CD1 . PHE C 2 254 . 254 PHE C CD1 35.691 2.508 21.163 1 4822 +ATOM C CD2 . PHE C 2 254 . 254 PHE C CD2 37.561 1.127 21.686 1 4823 +ATOM C CE1 . PHE C 2 254 . 254 PHE C CE1 36.426 3.633 21.513 1 4824 +ATOM C CE2 . PHE C 2 254 . 254 PHE C CE2 38.293 2.253 22.035 1 4825 +ATOM C CZ . PHE C 2 254 . 254 PHE C CZ 37.726 3.503 21.945 1 4826 +ATOM N N . SER C 2 255 . 255 SER C N 36.002 -2.159 23.087 1 4827 +ATOM C CA . SER C 2 255 . 255 SER C CA 36.69 -2.877 24.191 1 4828 +ATOM C C . SER C 2 255 . 255 SER C C 38.192 -2.947 23.894 1 4829 +ATOM O O . SER C 2 255 . 255 SER C O 38.559 -3.422 22.79 1 4830 +ATOM C CB . SER C 2 255 . 255 SER C CB 36.101 -4.255 24.387 1 4831 +ATOM O OG . SER C 2 255 . 255 SER C OG 34.719 -4.18 24.72 1 4832 +ATOM N N . TRP C 2 256 . 256 TRP C N 39.019 -2.513 24.851 1 4833 +ATOM C CA . TRP C 2 256 . 256 TRP C CA 40.474 -2.273 24.666 1 4834 +ATOM C C . TRP C 2 256 . 256 TRP C C 41.255 -3.593 24.595 1 4835 +ATOM O O . TRP C 2 256 . 256 TRP C O 42.329 -3.588 23.971 1 4836 +ATOM C CB . TRP C 2 256 . 256 TRP C CB 41.018 -1.343 25.758 1 4837 +ATOM C CG . TRP C 2 256 . 256 TRP C CG 40.708 0.101 25.509 1 4838 +ATOM C CD1 . TRP C 2 256 . 256 TRP C CD1 39.695 0.834 26.055 1 4839 +ATOM C CD2 . TRP C 2 256 . 256 TRP C CD2 41.41 0.987 24.62 1 4840 +ATOM C CE2 . TRP C 2 256 . 256 TRP C CE2 40.764 2.241 24.694 1 4841 +ATOM C CE3 . TRP C 2 256 . 256 TRP C CE3 42.516 0.848 23.774 1 4842 +ATOM N NE1 . TRP C 2 256 . 256 TRP C NE1 39.723 2.115 25.574 1 4843 +ATOM C CZ2 . TRP C 2 256 . 256 TRP C CZ2 41.191 3.344 23.956 1 4844 +ATOM C CZ3 . TRP C 2 256 . 256 TRP C CZ3 42.937 1.939 23.044 1 4845 +ATOM C CH2 . TRP C 2 256 . 256 TRP C CH2 42.284 3.169 23.138 1 4846 +ATOM N N . TYR C 2 257 . 257 TYR C N 40.741 -4.687 25.166 1 4847 +ATOM C CA . TYR C 2 257 . 257 TYR C CA 41.447 -5.998 25.229 1 4848 +ATOM C C . TYR C 2 257 . 257 TYR C C 41.451 -6.692 23.858 1 4849 +ATOM O O . TYR C 2 257 . 257 TYR C O 42.167 -7.704 23.71 1 4850 +ATOM C CB . TYR C 2 257 . 257 TYR C CB 40.861 -6.894 26.323 1 4851 +ATOM C CG . TYR C 2 257 . 257 TYR C CG 39.505 -7.495 26.052 1 4852 +ATOM C CD1 . TYR C 2 257 . 257 TYR C CD1 38.341 -6.815 26.372 1 4853 +ATOM C CD2 . TYR C 2 257 . 257 TYR C CD2 39.386 -8.779 25.542 1 4854 +ATOM C CE1 . TYR C 2 257 . 257 TYR C CE1 37.092 -7.377 26.158 1 4855 +ATOM C CE2 . TYR C 2 257 . 257 TYR C CE2 38.145 -9.355 25.322 1 4856 +ATOM C CZ . TYR C 2 257 . 257 TYR C CZ 36.993 -8.653 25.631 1 4857 +ATOM O OH . TYR C 2 257 . 257 TYR C OH 35.767 -9.214 25.411 1 4858 +HETATM N N . LLP C 2 258 . 258 LLP C N 40.69 -6.174 22.888 1 4859 +HETATM C CA . LLP C 2 258 . 258 LLP C CA 40.777 -6.589 21.463 1 4860 +HETATM C C . LLP C 2 258 . 258 LLP C C 41.625 -5.555 20.701 1 4861 +HETATM O O . LLP C 2 258 . 258 LLP C O 41.875 -5.779 19.502 1 4862 +HETATM C CB . LLP C 2 258 . 258 LLP C CB 39.355 -6.826 20.932 1 4863 +HETATM C CG . LLP C 2 258 . 258 LLP C CG 38.57 -7.863 21.738 1 4864 +HETATM C CD . LLP C 2 258 . 258 LLP C CD 37.189 -8.217 21.203 1 4865 +HETATM C CE . LLP C 2 258 . 258 LLP C CE 36.396 -9.121 22.142 1 4866 +HETATM N NZ . LLP C 2 258 . 258 LLP C NZ 35.133 -9.555 21.535 1 4867 +HETATM P P . LLP C 2 258 . 258 LLP C P 32.928 -6.994 26.4 1 4868 +HETATM C "C5'" . LLP C 2 258 . 258 LLP C "C5'" 32.122 -8.31 24.223 1 4869 +HETATM C "C4'" . LLP C 2 258 . 258 LLP C "C4'" 34.045 -9.51 22.226 1 4870 +HETATM C "C2'" . LLP C 2 258 . 258 LLP C "C2'" 31.248 -7.486 18.589 1 4871 +HETATM N N1 . LLP C 2 258 . 258 LLP C N1 30.698 -7.214 20.94 1 4872 +HETATM C C2 . LLP C 2 258 . 258 LLP C C2 31.526 -7.734 20.037 1 4873 +HETATM C C3 . LLP C 2 258 . 258 LLP C C3 32.634 -8.499 20.442 1 4874 +HETATM O O3 . LLP C 2 258 . 258 LLP C O3 33.448 -9.006 19.489 1 4875 +HETATM C C4 . LLP C 2 258 . 258 LLP C C4 32.875 -8.716 21.811 1 4876 +HETATM C C5 . LLP C 2 258 . 258 LLP C C5 31.975 -8.152 22.736 1 4877 +HETATM C C6 . LLP C 2 258 . 258 LLP C C6 30.924 -7.42 22.253 1 4878 +HETATM O OP1 . LLP C 2 258 . 258 LLP C OP1 33.823 -5.776 26.57 1 4879 +HETATM O OP2 . LLP C 2 258 . 258 LLP C OP2 33.623 -8.301 26.73 1 4880 +HETATM O OP3 . LLP C 2 258 . 258 LLP C OP3 31.592 -6.852 27.101 1 4881 +HETATM O OP4 . LLP C 2 258 . 258 LLP C OP4 32.586 -7.062 24.826 1 4882 +ATOM N N . LEU C 2 259 . 259 LEU C N 42.125 -4.53 21.408 1 4883 +ATOM C CA . LEU C 2 259 . 259 LEU C CA 42.882 -3.353 20.886 1 4884 +ATOM C C . LEU C 2 259 . 259 LEU C C 44.269 -3.271 21.542 1 4885 +ATOM O O . LEU C 2 259 . 259 LEU C O 44.722 -2.138 21.816 1 4886 +ATOM C CB . LEU C 2 259 . 259 LEU C CB 42.083 -2.085 21.214 1 4887 +ATOM C CG . LEU C 2 259 . 259 LEU C CG 41.388 -1.396 20.046 1 4888 +ATOM C CD1 . LEU C 2 259 . 259 LEU C CD1 39.9 -1.298 20.314 1 4889 +ATOM C CD2 . LEU C 2 259 . 259 LEU C CD2 41.991 -0.021 19.79 1 4890 +ATOM N N . PHE C 2 260 . 260 PHE C N 44.92 -4.409 21.801 1 4891 +ATOM C CA . PHE C 2 260 . 260 PHE C CA 46.3 -4.475 22.351 1 4892 +ATOM C C . PHE C 2 260 . 260 PHE C C 46.338 -3.805 23.735 1 4893 +ATOM O O . PHE C 2 260 . 260 PHE C O 47.387 -3.258 24.133 1 4894 +ATOM C CB . PHE C 2 260 . 260 PHE C CB 47.278 -3.816 21.372 1 4895 +ATOM C CG . PHE C 2 260 . 260 PHE C CG 47.04 -4.16 19.922 1 4896 +ATOM C CD1 . PHE C 2 260 . 260 PHE C CD1 47.429 -5.391 19.416 1 4897 +ATOM C CD2 . PHE C 2 260 . 260 PHE C CD2 46.413 -3.263 19.067 1 4898 +ATOM C CE1 . PHE C 2 260 . 260 PHE C CE1 47.204 -5.713 18.087 1 4899 +ATOM C CE2 . PHE C 2 260 . 260 PHE C CE2 46.183 -3.592 17.74 1 4900 +ATOM C CZ . PHE C 2 260 . 260 PHE C CZ 46.58 -4.815 17.253 1 4901 +ATOM N N . GLY C 2 261 . 261 GLY C N 45.219 -3.861 24.461 1 4902 +ATOM C CA . GLY C 2 261 . 261 GLY C CA 45.006 -3.09 25.698 1 4903 +ATOM C C . GLY C 2 261 . 261 GLY C C 44.352 -3.927 26.791 1 4904 +ATOM O O . GLY C 2 261 . 261 GLY C O 44.237 -5.145 26.667 1 4905 +ATOM N N . PRO C 2 262 . 262 PRO C N 43.926 -3.284 27.902 1 4906 +ATOM C CA . PRO C 2 262 . 262 PRO C CA 43.314 -3.985 29.032 1 4907 +ATOM C C . PRO C 2 262 . 262 PRO C C 41.827 -4.316 28.817 1 4908 +ATOM O O . PRO C 2 262 . 262 PRO C O 41.222 -3.758 27.917 1 4909 +ATOM C CB . PRO C 2 262 . 262 PRO C CB 43.482 -2.966 30.168 1 4910 +ATOM C CG . PRO C 2 262 . 262 PRO C CG 43.37 -1.625 29.471 1 4911 +ATOM C CD . PRO C 2 262 . 262 PRO C CD 44.039 -1.833 28.13 1 4912 +ATOM N N . HIS C 2 263 . 263 HIS C N 41.276 -5.214 29.639 1 4913 +ATOM C CA . HIS C 2 263 . 263 HIS C CA 39.822 -5.529 29.677 1 4914 +ATOM C C . HIS C 2 263 . 263 HIS C C 39.08 -4.309 30.236 1 4915 +ATOM O O . HIS C 2 263 . 263 HIS C O 38.916 -4.217 31.469 1 4916 +ATOM C CB . HIS C 2 263 . 263 HIS C CB 39.563 -6.822 30.46 1 4917 +ATOM C CG . HIS C 2 263 . 263 HIS C CG 40.125 -8.034 29.798 1 4918 +ATOM C CD2 . HIS C 2 263 . 263 HIS C CD2 39.61 -8.858 28.861 1 4919 +ATOM N ND1 . HIS C 2 263 . 263 HIS C ND1 41.388 -8.512 30.083 1 4920 +ATOM C CE1 . HIS C 2 263 . 263 HIS C CE1 41.625 -9.582 29.351 1 4921 +ATOM N NE2 . HIS C 2 263 . 263 HIS C NE2 40.55 -9.815 28.59 1 4922 +ATOM N N . LEU C 2 264 . 264 LEU C N 38.696 -3.396 29.342 1 4923 +ATOM C CA . LEU C 2 264 . 264 LEU C CA 38.153 -2.051 29.657 1 4924 +ATOM C C . LEU C 2 264 . 264 LEU C C 37.321 -1.578 28.464 1 4925 +ATOM O O . LEU C 2 264 . 264 LEU C O 37.803 -1.71 27.322 1 4926 +ATOM C CB . LEU C 2 264 . 264 LEU C CB 39.322 -1.096 29.917 1 4927 +ATOM C CG . LEU C 2 264 . 264 LEU C CG 38.957 0.27 30.496 1 4928 +ATOM C CD1 . LEU C 2 264 . 264 LEU C CD1 38.606 0.161 31.974 1 4929 +ATOM C CD2 . LEU C 2 264 . 264 LEU C CD2 40.094 1.262 30.293 1 4930 +ATOM N N . GLY C 2 265 . 265 GLY C N 36.119 -1.063 28.721 1 4931 +ATOM C CA . GLY C 2 265 . 265 GLY C CA 35.241 -0.481 27.689 1 4932 +ATOM C C . GLY C 2 265 . 265 GLY C C 35.448 1.016 27.567 1 4933 +ATOM O O . GLY C 2 265 . 265 GLY C O 35.903 1.637 28.55 1 4934 +ATOM N N . THR C 2 266 . 266 THR C N 35.14 1.572 26.396 1 4935 +ATOM C CA . THR C 2 266 . 266 THR C CA 34.956 3.03 26.175 1 4936 +ATOM C C . THR C 2 266 . 266 THR C C 33.679 3.237 25.355 1 4937 +ATOM O O . THR C 2 266 . 266 THR C O 33.548 2.603 24.291 1 4938 +ATOM C CB . THR C 2 266 . 266 THR C CB 36.189 3.664 25.523 1 4939 +ATOM C CG2 . THR C 2 266 . 266 THR C CG2 36.0 5.131 25.195 1 4940 +ATOM O OG1 . THR C 2 266 . 266 THR C OG1 37.259 3.49 26.452 1 4941 +ATOM N N . LEU C 2 267 . 267 LEU C N 32.775 4.072 25.871 1 4942 +ATOM C CA . LEU C 2 267 . 267 LEU C CA 31.492 4.452 25.231 1 4943 +ATOM C C . LEU C 2 267 . 267 LEU C C 31.598 5.913 24.794 1 4944 +ATOM O O . LEU C 2 267 . 267 LEU C O 32.068 6.735 25.601 1 4945 +ATOM C CB . LEU C 2 267 . 267 LEU C CB 30.347 4.252 26.231 1 4946 +ATOM C CG . LEU C 2 267 . 267 LEU C CG 28.968 4.721 25.763 1 4947 +ATOM C CD1 . LEU C 2 267 . 267 LEU C CD1 28.494 3.915 24.563 1 4948 +ATOM C CD2 . LEU C 2 267 . 267 LEU C CD2 27.953 4.638 26.893 1 4949 +ATOM N N . TYR C 2 268 . 268 TYR C N 31.213 6.195 23.549 1 4950 +ATOM C CA . TYR C 2 268 . 268 TYR C CA 31.018 7.561 23.003 1 4951 +ATOM C C . TYR C 2 268 . 268 TYR C C 29.519 7.86 22.985 1 4952 +ATOM O O . TYR C 2 268 . 268 TYR C O 28.728 6.98 22.589 1 4953 +ATOM C CB . TYR C 2 268 . 268 TYR C CB 31.616 7.693 21.603 1 4954 +ATOM C CG . TYR C 2 268 . 268 TYR C CG 31.101 8.869 20.812 1 4955 +ATOM C CD1 . TYR C 2 268 . 268 TYR C CD1 31.558 10.155 21.055 1 4956 +ATOM C CD2 . TYR C 2 268 . 268 TYR C CD2 30.159 8.695 19.81 1 4957 +ATOM C CE1 . TYR C 2 268 . 268 TYR C CE1 31.091 11.237 20.325 1 4958 +ATOM C CE2 . TYR C 2 268 . 268 TYR C CE2 29.684 9.766 19.071 1 4959 +ATOM C CZ . TYR C 2 268 . 268 TYR C CZ 30.154 11.042 19.325 1 4960 +ATOM O OH . TYR C 2 268 . 268 TYR C OH 29.686 12.097 18.593 1 4961 +ATOM N N . ALA C 2 269 . 269 ALA C N 29.152 9.065 23.419 1 4962 +ATOM C CA . ALA C 2 269 . 269 ALA C CA 27.788 9.625 23.309 1 4963 +ATOM C C . ALA C 2 269 . 269 ALA C C 27.91 11.057 22.784 1 4964 +ATOM O O . ALA C 2 269 . 269 ALA C O 28.652 11.837 23.406 1 4965 +ATOM C CB . ALA C 2 269 . 269 ALA C CB 27.1 9.569 24.65 1 4966 +ATOM N N . SER C 2 270 . 270 SER C N 27.246 11.364 21.664 1 4967 +ATOM C CA . SER C 2 270 . 270 SER C CA 27.194 12.719 21.051 1 4968 +ATOM C C . SER C 2 270 . 270 SER C C 26.6 13.705 22.063 1 4969 +ATOM O O . SER C 2 270 . 270 SER C O 25.833 13.259 22.942 1 4970 +ATOM C CB . SER C 2 270 . 270 SER C CB 26.413 12.723 19.76 1 4971 +ATOM O OG . SER C 2 270 . 270 SER C OG 25.016 12.688 20.006 1 4972 +ATOM N N . ARG C 2 271 . 271 ARG C N 26.946 14.99 21.937 1 4973 +ATOM C CA . ARG C 2 271 . 271 ARG C CA 26.457 16.079 22.825 1 4974 +ATOM C C . ARG C 2 271 . 271 ARG C C 24.926 16.137 22.748 1 4975 +ATOM O O . ARG C 2 271 . 271 ARG C O 24.296 16.184 23.817 1 4976 +ATOM C CB . ARG C 2 271 . 271 ARG C CB 27.125 17.407 22.45 1 4977 +ATOM C CG . ARG C 2 271 . 271 ARG C CG 26.696 18.602 23.293 1 4978 +ATOM C CD . ARG C 2 271 . 271 ARG C CD 26.598 18.326 24.783 1 4979 +ATOM N NE . ARG C 2 271 . 271 ARG C NE 27.78 17.684 25.35 1 4980 +ATOM C CZ . ARG C 2 271 . 271 ARG C CZ 27.817 17.069 26.533 1 4981 +ATOM N NH1 . ARG C 2 271 . 271 ARG C NH1 26.73 16.994 27.284 1 4982 +ATOM N NH2 . ARG C 2 271 . 271 ARG C NH2 28.942 16.516 26.959 1 4983 +ATOM N N . LYS C 2 272 . 272 LYS C N 24.359 16.096 21.537 1 4984 +ATOM C CA . LYS C 2 272 . 272 LYS C CA 22.889 16.112 21.29 1 4985 +ATOM C C . LYS C 2 272 . 272 LYS C C 22.223 14.961 22.053 1 4986 +ATOM O O . LYS C 2 272 . 272 LYS C O 21.251 15.229 22.782 1 4987 +ATOM C CB . LYS C 2 272 . 272 LYS C CB 22.569 16.006 19.795 1 4988 +ATOM C CG . LYS C 2 272 . 272 LYS C CG 21.097 16.207 19.449 1 4989 +ATOM C CD . LYS C 2 272 . 272 LYS C CD 20.846 16.573 18.002 1 4990 +ATOM C CE . LYS C 2 272 . 272 LYS C CE 20.711 15.371 17.091 1 4991 +ATOM N NZ . LYS C 2 272 . 272 LYS C NZ 19.311 14.885 17.029 1 4992 +ATOM N N . ALA C 2 273 . 273 ALA C N 22.74 13.739 21.893 1 4993 +ATOM C CA . ALA C 2 273 . 273 ALA C CA 22.212 12.496 22.507 1 4994 +ATOM C C . ALA C 2 273 . 273 ALA C C 22.259 12.605 24.035 1 4995 +ATOM O O . ALA C 2 273 . 273 ALA C O 21.279 12.187 24.693 1 4996 +ATOM C CB . ALA C 2 273 . 273 ALA C CB 22.994 11.303 22.02 1 4997 +ATOM N N . GLN C 2 274 . 274 GLN C N 23.356 13.148 24.572 1 4998 +ATOM C CA . GLN C 2 274 . 274 GLN C CA 23.547 13.382 26.028 1 4999 +ATOM C C . GLN C 2 274 . 274 GLN C C 22.485 14.368 26.529 1 5000 +ATOM O O . GLN C 2 274 . 274 GLN C O 21.815 14.055 27.529 1 5001 +ATOM C CB . GLN C 2 274 . 274 GLN C CB 24.956 13.905 26.316 1 5002 +ATOM C CG . GLN C 2 274 . 274 GLN C CG 26.032 12.828 26.261 1 5003 +ATOM C CD . GLN C 2 274 . 274 GLN C CD 27.32 13.3 26.892 1 5004 +ATOM N NE2 . GLN C 2 274 . 274 GLN C NE2 28.407 13.215 26.146 1 5005 +ATOM O OE1 . GLN C 2 274 . 274 GLN C OE1 27.345 13.745 28.034 1 5006 +ATOM N N . ASP C 2 275 . 275 ASP C N 22.323 15.499 25.839 1 5007 +ATOM C CA . ASP C 2 275 . 275 ASP C CA 21.486 16.638 26.303 1 5008 +ATOM C C . ASP C 2 275 . 275 ASP C C 19.999 16.277 26.197 1 5009 +ATOM O O . ASP C 2 275 . 275 ASP C O 19.245 16.698 27.095 1 5010 +ATOM C CB . ASP C 2 275 . 275 ASP C CB 21.84 17.925 25.55 1 5011 +ATOM C CG . ASP C 2 275 . 275 ASP C CG 23.203 18.491 25.92 1 5012 +ATOM O OD1 . ASP C 2 275 . 275 ASP C OD1 23.871 17.893 26.792 1 5013 +ATOM O OD2 . ASP C 2 275 . 275 ASP C OD2 23.592 19.526 25.335 1 5014 +ATOM N N . ARG C 2 276 . 276 ARG C N 19.598 15.513 25.173 1 5015 +ATOM C CA . ARG C 2 276 . 276 ARG C CA 18.168 15.289 24.816 1 5016 +ATOM C C . ARG C 2 276 . 276 ARG C C 17.647 13.928 25.298 1 5017 +ATOM O O . ARG C 2 276 . 276 ARG C O 16.449 13.868 25.611 1 5018 +ATOM C CB . ARG C 2 276 . 276 ARG C CB 17.966 15.382 23.301 1 5019 +ATOM C CG . ARG C 2 276 . 276 ARG C CG 18.04 16.798 22.752 1 5020 +ATOM C CD . ARG C 2 276 . 276 ARG C CD 17.476 16.86 21.351 1 5021 +ATOM N NE . ARG C 2 276 . 276 ARG C NE 17.756 18.126 20.691 1 5022 +ATOM C CZ . ARG C 2 276 . 276 ARG C CZ 17.369 18.427 19.457 1 5023 +ATOM N NH1 . ARG C 2 276 . 276 ARG C NH1 16.674 17.55 18.749 1 5024 +ATOM N NH2 . ARG C 2 276 . 276 ARG C NH2 17.675 19.603 18.935 1 5025 +ATOM N N . TYR C 2 277 . 277 TYR C N 18.472 12.875 25.31 1 5026 +ATOM C CA . TYR C 2 277 . 277 TYR C CA 18.009 11.472 25.508 1 5027 +ATOM C C . TYR C 2 277 . 277 TYR C C 18.472 10.887 26.853 1 5028 +ATOM O O . TYR C 2 277 . 277 TYR C O 17.818 9.937 27.323 1 5029 +ATOM C CB . TYR C 2 277 . 277 TYR C CB 18.467 10.574 24.354 1 5030 +ATOM C CG . TYR C 2 277 . 277 TYR C CG 18.214 11.116 22.967 1 5031 +ATOM C CD1 . TYR C 2 277 . 277 TYR C CD1 17.063 11.829 22.662 1 5032 +ATOM C CD2 . TYR C 2 277 . 277 TYR C CD2 19.126 10.894 21.948 1 5033 +ATOM C CE1 . TYR C 2 277 . 277 TYR C CE1 16.837 12.325 21.387 1 5034 +ATOM C CE2 . TYR C 2 277 . 277 TYR C CE2 18.917 11.384 20.669 1 5035 +ATOM C CZ . TYR C 2 277 . 277 TYR C CZ 17.768 12.101 20.387 1 5036 +ATOM O OH . TYR C 2 277 . 277 TYR C OH 17.573 12.577 19.124 1 5037 +ATOM N N . MET C 2 278 . 278 MET C N 19.546 11.408 27.456 1 5038 +ATOM C CA . MET C 2 278 . 278 MET C CA 20.234 10.747 28.598 1 5039 +ATOM C C . MET C 2 278 . 278 MET C C 19.868 11.417 29.927 1 5040 +ATOM O O . MET C 2 278 . 278 MET C O 19.875 12.661 30.0 1 5041 +ATOM C CB . MET C 2 278 . 278 MET C CB 21.754 10.782 28.425 1 5042 +ATOM C CG . MET C 2 278 . 278 MET C CG 22.247 9.91 27.297 1 5043 +ATOM S SD . MET C 2 278 . 278 MET C SD 24.042 9.729 27.327 1 5044 +ATOM C CE . MET C 2 278 . 278 MET C CE 24.226 8.522 28.636 1 5045 +ATOM N N . THR C 2 279 . 279 THR C N 19.576 10.592 30.937 1 5046 +ATOM C CA . THR C 2 279 . 279 THR C CA 19.399 10.979 32.362 1 5047 +ATOM C C . THR C 2 279 . 279 THR C C 20.553 10.376 33.164 1 5048 +ATOM O O . THR C 2 279 . 279 THR C O 20.907 9.211 32.893 1 5049 +ATOM C CB . THR C 2 279 . 279 THR C CB 18.043 10.507 32.896 1 5050 +ATOM C CG2 . THR C 2 279 . 279 THR C CG2 17.807 10.874 34.344 1 5051 +ATOM O OG1 . THR C 2 279 . 279 THR C OG1 17.044 11.106 32.071 1 5052 +ATOM N N . SER C 2 280 . 280 SER C N 21.124 11.143 34.095 1 5053 +ATOM C CA . SER C 2 280 . 280 SER C CA 22.212 10.691 34.999 1 5054 +ATOM C C . SER C 2 280 . 280 SER C C 21.737 9.473 35.797 1 5055 +ATOM O O . SER C 2 280 . 280 SER C O 20.572 9.472 36.237 1 5056 +ATOM C CB . SER C 2 280 . 280 SER C CB 22.664 11.795 35.918 1 5057 +ATOM O OG . SER C 2 280 . 280 SER C OG 23.749 11.358 36.727 1 5058 +ATOM N N . ILE C 2 281 . 281 ILE C N 22.611 8.479 35.968 1 5059 +ATOM C CA . ILE C 2 281 . 281 ILE C CA 22.4 7.318 36.886 1 5060 +ATOM C C . ILE C 2 281 . 281 ILE C C 23.382 7.433 38.06 1 5061 +ATOM O O . ILE C 2 281 . 281 ILE C O 23.528 6.446 38.796 1 5062 +ATOM C CB . ILE C 2 281 . 281 ILE C CB 22.549 5.984 36.125 1 5063 +ATOM C CG1 . ILE C 2 281 . 281 ILE C CG1 23.976 5.761 35.614 1 5064 +ATOM C CG2 . ILE C 2 281 . 281 ILE C CG2 21.527 5.905 35.003 1 5065 +ATOM C CD1 . ILE C 2 281 . 281 ILE C CD1 24.265 4.339 35.198 1 5066 +ATOM N N . ASN C 2 282 . 282 ASN C N 24.029 8.592 38.215 1 5067 +ATOM C CA . ASN C 2 282 . 282 ASN C CA 25.08 8.862 39.232 1 5068 +ATOM C C . ASN C 2 282 . 282 ASN C C 24.416 9.521 40.448 1 5069 +ATOM O O . ASN C 2 282 . 282 ASN C O 23.182 9.726 40.405 1 5070 +ATOM C CB . ASN C 2 282 . 282 ASN C CB 26.196 9.723 38.631 1 5071 +ATOM C CG . ASN C 2 282 . 282 ASN C CG 27.569 9.476 39.222 1 5072 +ATOM N ND2 . ASN C 2 282 . 282 ASN C ND2 28.601 9.856 38.487 1 5073 +ATOM O OD1 . ASN C 2 282 . 282 ASN C OD1 27.704 8.969 40.337 1 5074 +ATOM N N . HIS C 2 283 . 283 HIS C N 25.194 9.843 41.488 1 5075 +ATOM C CA . HIS C 2 283 . 283 HIS C CA 24.699 10.505 42.728 1 5076 +ATOM C C . HIS C 2 283 . 283 HIS C C 24.081 11.861 42.369 1 5077 +ATOM O O . HIS C 2 283 . 283 HIS C O 24.545 12.48 41.398 1 5078 +ATOM C CB . HIS C 2 283 . 283 HIS C CB 25.81 10.677 43.776 1 5079 +ATOM C CG . HIS C 2 283 . 283 HIS C CG 26.625 9.452 44.021 1 5080 +ATOM C CD2 . HIS C 2 283 . 283 HIS C CD2 27.954 9.289 44.191 1 5081 +ATOM N ND1 . HIS C 2 283 . 283 HIS C ND1 26.061 8.198 44.126 1 5082 +ATOM C CE1 . HIS C 2 283 . 283 HIS C CE1 27.011 7.312 44.341 1 5083 +ATOM N NE2 . HIS C 2 283 . 283 HIS C NE2 28.182 7.958 44.392 1 5084 +ATOM N N . TYR C 2 284 . 284 TYR C N 23.094 12.306 43.151 1 5085 +ATOM C CA . TYR C 2 284 . 284 TYR C CA 22.33 13.562 42.931 1 5086 +ATOM C C . TYR C 2 284 . 284 TYR C C 23.282 14.766 42.922 1 5087 +ATOM O O . TYR C 2 284 . 284 TYR C O 22.985 15.73 42.198 1 5088 +ATOM C CB . TYR C 2 284 . 284 TYR C CB 21.225 13.715 43.982 1 5089 +ATOM C CG . TYR C 2 284 . 284 TYR C CG 21.693 14.129 45.355 1 5090 +ATOM C CD1 . TYR C 2 284 . 284 TYR C CD1 21.9 15.463 45.67 1 5091 +ATOM C CD2 . TYR C 2 284 . 284 TYR C CD2 21.923 13.189 46.347 1 5092 +ATOM C CE1 . TYR C 2 284 . 284 TYR C CE1 22.333 15.852 46.927 1 5093 +ATOM C CE2 . TYR C 2 284 . 284 TYR C CE2 22.351 13.56 47.612 1 5094 +ATOM C CZ . TYR C 2 284 . 284 TYR C CZ 22.556 14.897 47.904 1 5095 +ATOM O OH . TYR C 2 284 . 284 TYR C OH 22.974 15.276 49.149 1 5096 +ATOM N N . PHE C 2 285 . 285 PHE C N 24.384 14.708 43.684 1 5097 +ATOM C CA . PHE C 2 285 . 285 PHE C CA 25.347 15.83 43.887 1 5098 +ATOM C C . PHE C 2 285 . 285 PHE C C 26.405 15.875 42.769 1 5099 +ATOM O O . PHE C 2 285 . 285 PHE C O 27.125 16.9 42.684 1 5100 +ATOM C CB . PHE C 2 285 . 285 PHE C CB 25.995 15.755 45.275 1 5101 +ATOM C CG . PHE C 2 285 . 285 PHE C CG 26.632 14.438 45.649 1 5102 +ATOM C CD1 . PHE C 2 285 . 285 PHE C CD1 27.87 14.073 45.14 1 5103 +ATOM C CD2 . PHE C 2 285 . 285 PHE C CD2 26.011 13.579 46.546 1 5104 +ATOM C CE1 . PHE C 2 285 . 285 PHE C CE1 28.461 12.87 45.499 1 5105 +ATOM C CE2 . PHE C 2 285 . 285 PHE C CE2 26.603 12.376 46.903 1 5106 +ATOM C CZ . PHE C 2 285 . 285 PHE C CZ 27.827 12.025 46.381 1 5107 +ATOM N N . VAL C 2 286 . 286 VAL C N 26.509 14.824 41.941 1 5108 +ATOM C CA . VAL C 2 286 . 286 VAL C CA 27.481 14.745 40.805 1 5109 +ATOM C C . VAL C 2 286 . 286 VAL C C 26.862 15.435 39.582 1 5110 +ATOM O O . VAL C 2 286 . 286 VAL C O 25.759 15.026 39.169 1 5111 +ATOM C CB . VAL C 2 286 . 286 VAL C CB 27.88 13.288 40.488 1 5112 +ATOM C CG1 . VAL C 2 286 . 286 VAL C CG1 28.814 13.2 39.286 1 5113 +ATOM C CG2 . VAL C 2 286 . 286 VAL C CG2 28.503 12.595 41.689 1 5114 +ATOM N N . SER C 2 287 . 287 SER C N 27.554 16.433 39.018 1 5115 +ATOM C CA . SER C 2 287 . 287 SER C CA 27.126 17.181 37.804 1 5116 +ATOM C C . SER C 2 287 . 287 SER C C 27.038 16.212 36.62 1 5117 +ATOM O O . SER C 2 287 . 287 SER C O 27.954 15.379 36.473 1 5118 +ATOM C CB . SER C 2 287 . 287 SER C CB 28.048 18.336 37.503 1 5119 +ATOM O OG . SER C 2 287 . 287 SER C OG 29.259 17.878 36.922 1 5120 +ATOM N N . SER C 2 288 . 288 SER C N 25.982 16.334 35.81 1 5121 +ATOM C CA . SER C 2 288 . 288 SER C CA 25.58 15.359 34.761 1 5122 +ATOM C C . SER C 2 288 . 288 SER C C 25.875 15.896 33.35 1 5123 +ATOM O O . SER C 2 288 . 288 SER C O 25.33 15.327 32.389 1 5124 +ATOM C CB . SER C 2 288 . 288 SER C CB 24.12 15.012 34.921 1 5125 +ATOM O OG . SER C 2 288 . 288 SER C OG 23.31 16.178 34.842 1 5126 +ATOM N N . SER C 2 289 . 289 SER C N 26.709 16.934 33.219 1 5127 +ATOM C CA . SER C 2 289 . 289 SER C CA 27.037 17.589 31.923 1 5128 +ATOM C C . SER C 2 289 . 289 SER C C 27.876 16.65 31.046 1 5129 +ATOM O O . SER C 2 289 . 289 SER C O 27.515 16.475 29.868 1 5130 +ATOM C CB . SER C 2 289 . 289 SER C CB 27.724 18.915 32.131 1 5131 +ATOM O OG . SER C 2 289 . 289 SER C OG 26.832 19.854 32.72 1 5132 +ATOM N N . SER C 2 290 . 290 SER C N 28.95 16.072 31.595 1 5133 +ATOM C CA . SER C 2 290 . 290 SER C CA 29.849 15.12 30.889 1 5134 +ATOM C C . SER C 2 290 . 290 SER C C 29.228 13.717 30.893 1 5135 +ATOM O O . SER C 2 290 . 290 SER C O 28.303 13.474 31.695 1 5136 +ATOM C CB . SER C 2 290 . 290 SER C CB 31.239 15.111 31.49 1 5137 +ATOM O OG . SER C 2 290 . 290 SER C OG 31.267 14.4 32.72 1 5138 +ATOM N N . LEU C 2 291 . 291 LEU C N 29.723 12.831 30.026 1 5139 +ATOM C CA . LEU C 2 291 . 291 LEU C CA 29.29 11.41 29.972 1 5140 +ATOM C C . LEU C 2 291 . 291 LEU C C 29.769 10.69 31.24 1 5141 +ATOM O O . LEU C 2 291 . 291 LEU C O 29.036 9.813 31.728 1 5142 +ATOM C CB . LEU C 2 291 . 291 LEU C CB 29.84 10.749 28.703 1 5143 +ATOM C CG . LEU C 2 291 . 291 LEU C CG 29.267 9.366 28.394 1 5144 +ATOM C CD1 . LEU C 2 291 . 291 LEU C CD1 27.754 9.424 28.228 1 5145 +ATOM C CD2 . LEU C 2 291 . 291 LEU C CD2 29.919 8.778 27.154 1 5146 +ATOM N N . ASP C 2 292 . 292 ASP C N 30.956 11.047 31.743 1 5147 +ATOM C CA . ASP C 2 292 . 292 ASP C CA 31.504 10.56 33.038 1 5148 +ATOM C C . ASP C 2 292 . 292 ASP C C 30.482 10.852 34.144 1 5149 +ATOM O O . ASP C 2 292 . 292 ASP C O 30.109 9.91 34.868 1 5150 +ATOM C CB . ASP C 2 292 . 292 ASP C CB 32.859 11.202 33.356 1 5151 +ATOM C CG . ASP C 2 292 . 292 ASP C CG 34.047 10.551 32.669 1 5152 +ATOM O OD1 . ASP C 2 292 . 292 ASP C OD1 33.844 9.564 31.942 1 5153 +ATOM O OD2 . ASP C 2 292 . 292 ASP C OD2 35.174 11.038 32.872 1 5154 +ATOM N N . GLY C 2 293 . 293 GLY C N 30.027 12.106 34.234 1 5155 +ATOM C CA . GLY C 2 293 . 293 GLY C CA 29.04 12.579 35.224 1 5156 +ATOM C C . GLY C 2 293 . 293 GLY C C 27.766 11.748 35.231 1 5157 +ATOM O O . GLY C 2 293 . 293 GLY C O 27.18 11.584 36.315 1 5158 +ATOM N N . LYS C 2 294 . 294 LYS C N 27.337 11.254 34.068 1 5159 +ATOM C CA . LYS C 2 294 . 294 LYS C CA 26.052 10.526 33.893 1 5160 +ATOM C C . LYS C 2 294 . 294 LYS C C 26.211 9.04 34.238 1 5161 +ATOM O O . LYS C 2 294 . 294 LYS C O 25.293 8.506 34.883 1 5162 +ATOM C CB . LYS C 2 294 . 294 LYS C CB 25.539 10.671 32.458 1 5163 +ATOM C CG . LYS C 2 294 . 294 LYS C CG 25.05 12.063 32.086 1 5164 +ATOM C CD . LYS C 2 294 . 294 LYS C CD 24.303 12.096 30.776 1 5165 +ATOM C CE . LYS C 2 294 . 294 LYS C CE 24.011 13.5 30.294 1 5166 +ATOM N NZ . LYS C 2 294 . 294 LYS C NZ 25.257 14.227 29.941 1 5167 +ATOM N N . LEU C 2 295 . 295 LEU C N 27.307 8.397 33.818 1 5168 +ATOM C CA . LEU C 2 295 . 295 LEU C CA 27.412 6.911 33.744 1 5169 +ATOM C C . LEU C 2 295 . 295 LEU C C 28.474 6.337 34.696 1 5170 +ATOM O O . LEU C 2 295 . 295 LEU C O 28.508 5.099 34.828 1 5171 +ATOM C CB . LEU C 2 295 . 295 LEU C CB 27.724 6.512 32.296 1 5172 +ATOM C CG . LEU C 2 295 . 295 LEU C CG 26.592 6.708 31.289 1 5173 +ATOM C CD1 . LEU C 2 295 . 295 LEU C CD1 26.96 6.082 29.953 1 5174 +ATOM C CD2 . LEU C 2 295 . 295 LEU C CD2 25.284 6.121 31.8 1 5175 +ATOM N N . ALA C 2 296 . 296 ALA C N 29.317 7.164 35.319 1 5176 +ATOM C CA . ALA C 2 296 . 296 ALA C CA 30.426 6.697 36.187 1 5177 +ATOM C C . ALA C 2 296 . 296 ALA C C 29.849 6.202 37.515 1 5178 +ATOM O O . ALA C 2 296 . 296 ALA C O 29.28 7.028 38.251 1 5179 +ATOM C CB . ALA C 2 296 . 296 ALA C CB 31.44 7.791 36.41 1 5180 +ATOM N N . LEU C 2 297 . 297 LEU C N 29.969 4.9 37.789 1 5181 +ATOM C CA . LEU C 2 297 . 297 LEU C CA 29.53 4.274 39.064 1 5182 +ATOM C C . LEU C 2 297 . 297 LEU C C 30.769 4.014 39.926 1 5183 +ATOM O O . LEU C 2 297 . 297 LEU C O 31.498 3.04 39.648 1 5184 +ATOM C CB . LEU C 2 297 . 297 LEU C CB 28.748 2.99 38.765 1 5185 +ATOM C CG . LEU C 2 297 . 297 LEU C CG 27.467 3.171 37.947 1 5186 +ATOM C CD1 . LEU C 2 297 . 297 LEU C CD1 26.682 1.87 37.878 1 5187 +ATOM C CD2 . LEU C 2 297 . 297 LEU C CD2 26.592 4.28 38.516 1 5188 +ATOM N N . GLY C 2 298 . 298 GLY C N 30.999 4.883 40.915 1 5189 +ATOM C CA . GLY C 2 298 . 298 GLY C CA 32.14 4.803 41.844 1 5190 +ATOM C C . GLY C 2 298 . 298 GLY C C 33.414 5.315 41.2 1 5191 +ATOM O O . GLY C 2 298 . 298 GLY C O 33.33 5.938 40.12 1 5192 +ATOM N N . MET C 2 299 . 299 MET C N 34.558 5.053 41.836 1 5193 +ATOM C CA . MET C 2 299 . 299 MET C CA 35.883 5.564 41.397 1 5194 +ATOM C C . MET C 2 299 . 299 MET C C 36.288 4.858 40.101 1 5195 +ATOM O O . MET C 2 299 . 299 MET C O 36.221 3.633 40.011 1 5196 +ATOM C CB . MET C 2 299 . 299 MET C CB 36.946 5.338 42.478 1 5197 +ATOM C CG . MET C 2 299 . 299 MET C CG 38.076 6.36 42.457 1 5198 +ATOM S SD . MET C 2 299 . 299 MET C SD 37.493 8.069 42.652 1 5199 +ATOM C CE . MET C 2 299 . 299 MET C CE 39.021 8.894 43.098 1 5200 +ATOM N N . PRO C 2 300 . 300 PRO C N 36.685 5.606 39.044 1 5201 +ATOM C CA . PRO C 2 300 . 300 PRO C CA 37.183 4.998 37.809 1 5202 +ATOM C C . PRO C 2 300 . 300 PRO C C 38.499 4.228 38.012 1 5203 +ATOM O O . PRO C 2 300 . 300 PRO C O 39.248 4.556 38.914 1 5204 +ATOM C CB . PRO C 2 300 . 300 PRO C CB 37.414 6.187 36.86 1 5205 +ATOM C CG . PRO C 2 300 . 300 PRO C CG 36.632 7.335 37.469 1 5206 +ATOM C CD . PRO C 2 300 . 300 PRO C CD 36.642 7.074 38.959 1 5207 +ATOM N N . SER C 2 301 . 301 SER C N 38.744 3.223 37.168 1 5208 +ATOM C CA . SER C 2 301 . 301 SER C CA 40.001 2.431 37.137 1 5209 +ATOM C C . SER C 2 301 . 301 SER C C 41.07 3.213 36.367 1 5210 +ATOM O O . SER C 2 301 . 301 SER C O 41.331 2.864 35.201 1 5211 +ATOM C CB . SER C 2 301 . 301 SER C CB 39.771 1.062 36.547 1 5212 +ATOM O OG . SER C 2 301 . 301 SER C OG 40.985 0.32 36.508 1 5213 +ATOM N N . PHE C 2 302 . 302 PHE C N 41.663 4.228 37.005 1 5214 +ATOM C CA . PHE C 2 302 . 302 PHE C CA 42.676 5.137 36.404 1 5215 +ATOM C C . PHE C 2 302 . 302 PHE C C 43.832 4.307 35.835 1 5216 +ATOM O O . PHE C 2 302 . 302 PHE C O 44.313 4.636 34.728 1 5217 +ATOM C CB . PHE C 2 302 . 302 PHE C CB 43.191 6.154 37.427 1 5218 +ATOM C CG . PHE C 2 302 . 302 PHE C CG 42.112 6.939 38.13 1 5219 +ATOM C CD1 . PHE C 2 302 . 302 PHE C CD1 41.357 7.879 37.445 1 5220 +ATOM C CD2 . PHE C 2 302 . 302 PHE C CD2 41.845 6.733 39.474 1 5221 +ATOM C CE1 . PHE C 2 302 . 302 PHE C CE1 40.359 8.595 38.09 1 5222 +ATOM C CE2 . PHE C 2 302 . 302 PHE C CE2 40.85 7.452 40.118 1 5223 +ATOM C CZ . PHE C 2 302 . 302 PHE C CZ 40.111 8.384 39.427 1 5224 +ATOM N N . GLU C 2 303 . 303 GLU C N 44.248 3.262 36.564 1 5225 +ATOM C CA . GLU C 2 303 . 303 GLU C CA 45.395 2.384 36.2 1 5226 +ATOM C C . GLU C 2 303 . 303 GLU C C 45.164 1.777 34.805 1 5227 +ATOM O O . GLU C 2 303 . 303 GLU C O 46.119 1.792 34.007 1 5228 +ATOM C CB . GLU C 2 303 . 303 GLU C CB 45.673 1.336 37.288 1 5229 +ATOM C CG . GLU C 2 303 . 303 GLU C CG 44.524 0.389 37.607 1 5230 +ATOM C CD . GLU C 2 303 . 303 GLU C CD 43.539 0.849 38.674 1 5231 +ATOM O OE1 . GLU C 2 303 . 303 GLU C OE1 43.675 1.99 39.162 1 5232 +ATOM O OE2 . GLU C 2 303 . 303 GLU C OE2 42.626 0.063 39.008 1 5233 +ATOM N N . LEU C 2 304 . 304 LEU C N 43.947 1.306 34.499 1 5234 +ATOM C CA . LEU C 2 304 . 304 LEU C CA 43.618 0.676 33.188 1 5235 +ATOM C C . LEU C 2 304 . 304 LEU C C 43.479 1.754 32.105 1 5236 +ATOM O O . LEU C 2 304 . 304 LEU C O 43.895 1.482 30.962 1 5237 +ATOM C CB . LEU C 2 304 . 304 LEU C CB 42.334 -0.154 33.301 1 5238 +ATOM C CG . LEU C 2 304 . 304 LEU C CG 42.431 -1.444 34.118 1 5239 +ATOM C CD1 . LEU C 2 304 . 304 LEU C CD1 41.195 -2.303 33.907 1 5240 +ATOM C CD2 . LEU C 2 304 . 304 LEU C CD2 43.683 -2.241 33.778 1 5241 +ATOM N N . GLN C 2 305 . 305 GLN C N 42.923 2.923 32.443 1 5242 +ATOM C CA . GLN C 2 305 . 305 GLN C CA 42.693 4.038 31.481 1 5243 +ATOM C C . GLN C 2 305 . 305 GLN C C 44.031 4.494 30.886 1 5244 +ATOM O O . GLN C 2 305 . 305 GLN C O 44.118 4.624 29.651 1 5245 +ATOM C CB . GLN C 2 305 . 305 GLN C CB 42.005 5.227 32.154 1 5246 +ATOM C CG . GLN C 2 305 . 305 GLN C CG 40.585 4.936 32.618 1 5247 +ATOM C CD . GLN C 2 305 . 305 GLN C CD 39.899 6.169 33.154 1 5248 +ATOM N NE2 . GLN C 2 305 . 305 GLN C NE2 38.811 5.964 33.88 1 5249 +ATOM O OE1 . GLN C 2 305 . 305 GLN C OE1 40.332 7.296 32.917 1 5250 +ATOM N N . LEU C 2 306 . 306 LEU C N 45.034 4.718 31.736 1 5251 +ATOM C CA . LEU C 2 306 . 306 LEU C CA 46.345 5.305 31.348 1 5252 +ATOM C C . LEU C 2 306 . 306 LEU C C 47.173 4.296 30.538 1 5253 +ATOM O O . LEU C 2 306 . 306 LEU C O 48.3 4.652 30.144 1 5254 +ATOM C CB . LEU C 2 306 . 306 LEU C CB 47.071 5.764 32.616 1 5255 +ATOM C CG . LEU C 2 306 . 306 LEU C CG 46.453 6.989 33.288 1 5256 +ATOM C CD1 . LEU C 2 306 . 306 LEU C CD1 46.841 7.065 34.753 1 5257 +ATOM C CD2 . LEU C 2 306 . 306 LEU C CD2 46.858 8.263 32.565 1 5258 +ATOM N N . MET C 2 307 . 307 MET C N 46.632 3.098 30.281 1 5259 +ATOM C CA . MET C 2 307 . 307 MET C CA 47.21 2.1 29.341 1 5260 +ATOM C C . MET C 2 307 . 307 MET C C 46.834 2.441 27.891 1 5261 +ATOM O O . MET C 2 307 . 307 MET C O 47.447 1.849 26.984 1 5262 +ATOM C CB . MET C 2 307 . 307 MET C CB 46.696 0.689 29.645 1 5263 +ATOM C CG . MET C 2 307 . 307 MET C CG 47.147 0.141 30.979 1 5264 +ATOM S SD . MET C 2 307 . 307 MET C SD 46.679 -1.598 31.201 1 5265 +ATOM C CE . MET C 2 307 . 307 MET C CE 47.753 -2.404 30.016 1 5266 +ATOM N N . CYS C 2 308 . 308 CYS C N 45.875 3.349 27.674 1 5267 +ATOM C CA . CYS C 2 308 . 308 CYS C CA 45.124 3.479 26.393 1 5268 +ATOM C C . CYS C 2 308 . 308 CYS C C 45.784 4.494 25.448 1 5269 +ATOM O O . CYS C 2 308 . 308 CYS C O 45.816 4.204 24.239 1 5270 +ATOM C CB . CYS C 2 308 . 308 CYS C CB 43.661 3.817 26.655 1 5271 +ATOM S SG . CYS C 2 308 . 308 CYS C SG 42.772 2.459 27.463 1 5272 +ATOM N N . SER C 2 309 . 309 SER C N 46.299 5.621 25.958 1 5273 +ATOM C CA . SER C 2 309 . 309 SER C CA 46.929 6.691 25.135 1 5274 +ATOM C C . SER C 2 309 . 309 SER C C 48.198 6.174 24.453 1 5275 +ATOM O O . SER C 2 309 . 309 SER C O 48.429 6.504 23.292 1 5276 +ATOM C CB . SER C 2 309 . 309 SER C CB 47.19 7.955 25.922 1 5277 +ATOM O OG . SER C 2 309 . 309 SER C OG 48.072 7.722 27.007 1 5278 +ATOM N N . PRO C 2 310 . 310 PRO C N 49.062 5.363 25.116 1 5279 +ATOM C CA . PRO C 2 310 . 310 PRO C CA 50.196 4.739 24.429 1 5280 +ATOM C C . PRO C 2 310 . 310 PRO C C 49.761 3.892 23.222 1 5281 +ATOM O O . PRO C 2 310 . 310 PRO C O 50.465 3.903 22.225 1 5282 +ATOM C CB . PRO C 2 310 . 310 PRO C CB 50.844 3.852 25.504 1 5283 +ATOM C CG . PRO C 2 310 . 310 PRO C CG 50.426 4.49 26.814 1 5284 +ATOM C CD . PRO C 2 310 . 310 PRO C CD 49.033 5.019 26.548 1 5285 +ATOM N N . ILE C 2 311 . 311 ILE C N 48.617 3.205 23.333 1 5286 +ATOM C CA . ILE C 2 311 . 311 ILE C CA 48.045 2.347 22.251 1 5287 +ATOM C C . ILE C 2 311 . 311 ILE C C 47.624 3.251 21.085 1 5288 +ATOM O O . ILE C 2 311 . 311 ILE C O 47.874 2.862 19.924 1 5289 +ATOM C CB . ILE C 2 311 . 311 ILE C CB 46.88 1.474 22.765 1 5290 +ATOM C CG1 . ILE C 2 311 . 311 ILE C CG1 47.326 0.541 23.896 1 5291 +ATOM C CG2 . ILE C 2 311 . 311 ILE C CG2 46.245 0.697 21.62 1 5292 +ATOM C CD1 . ILE C 2 311 . 311 ILE C CD1 46.192 -0.122 24.643 1 5293 +ATOM N N . VAL C 2 312 . 312 VAL C N 47.032 4.415 21.383 1 5294 +ATOM C CA . VAL C 2 312 . 312 VAL C CA 46.604 5.427 20.369 1 5295 +ATOM C C . VAL C 2 312 . 312 VAL C C 47.827 5.836 19.539 1 5296 +ATOM O O . VAL C 2 312 . 312 VAL C O 47.77 5.686 18.303 1 5297 +ATOM C CB . VAL C 2 312 . 312 VAL C CB 45.925 6.649 21.021 1 5298 +ATOM C CG1 . VAL C 2 312 . 312 VAL C CG1 45.717 7.786 20.028 1 5299 +ATOM C CG2 . VAL C 2 312 . 312 VAL C CG2 44.606 6.269 21.678 1 5300 +ATOM N N . SER C 2 313 . 313 SER C N 48.889 6.314 20.199 1 5301 +ATOM C CA . SER C 2 313 . 313 SER C CA 50.15 6.783 19.564 1 5302 +ATOM C C . SER C 2 313 . 313 SER C C 50.803 5.642 18.772 1 5303 +ATOM O O . SER C 2 313 . 313 SER C O 51.253 5.905 17.646 1 5304 +ATOM C CB . SER C 2 313 . 313 SER C CB 51.101 7.363 20.581 1 5305 +ATOM O OG . SER C 2 313 . 313 SER C OG 51.286 6.467 21.665 1 5306 +ATOM N N . TYR C 2 314 . 314 TYR C N 50.842 4.424 19.325 1 5307 +ATOM C CA . TYR C 2 314 . 314 TYR C CA 51.427 3.229 18.661 1 5308 +ATOM C C . TYR C 2 314 . 314 TYR C C 50.722 2.967 17.322 1 5309 +ATOM O O . TYR C 2 314 . 314 TYR C O 51.416 2.858 16.297 1 5310 +ATOM C CB . TYR C 2 314 . 314 TYR C CB 51.351 1.975 19.536 1 5311 +ATOM C CG . TYR C 2 314 . 314 TYR C CG 51.686 0.711 18.784 1 5312 +ATOM C CD1 . TYR C 2 314 . 314 TYR C CD1 52.982 0.451 18.368 1 5313 +ATOM C CD2 . TYR C 2 314 . 314 TYR C CD2 50.703 -0.203 18.442 1 5314 +ATOM C CE1 . TYR C 2 314 . 314 TYR C CE1 53.299 -0.696 17.657 1 5315 +ATOM C CE2 . TYR C 2 314 . 314 TYR C CE2 51.0 -1.353 17.73 1 5316 +ATOM C CZ . TYR C 2 314 . 314 TYR C CZ 52.302 -1.602 17.338 1 5317 +ATOM O OH . TYR C 2 314 . 314 TYR C OH 52.594 -2.734 16.636 1 5318 +ATOM N N . LEU C 2 315 . 315 LEU C N 49.391 2.85 17.33 1 5319 +ATOM C CA . LEU C 2 315 . 315 LEU C CA 48.59 2.508 16.121 1 5320 +ATOM C C . LEU C 2 315 . 315 LEU C C 48.665 3.649 15.094 1 5321 +ATOM O O . LEU C 2 315 . 315 LEU C O 48.856 3.346 13.897 1 5322 +ATOM C CB . LEU C 2 315 . 315 LEU C CB 47.137 2.227 16.52 1 5323 +ATOM C CG . LEU C 2 315 . 315 LEU C CG 46.903 0.964 17.349 1 5324 +ATOM C CD1 . LEU C 2 315 . 315 LEU C CD1 45.439 0.848 17.74 1 5325 +ATOM C CD2 . LEU C 2 315 . 315 LEU C CD2 47.351 -0.285 16.603 1 5326 +ATOM N N . GLN C 2 316 . 316 GLN C N 48.532 4.904 15.536 1 5327 +ATOM C CA . GLN C 2 316 . 316 GLN C CA 48.363 6.083 14.643 1 5328 +ATOM C C . GLN C 2 316 . 316 GLN C C 49.719 6.549 14.092 1 5329 +ATOM O O . GLN C 2 316 . 316 GLN C O 49.769 6.879 12.89 1 5330 +ATOM C CB . GLN C 2 316 . 316 GLN C CB 47.655 7.224 15.378 1 5331 +ATOM C CG . GLN C 2 316 . 316 GLN C CG 46.165 6.985 15.578 1 5332 +ATOM C CD . GLN C 2 316 . 316 GLN C CD 45.44 8.221 16.056 1 5333 +ATOM N NE2 . GLN C 2 316 . 316 GLN C NE2 44.136 8.255 15.836 1 5334 +ATOM O OE1 . GLN C 2 316 . 316 GLN C OE1 46.037 9.137 16.619 1 5335 +ATOM N N . ASP C 2 317 . 317 ASP C N 50.764 6.576 14.931 1 5336 +ATOM C CA . ASP C 2 317 . 317 ASP C CA 52.062 7.249 14.645 1 5337 +ATOM C C . ASP C 2 317 . 317 ASP C C 53.194 6.227 14.457 1 5338 +ATOM O O . ASP C 2 317 . 317 ASP C O 53.986 6.418 13.514 1 5339 +ATOM C CB . ASP C 2 317 . 317 ASP C CB 52.412 8.258 15.744 1 5340 +ATOM C CG . ASP C 2 317 . 317 ASP C CG 51.446 9.428 15.843 1 5341 +ATOM O OD1 . ASP C 2 317 . 317 ASP C OD1 50.625 9.601 14.918 1 5342 +ATOM O OD2 . ASP C 2 317 . 317 ASP C OD2 51.525 10.164 16.845 1 5343 +ATOM N N . THR C 2 318 . 318 THR C N 53.297 5.207 15.319 1 5344 +ATOM C CA . THR C 2 318 . 318 THR C CA 54.427 4.23 15.325 1 5345 +ATOM C C . THR C 2 318 . 318 THR C C 54.336 3.315 14.095 1 5346 +ATOM O O . THR C 2 318 . 318 THR C O 55.348 3.217 13.369 1 5347 +ATOM C CB . THR C 2 318 . 318 THR C CB 54.489 3.418 16.627 1 5348 +ATOM C CG2 . THR C 2 318 . 318 THR C CG2 55.642 2.437 16.658 1 5349 +ATOM O OG1 . THR C 2 318 . 318 THR C OG1 54.616 4.322 17.726 1 5350 +ATOM N N . VAL C 2 319 . 319 VAL C N 53.182 2.676 13.866 1 5351 +ATOM C CA . VAL C 2 319 . 319 VAL C CA 52.979 1.727 12.727 1 5352 +ATOM C C . VAL C 2 319 . 319 VAL C C 52.103 2.394 11.652 1 5353 +ATOM O O . VAL C 2 319 . 319 VAL C O 52.512 2.359 10.475 1 5354 +ATOM C CB . VAL C 2 319 . 319 VAL C CB 52.436 0.357 13.189 1 5355 +ATOM C CG1 . VAL C 2 319 . 319 VAL C CG1 51.236 0.464 14.115 1 5356 +ATOM C CG2 . VAL C 2 319 . 319 VAL C CG2 52.122 -0.555 12.011 1 5357 +ATOM N N . GLY C 2 320 . 320 GLY C N 50.968 2.993 12.032 1 5358 +ATOM C CA . GLY C 2 320 . 320 GLY C CA 50.056 3.706 11.112 1 5359 +ATOM C C . GLY C 2 320 . 320 GLY C C 49.02 2.784 10.484 1 5360 +ATOM O O . GLY C 2 320 . 320 GLY C O 49.345 1.606 10.227 1 5361 +ATOM N N . TRP C 2 321 . 321 TRP C N 47.819 3.311 10.224 1 5362 +ATOM C CA . TRP C 2 321 . 321 TRP C CA 46.648 2.551 9.703 1 5363 +ATOM C C . TRP C 2 321 . 321 TRP C C 46.895 2.08 8.265 1 5364 +ATOM O O . TRP C 2 321 . 321 TRP C O 46.356 1.018 7.902 1 5365 +ATOM C CB . TRP C 2 321 . 321 TRP C CB 45.364 3.384 9.815 1 5366 +ATOM C CG . TRP C 2 321 . 321 TRP C CG 44.855 3.483 11.22 1 5367 +ATOM C CD1 . TRP C 2 321 . 321 TRP C CD1 44.837 4.593 12.012 1 5368 +ATOM C CD2 . TRP C 2 321 . 321 TRP C CD2 44.324 2.41 12.02 1 5369 +ATOM C CE2 . TRP C 2 321 . 321 TRP C CE2 43.997 2.959 13.278 1 5370 +ATOM C CE3 . TRP C 2 321 . 321 TRP C CE3 44.086 1.047 11.797 1 5371 +ATOM N NE1 . TRP C 2 321 . 321 TRP C NE1 44.319 4.289 13.242 1 5372 +ATOM C CZ2 . TRP C 2 321 . 321 TRP C CZ2 43.442 2.193 14.303 1 5373 +ATOM C CZ3 . TRP C 2 321 . 321 TRP C CZ3 43.536 0.291 12.81 1 5374 +ATOM C CH2 . TRP C 2 321 . 321 TRP C CH2 43.222 0.858 14.047 1 5375 +ATOM N N . ASP C 2 322 . 322 ASP C N 47.674 2.831 7.48 1 5376 +ATOM C CA . ASP C 2 322 . 322 ASP C CA 48.028 2.469 6.08 1 5377 +ATOM C C . ASP C 2 322 . 322 ASP C C 48.663 1.071 6.07 1 5378 +ATOM O O . ASP C 2 322 . 322 ASP C O 48.209 0.222 5.283 1 5379 +ATOM C CB . ASP C 2 322 . 322 ASP C CB 48.953 3.512 5.447 1 5380 +ATOM C CG . ASP C 2 322 . 322 ASP C CG 49.182 3.301 3.959 1 5381 +ATOM O OD1 . ASP C 2 322 . 322 ASP C OD1 48.206 3.415 3.193 1 5382 +ATOM O OD2 . ASP C 2 322 . 322 ASP C OD2 50.332 3.015 3.579 1 5383 +ATOM N N . ARG C 2 323 . 323 ARG C N 49.651 0.837 6.938 1 5384 +ATOM C CA . ARG C 2 323 . 323 ARG C CA 50.431 -0.43 6.991 1 5385 +ATOM C C . ARG C 2 323 . 323 ARG C C 49.629 -1.51 7.73 1 5386 +ATOM O O . ARG C 2 323 . 323 ARG C O 49.689 -2.675 7.283 1 5387 +ATOM C CB . ARG C 2 323 . 323 ARG C CB 51.811 -0.186 7.614 1 5388 +ATOM C CG . ARG C 2 323 . 323 ARG C CG 52.919 0.022 6.59 1 5389 +ATOM C CD . ARG C 2 323 . 323 ARG C CD 53.852 1.182 6.896 1 5390 +ATOM N NE . ARG C 2 323 . 323 ARG C NE 54.457 1.125 8.222 1 5391 +ATOM C CZ . ARG C 2 323 . 323 ARG C CZ 55.259 2.059 8.735 1 5392 +ATOM N NH1 . ARG C 2 323 . 323 ARG C NH1 55.572 3.136 8.031 1 5393 +ATOM N NH2 . ARG C 2 323 . 323 ARG C NH2 55.749 1.907 9.955 1 5394 +ATOM N N . ILE C 2 324 . 324 ILE C N 48.908 -1.141 8.799 1 5395 +ATOM C CA . ILE C 2 324 . 324 ILE C CA 48.048 -2.063 9.607 1 5396 +ATOM C C . ILE C 2 324 . 324 ILE C C 47.048 -2.76 8.675 1 5397 +ATOM O O . ILE C 2 324 . 324 ILE C O 47.037 -4.007 8.645 1 5398 +ATOM C CB . ILE C 2 324 . 324 ILE C CB 47.333 -1.312 10.751 1 5399 +ATOM C CG1 . ILE C 2 324 . 324 ILE C CG1 48.303 -0.952 11.88 1 5400 +ATOM C CG2 . ILE C 2 324 . 324 ILE C CG2 46.145 -2.111 11.274 1 5401 +ATOM C CD1 . ILE C 2 324 . 324 ILE C CD1 47.744 0.03 12.888 1 5402 +ATOM N N . VAL C 2 325 . 325 VAL C N 46.241 -1.978 7.952 1 5403 +ATOM C CA . VAL C 2 325 . 325 VAL C CA 45.159 -2.48 7.051 1 5404 +ATOM C C . VAL C 2 325 . 325 VAL C C 45.792 -3.336 5.943 1 5405 +ATOM O O . VAL C 2 325 . 325 VAL C O 45.308 -4.465 5.722 1 5406 +ATOM C CB . VAL C 2 325 . 325 VAL C CB 44.319 -1.323 6.473 1 5407 +ATOM C CG1 . VAL C 2 325 . 325 VAL C CG1 43.327 -1.807 5.422 1 5408 +ATOM C CG2 . VAL C 2 325 . 325 VAL C CG2 43.592 -0.552 7.565 1 5409 +ATOM N N . ARG C 2 326 . 326 ARG C N 46.837 -2.822 5.285 1 5410 +ATOM C CA . ARG C 2 326 . 326 ARG C CA 47.518 -3.5 4.148 1 5411 +ATOM C C . ARG C 2 326 . 326 ARG C C 48.093 -4.843 4.617 1 5412 +ATOM O O . ARG C 2 326 . 326 ARG C O 47.908 -5.838 3.891 1 5413 +ATOM C CB . ARG C 2 326 . 326 ARG C CB 48.603 -2.606 3.542 1 5414 +ATOM C CG . ARG C 2 326 . 326 ARG C CG 48.061 -1.516 2.627 1 5415 +ATOM C CD . ARG C 2 326 . 326 ARG C CD 49.141 -0.527 2.234 1 5416 +ATOM N NE . ARG C 2 326 . 326 ARG C NE 50.214 -1.179 1.494 1 5417 +ATOM C CZ . ARG C 2 326 . 326 ARG C CZ 51.473 -0.75 1.422 1 5418 +ATOM N NH1 . ARG C 2 326 . 326 ARG C NH1 51.856 0.344 2.062 1 5419 +ATOM N NH2 . ARG C 2 326 . 326 ARG C NH2 52.356 -1.433 0.713 1 5420 +ATOM N N . GLN C 2 327 . 327 GLN C N 48.752 -4.873 5.781 1 5421 +ATOM C CA . GLN C 2 327 . 327 GLN C CA 49.304 -6.119 6.382 1 5422 +ATOM C C . GLN C 2 327 . 327 GLN C C 48.151 -7.106 6.6 1 5423 +ATOM O O . GLN C 2 327 . 327 GLN C O 48.249 -8.243 6.106 1 5424 +ATOM C CB . GLN C 2 327 . 327 GLN C CB 50.046 -5.814 7.688 1 5425 +ATOM C CG . GLN C 2 327 . 327 GLN C CG 50.534 -7.047 8.443 1 5426 +ATOM C CD . GLN C 2 327 . 327 GLN C CD 51.626 -7.822 7.74 1 5427 +ATOM N NE2 . GLN C 2 327 . 327 GLN C NE2 52.019 -8.937 8.337 1 5428 +ATOM O OE1 . GLN C 2 327 . 327 GLN C OE1 52.127 -7.43 6.686 1 5429 +ATOM N N . GLU C 2 328 . 328 GLU C N 47.092 -6.666 7.284 1 5430 +ATOM C CA . GLU C 2 328 . 328 GLU C CA 45.921 -7.51 7.648 1 5431 +ATOM C C . GLU C 2 328 . 328 GLU C C 45.229 -8.015 6.375 1 5432 +ATOM O O . GLU C 2 328 . 328 GLU C O 44.716 -9.148 6.401 1 5433 +ATOM C CB . GLU C 2 328 . 328 GLU C CB 44.964 -6.738 8.562 1 5434 +ATOM C CG . GLU C 2 328 . 328 GLU C CG 45.465 -6.634 9.994 1 5435 +ATOM C CD . GLU C 2 328 . 328 GLU C CD 44.513 -5.989 10.99 1 5436 +ATOM O OE1 . GLU C 2 328 . 328 GLU C OE1 43.324 -5.803 10.648 1 5437 +ATOM O OE2 . GLU C 2 328 . 328 GLU C OE2 44.967 -5.669 12.11 1 5438 +ATOM N N . THR C 2 329 . 329 THR C N 45.222 -7.218 5.303 1 5439 +ATOM C CA . THR C 2 329 . 329 THR C CA 44.627 -7.592 3.991 1 5440 +ATOM C C . THR C 2 329 . 329 THR C C 45.44 -8.74 3.373 1 5441 +ATOM O O . THR C 2 329 . 329 THR C O 44.82 -9.694 2.867 1 5442 +ATOM C CB . THR C 2 329 . 329 THR C CB 44.507 -6.371 3.069 1 5443 +ATOM C CG2 . THR C 2 329 . 329 THR C CG2 44.056 -6.717 1.665 1 5444 +ATOM O OG1 . THR C 2 329 . 329 THR C OG1 43.565 -5.484 3.674 1 5445 +ATOM N N . VAL C 2 330 . 330 VAL C N 46.773 -8.664 3.412 1 5446 +ATOM C CA . VAL C 2 330 . 330 VAL C CA 47.67 -9.731 2.872 1 5447 +ATOM C C . VAL C 2 330 . 330 VAL C C 47.395 -11.032 3.639 1 5448 +ATOM O O . VAL C 2 330 . 330 VAL C O 47.378 -12.099 2.993 1 5449 +ATOM C CB . VAL C 2 330 . 330 VAL C CB 49.158 -9.332 2.938 1 5450 +ATOM C CG1 . VAL C 2 330 . 330 VAL C CG1 50.072 -10.496 2.583 1 5451 +ATOM C CG2 . VAL C 2 330 . 330 VAL C CG2 49.464 -8.136 2.047 1 5452 +ATOM N N . LEU C 2 331 . 331 LEU C N 47.168 -10.943 4.955 1 5453 +ATOM C CA . LEU C 2 331 . 331 LEU C CA 46.953 -12.116 5.846 1 5454 +ATOM C C . LEU C 2 331 . 331 LEU C C 45.652 -12.835 5.458 1 5455 +ATOM O O . LEU C 2 331 . 331 LEU C O 45.696 -14.074 5.3 1 5456 +ATOM C CB . LEU C 2 331 . 331 LEU C CB 46.934 -11.66 7.309 1 5457 +ATOM C CG . LEU C 2 331 . 331 LEU C CG 48.235 -11.046 7.834 1 5458 +ATOM C CD1 . LEU C 2 331 . 331 LEU C CD1 48.066 -10.562 9.266 1 5459 +ATOM C CD2 . LEU C 2 331 . 331 LEU C CD2 49.394 -12.028 7.743 1 5460 +ATOM N N . VAL C 2 332 . 332 VAL C N 44.547 -12.1 5.288 1 5461 +ATOM C CA . VAL C 2 332 . 332 VAL C CA 43.221 -12.684 4.913 1 5462 +ATOM C C . VAL C 2 332 . 332 VAL C C 43.277 -13.217 3.476 1 5463 +ATOM O O . VAL C 2 332 . 332 VAL C O 42.62 -14.241 3.214 1 5464 +ATOM C CB . VAL C 2 332 . 332 VAL C CB 42.049 -11.696 5.086 1 5465 +ATOM C CG1 . VAL C 2 332 . 332 VAL C CG1 41.753 -11.435 6.549 1 5466 +ATOM C CG2 . VAL C 2 332 . 332 VAL C CG2 42.254 -10.388 4.339 1 5467 +ATOM N N . THR C 2 333 . 333 THR C N 44.007 -12.55 2.578 1 5468 +ATOM C CA . THR C 2 333 . 333 THR C CA 44.182 -12.989 1.166 1 5469 +ATOM C C . THR C 2 333 . 333 THR C C 44.721 -14.424 1.162 1 5470 +ATOM O O . THR C 2 333 . 333 THR C O 44.121 -15.275 0.478 1 5471 +ATOM C CB . THR C 2 333 . 333 THR C CB 45.097 -12.047 0.372 1 5472 +ATOM C CG2 . THR C 2 333 . 333 THR C CG2 45.193 -12.416 -1.093 1 5473 +ATOM O OG1 . THR C 2 333 . 333 THR C OG1 44.588 -10.719 0.495 1 5474 +ATOM N N . ILE C 2 334 . 334 ILE C N 45.79 -14.68 1.921 1 5475 +ATOM C CA . ILE C 2 334 . 334 ILE C CA 46.503 -15.993 1.956 1 5476 +ATOM C C . ILE C 2 334 . 334 ILE C C 45.568 -17.057 2.552 1 5477 +ATOM O O . ILE C 2 334 . 334 ILE C O 45.507 -18.165 1.973 1 5478 +ATOM C CB . ILE C 2 334 . 334 ILE C CB 47.841 -15.887 2.719 1 5479 +ATOM C CG1 . ILE C 2 334 . 334 ILE C CG1 48.807 -14.916 2.033 1 5480 +ATOM C CG2 . ILE C 2 334 . 334 ILE C CG2 48.474 -17.26 2.901 1 5481 +ATOM C CD1 . ILE C 2 334 . 334 ILE C CD1 49.97 -14.487 2.897 1 5482 +ATOM N N . LEU C 2 335 . 335 LEU C N 44.866 -16.737 3.647 1 5483 +ATOM C CA . LEU C 2 335 . 335 LEU C CA 43.946 -17.681 4.344 1 5484 +ATOM C C . LEU C 2 335 . 335 LEU C C 42.772 -18.029 3.42 1 5485 +ATOM O O . LEU C 2 335 . 335 LEU C O 42.509 -19.233 3.234 1 5486 +ATOM C CB . LEU C 2 335 . 335 LEU C CB 43.449 -17.064 5.657 1 5487 +ATOM C CG . LEU C 2 335 . 335 LEU C CG 42.527 -17.954 6.494 1 5488 +ATOM C CD1 . LEU C 2 335 . 335 LEU C CD1 43.231 -19.235 6.921 1 5489 +ATOM C CD2 . LEU C 2 335 . 335 LEU C CD2 42.009 -17.208 7.713 1 5490 +ATOM N N . LEU C 2 336 . 336 LEU C N 42.095 -17.018 2.867 1 5491 +ATOM C CA . LEU C 2 336 . 336 LEU C CA 40.928 -17.197 1.958 1 5492 +ATOM C C . LEU C 2 336 . 336 LEU C C 41.368 -17.95 0.699 1 5493 +ATOM O O . LEU C 2 336 . 336 LEU C O 40.608 -18.832 0.251 1 5494 +ATOM C CB . LEU C 2 336 . 336 LEU C CB 40.328 -15.832 1.608 1 5495 +ATOM C CG . LEU C 2 336 . 336 LEU C CG 39.61 -15.121 2.754 1 5496 +ATOM C CD1 . LEU C 2 336 . 336 LEU C CD1 39.181 -13.72 2.34 1 5497 +ATOM C CD2 . LEU C 2 336 . 336 LEU C CD2 38.41 -15.924 3.232 1 5498 +ATOM N N . GLU C 2 337 . 337 GLU C N 42.549 -17.624 0.162 1 5499 +ATOM C CA . GLU C 2 337 . 337 GLU C CA 43.123 -18.28 -1.044 1 5500 +ATOM C C . GLU C 2 337 . 337 GLU C C 43.115 -19.798 -0.833 1 5501 +ATOM O O . GLU C 2 337 . 337 GLU C O 42.682 -20.517 -1.755 1 5502 +ATOM C CB . GLU C 2 337 . 337 GLU C CB 44.536 -17.767 -1.329 1 5503 +ATOM N N . TYR C 2 338 . 338 TYR C N 43.552 -20.259 0.344 1 5504 +ATOM C CA . TYR C 2 338 . 338 TYR C CA 43.612 -21.699 0.709 1 5505 +ATOM C C . TYR C 2 338 . 338 TYR C C 42.192 -22.257 0.886 1 5506 +ATOM O O . TYR C 2 338 . 338 TYR C O 41.862 -23.272 0.235 1 5507 +ATOM C CB . TYR C 2 338 . 338 TYR C CB 44.433 -21.939 1.981 1 5508 +ATOM C CG . TYR C 2 338 . 338 TYR C CG 44.243 -23.331 2.525 1 5509 +ATOM C CD1 . TYR C 2 338 . 338 TYR C CD1 44.745 -24.43 1.845 1 5510 +ATOM C CD2 . TYR C 2 338 . 338 TYR C CD2 43.496 -23.559 3.669 1 5511 +ATOM C CE1 . TYR C 2 338 . 338 TYR C CE1 44.542 -25.719 2.309 1 5512 +ATOM C CE2 . TYR C 2 338 . 338 TYR C CE2 43.285 -24.842 4.147 1 5513 +ATOM C CZ . TYR C 2 338 . 338 TYR C CZ 43.81 -25.925 3.464 1 5514 +ATOM O OH . TYR C 2 338 . 338 TYR C OH 43.606 -27.193 3.921 1 5515 +ATOM N N . LEU C 2 339 . 339 LEU C N 41.389 -21.629 1.753 1 5516 +ATOM C CA . LEU C 2 339 . 339 LEU C CA 40.04 -22.121 2.149 1 5517 +ATOM C C . LEU C 2 339 . 339 LEU C C 39.134 -22.214 0.914 1 5518 +ATOM O O . LEU C 2 339 . 339 LEU C O 38.416 -23.226 0.797 1 5519 +ATOM C CB . LEU C 2 339 . 339 LEU C CB 39.438 -21.19 3.207 1 5520 +ATOM C CG . LEU C 2 339 . 339 LEU C CG 40.13 -21.197 4.569 1 5521 +ATOM C CD1 . LEU C 2 339 . 339 LEU C CD1 39.616 -20.061 5.438 1 5522 +ATOM C CD2 . LEU C 2 339 . 339 LEU C CD2 39.942 -22.531 5.277 1 5523 +ATOM N N . LEU C 2 340 . 340 LEU C N 39.179 -21.214 0.027 1 5524 +ATOM C CA . LEU C 2 340 . 340 LEU C CA 38.302 -21.126 -1.174 1 5525 +ATOM C C . LEU C 2 340 . 340 LEU C C 38.854 -21.985 -2.324 1 5526 +ATOM O O . LEU C 2 340 . 340 LEU C O 38.103 -22.183 -3.296 1 5527 +ATOM C CB . LEU C 2 340 . 340 LEU C CB 38.156 -19.66 -1.596 1 5528 +ATOM C CG . LEU C 2 340 . 340 LEU C CG 37.487 -18.743 -0.571 1 5529 +ATOM C CD1 . LEU C 2 340 . 340 LEU C CD1 37.533 -17.292 -1.031 1 5530 +ATOM C CD2 . LEU C 2 340 . 340 LEU C CD2 36.05 -19.172 -0.301 1 5531 +ATOM N N . SER C 2 341 . 341 SER C N 40.096 -22.481 -2.223 1 5532 +ATOM C CA . SER C 2 341 . 341 SER C CA 40.706 -23.424 -3.2 1 5533 +ATOM C C . SER C 2 341 . 341 SER C C 40.062 -24.812 -3.07 1 5534 +ATOM O O . SER C 2 341 . 341 SER C O 40.123 -25.576 -4.052 1 5535 +ATOM C CB . SER C 2 341 . 341 SER C CB 42.212 -23.505 -3.051 1 5536 +ATOM O OG . SER C 2 341 . 341 SER C OG 42.581 -24.265 -1.909 1 5537 +ATOM N N . LYS C 2 342 . 342 LYS C N 39.477 -25.128 -1.905 1 5538 +ATOM C CA . LYS C 2 342 . 342 LYS C CA 38.85 -26.447 -1.606 1 5539 +ATOM C C . LYS C 2 342 . 342 LYS C C 37.411 -26.229 -1.136 1 5540 +ATOM O O . LYS C 2 342 . 342 LYS C O 37.113 -26.363 0.05 1 5541 +ATOM C CB . LYS C 2 342 . 342 LYS C CB 39.705 -27.206 -0.585 1 5542 +ATOM C CG . LYS C 2 342 . 342 LYS C CG 41.135 -27.473 -1.038 1 5543 +ATOM C CD . LYS C 2 342 . 342 LYS C CD 41.92 -28.395 -0.129 1 5544 +ATOM C CE . LYS C 2 342 . 342 LYS C CE 43.298 -28.715 -0.675 1 5545 +ATOM N NZ . LYS C 2 342 . 342 LYS C NZ 43.896 -29.894 -0.005 1 5546 +ATOM N N . PRO C 2 343 . 343 PRO C N 36.472 -25.91 -2.059 1 5547 +ATOM C CA . PRO C 2 343 . 343 PRO C CA 35.084 -25.615 -1.692 1 5548 +ATOM C C . PRO C 2 343 . 343 PRO C C 34.287 -26.829 -1.182 1 5549 +ATOM O O . PRO C 2 343 . 343 PRO C O 33.287 -26.625 -0.514 1 5550 +ATOM C CB . PRO C 2 343 . 343 PRO C CB 34.469 -25.097 -3.004 1 5551 +ATOM C CG . PRO C 2 343 . 343 PRO C CG 35.294 -25.753 -4.09 1 5552 +ATOM C CD . PRO C 2 343 . 343 PRO C CD 36.693 -25.83 -3.513 1 5553 +ATOM N N . SER C 2 344 . 344 SER C N 34.731 -28.046 -1.515 1 5554 +ATOM C CA . SER C 2 344 . 344 SER C CA 34.117 -29.333 -1.089 1 5555 +ATOM C C . SER C 2 344 . 344 SER C C 34.473 -29.633 0.373 1 5556 +ATOM O O . SER C 2 344 . 344 SER C O 33.769 -30.457 0.994 1 5557 +ATOM C CB . SER C 2 344 . 344 SER C CB 34.552 -30.46 -1.992 1 5558 +ATOM O OG . SER C 2 344 . 344 SER C OG 35.97 -30.555 -2.036 1 5559 +ATOM N N . VAL C 2 345 . 345 VAL C N 35.528 -28.992 0.892 1 5560 +ATOM C CA . VAL C 2 345 . 345 VAL C CA 36.11 -29.251 2.244 1 5561 +ATOM C C . VAL C 2 345 . 345 VAL C C 35.746 -28.103 3.193 1 5562 +ATOM O O . VAL C 2 345 . 345 VAL C O 35.366 -28.396 4.343 1 5563 +ATOM C CB . VAL C 2 345 . 345 VAL C CB 37.635 -29.447 2.152 1 5564 +ATOM C CG1 . VAL C 2 345 . 345 VAL C CG1 38.3 -29.438 3.521 1 5565 +ATOM C CG2 . VAL C 2 345 . 345 VAL C CG2 37.991 -30.719 1.395 1 5566 +ATOM N N . TYR C 2 346 . 346 TYR C N 35.878 -26.852 2.741 1 5567 +ATOM C CA . TYR C 2 346 . 346 TYR C CA 35.665 -25.631 3.564 1 5568 +ATOM C C . TYR C 2 346 . 346 TYR C C 34.441 -24.857 3.062 1 5569 +ATOM O O . TYR C 2 346 . 346 TYR C O 34.395 -24.486 1.871 1 5570 +ATOM C CB . TYR C 2 346 . 346 TYR C CB 36.916 -24.751 3.549 1 5571 +ATOM C CG . TYR C 2 346 . 346 TYR C CG 38.152 -25.436 4.07 1 5572 +ATOM C CD1 . TYR C 2 346 . 346 TYR C CD1 38.269 -25.778 5.408 1 5573 +ATOM C CD2 . TYR C 2 346 . 346 TYR C CD2 39.203 -25.754 3.225 1 5574 +ATOM C CE1 . TYR C 2 346 . 346 TYR C CE1 39.398 -26.418 5.894 1 5575 +ATOM C CE2 . TYR C 2 346 . 346 TYR C CE2 40.34 -26.391 3.696 1 5576 +ATOM C CZ . TYR C 2 346 . 346 TYR C CZ 40.439 -26.725 5.035 1 5577 +ATOM O OH . TYR C 2 346 . 346 TYR C OH 41.562 -27.352 5.498 1 5578 +ATOM N N . ARG C 2 347 . 347 ARG C N 33.483 -24.632 3.965 1 5579 +ATOM C CA . ARG C 2 347 . 347 ARG C CA 32.336 -23.703 3.799 1 5580 +ATOM C C . ARG C 2 347 . 347 ARG C C 32.681 -22.41 4.546 1 5581 +ATOM O O . ARG C 2 347 . 347 ARG C O 32.666 -22.429 5.793 1 5582 +ATOM C CB . ARG C 2 347 . 347 ARG C CB 31.057 -24.36 4.33 1 5583 +ATOM C CG . ARG C 2 347 . 347 ARG C CG 29.803 -23.504 4.223 1 5584 +ATOM C CD . ARG C 2 347 . 347 ARG C CD 28.697 -24.055 5.103 1 5585 +ATOM N NE . ARG C 2 347 . 347 ARG C NE 27.51 -23.211 5.123 1 5586 +ATOM C CZ . ARG C 2 347 . 347 ARG C CZ 26.459 -23.392 5.923 1 5587 +ATOM N NH1 . ARG C 2 347 . 347 ARG C NH1 26.437 -24.395 6.787 1 5588 +ATOM N NH2 . ARG C 2 347 . 347 ARG C NH2 25.432 -22.562 5.858 1 5589 +ATOM N N . VAL C 2 348 . 348 VAL C N 33.021 -21.348 3.809 1 5590 +ATOM C CA . VAL C 2 348 . 348 VAL C CA 33.467 -20.036 4.366 1 5591 +ATOM C C . VAL C 2 348 . 348 VAL C C 32.235 -19.139 4.521 1 5592 +ATOM O O . VAL C 2 348 . 348 VAL C O 31.483 -18.997 3.54 1 5593 +ATOM C CB . VAL C 2 348 . 348 VAL C CB 34.541 -19.385 3.471 1 5594 +ATOM C CG1 . VAL C 2 348 . 348 VAL C CG1 34.987 -18.031 4.002 1 5595 +ATOM C CG2 . VAL C 2 348 . 348 VAL C CG2 35.739 -20.304 3.277 1 5596 +ATOM N N . PHE C 2 349 . 349 PHE C N 32.035 -18.577 5.717 1 5597 +ATOM C CA . PHE C 2 349 . 349 PHE C CA 30.979 -17.575 6.019 1 5598 +ATOM C C . PHE C 2 349 . 349 PHE C C 31.551 -16.173 5.791 1 5599 +ATOM O O . PHE C 2 349 . 349 PHE C O 32.793 -16.008 5.764 1 5600 +ATOM C CB . PHE C 2 349 . 349 PHE C CB 30.453 -17.728 7.45 1 5601 +ATOM C CG . PHE C 2 349 . 349 PHE C CG 29.895 -19.088 7.779 1 5602 +ATOM C CD1 . PHE C 2 349 . 349 PHE C CD1 28.6 -19.431 7.422 1 5603 +ATOM C CD2 . PHE C 2 349 . 349 PHE C CD2 30.668 -20.029 8.443 1 5604 +ATOM C CE1 . PHE C 2 349 . 349 PHE C CE1 28.092 -20.687 7.721 1 5605 +ATOM C CE2 . PHE C 2 349 . 349 PHE C CE2 30.157 -21.284 8.744 1 5606 +ATOM C CZ . PHE C 2 349 . 349 PHE C CZ 28.87 -21.611 8.382 1 5607 +ATOM N N . GLY C 2 350 . 350 GLY C N 30.661 -15.193 5.628 1 5608 +ATOM C CA . GLY C 2 350 . 350 GLY C CA 31.021 -13.78 5.423 1 5609 +ATOM C C . GLY C 2 350 . 350 GLY C C 31.45 -13.508 3.992 1 5610 +ATOM O O . GLY C 2 350 . 350 GLY C O 31.049 -14.273 3.083 1 5611 +ATOM N N . ARG C 2 351 . 351 ARG C N 32.241 -12.45 3.801 1 5612 +ATOM C CA . ARG C 2 351 . 351 ARG C CA 32.653 -11.942 2.469 1 5613 +ATOM C C . ARG C 2 351 . 351 ARG C C 33.843 -12.768 1.975 1 5614 +ATOM O O . ARG C 2 351 . 351 ARG C O 34.925 -12.68 2.592 1 5615 +ATOM C CB . ARG C 2 351 . 351 ARG C CB 32.965 -10.444 2.556 1 5616 +ATOM C CG . ARG C 2 351 . 351 ARG C CG 33.016 -9.738 1.209 1 5617 +ATOM C CD . ARG C 2 351 . 351 ARG C CD 31.75 -9.9 0.384 1 5618 +ATOM N NE . ARG C 2 351 . 351 ARG C NE 30.548 -9.49 1.1 1 5619 +ATOM C CZ . ARG C 2 351 . 351 ARG C CZ 30.15 -8.233 1.276 1 5620 +ATOM N NH1 . ARG C 2 351 . 351 ARG C NH1 30.863 -7.224 0.802 1 5621 +ATOM N NH2 . ARG C 2 351 . 351 ARG C NH2 29.036 -7.988 1.942 1 5622 +ATOM N N . ARG C 2 352 . 352 ARG C N 33.645 -13.54 0.9 1 5623 +ATOM C CA . ARG C 2 352 . 352 ARG C CA 34.656 -14.474 0.333 1 5624 +ATOM C C . ARG C 2 352 . 352 ARG C C 35.595 -13.704 -0.605 1 5625 +ATOM O O . ARG C 2 352 . 352 ARG C O 35.77 -14.128 -1.765 1 5626 +ATOM C CB . ARG C 2 352 . 352 ARG C CB 33.959 -15.639 -0.376 1 5627 +ATOM C CG . ARG C 2 352 . 352 ARG C CG 33.14 -16.529 0.548 1 5628 +ATOM C CD . ARG C 2 352 . 352 ARG C CD 32.322 -17.534 -0.239 1 5629 +ATOM N NE . ARG C 2 352 . 352 ARG C NE 31.371 -16.87 -1.124 1 5630 +ATOM C CZ . ARG C 2 352 . 352 ARG C CZ 30.765 -17.437 -2.166 1 5631 +ATOM N NH1 . ARG C 2 352 . 352 ARG C NH1 31.003 -18.702 -2.479 1 5632 +ATOM N NH2 . ARG C 2 352 . 352 ARG C NH2 29.916 -16.732 -2.896 1 5633 +ATOM N N . ASN C 2 353 . 353 ASN C N 36.165 -12.606 -0.105 1 5634 +ATOM C CA . ASN C 2 353 . 353 ASN C CA 37.255 -11.821 -0.741 1 5635 +ATOM C C . ASN C 2 353 . 353 ASN C C 37.975 -11.058 0.379 1 5636 +ATOM O O . ASN C 2 353 . 353 ASN C O 37.465 -11.067 1.516 1 5637 +ATOM C CB . ASN C 2 353 . 353 ASN C CB 36.732 -10.915 -1.862 1 5638 +ATOM C CG . ASN C 2 353 . 353 ASN C CG 35.903 -9.752 -1.358 1 5639 +ATOM N ND2 . ASN C 2 353 . 353 ASN C ND2 34.674 -9.638 -1.837 1 5640 +ATOM O OD1 . ASN C 2 353 . 353 ASN C OD1 36.374 -8.956 -0.548 1 5641 +ATOM N N . SER C 2 354 . 354 SER C N 39.106 -10.424 0.061 1 5642 +ATOM C CA . SER C 2 354 . 354 SER C CA 40.035 -9.777 1.025 1 5643 +ATOM C C . SER C 2 354 . 354 SER C C 39.885 -8.25 0.995 1 5644 +ATOM O O . SER C 2 354 . 354 SER C O 40.668 -7.571 1.692 1 5645 +ATOM C CB . SER C 2 354 . 354 SER C CB 41.45 -10.189 0.725 1 5646 +ATOM O OG . SER C 2 354 . 354 SER C OG 41.75 -9.978 -0.646 1 5647 +ATOM N N . ASP C 2 355 . 355 ASP C N 38.924 -7.726 0.228 1 5648 +ATOM C CA . ASP C 2 355 . 355 ASP C CA 38.744 -6.264 0.02 1 5649 +ATOM C C . ASP C 2 355 . 355 ASP C C 38.656 -5.59 1.389 1 5650 +ATOM O O . ASP C 2 355 . 355 ASP C O 37.751 -5.886 2.167 1 5651 +ATOM C CB . ASP C 2 355 . 355 ASP C CB 37.525 -5.967 -0.862 1 5652 +ATOM C CG . ASP C 2 355 . 355 ASP C CG 37.396 -4.509 -1.279 1 5653 +ATOM O OD1 . ASP C 2 355 . 355 ASP C OD1 37.93 -3.638 -0.561 1 5654 +ATOM O OD2 . ASP C 2 355 . 355 ASP C OD2 36.76 -4.251 -2.322 1 5655 +ATOM N N . PRO C 2 356 . 356 PRO C N 39.598 -4.678 1.734 1 5656 +ATOM C CA . PRO C 2 356 . 356 PRO C CA 39.597 -4.027 3.048 1 5657 +ATOM C C . PRO C 2 356 . 356 PRO C C 38.346 -3.177 3.329 1 5658 +ATOM O O . PRO C 2 356 . 356 PRO C O 37.999 -3.025 4.487 1 5659 +ATOM C CB . PRO C 2 356 . 356 PRO C CB 40.852 -3.136 3.019 1 5660 +ATOM C CG . PRO C 2 356 . 356 PRO C CG 41.132 -2.929 1.543 1 5661 +ATOM C CD . PRO C 2 356 . 356 PRO C CD 40.718 -4.229 0.89 1 5662 +ATOM N N . SER C 2 357 . 357 SER C N 37.709 -2.647 2.279 1 5663 +ATOM C CA . SER C 2 357 . 357 SER C CA 36.466 -1.833 2.353 1 5664 +ATOM C C . SER C 2 357 . 357 SER C C 35.25 -2.709 2.686 1 5665 +ATOM O O . SER C 2 357 . 357 SER C O 34.251 -2.15 3.181 1 5666 +ATOM C CB . SER C 2 357 . 357 SER C CB 36.238 -1.079 1.071 1 5667 +ATOM O OG . SER C 2 357 . 357 SER C OG 35.748 -1.938 0.047 1 5668 +ATOM N N . GLN C 2 358 . 358 GLN C N 35.32 -4.014 2.399 1 5669 +ATOM C CA . GLN C 2 358 . 358 GLN C CA 34.191 -4.976 2.536 1 5670 +ATOM C C . GLN C 2 358 . 358 GLN C C 34.307 -5.775 3.843 1 5671 +ATOM O O . GLN C 2 358 . 358 GLN C O 33.26 -5.994 4.473 1 5672 +ATOM C CB . GLN C 2 358 . 358 GLN C CB 34.14 -5.921 1.333 1 5673 +ATOM C CG . GLN C 2 358 . 358 GLN C CG 33.791 -5.227 0.025 1 5674 +ATOM C CD . GLN C 2 358 . 358 GLN C CD 33.648 -6.21 -1.113 1 5675 +ATOM N NE2 . GLN C 2 358 . 358 GLN C NE2 34.32 -5.936 -2.219 1 5676 +ATOM O OE1 . GLN C 2 358 . 358 GLN C OE1 32.95 -7.214 -1.002 1 5677 +ATOM N N . ARG C 2 359 . 359 ARG C N 35.511 -6.214 4.228 1 5678 +ATOM C CA . ARG C 2 359 . 359 ARG C CA 35.714 -7.131 5.385 1 5679 +ATOM C C . ARG C 2 359 . 359 ARG C C 37.031 -6.83 6.108 1 5680 +ATOM O O . ARG C 2 359 . 359 ARG C O 37.976 -6.34 5.457 1 5681 +ATOM C CB . ARG C 2 359 . 359 ARG C CB 35.706 -8.595 4.934 1 5682 +ATOM C CG . ARG C 2 359 . 359 ARG C CG 36.862 -8.988 4.022 1 5683 +ATOM C CD . ARG C 2 359 . 359 ARG C CD 37.589 -10.225 4.524 1 5684 +ATOM N NE . ARG C 2 359 . 359 ARG C NE 36.735 -11.409 4.548 1 5685 +ATOM C CZ . ARG C 2 359 . 359 ARG C CZ 36.341 -12.071 5.639 1 5686 +ATOM N NH1 . ARG C 2 359 . 359 ARG C NH1 36.715 -11.686 6.85 1 5687 +ATOM N NH2 . ARG C 2 359 . 359 ARG C NH2 35.567 -13.136 5.51 1 5688 +ATOM N N . VAL C 2 360 . 360 VAL C N 37.075 -7.14 7.409 1 5689 +ATOM C CA . VAL C 2 360 . 360 VAL C CA 38.289 -7.057 8.278 1 5690 +ATOM C C . VAL C 2 360 . 360 VAL C C 38.829 -8.479 8.486 1 5691 +ATOM O O . VAL C 2 360 . 360 VAL C O 38.188 -9.439 7.997 1 5692 +ATOM C CB . VAL C 2 360 . 360 VAL C CB 37.991 -6.35 9.616 1 5693 +ATOM C CG1 . VAL C 2 360 . 360 VAL C CG1 37.439 -4.95 9.396 1 5694 +ATOM C CG2 . VAL C 2 360 . 360 VAL C CG2 37.057 -7.158 10.507 1 5695 +ATOM N N . ALA C 2 361 . 361 ALA C N 39.948 -8.609 9.207 1 5696 +ATOM C CA . ALA C 2 361 . 361 ALA C CA 40.743 -9.855 9.335 1 5697 +ATOM C C . ALA C 2 361 . 361 ALA C C 40.133 -10.784 10.394 1 5698 +ATOM O O . ALA C 2 361 . 361 ALA C O 40.885 -11.288 11.258 1 5699 +ATOM C CB . ALA C 2 361 . 361 ALA C CB 42.179 -9.51 9.644 1 5700 +ATOM N N . ILE C 2 362 . 362 ILE C N 38.82 -11.015 10.31 1 5701 +ATOM C CA . ILE C 2 362 . 362 ILE C CA 38.077 -12.022 11.124 1 5702 +ATOM C C . ILE C 2 362 . 362 ILE C C 37.389 -12.986 10.152 1 5703 +ATOM O O . ILE C 2 362 . 362 ILE C O 36.418 -12.568 9.501 1 5704 +ATOM C CB . ILE C 2 362 . 362 ILE C CB 37.078 -11.346 12.084 1 5705 +ATOM C CG1 . ILE C 2 362 . 362 ILE C CG1 37.771 -10.351 13.02 1 5706 +ATOM C CG2 . ILE C 2 362 . 362 ILE C CG2 36.3 -12.398 12.862 1 5707 +ATOM C CD1 . ILE C 2 362 . 362 ILE C CD1 36.822 -9.524 13.861 1 5708 +ATOM N N . VAL C 2 363 . 363 VAL C N 37.889 -14.22 10.057 1 5709 +ATOM C CA . VAL C 2 363 . 363 VAL C CA 37.417 -15.245 9.081 1 5710 +ATOM C C . VAL C 2 363 . 363 VAL C C 36.778 -16.404 9.855 1 5711 +ATOM O O . VAL C 2 363 . 363 VAL C O 37.48 -17.041 10.668 1 5712 +ATOM C CB . VAL C 2 363 . 363 VAL C CB 38.566 -15.722 8.17 1 5713 +ATOM C CG1 . VAL C 2 363 . 363 VAL C CG1 38.099 -16.768 7.169 1 5714 +ATOM C CG2 . VAL C 2 363 . 363 VAL C CG2 39.229 -14.558 7.447 1 5715 +ATOM N N . THR C 2 364 . 364 THR C N 35.489 -16.656 9.613 1 5716 +ATOM C CA . THR C 2 364 . 364 THR C CA 34.748 -17.83 10.142 1 5717 +ATOM C C . THR C 2 364 . 364 THR C C 34.455 -18.797 8.993 1 5718 +ATOM O O . THR C 2 364 . 364 THR C O 34.171 -18.328 7.868 1 5719 +ATOM C CB . THR C 2 364 . 364 THR C CB 33.467 -17.418 10.878 1 5720 +ATOM C CG2 . THR C 2 364 . 364 THR C CG2 33.753 -16.666 12.158 1 5721 +ATOM O OG1 . THR C 2 364 . 364 THR C OG1 32.675 -16.614 10.005 1 5722 +ATOM N N . PHE C 2 365 . 365 PHE C N 34.531 -20.096 9.282 1 5723 +ATOM C CA . PHE C 2 365 . 365 PHE C CA 34.238 -21.203 8.338 1 5724 +ATOM C C . PHE C 2 365 . 365 PHE C C 33.857 -22.447 9.144 1 5725 +ATOM O O . PHE C 2 365 . 365 PHE C O 33.986 -22.425 10.384 1 5726 +ATOM C CB . PHE C 2 365 . 365 PHE C CB 35.445 -21.457 7.431 1 5727 +ATOM C CG . PHE C 2 365 . 365 PHE C CG 36.713 -21.843 8.15 1 5728 +ATOM C CD1 . PHE C 2 365 . 365 PHE C CD1 37.544 -20.876 8.698 1 5729 +ATOM C CD2 . PHE C 2 365 . 365 PHE C CD2 37.081 -23.176 8.273 1 5730 +ATOM C CE1 . PHE C 2 365 . 365 PHE C CE1 38.711 -21.234 9.357 1 5731 +ATOM C CE2 . PHE C 2 365 . 365 PHE C CE2 38.247 -23.532 8.934 1 5732 +ATOM C CZ . PHE C 2 365 . 365 PHE C CZ 39.06 -22.561 9.475 1 5733 +ATOM N N . GLU C 2 366 . 366 GLU C N 33.389 -23.492 8.461 1 5734 +ATOM C CA . GLU C 2 366 . 366 GLU C CA 33.308 -24.861 9.032 1 5735 +ATOM C C . GLU C 2 366 . 366 GLU C C 33.903 -25.848 8.021 1 5736 +ATOM O O . GLU C 2 366 . 366 GLU C O 33.936 -25.531 6.814 1 5737 +ATOM C CB . GLU C 2 366 . 366 GLU C CB 31.876 -25.206 9.456 1 5738 +ATOM C CG . GLU C 2 366 . 366 GLU C CG 30.889 -25.389 8.316 1 5739 +ATOM C CD . GLU C 2 366 . 366 GLU C CD 29.543 -25.942 8.756 1 5740 +ATOM O OE1 . GLU C 2 366 . 366 GLU C OE1 29.497 -26.641 9.788 1 5741 +ATOM O OE2 . GLU C 2 366 . 366 GLU C OE2 28.539 -25.671 8.072 1 5742 +ATOM N N . VAL C 2 367 . 367 VAL C N 34.397 -26.98 8.523 1 5743 +ATOM C CA . VAL C 2 367 . 367 VAL C CA 34.919 -28.114 7.709 1 5744 +ATOM C C . VAL C 2 367 . 367 VAL C C 33.749 -29.072 7.467 1 5745 +ATOM O O . VAL C 2 367 . 367 VAL C O 33.206 -29.598 8.462 1 5746 +ATOM C CB . VAL C 2 367 . 367 VAL C CB 36.106 -28.8 8.413 1 5747 +ATOM C CG1 . VAL C 2 367 . 367 VAL C CG1 36.697 -29.921 7.569 1 5748 +ATOM C CG2 . VAL C 2 367 . 367 VAL C CG2 37.179 -27.79 8.799 1 5749 +ATOM N N . VAL C 2 368 . 368 VAL C N 33.366 -29.267 6.201 1 5750 +ATOM C CA . VAL C 2 368 . 368 VAL C CA 32.187 -30.091 5.799 1 5751 +ATOM C C . VAL C 2 368 . 368 VAL C C 32.372 -31.499 6.381 1 5752 +ATOM O O . VAL C 2 368 . 368 VAL C O 33.428 -32.109 6.12 1 5753 +ATOM C CB . VAL C 2 368 . 368 VAL C CB 31.993 -30.122 4.267 1 5754 +ATOM C CG1 . VAL C 2 368 . 368 VAL C CG1 30.772 -30.941 3.871 1 5755 +ATOM C CG2 . VAL C 2 368 . 368 VAL C CG2 31.901 -28.723 3.669 1 5756 +ATOM N N . GLY C 2 369 . 369 GLY C N 31.4 -31.964 7.176 1 5757 +ATOM C CA . GLY C 2 369 . 369 GLY C CA 31.345 -33.33 7.735 1 5758 +ATOM C C . GLY C 2 369 . 369 GLY C C 32.075 -33.473 9.064 1 5759 +ATOM O O . GLY C 2 369 . 369 GLY C O 31.969 -34.566 9.661 1 5760 +ATOM N N . ARG C 2 370 . 370 ARG C N 32.774 -32.427 9.527 1 5761 +ATOM C CA . ARG C 2 370 . 370 ARG C CA 33.685 -32.481 10.706 1 5762 +ATOM C C . ARG C 2 370 . 370 ARG C C 33.252 -31.464 11.773 1 5763 +ATOM O O . ARG C 2 370 . 370 ARG C O 32.77 -30.368 11.404 1 5764 +ATOM C CB . ARG C 2 370 . 370 ARG C CB 35.132 -32.229 10.266 1 5765 +ATOM N N . SER C 2 371 . 371 SER C N 33.433 -31.823 13.049 1 5766 +ATOM C CA . SER C 2 371 . 371 SER C CA 33.186 -30.965 14.238 1 5767 +ATOM C C . SER C 2 371 . 371 SER C C 34.163 -29.784 14.235 1 5768 +ATOM O O . SER C 2 371 . 371 SER C O 35.373 -30.024 14.085 1 5769 +ATOM C CB . SER C 2 371 . 371 SER C CB 33.314 -31.755 15.518 1 5770 +ATOM O OG . SER C 2 371 . 371 SER C OG 32.35 -32.794 15.578 1 5771 +ATOM N N . SER C 2 372 . 372 SER C N 33.648 -28.563 14.405 1 5772 +ATOM C CA . SER C 2 372 . 372 SER C CA 34.439 -27.306 14.495 1 5773 +ATOM C C . SER C 2 372 . 372 SER C C 35.275 -27.308 15.781 1 5774 +ATOM O O . SER C 2 372 . 372 SER C O 36.42 -26.82 15.739 1 5775 +ATOM C CB . SER C 2 372 . 372 SER C CB 33.539 -26.101 14.419 1 5776 +ATOM O OG . SER C 2 372 . 372 SER C OG 32.813 -26.1 13.2 1 5777 +ATOM N N . GLY C 2 373 . 373 GLY C N 34.716 -27.842 16.872 1 5778 +ATOM C CA . GLY C 2 373 . 373 GLY C CA 35.386 -27.972 18.181 1 5779 +ATOM C C . GLY C 2 373 . 373 GLY C C 36.551 -28.945 18.125 1 5780 +ATOM O O . GLY C 2 373 . 373 GLY C O 37.567 -28.685 18.797 1 5781 +ATOM N N . ASP C 2 374 . 374 ASP C N 36.408 -30.026 17.351 1 5782 +ATOM C CA . ASP C 2 374 . 374 ASP C CA 37.449 -31.071 17.15 1 5783 +ATOM C C . ASP C 2 374 . 374 ASP C C 38.615 -30.475 16.347 1 5784 +ATOM O O . ASP C 2 374 . 374 ASP C O 39.771 -30.749 16.707 1 5785 +ATOM C CB . ASP C 2 374 . 374 ASP C CB 36.854 -32.311 16.475 1 5786 +ATOM C CG . ASP C 2 374 . 374 ASP C CG 37.761 -33.534 16.462 1 5787 +ATOM O OD1 . ASP C 2 374 . 374 ASP C OD1 38.766 -33.542 17.208 1 5788 +ATOM O OD2 . ASP C 2 374 . 374 ASP C OD2 37.448 -34.479 15.707 1 5789 +ATOM N N . VAL C 2 375 . 375 VAL C N 38.322 -29.687 15.308 1 5790 +ATOM C CA . VAL C 2 375 . 375 VAL C CA 39.348 -29.025 14.446 1 5791 +ATOM C C . VAL C 2 375 . 375 VAL C C 40.153 -28.043 15.306 1 5792 +ATOM O O . VAL C 2 375 . 375 VAL C O 41.393 -28.088 15.238 1 5793 +ATOM C CB . VAL C 2 375 . 375 VAL C CB 38.711 -28.326 13.228 1 5794 +ATOM C CG1 . VAL C 2 375 . 375 VAL C CG1 39.688 -27.392 12.525 1 5795 +ATOM C CG2 . VAL C 2 375 . 375 VAL C CG2 38.141 -29.336 12.241 1 5796 +ATOM N N . ALA C 2 376 . 376 ALA C N 39.468 -27.198 16.084 1 5797 +ATOM C CA . ALA C 2 376 . 376 ALA C CA 40.072 -26.165 16.961 1 5798 +ATOM C C . ALA C 2 376 . 376 ALA C C 40.907 -26.829 18.065 1 5799 +ATOM O O . ALA C 2 376 . 376 ALA C O 41.987 -26.297 18.378 1 5800 +ATOM C CB . ALA C 2 376 . 376 ALA C CB 38.997 -25.276 17.537 1 5801 +ATOM N N . MET C 2 377 . 377 MET C N 40.422 -27.94 18.63 1 5802 +ATOM C CA . MET C 2 377 . 377 MET C CA 41.125 -28.732 19.677 1 5803 +ATOM C C . MET C 2 377 . 377 MET C C 42.446 -29.266 19.104 1 5804 +ATOM O O . MET C 2 377 . 377 MET C O 43.493 -29.058 19.748 1 5805 +ATOM C CB . MET C 2 377 . 377 MET C CB 40.257 -29.901 20.159 1 5806 +ATOM C CG . MET C 2 377 . 377 MET C CG 40.939 -30.81 21.175 1 5807 +ATOM S SD . MET C 2 377 . 377 MET C SD 40.996 -30.104 22.844 1 5808 +ATOM C CE . MET C 2 377 . 377 MET C CE 39.526 -30.837 23.563 1 5809 +ATOM N N . ARG C 2 378 . 378 ARG C N 42.393 -29.92 17.938 1 5810 +ATOM C CA . ARG C 2 378 . 378 ARG C CA 43.569 -30.537 17.265 1 5811 +ATOM C C . ARG C 2 378 . 378 ARG C C 44.643 -29.471 17.011 1 5812 +ATOM O O . ARG C 2 378 . 378 ARG C O 45.825 -29.77 17.256 1 5813 +ATOM C CB . ARG C 2 378 . 378 ARG C CB 43.154 -31.227 15.962 1 5814 +ATOM C CG . ARG C 2 378 . 378 ARG C CG 42.451 -32.563 16.16 1 5815 +ATOM C CD . ARG C 2 378 . 378 ARG C CD 42.543 -33.443 14.928 1 5816 +ATOM N NE . ARG C 2 378 . 378 ARG C NE 41.531 -34.495 14.884 1 5817 +ATOM C CZ . ARG C 2 378 . 378 ARG C CZ 40.337 -34.401 14.293 1 5818 +ATOM N NH1 . ARG C 2 378 . 378 ARG C NH1 39.96 -33.288 13.679 1 5819 +ATOM N NH2 . ARG C 2 378 . 378 ARG C NH2 39.513 -35.435 14.321 1 5820 +ATOM N N . VAL C 2 379 . 379 VAL C N 44.249 -28.278 16.55 1 5821 +ATOM C CA . VAL C 2 379 . 379 VAL C CA 45.181 -27.142 16.275 1 5822 +ATOM C C . VAL C 2 379 . 379 VAL C C 45.794 -26.671 17.599 1 5823 +ATOM O O . VAL C 2 379 . 379 VAL C O 47.032 -26.563 17.664 1 5824 +ATOM C CB . VAL C 2 379 . 379 VAL C CB 44.486 -25.981 15.534 1 5825 +ATOM C CG1 . VAL C 2 379 . 379 VAL C CG1 45.358 -24.734 15.494 1 5826 +ATOM C CG2 . VAL C 2 379 . 379 VAL C CG2 44.069 -26.38 14.126 1 5827 +ATOM N N . ASN C 2 380 . 380 ASN C N 44.959 -26.403 18.609 1 5828 +ATOM C CA . ASN C 2 380 . 380 ASN C CA 45.39 -25.798 19.899 1 5829 +ATOM C C . ASN C 2 380 . 380 ASN C C 46.182 -26.82 20.729 1 5830 +ATOM O O . ASN C 2 380 . 380 ASN C O 47.086 -26.383 21.461 1 5831 +ATOM C CB . ASN C 2 380 . 380 ASN C CB 44.208 -25.217 20.68 1 5832 +ATOM C CG . ASN C 2 380 . 380 ASN C CG 43.52 -24.073 19.962 1 5833 +ATOM N ND2 . ASN C 2 380 . 380 ASN C ND2 42.247 -23.867 20.253 1 5834 +ATOM O OD1 . ASN C 2 380 . 380 ASN C OD1 44.129 -23.381 19.15 1 5835 +ATOM N N . THR C 2 381 . 381 THR C N 45.868 -28.117 20.619 1 5836 +ATOM C CA . THR C 2 381 . 381 THR C CA 46.6 -29.224 21.3 1 5837 +ATOM C C . THR C 2 381 . 381 THR C C 48.04 -29.282 20.772 1 5838 +ATOM O O . THR C 2 381 . 381 THR C O 48.948 -29.506 21.592 1 5839 +ATOM C CB . THR C 2 381 . 381 THR C CB 45.874 -30.568 21.15 1 5840 +ATOM C CG2 . THR C 2 381 . 381 THR C CG2 46.637 -31.734 21.743 1 5841 +ATOM O OG1 . THR C 2 381 . 381 THR C OG1 44.613 -30.452 21.808 1 5842 +ATOM N N . ARG C 2 382 . 382 ARG C N 48.233 -29.086 19.461 1 5843 +ATOM C CA . ARG C 2 382 . 382 ARG C CA 49.572 -28.947 18.821 1 5844 +ATOM C C . ARG C 2 382 . 382 ARG C C 50.234 -27.654 19.313 1 5845 +ATOM O O . ARG C 2 382 . 382 ARG C O 51.461 -27.669 19.54 1 5846 +ATOM C CB . ARG C 2 382 . 382 ARG C CB 49.462 -28.947 17.294 1 5847 +ATOM C CG . ARG C 2 382 . 382 ARG C CG 49.178 -30.314 16.689 1 5848 +ATOM C CD . ARG C 2 382 . 382 ARG C CD 49.491 -30.334 15.206 1 5849 +ATOM N NE . ARG C 2 382 . 382 ARG C NE 48.99 -31.53 14.544 1 5850 +ATOM C CZ . ARG C 2 382 . 382 ARG C CZ 49.199 -31.833 13.264 1 5851 +ATOM N NH1 . ARG C 2 382 . 382 ARG C NH1 49.907 -31.024 12.491 1 5852 +ATOM N NH2 . ARG C 2 382 . 382 ARG C NH2 48.698 -32.95 12.762 1 5853 +ATOM N N . ASN C 2 383 . 383 ASN C N 49.445 -26.58 19.431 1 5854 +ATOM C CA . ASN C 2 383 . 383 ASN C CA 49.794 -25.319 20.143 1 5855 +ATOM C C . ASN C 2 383 . 383 ASN C C 50.885 -24.549 19.383 1 5856 +ATOM O O . ASN C 2 383 . 383 ASN C O 51.581 -23.733 20.026 1 5857 +ATOM C CB . ASN C 2 383 . 383 ASN C CB 50.212 -25.596 21.592 1 5858 +ATOM C CG . ASN C 2 383 . 383 ASN C CG 49.99 -24.414 22.514 1 5859 +ATOM N ND2 . ASN C 2 383 . 383 ASN C ND2 50.947 -24.142 23.387 1 5860 +ATOM O OD1 . ASN C 2 383 . 383 ASN C OD1 48.957 -23.751 22.443 1 5861 +ATOM N N . ARG C 2 384 . 384 ARG C N 51.026 -24.782 18.073 1 5862 +ATOM C CA . ARG C 2 384 . 384 ARG C CA 51.831 -23.922 17.163 1 5863 +ATOM C C . ARG C 2 384 . 384 ARG C C 50.978 -22.704 16.779 1 5864 +ATOM O O . ARG C 2 384 . 384 ARG C O 51.556 -21.626 16.526 1 5865 +ATOM C CB . ARG C 2 384 . 384 ARG C CB 52.297 -24.718 15.938 1 5866 +ATOM N N . PHE C 2 385 . 385 PHE C N 49.651 -22.876 16.754 1 5867 +ATOM C CA . PHE C 2 385 . 385 PHE C CA 48.646 -21.82 16.46 1 5868 +ATOM C C . PHE C 2 385 . 385 PHE C C 47.522 -21.88 17.502 1 5869 +ATOM O O . PHE C 2 385 . 385 PHE C O 47.156 -22.995 17.928 1 5870 +ATOM C CB . PHE C 2 385 . 385 PHE C CB 48.109 -21.987 15.035 1 5871 +ATOM C CG . PHE C 2 385 . 385 PHE C CG 49.173 -21.929 13.968 1 5872 +ATOM C CD1 . PHE C 2 385 . 385 PHE C CD1 49.647 -20.711 13.506 1 5873 +ATOM C CD2 . PHE C 2 385 . 385 PHE C CD2 49.721 -23.091 13.447 1 5874 +ATOM C CE1 . PHE C 2 385 . 385 PHE C CE1 50.638 -20.656 12.537 1 5875 +ATOM C CE2 . PHE C 2 385 . 385 PHE C CE2 50.713 -23.036 12.478 1 5876 +ATOM C CZ . PHE C 2 385 . 385 PHE C CZ 51.167 -21.818 12.024 1 5877 +ATOM N N . ARG C 2 386 . 386 ARG C N 47.009 -20.711 17.903 1 5878 +ATOM C CA . ARG C 2 386 . 386 ARG C CA 45.843 -20.559 18.815 1 5879 +ATOM C C . ARG C 2 386 . 386 ARG C C 44.65 -20.029 18.011 1 5880 +ATOM O O . ARG C 2 386 . 386 ARG C O 44.665 -18.837 17.642 1 5881 +ATOM C CB . ARG C 2 386 . 386 ARG C CB 46.178 -19.62 19.979 1 5882 +ATOM C CG . ARG C 2 386 . 386 ARG C CG 45.102 -19.544 21.056 1 5883 +ATOM C CD . ARG C 2 386 . 386 ARG C CD 45.017 -20.798 21.907 1 5884 +ATOM N NE . ARG C 2 386 . 386 ARG C NE 46.327 -21.224 22.395 1 5885 +ATOM C CZ . ARG C 2 386 . 386 ARG C CZ 46.906 -20.817 23.524 1 5886 +ATOM N NH1 . ARG C 2 386 . 386 ARG C NH1 46.3 -19.956 24.327 1 5887 +ATOM N NH2 . ARG C 2 386 . 386 ARG C NH2 48.103 -21.278 23.846 1 5888 +ATOM N N . ILE C 2 387 . 387 ILE C N 43.665 -20.893 17.746 1 5889 +ATOM C CA . ILE C 2 387 . 387 ILE C CA 42.371 -20.534 17.092 1 5890 +ATOM C C . ILE C 2 387 . 387 ILE C C 41.245 -20.86 18.079 1 5891 +ATOM O O . ILE C 2 387 . 387 ILE C O 41.566 -21.252 19.224 1 5892 +ATOM C CB . ILE C 2 387 . 387 ILE C CB 42.211 -21.257 15.737 1 5893 +ATOM C CG1 . ILE C 2 387 . 387 ILE C CG1 42.059 -22.773 15.899 1 5894 +ATOM C CG2 . ILE C 2 387 . 387 ILE C CG2 43.365 -20.897 14.814 1 5895 +ATOM C CD1 . ILE C 2 387 . 387 ILE C CD1 41.592 -23.481 14.65 1 5896 +ATOM N N . THR C 2 388 . 388 THR C N 39.985 -20.696 17.66 1 5897 +ATOM C CA . THR C 2 388 . 388 THR C CA 38.79 -20.931 18.512 1 5898 +ATOM C C . THR C 2 388 . 388 THR C C 37.654 -21.559 17.696 1 5899 +ATOM O O . THR C 2 388 . 388 THR C O 37.706 -21.516 16.449 1 5900 +ATOM C CB . THR C 2 388 . 388 THR C CB 38.323 -19.634 19.182 1 5901 +ATOM C CG2 . THR C 2 388 . 388 THR C CG2 37.583 -18.702 18.245 1 5902 +ATOM O OG1 . THR C 2 388 . 388 THR C OG1 37.486 -20.027 20.269 1 5903 +ATOM N N . SER C 2 389 . 389 SER C N 36.668 -22.107 18.409 1 5904 +ATOM C CA . SER C 2 389 . 389 SER C CA 35.418 -22.699 17.87 1 5905 +ATOM C C . SER C 2 389 . 389 SER C C 34.239 -22.28 18.753 1 5906 +ATOM O O . SER C 2 389 . 389 SER C O 34.485 -21.747 19.852 1 5907 +ATOM C CB . SER C 2 389 . 389 SER C CB 35.526 -24.197 17.797 1 5908 +ATOM O OG . SER C 2 389 . 389 SER C OG 35.657 -24.749 19.099 1 5909 +ATOM N N . GLY C 2 390 . 390 GLY C N 33.012 -22.527 18.287 1 5910 +ATOM C CA . GLY C 2 390 . 390 GLY C CA 31.786 -22.41 19.098 1 5911 +ATOM C C . GLY C 2 390 . 390 GLY C C 30.84 -21.345 18.576 1 5912 +ATOM O O . GLY C 2 390 . 390 GLY C O 30.839 -21.084 17.352 1 5913 +ATOM N N . THR C 2 391 . 391 THR C N 30.05 -20.778 19.491 1 5914 +ATOM C CA . THR C 2 391 . 391 THR C CA 29.019 -19.736 19.253 1 5915 +ATOM C C . THR C 2 391 . 391 THR C C 29.631 -18.347 19.479 1 5916 +ATOM O O . THR C 2 391 . 391 THR C O 29.085 -17.365 18.935 1 5917 +ATOM C CB . THR C 2 391 . 391 THR C CB 27.813 -19.999 20.162 1 5918 +ATOM C CG2 . THR C 2 391 . 391 THR C CG2 26.684 -19.013 19.97 1 5919 +ATOM O OG1 . THR C 2 391 . 391 THR C OG1 27.368 -21.322 19.866 1 5920 +ATOM N N . LEU C 2 392 . 392 LEU C N 30.699 -18.275 20.28 1 5921 +ATOM C CA . LEU C 2 392 . 392 LEU C CA 31.502 -17.049 20.537 1 5922 +ATOM C C . LEU C 2 392 . 392 LEU C C 30.591 -15.916 21.037 1 5923 +ATOM O O . LEU C 2 392 . 392 LEU C O 30.73 -14.775 20.545 1 5924 +ATOM C CB . LEU C 2 392 . 392 LEU C CB 32.245 -16.684 19.248 1 5925 +ATOM C CG . LEU C 2 392 . 392 LEU C CG 33.141 -17.792 18.691 1 5926 +ATOM C CD1 . LEU C 2 392 . 392 LEU C CD1 33.588 -17.482 17.272 1 5927 +ATOM C CD2 . LEU C 2 392 . 392 LEU C CD2 34.344 -18.018 19.594 1 5928 +ATOM N N . MET C 2 393 . 393 MET C N 29.684 -16.238 21.97 1 5929 +ATOM C CA . MET C 2 393 . 393 MET C CA 28.821 -15.274 22.709 1 5930 +ATOM C C . MET C 2 393 . 393 MET C C 27.908 -14.509 21.735 1 5931 +ATOM O O . MET C 2 393 . 393 MET C O 27.457 -13.404 22.09 1 5932 +ATOM C CB . MET C 2 393 . 393 MET C CB 29.69 -14.282 23.493 1 5933 +ATOM C CG . MET C 2 393 . 393 MET C CG 30.741 -14.945 24.375 1 5934 +ATOM S SD . MET C 2 393 . 393 MET C SD 30.122 -15.391 26.019 1 5935 +ATOM C CE . MET C 2 393 . 393 MET C CE 29.906 -13.773 26.759 1 5936 +ATOM N N . ALA C 2 394 . 394 ALA C N 27.626 -15.082 20.561 1 5937 +ATOM C CA . ALA C 2 394 . 394 ALA C CA 26.815 -14.461 19.485 1 5938 +ATOM C C . ALA C 2 394 . 394 ALA C C 25.916 -15.521 18.852 1 5939 +ATOM O O . ALA C 2 394 . 394 ALA C O 26.047 -15.837 17.671 1 5940 +ATOM C CB . ALA C 2 394 . 394 ALA C CB 27.724 -13.802 18.476 1 5941 +ATOM N N . PRO C 2 395 . 395 PRO C N 24.965 -16.096 19.622 1 5942 +ATOM C CA . PRO C 2 395 . 395 PRO C CA 24.082 -17.148 19.111 1 5943 +ATOM C C . PRO C 2 395 . 395 PRO C C 23.125 -16.701 17.991 1 5944 +ATOM O O . PRO C 2 395 . 395 PRO C O 22.74 -17.534 17.186 1 5945 +ATOM C CB . PRO C 2 395 . 395 PRO C CB 23.285 -17.564 20.358 1 5946 +ATOM C CG . PRO C 2 395 . 395 PRO C CG 23.294 -16.327 21.23 1 5947 +ATOM C CD . PRO C 2 395 . 395 PRO C CD 24.677 -15.75 21.023 1 5948 +ATOM N N . ARG C 2 396 . 396 ARG C N 22.755 -15.418 17.965 1 5949 +ATOM C CA . ARG C 2 396 . 396 ARG C CA 21.785 -14.863 16.984 1 5950 +ATOM C C . ARG C 2 396 . 396 ARG C C 22.413 -14.897 15.591 1 5951 +ATOM O O . ARG C 2 396 . 396 ARG C O 21.867 -15.544 14.702 1 5952 +ATOM C CB . ARG C 2 396 . 396 ARG C CB 21.29 -13.491 17.456 1 5953 +ATOM C CG . ARG C 2 396 . 396 ARG C CG 20.524 -13.564 18.769 1 5954 +ATOM C CD . ARG C 2 396 . 396 ARG C CD 20.164 -12.222 19.378 1 5955 +ATOM N NE . ARG C 2 396 . 396 ARG C NE 19.781 -12.406 20.771 1 5956 +ATOM C CZ . ARG C 2 396 . 396 ARG C CZ 18.604 -12.868 21.193 1 5957 +ATOM N NH1 . ARG C 2 396 . 396 ARG C NH1 17.646 -13.182 20.336 1 5958 +ATOM N NH2 . ARG C 2 396 . 396 ARG C NH2 18.39 -13.015 22.488 1 5959 +ATOM N N . PRO C 2 397 . 397 PRO C N 23.585 -14.265 15.339 1 5960 +ATOM C CA . PRO C 2 397 . 397 PRO C CA 24.233 -14.382 14.031 1 5961 +ATOM C C . PRO C 2 397 . 397 PRO C C 24.618 -15.828 13.674 1 5962 +ATOM O O . PRO C 2 397 . 397 PRO C O 24.413 -16.217 12.546 1 5963 +ATOM C CB . PRO C 2 397 . 397 PRO C CB 25.495 -13.51 14.13 1 5964 +ATOM C CG . PRO C 2 397 . 397 PRO C CG 25.708 -13.296 15.619 1 5965 +ATOM C CD . PRO C 2 397 . 397 PRO C CD 24.338 -13.4 16.26 1 5966 +ATOM N N . THR C 2 398 . 398 THR C N 25.161 -16.583 14.633 1 5967 +ATOM C CA . THR C 2 398 . 398 THR C CA 25.677 -17.962 14.425 1 5968 +ATOM C C . THR C 2 398 . 398 THR C C 24.526 -18.889 14.004 1 5969 +ATOM O O . THR C 2 398 . 398 THR C O 24.624 -19.469 12.905 1 5970 +ATOM C CB . THR C 2 398 . 398 THR C CB 26.421 -18.474 15.667 1 5971 +ATOM C CG2 . THR C 2 398 . 398 THR C CG2 26.956 -19.88 15.504 1 5972 +ATOM O OG1 . THR C 2 398 . 398 THR C OG1 27.508 -17.586 15.942 1 5973 +ATOM N N . TRP C 2 399 . 399 TRP C N 23.475 -19.01 14.825 1 5974 +ATOM C CA . TRP C 2 399 . 399 TRP C CA 22.443 -20.081 14.7 1 5975 +ATOM C C . TRP C 2 399 . 399 TRP C C 21.204 -19.613 13.922 1 5976 +ATOM O O . TRP C 2 399 . 399 TRP C O 20.551 -20.482 13.31 1 5977 +ATOM C CB . TRP C 2 399 . 399 TRP C CB 22.068 -20.632 16.081 1 5978 +ATOM C CG . TRP C 2 399 . 399 TRP C CG 23.215 -21.31 16.762 1 5979 +ATOM C CD1 . TRP C 2 399 . 399 TRP C CD1 23.83 -20.927 17.918 1 5980 +ATOM C CD2 . TRP C 2 399 . 399 TRP C CD2 23.918 -22.479 16.302 1 5981 +ATOM C CE2 . TRP C 2 399 . 399 TRP C CE2 24.934 -22.744 17.243 1 5982 +ATOM C CE3 . TRP C 2 399 . 399 TRP C CE3 23.785 -23.327 15.196 1 5983 +ATOM N NE1 . TRP C 2 399 . 399 TRP C NE1 24.855 -21.784 18.216 1 5984 +ATOM C CZ2 . TRP C 2 399 . 399 TRP C CZ2 25.808 -23.822 17.109 1 5985 +ATOM C CZ3 . TRP C 2 399 . 399 TRP C CZ3 24.65 -24.391 15.063 1 5986 +ATOM C CH2 . TRP C 2 399 . 399 TRP C CH2 25.648 -24.632 16.009 1 5987 +ATOM N N . ASP C 2 400 . 400 ASP C N 20.872 -18.318 13.944 1 5988 +ATOM C CA . ASP C 2 400 . 400 ASP C CA 19.667 -17.78 13.253 1 5989 +ATOM C C . ASP C 2 400 . 400 ASP C C 20.017 -17.355 11.822 1 5990 +ATOM O O . ASP C 2 400 . 400 ASP C O 19.115 -17.417 10.967 1 5991 +ATOM C CB . ASP C 2 400 . 400 ASP C CB 19.031 -16.625 14.028 1 5992 +ATOM C CG . ASP C 2 400 . 400 ASP C CG 18.59 -17.004 15.428 1 5993 +ATOM O OD1 . ASP C 2 400 . 400 ASP C OD1 18.543 -18.218 15.716 1 5994 +ATOM O OD2 . ASP C 2 400 . 400 ASP C OD2 18.297 -16.083 16.218 1 5995 +ATOM N N . VAL C 2 401 . 401 VAL C N 21.266 -16.953 11.56 1 5996 +ATOM C CA . VAL C 2 401 . 401 VAL C CA 21.697 -16.403 10.236 1 5997 +ATOM C C . VAL C 2 401 . 401 VAL C C 22.62 -17.406 9.522 1 5998 +ATOM O O . VAL C 2 401 . 401 VAL C O 22.204 -17.939 8.474 1 5999 +ATOM C CB . VAL C 2 401 . 401 VAL C CB 22.358 -15.02 10.401 1 6000 +ATOM C CG1 . VAL C 2 401 . 401 VAL C CG1 22.719 -14.399 9.058 1 6001 +ATOM C CG2 . VAL C 2 401 . 401 VAL C CG2 21.476 -14.077 11.206 1 6002 +ATOM N N . LEU C 2 402 . 402 LEU C N 23.82 -17.646 10.063 1 6003 +ATOM C CA . LEU C 2 402 . 402 LEU C CA 24.905 -18.435 9.411 1 6004 +ATOM C C . LEU C 2 402 . 402 LEU C C 24.505 -19.915 9.326 1 6005 +ATOM O O . LEU C 2 402 . 402 LEU C O 24.689 -20.506 8.245 1 6006 +ATOM C CB . LEU C 2 402 . 402 LEU C CB 26.214 -18.253 10.191 1 6007 +ATOM C CG . LEU C 2 402 . 402 LEU C CG 27.102 -17.087 9.752 1 6008 +ATOM C CD1 . LEU C 2 402 . 402 LEU C CD1 26.315 -15.795 9.642 1 6009 +ATOM C CD2 . LEU C 2 402 . 402 LEU C CD2 28.267 -16.908 10.712 1 6010 +ATOM N N . LYS C 2 403 . 403 LYS C N 23.997 -20.487 10.424 1 6011 +ATOM C CA . LYS C 2 403 . 403 LYS C CA 23.441 -21.87 10.495 1 6012 +ATOM C C . LYS C 2 403 . 403 LYS C C 24.501 -22.888 10.076 1 6013 +ATOM O O . LYS C 2 403 . 403 LYS C O 24.32 -23.615 9.1 1 6014 +ATOM C CB . LYS C 2 403 . 403 LYS C CB 22.196 -22.001 9.609 1 6015 +ATOM C CG . LYS C 2 403 . 403 LYS C CG 21.024 -21.112 9.998 1 6016 +ATOM C CD . LYS C 2 403 . 403 LYS C CD 19.758 -21.404 9.217 1 6017 +ATOM C CE . LYS C 2 403 . 403 LYS C CE 18.57 -20.585 9.679 1 6018 +ATOM N NZ . LYS C 2 403 . 403 LYS C NZ 18.21 -20.88 11.087 1 6019 +ATOM N N . PRO C 2 404 . 404 PRO C N 25.64 -22.986 10.797 1 6020 +ATOM C CA . PRO C 2 404 . 404 PRO C CA 26.615 -24.042 10.534 1 6021 +ATOM C C . PRO C 2 404 . 404 PRO C C 25.998 -25.422 10.81 1 6022 +ATOM O O . PRO C 2 404 . 404 PRO C O 25.263 -25.552 11.775 1 6023 +ATOM C CB . PRO C 2 404 . 404 PRO C CB 27.773 -23.724 11.491 1 6024 +ATOM C CG . PRO C 2 404 . 404 PRO C CG 27.128 -22.914 12.596 1 6025 +ATOM C CD . PRO C 2 404 . 404 PRO C CD 26.043 -22.112 11.908 1 6026 +ATOM N N . LYS C 2 405 . 405 LYS C N 26.289 -26.399 9.945 1 6027 +ATOM C CA . LYS C 2 405 . 405 LYS C CA 25.785 -27.797 10.041 1 6028 +ATOM C C . LYS C 2 405 . 405 LYS C C 26.464 -28.518 11.216 1 6029 +ATOM O O . LYS C 2 405 . 405 LYS C O 25.876 -29.494 11.729 1 6030 +ATOM C CB . LYS C 2 405 . 405 LYS C CB 25.991 -28.511 8.701 1 6031 +ATOM C CG . LYS C 2 405 . 405 LYS C CG 25.14 -27.953 7.566 1 6032 +ATOM C CD . LYS C 2 405 . 405 LYS C CD 25.383 -28.593 6.216 1 6033 +ATOM C CE . LYS C 2 405 . 405 LYS C CE 24.292 -28.279 5.21 1 6034 +ATOM N NZ . LYS C 2 405 . 405 LYS C NZ 24.038 -26.821 5.093 1 6035 +ATOM N N . SER C 2 406 . 406 SER C N 27.648 -28.049 11.627 1 6036 +ATOM C CA . SER C 2 406 . 406 SER C CA 28.379 -28.505 12.84 1 6037 +ATOM C C . SER C 2 406 . 406 SER C C 27.61 -28.068 14.094 1 6038 +ATOM O O . SER C 2 406 . 406 SER C O 27.332 -26.86 14.218 1 6039 +ATOM C CB . SER C 2 406 . 406 SER C CB 29.786 -27.963 12.845 1 6040 +ATOM O OG . SER C 2 406 . 406 SER C OG 30.516 -28.441 13.963 1 6041 +ATOM N N . SER C 2 407 . 407 SER C N 27.277 -29.014 14.981 1 6042 +ATOM C CA . SER C 2 407 . 407 SER C CA 26.588 -28.762 16.277 1 6043 +ATOM C C . SER C 2 407 . 407 SER C C 27.497 -27.944 17.203 1 6044 +ATOM O O . SER C 2 407 . 407 SER C O 26.964 -27.204 18.052 1 6045 +ATOM C CB . SER C 2 407 . 407 SER C CB 26.164 -30.05 16.94 1 6046 +ATOM O OG . SER C 2 407 . 407 SER C OG 25.077 -30.641 16.246 1 6047 +ATOM N N . ASP C 2 408 . 408 ASP C N 28.816 -28.082 17.036 1 6048 +ATOM C CA . ASP C 2 408 . 408 ASP C CA 29.86 -27.375 17.827 1 6049 +ATOM C C . ASP C 2 408 . 408 ASP C C 29.954 -25.905 17.391 1 6050 +ATOM O O . ASP C 2 408 . 408 ASP C O 30.596 -25.126 18.115 1 6051 +ATOM C CB . ASP C 2 408 . 408 ASP C CB 31.212 -28.084 17.69 1 6052 +ATOM C CG . ASP C 2 408 . 408 ASP C CG 31.267 -29.439 18.375 1 6053 +ATOM O OD1 . ASP C 2 408 . 408 ASP C OD1 30.238 -29.848 18.957 1 6054 +ATOM O OD2 . ASP C 2 408 . 408 ASP C OD2 32.341 -30.075 18.327 1 6055 +ATOM N N . GLY C 2 409 . 409 GLY C N 29.364 -25.542 16.247 1 6056 +ATOM C CA . GLY C 2 409 . 409 GLY C CA 29.301 -24.152 15.757 1 6057 +ATOM C C . GLY C 2 409 . 409 GLY C C 30.377 -23.864 14.724 1 6058 +ATOM O O . GLY C 2 409 . 409 GLY C O 30.629 -24.74 13.877 1 6059 +ATOM N N . LEU C 2 410 . 410 LEU C N 30.989 -22.678 14.796 1 6060 +ATOM C CA . LEU C 2 410 . 410 LEU C CA 31.926 -22.144 13.769 1 6061 +ATOM C C . LEU C 2 410 . 410 LEU C C 33.369 -22.424 14.192 1 6062 +ATOM O O . LEU C 2 410 . 410 LEU C O 33.602 -22.595 15.403 1 6063 +ATOM C CB . LEU C 2 410 . 410 LEU C CB 31.719 -20.634 13.625 1 6064 +ATOM C CG . LEU C 2 410 . 410 LEU C CG 30.277 -20.169 13.447 1 6065 +ATOM C CD1 . LEU C 2 410 . 410 LEU C CD1 30.182 -18.661 13.614 1 6066 +ATOM C CD2 . LEU C 2 410 . 410 LEU C CD2 29.73 -20.599 12.095 1 6067 +ATOM N N . VAL C 2 411 . 411 VAL C N 34.283 -22.462 13.217 1 6068 +ATOM C CA . VAL C 2 411 . 411 VAL C CA 35.744 -22.234 13.416 1 6069 +ATOM C C . VAL C 2 411 . 411 VAL C C 36.013 -20.771 13.055 1 6070 +ATOM O O . VAL C 2 411 . 411 VAL C O 35.483 -20.323 12.031 1 6071 +ATOM C CB . VAL C 2 411 . 411 VAL C CB 36.608 -23.186 12.566 1 6072 +ATOM C CG1 . VAL C 2 411 . 411 VAL C CG1 38.088 -23.038 12.893 1 6073 +ATOM C CG2 . VAL C 2 411 . 411 VAL C CG2 36.172 -24.635 12.715 1 6074 +ATOM N N . ARG C 2 412 . 412 ARG C N 36.789 -20.056 13.872 1 6075 +ATOM C CA . ARG C 2 412 . 412 ARG C CA 37.175 -18.647 13.604 1 6076 +ATOM C C . ARG C 2 412 . 412 ARG C C 38.695 -18.493 13.704 1 6077 +ATOM O O . ARG C 2 412 . 412 ARG C O 39.274 -18.893 14.739 1 6078 +ATOM C CB . ARG C 2 412 . 412 ARG C CB 36.494 -17.684 14.579 1 6079 +ATOM C CG . ARG C 2 412 . 412 ARG C CG 36.701 -16.218 14.227 1 6080 +ATOM C CD . ARG C 2 412 . 412 ARG C CD 36.038 -15.307 15.234 1 6081 +ATOM N NE . ARG C 2 412 . 412 ARG C NE 36.689 -15.379 16.532 1 6082 +ATOM C CZ . ARG C 2 412 . 412 ARG C CZ 36.271 -14.751 17.625 1 6083 +ATOM N NH1 . ARG C 2 412 . 412 ARG C NH1 35.188 -13.99 17.589 1 6084 +ATOM N NH2 . ARG C 2 412 . 412 ARG C NH2 36.95 -14.882 18.751 1 6085 +ATOM N N . VAL C 2 413 . 413 VAL C N 39.298 -17.918 12.662 1 6086 +ATOM C CA . VAL C 2 413 . 413 VAL C CA 40.693 -17.393 12.668 1 6087 +ATOM C C . VAL C 2 413 . 413 VAL C C 40.595 -15.87 12.555 1 6088 +ATOM O O . VAL C 2 413 . 413 VAL C O 39.813 -15.393 11.719 1 6089 +ATOM C CB . VAL C 2 413 . 413 VAL C CB 41.538 -18.012 11.539 1 6090 +ATOM C CG1 . VAL C 2 413 . 413 VAL C CG1 42.979 -17.526 11.577 1 6091 +ATOM C CG2 . VAL C 2 413 . 413 VAL C CG2 41.485 -19.532 11.582 1 6092 +ATOM N N . SER C 2 414 . 414 SER C N 41.318 -15.149 13.411 1 6093 +ATOM C CA . SER C 2 414 . 414 SER C CA 41.243 -13.671 13.55 1 6094 +ATOM C C . SER C 2 414 . 414 SER C C 42.659 -13.101 13.673 1 6095 +ATOM O O . SER C 2 414 . 414 SER C O 43.36 -13.434 14.662 1 6096 +ATOM C CB . SER C 2 414 . 414 SER C CB 40.366 -13.269 14.718 1 6097 +ATOM O OG . SER C 2 414 . 414 SER C OG 40.613 -14.087 15.849 1 6098 +ATOM N N . PHE C 2 415 . 415 PHE C N 43.056 -12.288 12.692 1 6099 +ATOM C CA . PHE C 2 415 . 415 PHE C CA 44.409 -11.689 12.567 1 6100 +ATOM C C . PHE C 2 415 . 415 PHE C C 44.379 -10.233 13.039 1 6101 +ATOM O O . PHE C 2 415 . 415 PHE C O 43.324 -9.576 12.936 1 6102 +ATOM C CB . PHE C 2 415 . 415 PHE C CB 44.907 -11.728 11.119 1 6103 +ATOM C CG . PHE C 2 415 . 415 PHE C CG 44.914 -13.088 10.469 1 6104 +ATOM C CD1 . PHE C 2 415 . 415 PHE C CD1 45.482 -14.178 11.108 1 6105 +ATOM C CD2 . PHE C 2 415 . 415 PHE C CD2 44.38 -13.274 9.202 1 6106 +ATOM C CE1 . PHE C 2 415 . 415 PHE C CE1 45.499 -15.425 10.503 1 6107 +ATOM C CE2 . PHE C 2 415 . 415 PHE C CE2 44.397 -14.523 8.598 1 6108 +ATOM C CZ . PHE C 2 415 . 415 PHE C CZ 44.959 -15.596 9.25 1 6109 +ATOM N N . VAL C 2 416 . 416 VAL C N 45.526 -9.761 13.533 1 6110 +ATOM C CA . VAL C 2 416 . 416 VAL C CA 45.858 -8.318 13.723 1 6111 +ATOM C C . VAL C 2 416 . 416 VAL C C 47.244 -8.088 13.112 1 6112 +ATOM O O . VAL C 2 416 . 416 VAL C O 47.913 -9.089 12.782 1 6113 +ATOM C CB . VAL C 2 416 . 416 VAL C CB 45.799 -7.896 15.204 1 6114 +ATOM C CG1 . VAL C 2 416 . 416 VAL C CG1 44.366 -7.809 15.708 1 6115 +ATOM C CG2 . VAL C 2 416 . 416 VAL C CG2 46.63 -8.807 16.095 1 6116 +ATOM N N . HIS C 2 417 . 417 HIS C N 47.658 -6.825 12.985 1 6117 +ATOM C CA . HIS C 2 417 . 417 HIS C CA 48.814 -6.382 12.156 1 6118 +ATOM C C . HIS C 2 417 . 417 HIS C C 50.114 -7.095 12.564 1 6119 +ATOM O O . HIS C 2 417 . 417 HIS C O 50.999 -7.188 11.699 1 6120 +ATOM C CB . HIS C 2 417 . 417 HIS C CB 48.955 -4.854 12.197 1 6121 +ATOM C CG . HIS C 2 417 . 417 HIS C CG 49.359 -4.314 13.526 1 6122 +ATOM C CD2 . HIS C 2 417 . 417 HIS C CD2 50.576 -4.114 14.075 1 6123 +ATOM N ND1 . HIS C 2 417 . 417 HIS C ND1 48.435 -3.906 14.466 1 6124 +ATOM C CE1 . HIS C 2 417 . 417 HIS C CE1 49.066 -3.475 15.538 1 6125 +ATOM N NE2 . HIS C 2 417 . 417 HIS C NE2 50.381 -3.593 15.324 1 6126 +ATOM N N . TYR C 2 418 . 418 TYR C N 50.235 -7.581 13.809 1 6127 +ATOM C CA . TYR C 2 418 . 418 TYR C CA 51.487 -8.192 14.34 1 6128 +ATOM C C . TYR C 2 418 . 418 TYR C C 51.62 -9.659 13.898 1 6129 +ATOM O O . TYR C 2 418 . 418 TYR C O 52.694 -10.243 14.151 1 6130 +ATOM C CB . TYR C 2 418 . 418 TYR C CB 51.622 -7.99 15.857 1 6131 +ATOM C CG . TYR C 2 418 . 418 TYR C CG 50.578 -8.597 16.768 1 6132 +ATOM C CD1 . TYR C 2 418 . 418 TYR C CD1 50.144 -9.909 16.634 1 6133 +ATOM C CD2 . TYR C 2 418 . 418 TYR C CD2 50.079 -7.867 17.839 1 6134 +ATOM C CE1 . TYR C 2 418 . 418 TYR C CE1 49.214 -10.46 17.504 1 6135 +ATOM C CE2 . TYR C 2 418 . 418 TYR C CE2 49.152 -8.404 18.72 1 6136 +ATOM C CZ . TYR C 2 418 . 418 TYR C CZ 48.717 -9.706 18.554 1 6137 +ATOM O OH . TYR C 2 418 . 418 TYR C OH 47.8 -10.225 19.425 1 6138 +ATOM N N . ASN C 2 419 . 419 ASN C N 50.595 -10.23 13.25 1 6139 +ATOM C CA . ASN C 2 419 . 419 ASN C CA 50.652 -11.574 12.608 1 6140 +ATOM C C . ASN C 2 419 . 419 ASN C C 51.406 -11.464 11.274 1 6141 +ATOM O O . ASN C 2 419 . 419 ASN C O 51.371 -10.374 10.663 1 6142 +ATOM C CB . ASN C 2 419 . 419 ASN C CB 49.26 -12.181 12.407 1 6143 +ATOM C CG . ASN C 2 419 . 419 ASN C CG 48.584 -12.587 13.699 1 6144 +ATOM N ND2 . ASN C 2 419 . 419 ASN C ND2 49.273 -13.353 14.531 1 6145 +ATOM O OD1 . ASN C 2 419 . 419 ASN C OD1 47.44 -12.212 13.942 1 6146 +ATOM N N . THR C 2 420 . 420 THR C N 52.044 -12.56 10.84 1 6147 +ATOM C CA . THR C 2 420 . 420 THR C CA 52.961 -12.623 9.668 1 6148 +ATOM C C . THR C 2 420 . 420 THR C C 52.411 -13.581 8.604 1 6149 +ATOM O O . THR C 2 420 . 420 THR C O 51.636 -14.489 8.966 1 6150 +ATOM C CB . THR C 2 420 . 420 THR C CB 54.364 -13.091 10.076 1 6151 +ATOM C CG2 . THR C 2 420 . 420 THR C CG2 54.89 -12.408 11.321 1 6152 +ATOM O OG1 . THR C 2 420 . 420 THR C OG1 54.297 -14.502 10.283 1 6153 +ATOM N N . VAL C 2 421 . 421 VAL C N 52.844 -13.4 7.35 1 6154 +ATOM C CA . VAL C 2 421 . 421 VAL C CA 52.469 -14.256 6.183 1 6155 +ATOM C C . VAL C 2 421 . 421 VAL C C 53.059 -15.66 6.378 1 6156 +ATOM O O . VAL C 2 421 . 421 VAL C O 52.443 -16.626 5.883 1 6157 +ATOM C CB . VAL C 2 421 . 421 VAL C CB 52.908 -13.64 4.837 1 6158 +ATOM C CG1 . VAL C 2 421 . 421 VAL C CG1 52.263 -12.282 4.604 1 6159 +ATOM C CG2 . VAL C 2 421 . 421 VAL C CG2 54.422 -13.546 4.692 1 6160 +ATOM N N . GLU C 2 422 . 422 GLU C N 54.203 -15.768 7.065 1 6161 +ATOM C CA . GLU C 2 422 . 422 GLU C CA 54.889 -17.057 7.358 1 6162 +ATOM C C . GLU C 2 422 . 422 GLU C C 54.001 -17.906 8.278 1 6163 +ATOM O O . GLU C 2 422 . 422 GLU C O 53.934 -19.128 8.068 1 6164 +ATOM C CB . GLU C 2 422 . 422 GLU C CB 56.264 -16.827 7.993 1 6165 +ATOM C CG . GLU C 2 422 . 422 GLU C CG 57.302 -16.264 7.033 1 6166 +ATOM C CD . GLU C 2 422 . 422 GLU C CD 57.307 -14.751 6.864 1 6167 +ATOM O OE1 . GLU C 2 422 . 422 GLU C OE1 56.6 -14.058 7.629 1 6168 +ATOM O OE2 . GLU C 2 422 . 422 GLU C OE2 58.023 -14.267 5.961 1 6169 +ATOM N N . GLU C 2 423 . 423 GLU C N 53.343 -17.279 9.255 1 6170 +ATOM C CA . GLU C 2 423 . 423 GLU C CA 52.414 -17.961 10.196 1 6171 +ATOM C C . GLU C 2 423 . 423 GLU C C 51.202 -18.494 9.425 1 6172 +ATOM O O . GLU C 2 423 . 423 GLU C O 50.913 -19.699 9.55 1 6173 +ATOM C CB . GLU C 2 423 . 423 GLU C CB 51.97 -17.012 11.308 1 6174 +ATOM C CG . GLU C 2 423 . 423 GLU C CG 53.057 -16.722 12.323 1 6175 +ATOM C CD . GLU C 2 423 . 423 GLU C CD 52.68 -15.662 13.343 1 6176 +ATOM O OE1 . GLU C 2 423 . 423 GLU C OE1 51.889 -14.754 12.999 1 6177 +ATOM O OE2 . GLU C 2 423 . 423 GLU C OE2 53.173 -15.746 14.487 1 6178 +ATOM N N . VAL C 2 424 . 424 VAL C N 50.538 -17.633 8.648 1 6179 +ATOM C CA . VAL C 2 424 . 424 VAL C CA 49.29 -17.97 7.896 1 6180 +ATOM C C . VAL C 2 424 . 424 VAL C C 49.584 -19.151 6.958 1 6181 +ATOM O O . VAL C 2 424 . 424 VAL C O 48.822 -20.136 7.004 1 6182 +ATOM C CB . VAL C 2 424 . 424 VAL C CB 48.725 -16.759 7.124 1 6183 +ATOM C CG1 . VAL C 2 424 . 424 VAL C CG1 47.443 -17.111 6.382 1 6184 +ATOM C CG2 . VAL C 2 424 . 424 VAL C CG2 48.49 -15.558 8.028 1 6185 +ATOM N N . ARG C 2 425 . 425 ARG C N 50.653 -19.059 6.157 1 6186 +ATOM C CA . ARG C 2 425 . 425 ARG C CA 51.1 -20.123 5.211 1 6187 +ATOM C C . ARG C 2 425 . 425 ARG C C 51.335 -21.438 5.968 1 6188 +ATOM O O . ARG C 2 425 . 425 ARG C O 50.931 -22.496 5.449 1 6189 +ATOM C CB . ARG C 2 425 . 425 ARG C CB 52.37 -19.703 4.462 1 6190 +ATOM C CG . ARG C 2 425 . 425 ARG C CG 52.117 -18.726 3.322 1 6191 +ATOM C CD . ARG C 2 425 . 425 ARG C CD 53.271 -18.621 2.342 1 6192 +ATOM N NE . ARG C 2 425 . 425 ARG C NE 52.898 -17.752 1.232 1 6193 +ATOM C CZ . ARG C 2 425 . 425 ARG C CZ 53.137 -16.443 1.155 1 6194 +ATOM N NH1 . ARG C 2 425 . 425 ARG C NH1 53.785 -15.81 2.121 1 6195 +ATOM N NH2 . ARG C 2 425 . 425 ARG C NH2 52.729 -15.767 0.094 1 6196 +ATOM N N . ALA C 2 426 . 426 ALA C N 51.96 -21.373 7.146 1 6197 +ATOM C CA . ALA C 2 426 . 426 ALA C CA 52.255 -22.547 8.001 1 6198 +ATOM C C . ALA C 2 426 . 426 ALA C C 50.958 -23.074 8.635 1 6199 +ATOM O O . ALA C 2 426 . 426 ALA C O 50.842 -24.307 8.791 1 6200 +ATOM C CB . ALA C 2 426 . 426 ALA C CB 53.294 -22.191 9.039 1 6201 +ATOM N N . PHE C 2 427 . 427 PHE C N 50.014 -22.187 8.979 1 6202 +ATOM C CA . PHE C 2 427 . 427 PHE C CA 48.692 -22.554 9.558 1 6203 +ATOM C C . PHE C 2 427 . 427 PHE C C 47.873 -23.324 8.518 1 6204 +ATOM O O . PHE C 2 427 . 427 PHE C O 47.28 -24.363 8.869 1 6205 +ATOM C CB . PHE C 2 427 . 427 PHE C CB 47.914 -21.326 10.04 1 6206 +ATOM C CG . PHE C 2 427 . 427 PHE C CG 46.485 -21.622 10.423 1 6207 +ATOM C CD1 . PHE C 2 427 . 427 PHE C CD1 46.194 -22.328 11.583 1 6208 +ATOM C CD2 . PHE C 2 427 . 427 PHE C CD2 45.432 -21.223 9.613 1 6209 +ATOM C CE1 . PHE C 2 427 . 427 PHE C CE1 44.882 -22.615 11.929 1 6210 +ATOM C CE2 . PHE C 2 427 . 427 PHE C CE2 44.121 -21.511 9.961 1 6211 +ATOM C CZ . PHE C 2 427 . 427 PHE C CZ 43.848 -22.205 11.119 1 6212 +ATOM N N . CYS C 2 428 . 428 CYS C N 47.837 -22.811 7.285 1 6213 +ATOM C CA . CYS C 2 428 . 428 CYS C CA 47.16 -23.428 6.113 1 6214 +ATOM C C . CYS C 2 428 . 428 CYS C C 47.673 -24.86 5.905 1 6215 +ATOM O O . CYS C 2 428 . 428 CYS C O 46.831 -25.759 5.715 1 6216 +ATOM C CB . CYS C 2 428 . 428 CYS C CB 47.369 -22.587 4.858 1 6217 +ATOM S SG . CYS C 2 428 . 428 CYS C SG 46.386 -21.065 4.849 1 6218 +ATOM N N . SER C 2 429 . 429 SER C N 48.996 -25.06 5.953 1 6219 +ATOM C CA . SER C 2 429 . 429 SER C CA 49.667 -26.38 5.81 1 6220 +ATOM C C . SER C 2 429 . 429 SER C C 49.21 -27.33 6.923 1 6221 +ATOM O O . SER C 2 429 . 429 SER C O 48.898 -28.497 6.612 1 6222 +ATOM C CB . SER C 2 429 . 429 SER C CB 51.171 -26.241 5.798 1 6223 +ATOM O OG . SER C 2 429 . 429 SER C OG 51.615 -25.666 4.578 1 6224 +ATOM N N . GLU C 2 430 . 430 GLU C N 49.175 -26.847 8.169 1 6225 +ATOM C CA . GLU C 2 430 . 430 GLU C CA 48.787 -27.651 9.36 1 6226 +ATOM C C . GLU C 2 430 . 430 GLU C C 47.307 -28.034 9.253 1 6227 +ATOM O O . GLU C 2 430 . 430 GLU C O 46.991 -29.223 9.455 1 6228 +ATOM C CB . GLU C 2 430 . 430 GLU C CB 49.064 -26.882 10.652 1 6229 +ATOM C CG . GLU C 2 430 . 430 GLU C CG 48.59 -27.617 11.892 1 6230 +ATOM C CD . GLU C 2 430 . 430 GLU C CD 49.205 -27.144 13.196 1 6231 +ATOM O OE1 . GLU C 2 430 . 430 GLU C OE1 48.435 -26.856 14.135 1 6232 +ATOM O OE2 . GLU C 2 430 . 430 GLU C OE2 50.451 -27.08 13.276 1 6233 +ATOM N N . LEU C 2 431 . 431 LEU C N 46.445 -27.059 8.951 1 6234 +ATOM C CA . LEU C 2 431 . 431 LEU C CA 44.979 -27.257 8.786 1 6235 +ATOM C C . LEU C 2 431 . 431 LEU C C 44.73 -28.321 7.709 1 6236 +ATOM O O . LEU C 2 431 . 431 LEU C O 43.876 -29.199 7.936 1 6237 +ATOM C CB . LEU C 2 431 . 431 LEU C CB 44.322 -25.919 8.423 1 6238 +ATOM C CG . LEU C 2 431 . 431 LEU C CG 42.792 -25.91 8.408 1 6239 +ATOM C CD1 . LEU C 2 431 . 431 LEU C CD1 42.225 -26.379 9.741 1 6240 +ATOM C CD2 . LEU C 2 431 . 431 LEU C CD2 42.259 -24.526 8.062 1 6241 +ATOM N N . ASP C 2 432 . 432 ASP C N 45.465 -28.251 6.594 1 6242 +ATOM C CA . ASP C 2 432 . 432 ASP C CA 45.389 -29.224 5.47 1 6243 +ATOM C C . ASP C 2 432 . 432 ASP C C 45.729 -30.63 5.982 1 6244 +ATOM O O . ASP C 2 432 . 432 ASP C O 44.978 -31.564 5.653 1 6245 +ATOM C CB . ASP C 2 432 . 432 ASP C CB 46.309 -28.822 4.314 1 6246 +ATOM C CG . ASP C 2 432 . 432 ASP C CG 45.946 -29.479 2.994 1 6247 +ATOM O OD1 . ASP C 2 432 . 432 ASP C OD1 44.81 -29.261 2.527 1 6248 +ATOM O OD2 . ASP C 2 432 . 432 ASP C OD2 46.797 -30.207 2.449 1 6249 +ATOM N N . GLU C 2 433 . 433 GLU C N 46.807 -30.766 6.763 1 6250 +ATOM C CA . GLU C 2 433 . 433 GLU C CA 47.274 -32.06 7.337 1 6251 +ATOM C C . GLU C 2 433 . 433 GLU C C 46.185 -32.636 8.252 1 6252 +ATOM O O . GLU C 2 433 . 433 GLU C O 45.899 -33.846 8.135 1 6253 +ATOM C CB . GLU C 2 433 . 433 GLU C CB 48.59 -31.872 8.099 1 6254 +ATOM C CG . GLU C 2 433 . 433 GLU C CG 49.195 -33.171 8.607 1 6255 +ATOM C CD . GLU C 2 433 . 433 GLU C CD 50.411 -33.007 9.506 1 6256 +ATOM O OE1 . GLU C 2 433 . 433 GLU C OE1 50.882 -31.86 9.668 1 6257 +ATOM O OE2 . GLU C 2 433 . 433 GLU C OE2 50.879 -34.026 10.054 1 6258 +ATOM N N . ILE C 2 434 . 434 ILE C N 45.608 -31.799 9.122 1 6259 +ATOM C CA . ILE C 2 434 . 434 ILE C CA 44.538 -32.176 10.096 1 6260 +ATOM C C . ILE C 2 434 . 434 ILE C C 43.281 -32.608 9.327 1 6261 +ATOM O O . ILE C 2 434 . 434 ILE C O 42.636 -33.584 9.755 1 6262 +ATOM C CB . ILE C 2 434 . 434 ILE C CB 44.25 -31.014 11.07 1 6263 +ATOM C CG1 . ILE C 2 434 . 434 ILE C CG1 45.414 -30.792 12.04 1 6264 +ATOM C CG2 . ILE C 2 434 . 434 ILE C CG2 42.935 -31.233 11.806 1 6265 +ATOM C CD1 . ILE C 2 434 . 434 ILE C CD1 45.326 -29.51 12.839 1 6266 +ATOM N N . VAL C 2 435 . 435 VAL C N 42.951 -31.902 8.242 1 6267 +ATOM C CA . VAL C 2 435 . 435 VAL C CA 41.718 -32.124 7.427 1 6268 +ATOM C C . VAL C 2 435 . 435 VAL C C 41.905 -33.347 6.517 1 6269 +ATOM O O . VAL C 2 435 . 435 VAL C O 40.951 -34.141 6.419 1 6270 +ATOM C CB . VAL C 2 435 . 435 VAL C CB 41.348 -30.86 6.625 1 6271 +ATOM C CG1 . VAL C 2 435 . 435 VAL C CG1 40.592 -31.181 5.347 1 6272 +ATOM C CG2 . VAL C 2 435 . 435 VAL C CG2 40.56 -29.875 7.475 1 6273 +ATOM N N . THR C 2 436 . 436 THR C N 43.069 -33.489 5.869 1 6274 +ATOM C CA . THR C 2 436 . 436 THR C CA 43.36 -34.56 4.872 1 6275 +ATOM C C . THR C 2 436 . 436 THR C C 43.52 -35.926 5.56 1 6276 +ATOM O O . THR C 2 436 . 436 THR C O 43.365 -36.947 4.856 1 6277 +ATOM C CB . THR C 2 436 . 436 THR C CB 44.602 -34.241 4.026 1 6278 +ATOM C CG2 . THR C 2 436 . 436 THR C CG2 44.37 -33.113 3.044 1 6279 +ATOM O OG1 . THR C 2 436 . 436 THR C OG1 45.691 -33.91 4.889 1 6280 +ATOM N N . ARG C 2 437 . 437 ARG C N 43.828 -35.949 6.864 1 6281 +ATOM C CA . ARG C 2 437 . 437 ARG C CA 44.051 -37.189 7.663 1 6282 +ATOM C C . ARG C 2 437 . 437 ARG C C 42.729 -37.956 7.801 1 6283 +ATOM O O . ARG C 2 437 . 437 ARG C O 41.704 -37.403 8.207 1 6284 +ATOM C CB . ARG C 2 437 . 437 ARG C CB 44.628 -36.846 9.04 1 6285 +HETATM O O . HOH B 1 501 . 501 HOH B O 21.558 -14.116 61.336 1 6286 +HETATM O O . HOH B 1 502 . 502 HOH B O 33.295 6.005 44.746 1 6287 +HETATM O O . HOH B 1 503 . 503 HOH B O 51.846 19.077 55.137 1 6288 +HETATM O O . HOH B 1 504 . 504 HOH B O 30.163 6.787 66.201 1 6289 +HETATM O O . HOH B 1 505 . 505 HOH B O 44.099 8.471 48.881 1 6290 +HETATM O O . HOH B 1 506 . 506 HOH B O 4.168 -3.389 56.518 1 6291 +HETATM O O . HOH B 1 507 . 507 HOH B O 37.422 -1.846 50.799 1 6292 +HETATM O O . HOH B 1 508 . 508 HOH B O 22.315 9.935 44.915 1 6293 +HETATM O O . HOH B 1 509 . 509 HOH B O 32.491 16.378 69.278 1 6294 +HETATM O O . HOH B 1 510 . 510 HOH B O 20.251 -0.835 42.961 1 6295 +HETATM O O . HOH B 1 511 . 511 HOH B O 49.427 16.871 50.247 1 6296 +HETATM O O . HOH B 1 512 . 512 HOH B O 30.853 0.643 33.35 1 6297 +HETATM O O . HOH B 1 513 . 513 HOH B O 10.343 -2.441 56.827 1 6298 +HETATM O O . HOH B 1 514 . 514 HOH B O 25.238 2.18 58.33 1 6299 +HETATM O O . HOH B 1 515 . 515 HOH B O 27.491 2.774 52.996 1 6300 +HETATM O O . HOH B 1 516 . 516 HOH B O 19.333 10.082 60.444 1 6301 +HETATM O O . HOH B 1 517 . 517 HOH B O 7.913 -12.089 56.301 1 6302 +HETATM O O . HOH B 1 518 . 518 HOH B O 54.192 -10.204 25.912 1 6303 +HETATM O O . HOH B 1 519 . 519 HOH B O 27.03 -15.06 55.235 1 6304 +HETATM O O . HOH B 1 520 . 520 HOH B O 8.787 5.573 41.836 1 6305 +HETATM O O . HOH B 1 521 . 521 HOH B O 14.781 -10.386 45.249 1 6306 +HETATM O O . HOH B 1 522 . 522 HOH B O 17.393 -14.636 63.617 1 6307 +HETATM O O . HOH B 1 523 . 523 HOH B O 35.966 20.816 52.449 1 6308 +HETATM O O . HOH B 1 524 . 524 HOH B O 19.765 -1.069 40.521 1 6309 +HETATM O O . HOH B 1 525 . 525 HOH B O 30.749 4.254 61.587 1 6310 +HETATM O O . HOH B 1 526 . 526 HOH B O 7.637 -0.071 51.019 1 6311 +HETATM O O . HOH B 1 527 . 527 HOH B O 41.99 2.426 53.256 1 6312 +HETATM O O . HOH B 1 528 . 528 HOH B O 21.471 -19.495 42.352 1 6313 +HETATM O O . HOH B 1 529 . 529 HOH B O 42.608 1.876 72.838 1 6314 +HETATM O O . HOH B 1 530 . 530 HOH B O 39.724 -7.879 55.844 1 6315 +HETATM O O . HOH B 1 531 . 531 HOH B O 32.634 9.331 71.969 1 6316 +HETATM O O . HOH B 1 532 . 532 HOH B O 42.17 0.459 62.32 1 6317 +HETATM O O . HOH B 1 533 . 533 HOH B O 35.304 -15.686 62.712 1 6318 +HETATM O O . HOH B 1 534 . 534 HOH B O 32.394 -0.053 55.359 1 6319 +HETATM O O . HOH B 1 535 . 535 HOH B O 12.257 6.765 58.966 1 6320 +HETATM O O . HOH B 1 536 . 536 HOH B O 13.722 3.916 64.525 1 6321 +HETATM O O . HOH B 1 537 . 537 HOH B O 17.988 -13.893 54.334 1 6322 +HETATM O O . HOH B 1 538 . 538 HOH B O 46.978 6.772 50.606 1 6323 +HETATM O O . HOH B 1 539 . 539 HOH B O 39.545 -10.52 55.941 1 6324 +HETATM O O . HOH B 1 540 . 540 HOH B O 31.12 14.419 48.435 1 6325 +HETATM O O . HOH B 1 541 . 541 HOH B O 55.229 8.632 53.003 1 6326 +HETATM O O . HOH B 1 542 . 542 HOH B O 41.088 10.921 55.276 1 6327 +HETATM O O . HOH B 1 543 . 543 HOH B O 25.117 2.519 43.27 1 6328 +HETATM O O . HOH B 1 544 . 544 HOH B O 41.194 23.528 75.395 1 6329 +HETATM O O . HOH B 1 545 . 545 HOH B O 21.662 -15.704 53.85 1 6330 +HETATM O O . HOH B 1 546 . 546 HOH B O 28.584 -11.61 30.076 1 6331 +HETATM O O . HOH B 1 547 . 547 HOH B O 29.339 6.295 55.176 1 6332 +HETATM O O . HOH B 1 548 . 548 HOH B O 32.062 12.446 69.144 1 6333 +HETATM O O . HOH B 1 549 . 549 HOH B O 44.091 -11.771 28.37 1 6334 +HETATM O O . HOH B 1 550 . 550 HOH B O 40.301 8.493 74.239 1 6335 +HETATM O O . HOH B 1 551 . 551 HOH B O 25.125 9.515 51.858 1 6336 +HETATM O O . HOH B 1 552 . 552 HOH B O 41.489 -12.169 56.651 1 6337 +HETATM O O . HOH B 1 553 . 553 HOH B O 15.228 -11.649 41.591 1 6338 +HETATM O O . HOH B 1 554 . 554 HOH B O 8.71 -12.921 62.319 1 6339 +HETATM O O . HOH B 1 555 . 555 HOH B O 33.017 -18.928 60.672 1 6340 +HETATM O O . HOH B 1 556 . 556 HOH B O 3.88 -1.07 50.321 1 6341 +HETATM O O . HOH B 1 557 . 557 HOH B O 15.278 -13.429 56.738 1 6342 +HETATM O O . HOH B 1 558 . 558 HOH B O 43.608 -23.315 41.585 1 6343 +HETATM O O . HOH B 1 559 . 559 HOH B O 40.846 -14.718 39.845 1 6344 +HETATM O O . HOH B 1 560 . 560 HOH B O 36.348 -0.378 43.629 1 6345 +HETATM O O . HOH B 1 561 . 561 HOH B O 45.855 0.559 46.64 1 6346 +HETATM O O . HOH B 1 562 . 562 HOH B O 30.462 6.214 44.851 1 6347 +HETATM O O . HOH B 1 563 . 563 HOH B O 10.042 0.655 52.283 1 6348 +HETATM O O . HOH B 1 564 . 564 HOH B O 20.858 -9.475 67.755 1 6349 +HETATM O O . HOH B 1 565 . 565 HOH B O 34.537 6.345 65.387 1 6350 +HETATM O O . HOH B 1 566 . 566 HOH B O 24.638 -0.698 52.178 1 6351 +HETATM O O . HOH B 1 567 . 567 HOH B O 12.678 -12.419 49.701 1 6352 +HETATM O O . HOH B 1 568 . 568 HOH B O 44.456 1.822 43.479 1 6353 +HETATM O O . HOH B 1 569 . 569 HOH B O 55.948 10.488 49.582 1 6354 +HETATM O O . HOH B 1 570 . 570 HOH B O 50.818 -10.18 63.015 1 6355 +HETATM O O . HOH B 1 571 . 571 HOH B O 26.252 8.383 71.46 1 6356 +HETATM O O . HOH B 1 572 . 572 HOH B O 19.35 -16.218 56.334 1 6357 +HETATM O O . HOH B 1 573 . 573 HOH B O 45.285 -8.419 30.209 1 6358 +HETATM O O . HOH B 1 574 . 574 HOH B O 31.086 -6.051 63.843 1 6359 +HETATM O O . HOH B 1 575 . 575 HOH B O 9.86 -11.432 48.58 1 6360 +HETATM O O . HOH B 1 576 . 576 HOH B O 13.834 -16.646 52.421 1 6361 +HETATM O O . HOH B 1 577 . 577 HOH B O 34.136 7.709 47.881 1 6362 +HETATM O O . HOH B 1 578 . 578 HOH B O 32.384 25.919 64.854 1 6363 +HETATM O O . HOH B 1 579 . 579 HOH B O 29.523 1.393 68.684 1 6364 +HETATM O O . HOH B 1 580 . 580 HOH B O 44.295 3.673 45.268 1 6365 +HETATM O O . HOH B 1 581 . 581 HOH B O 7.466 1.686 61.033 1 6366 +HETATM O O . HOH B 1 582 . 582 HOH B O 35.584 7.504 50.936 1 6367 +HETATM O O . HOH B 1 583 . 583 HOH B O 21.142 12.544 72.182 1 6368 +HETATM O O . HOH B 1 584 . 584 HOH B O 36.705 -11.375 62.434 1 6369 +HETATM O O . HOH B 1 585 . 585 HOH B O 7.607 -7.977 61.787 1 6370 +HETATM O O . HOH B 1 586 . 586 HOH B O 54.211 -9.621 50.635 1 6371 +HETATM O O . HOH B 1 587 . 587 HOH B O 13.858 8.782 49.956 1 6372 +HETATM O O . HOH B 1 588 . 588 HOH B O 30.375 12.855 71.169 1 6373 +HETATM O O . HOH B 1 589 . 589 HOH B O 29.635 -8.282 64.256 1 6374 +HETATM O O . HOH B 1 590 . 590 HOH B O 41.858 -13.935 58.894 1 6375 +HETATM O O . HOH B 1 591 . 591 HOH B O 5.005 2.282 60.018 1 6376 +HETATM O O . HOH B 1 592 . 592 HOH B O 21.885 15.099 71.926 1 6377 +HETATM O O . HOH B 1 593 . 593 HOH B O 32.765 -11.577 62.762 1 6378 +HETATM O O . HOH B 1 594 . 594 HOH B O 10.704 4.828 63.66 1 6379 +HETATM O O . HOH C 2 501 . 501 HOH C O 10.491 1.134 14.736 1 6380 +HETATM O O . HOH C 2 502 . 502 HOH C O 48.858 -25.479 16.143 1 6381 +HETATM O O . HOH C 2 503 . 503 HOH C O 28.696 -11.205 5.506 1 6382 +HETATM O O . HOH C 2 504 . 504 HOH C O 17.263 -13.82 15.691 1 6383 +HETATM O O . HOH C 2 505 . 505 HOH C O 7.993 -6.563 29.753 1 6384 +HETATM O O . HOH C 2 506 . 506 HOH C O 23.27 10.904 10.036 1 6385 +HETATM O O . HOH C 2 507 . 507 HOH C O 16.898 11.042 14.782 1 6386 +HETATM O O . HOH C 2 508 . 508 HOH C O 46.918 -23.113 20.743 1 6387 +HETATM O O . HOH C 2 509 . 509 HOH C O 31.937 -4.356 15.871 1 6388 +HETATM O O . HOH C 2 510 . 510 HOH C O 17.799 -5.848 1.912 1 6389 +HETATM O O . HOH C 2 511 . 511 HOH C O 9.489 10.869 15.431 1 6390 +HETATM O O . HOH C 2 512 . 512 HOH C O 33.505 0.308 3.64 1 6391 +HETATM O O . HOH C 2 513 . 513 HOH C O 19.63 11.233 16.976 1 6392 +HETATM O O . HOH C 2 514 . 514 HOH C O 28.55 11.447 15.915 1 6393 +HETATM O O . HOH C 2 515 . 515 HOH C O 0.945 7.421 20.507 1 6394 +HETATM O O . HOH C 2 516 . 516 HOH C O 29.899 -8.562 9.904 1 6395 +HETATM O O . HOH C 2 517 . 517 HOH C O 29.672 7.367 41.056 1 6396 +HETATM O O . HOH C 2 518 . 518 HOH C O 41.544 -19.954 -4.067 1 6397 +HETATM O O . HOH C 2 519 . 519 HOH C O 35.904 7.918 32.061 1 6398 +HETATM O O . HOH C 2 520 . 520 HOH C O 40.093 2.409 15.391 1 6399 +HETATM O O . HOH C 2 521 . 521 HOH C O 12.812 -5.669 7.267 1 6400 +HETATM O O . HOH C 2 522 . 522 HOH C O 30.421 -4.618 37.993 1 6401 +HETATM O O . HOH C 2 523 . 523 HOH C O 26.68 -11.635 -7.405 1 6402 +HETATM O O . HOH C 2 524 . 524 HOH C O 20.243 -1.786 28.125 1 6403 +HETATM O O . HOH C 2 525 . 525 HOH C O 33.211 -11.139 5.954 1 6404 +HETATM O O . HOH C 2 526 . 526 HOH C O 19.987 17.686 29.449 1 6405 +HETATM O O . HOH C 2 527 . 527 HOH C O 23.688 -12.926 19.82 1 6406 +HETATM O O . HOH C 2 528 . 528 HOH C O 45.557 -13.151 20.642 1 6407 +HETATM O O . HOH C 2 529 . 529 HOH C O 37.185 -3.206 20.5 1 6408 +HETATM O O . HOH C 2 530 . 530 HOH C O 39.659 9.159 14.343 1 6409 +HETATM O O . HOH C 2 531 . 531 HOH C O 47.279 -19.701 0.669 1 6410 +HETATM O O . HOH C 2 532 . 532 HOH C O 19.751 -1.443 30.501 1 6411 +HETATM O O . HOH C 2 533 . 533 HOH C O 35.848 -9.189 18.295 1 6412 +HETATM O O . HOH C 2 534 . 534 HOH C O 45.521 -6.809 24.484 1 6413 +HETATM O O . HOH C 2 535 . 535 HOH C O 25.751 9.487 8.521 1 6414 +HETATM O O . HOH C 2 536 . 536 HOH C O 53.809 -15.96 18.27 1 6415 +HETATM O O . HOH C 2 537 . 537 HOH C O 27.605 -9.08 8.289 1 6416 +HETATM O O . HOH C 2 538 . 538 HOH C O 21.414 11.686 39.872 1 6417 +HETATM O O . HOH C 2 539 . 539 HOH C O 4.455 2.735 15.117 1 6418 +HETATM O O . HOH C 2 540 . 540 HOH C O 24.553 -5.782 28.15 1 6419 +HETATM O O . HOH C 2 541 . 541 HOH C O 37.182 -17.671 22.024 1 6420 +HETATM O O . HOH C 2 542 . 542 HOH C O 41.473 -6.393 8.775 1 6421 +HETATM O O . HOH C 2 543 . 543 HOH C O 24.49 -2.818 19.137 1 6422 +HETATM O O . HOH C 2 544 . 544 HOH C O 33.133 -25.653 19.438 1 6423 +HETATM O O . HOH C 2 545 . 545 HOH C O 41.378 -8.048 17.965 1 6424 +HETATM O O . HOH C 2 546 . 546 HOH C O 37.149 10.977 8.763 1 6425 +HETATM O O . HOH C 2 547 . 547 HOH C O 26.869 -6.558 18.498 1 6426 +HETATM O O . HOH C 2 548 . 548 HOH C O 14.797 12.059 33.261 1 6427 +HETATM O O . HOH C 2 549 . 549 HOH C O 34.498 -14.685 7.574 1 6428 +HETATM O O . HOH C 2 550 . 550 HOH C O 42.019 8.951 31.166 1 6429 +HETATM O O . HOH C 2 551 . 551 HOH C O 39.346 -16.541 16.214 1 6430 +HETATM O O . HOH C 2 552 . 552 HOH C O 28.148 -10.305 16.497 1 6431 +HETATM O O . HOH C 2 553 . 553 HOH C O 30.233 -16.845 2.406 1 6432 +HETATM O O . HOH C 2 554 . 554 HOH C O 45.435 -4.072 14.283 1 6433 +HETATM O O . HOH C 2 555 . 555 HOH C O 58.214 -10.835 20.373 1 6434 +HETATM O O . HOH C 2 556 . 556 HOH C O 9.767 -1.978 19.306 1 6435 +HETATM O O . HOH C 2 557 . 557 HOH C O 23.854 16.458 29.139 1 6436 +HETATM O O . HOH C 2 558 . 558 HOH C O 16.589 9.62 29.795 1 6437 +HETATM O O . HOH C 2 559 . 559 HOH C O 16.031 8.248 26.07 1 6438 +HETATM O O . HOH C 2 560 . 560 HOH C O 23.783 12.801 17.509 1 6439 +HETATM O O . HOH C 2 561 . 561 HOH C O 51.287 3.122 8.09 1 6440 +HETATM O O . HOH C 2 562 . 562 HOH C O 56.636 -15.237 11.611 1 6441 +HETATM O O . HOH C 2 563 . 563 HOH C O 11.284 9.679 22.982 1 6442 +HETATM O O . HOH C 2 564 . 564 HOH C O 30.498 -29.234 9.517 1 6443 +HETATM O O . HOH C 2 565 . 565 HOH C O 42.606 6.43 14.358 1 6444 +HETATM O O . HOH C 2 566 . 566 HOH C O 25.009 -12.127 0.156 1 6445 +HETATM O O . HOH C 2 567 . 567 HOH C O 33.882 -27.793 11.204 1 6446 +HETATM O O . HOH C 2 568 . 568 HOH C O 14.24 -7.593 9.235 1 6447 +HETATM O O . HOH C 2 569 . 569 HOH C O 51.623 -14.711 16.62 1 6448 +HETATM O O . HOH C 2 570 . 570 HOH C O 22.976 19.596 22.57 1 6449 +HETATM O O . HOH C 2 571 . 571 HOH C O 32.682 -12.265 23.656 1 6450 +HETATM O O . HOH C 2 572 . 572 HOH C O 31.149 -13.706 -0.519 1 6451 +HETATM O O . HOH C 2 573 . 573 HOH C O 40.415 5.074 15.16 1 6452 +HETATM O O . HOH C 2 574 . 574 HOH C O 24.445 -1.626 21.271 1 6453 +HETATM O O . HOH C 2 575 . 575 HOH C O 28.159 -17.281 0.571 1 6454 +HETATM O O . HOH C 2 576 . 576 HOH C O 34.691 6.283 10.274 1 6455 +HETATM O O . HOH C 2 577 . 577 HOH C O 47.794 6.218 10.801 1 6456 +HETATM O O . HOH C 2 578 . 578 HOH C O 16.826 -12.222 17.67 1 6457 +HETATM O O . HOH C 2 579 . 579 HOH C O 25.278 -9.773 30.054 1 6458 +HETATM O O . HOH C 2 580 . 580 HOH C O 24.185 18.65 36.315 1 6459 +HETATM O O . HOH C 2 581 . 581 HOH C O 43.802 -7.767 28.007 1 6460 +HETATM O O . HOH C 2 582 . 582 HOH C O 45.29 -15.756 20.065 1 6461 +HETATM O O . HOH C 2 583 . 583 HOH C O 29.23 -18.785 23.485 1 6462 +HETATM O O . HOH C 2 584 . 584 HOH C O 14.127 10.631 22.054 1 6463 +HETATM O O . HOH C 2 585 . 585 HOH C O 39.717 15.048 21.871 1 6464 +HETATM O O . HOH C 2 586 . 586 HOH C O 49.182 10.564 29.415 1 6465 +HETATM O O . HOH C 2 587 . 587 HOH C O 34.63 -13.064 20.423 1 6466 +HETATM O O . HOH C 2 588 . 588 HOH C O 18.378 7.314 36.152 1 6467 +HETATM O O . HOH C 2 589 . 589 HOH C O 56.977 -0.994 8.995 1 6468 +HETATM O O . HOH C 2 590 . 590 HOH C O 29.405 16.787 16.791 1 6469 +HETATM O O . HOH C 2 591 . 591 HOH C O 30.306 3.834 7.386 1 6470 +HETATM O O . HOH C 2 592 . 592 HOH C O 33.755 7.955 42.816 1 6471 +HETATM O O . HOH C 2 593 . 593 HOH C O 11.605 -8.358 22.87 1 6472 +HETATM O O . HOH C 2 594 . 594 HOH C O 55.757 2.532 20.679 1 6473 +HETATM O O . HOH C 2 595 . 595 HOH C O 14.525 9.876 24.614 1 6474 +HETATM O O . HOH C 2 596 . 596 HOH C O 29.372 -31.499 10.772 1 6475 +# +_cell.length_a 162.09 +_cell.length_b 162.09 +_cell.length_c 221.005 +_cell.angle_alpha 90.0 +_cell.angle_beta 90.0 +_cell.angle_gamma 90.0 +# diff --git a/tests/data/io/interactions/PTE_enhanced_KCX.cif b/tests/data/io/interactions/PTE_enhanced_KCX.cif new file mode 100644 index 00000000..00d1e3bc --- /dev/null +++ b/tests/data/io/interactions/PTE_enhanced_KCX.cif @@ -0,0 +1,232 @@ +data_structure +# +loop_ +_chem_comp_bond.pdbx_ordinal +_chem_comp_bond.comp_id +_chem_comp_bond.atom_id_1 +_chem_comp_bond.atom_id_2 +_chem_comp_bond.value_order +_chem_comp_bond.pdbx_aromatic_flag +_chem_comp_bond.pdbx_stereo_config +1 HIS N CA SING N ? +2 HIS CA C SING N ? +3 HIS CA CB SING N ? +4 HIS C O DOUB N ? +5 HIS CB CG SING N ? +6 HIS CG ND1 SING Y ? +7 HIS CG CD2 DOUB Y ? +8 HIS ND1 CE1 DOUB Y ? +9 HIS ND1 HD1 SING N ? +10 HIS CD2 NE2 SING Y ? +11 HIS CE1 NE2 SING Y ? +12 TRP N CA SING N ? +13 TRP CA C SING N ? +14 TRP CA CB SING N ? +15 TRP C O DOUB N ? +16 TRP CB CG SING N ? +17 TRP CG CD1 DOUB Y ? +18 TRP CG CD2 SING Y ? +19 TRP CD1 NE1 SING Y ? +20 TRP CD2 CE2 DOUB Y ? +21 TRP CD2 CE3 SING Y ? +22 TRP CE2 NE1 SING Y ? +23 TRP NE1 HE1 SING N ? +24 TRP CE2 CZ2 SING Y ? +25 TRP CE3 CZ3 DOUB Y ? +26 TRP CZ2 CH2 DOUB Y ? +27 TRP CZ3 CH2 SING Y ? +28 PHE N CA SING N ? +29 PHE CA C SING N ? +30 PHE CA CB SING N ? +31 PHE C O DOUB N ? +32 PHE CB CG SING N ? +33 PHE CG CD1 DOUB Y ? +34 PHE CG CD2 SING Y ? +35 PHE CD1 CE1 SING Y ? +36 PHE CD2 CE2 DOUB Y ? +37 PHE CE1 CZ DOUB Y ? +38 PHE CE2 CZ SING Y ? +39 LYS N CA SING N ? +40 LYS CA C SING N ? +41 LYS CA CB SING N ? +42 LYS C O DOUB N ? +43 LYS CB CG SING N ? +44 LYS CG CD SING N ? +45 LYS CD CE SING N ? +46 LYS CE NZ SING N ? +47 HIS NE2 HE2 SING N ? +48 ASP N CA SING N ? +49 ASP CA C SING N ? +50 ASP CA CB SING N ? +51 ASP C O DOUB N ? +52 ASP CB CG SING N ? +53 ASP CG OD1 DOUB N ? +54 ASP CG OD2 SING N ? +55 ASP OD2 HD2 SING N ? +# +loop_ +_atom_site.group_PDB +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.pdbx_PDB_model_num +_atom_site.id +ATOM N N . HIS A 1 55 . 55 HIS A N 48.96 26.745 27.716 1 1 +ATOM C CA . HIS A 1 55 . 55 HIS A CA 48.572 27.98 27.052 1 2 +ATOM C C . HIS A 1 55 . 55 HIS A C 49.496 28.197 25.871 1 3 +ATOM O O . HIS A 1 55 . 55 HIS A O 50.587 28.748 26.025 1 4 +ATOM C CB . HIS A 1 55 . 55 HIS A CB 48.557 29.225 27.983 1 5 +ATOM C CG . HIS A 1 55 . 55 HIS A CG 48.02 30.491 27.324 1 6 +ATOM C CD2 . HIS A 1 55 . 55 HIS A CD2 48.422 31.123 26.189 1 7 +ATOM N ND1 . HIS A 1 55 . 55 HIS A ND1 46.979 31.242 27.815 1 8 +ATOM C CE1 . HIS A 1 55 . 55 HIS A CE1 46.786 32.283 26.995 1 9 +ATOM N NE2 . HIS A 1 55 . 55 HIS A NE2 47.643 32.246 25.988 1 10 +ATOM H HD1 . HIS A 1 55 . 55 HIS A HD1 46.441 31.101 28.671 1 11 +ATOM N N . HIS A 1 57 . 57 HIS A N 49.58 28.384 21.158 1 12 +ATOM C CA . HIS A 1 57 . 57 HIS A CA 48.817 28.656 19.951 1 13 +ATOM C C . HIS A 1 57 . 57 HIS A C 49.577 28.119 18.769 1 14 +ATOM O O . HIS A 1 57 . 57 HIS A O 50.8 28.208 18.738 1 15 +ATOM C CB . HIS A 1 57 . 57 HIS A CB 48.739 30.166 19.648 1 16 +ATOM C CG . HIS A 1 57 . 57 HIS A CG 48.1 30.992 20.736 1 17 +ATOM C CD2 . HIS A 1 57 . 57 HIS A CD2 48.685 31.669 21.761 1 18 +ATOM N ND1 . HIS A 1 57 . 57 HIS A ND1 46.747 31.18 20.891 1 19 +ATOM C CE1 . HIS A 1 57 . 57 HIS A CE1 46.547 31.942 21.979 1 20 +ATOM N NE2 . HIS A 1 57 . 57 HIS A NE2 47.701 32.258 22.528 1 21 +ATOM H HD1 . HIS A 1 57 . 57 HIS A HD1 46.022 30.783 20.318 1 22 +ATOM N N . TRP A 1 131 . 131 TRP A N 38.16 36.063 22.669 1 23 +ATOM C CA . TRP A 1 131 . 131 TRP A CA 39.328 36.85 23.084 1 24 +ATOM C C . TRP A 1 131 . 131 TRP A C 39.0 38.326 22.972 1 25 +ATOM O O . TRP A 1 131 . 131 TRP A O 37.842 38.694 23.138 1 26 +ATOM C CB . TRP A 1 131 . 131 TRP A CB 40.534 36.409 22.253 1 27 +ATOM C CG . TRP A 1 131 . 131 TRP A CG 41.903 37.021 22.403 1 28 +ATOM C CD1 . TRP A 1 131 . 131 TRP A CD1 42.857 36.761 23.355 1 29 +ATOM C CD2 . TRP A 1 131 . 131 TRP A CD2 42.583 37.769 21.373 1 30 +ATOM C CE2 . TRP A 1 131 . 131 TRP A CE2 43.924 37.918 21.776 1 31 +ATOM C CE3 . TRP A 1 131 . 131 TRP A CE3 42.178 38.305 20.141 1 32 +ATOM N NE1 . TRP A 1 131 . 131 TRP A NE1 44.065 37.294 22.99 1 33 +ATOM C CZ2 . TRP A 1 131 . 131 TRP A CZ2 44.869 38.594 20.999 1 34 +ATOM C CZ3 . TRP A 1 131 . 131 TRP A CZ3 43.104 38.969 19.365 1 35 +ATOM C CH2 . TRP A 1 131 . 131 TRP A CH2 44.439 39.117 19.796 1 36 +ATOM H HE1 . TRP A 1 131 . 131 TRP A HE1 44.933 37.243 23.512 1 37 +ATOM N N . PHE A 1 132 . 132 PHE A N 39.956 39.198 22.692 1 38 +ATOM C CA . PHE A 1 132 . 132 PHE A CA 39.548 40.585 22.636 1 39 +ATOM C C . PHE A 1 132 . 132 PHE A C 39.334 41.249 21.284 1 40 +ATOM O O . PHE A 1 132 . 132 PHE A O 39.374 42.461 21.175 1 41 +ATOM C CB . PHE A 1 132 . 132 PHE A CB 40.348 41.456 23.63 1 42 +ATOM C CG . PHE A 1 132 . 132 PHE A CG 41.845 41.343 23.453 1 43 +ATOM C CD1 . PHE A 1 132 . 132 PHE A CD1 42.518 42.1 22.494 1 44 +ATOM C CD2 . PHE A 1 132 . 132 PHE A CD2 42.585 40.475 24.252 1 45 +ATOM C CE1 . PHE A 1 132 . 132 PHE A CE1 43.894 41.999 22.351 1 46 +ATOM C CE2 . PHE A 1 132 . 132 PHE A CE2 43.963 40.373 24.11 1 47 +ATOM C CZ . PHE A 1 132 . 132 PHE A CZ 44.624 41.138 23.161 1 48 +ATOM N N . LYS A 1 169 . 169 LYS A N 38.186 29.799 26.663 1 49 +ATOM C CA . LYS A 1 169 . 169 LYS A CA 39.117 30.89 26.804 1 50 +ATOM C C . LYS A 1 169 . 169 LYS A C 38.402 32.246 26.808 1 51 +ATOM O O . LYS A 1 169 . 169 LYS A O 37.528 32.501 25.973 1 52 +ATOM C CB . LYS A 1 169 . 169 LYS A CB 40.153 30.81 25.669 1 53 +ATOM C CG . LYS A 1 169 . 169 LYS A CG 40.985 32.091 25.515 1 54 +ATOM C CD . LYS A 1 169 . 169 LYS A CD 42.07 32.288 26.572 1 55 +ATOM C CE . LYS A 1 169 . 169 LYS A CE 42.976 33.491 26.324 1 56 +ATOM N NZ . LYS A 1 169 . 169 LYS A NZ 43.859 33.313 25.179 1 57 +ATOM N N . HIS A 1 201 . 201 HIS A N 43.014 35.388 29.821 1 58 +ATOM C CA . HIS A 1 201 . 201 HIS A CA 44.05 36.298 29.422 1 59 +ATOM C C . HIS A 1 201 . 201 HIS A C 43.841 37.486 30.376 1 60 +ATOM O O . HIS A 1 201 . 201 HIS A O 42.711 37.902 30.582 1 61 +ATOM C CB . HIS A 1 201 . 201 HIS A CB 43.855 36.807 27.979 1 62 +ATOM C CG . HIS A 1 201 . 201 HIS A CG 44.779 37.949 27.589 1 63 +ATOM C CD2 . HIS A 1 201 . 201 HIS A CD2 44.7 39.259 27.966 1 64 +ATOM N ND1 . HIS A 1 201 . 201 HIS A ND1 45.828 37.809 26.715 1 65 +ATOM C CE1 . HIS A 1 201 . 201 HIS A CE1 46.376 39.005 26.587 1 66 +ATOM N NE2 . HIS A 1 201 . 201 HIS A NE2 45.723 39.902 27.326 1 67 +ATOM H HE2 . HIS A 1 201 . 201 HIS A HE2 45.976 40.892 27.403 1 68 +ATOM N N . HIS A 1 230 . 230 HIS A N 48.727 34.697 32.141 1 69 +ATOM C CA . HIS A 1 230 . 230 HIS A CA 49.687 35.782 31.974 1 70 +ATOM C C . HIS A 1 230 . 230 HIS A C 50.07 36.336 33.334 1 71 +ATOM O O . HIS A 1 230 . 230 HIS A O 50.448 37.475 33.462 1 72 +ATOM C CB . HIS A 1 230 . 230 HIS A CB 49.108 36.884 31.075 1 73 +ATOM C CG . HIS A 1 230 . 230 HIS A CG 48.709 36.29 29.769 1 74 +ATOM C CD2 . HIS A 1 230 . 230 HIS A CD2 47.865 36.735 28.798 1 75 +ATOM N ND1 . HIS A 1 230 . 230 HIS A ND1 49.258 35.129 29.265 1 76 +ATOM C CE1 . HIS A 1 230 . 230 HIS A CE1 48.743 34.908 28.054 1 77 +ATOM N NE2 . HIS A 1 230 . 230 HIS A NE2 47.891 35.864 27.718 1 78 +ATOM H HD1 . HIS A 1 230 . 230 HIS A HD1 49.929 34.487 29.734 1 79 +ATOM N N . ASP A 1 233 . 233 ASP A N 53.354 38.095 34.351 1 80 +ATOM C CA . ASP A 1 233 . 233 ASP A CA 53.55 39.423 33.801 1 81 +ATOM C C . ASP A 1 233 . 233 ASP A C 53.646 40.448 34.934 1 82 +ATOM O O . ASP A 1 233 . 233 ASP A O 53.921 41.616 34.711 1 83 +ATOM C CB . ASP A 1 233 . 233 ASP A CB 52.422 39.845 32.859 1 84 +ATOM C CG . ASP A 1 233 . 233 ASP A CG 52.509 39.28 31.453 1 85 +ATOM O OD1 . ASP A 1 233 . 233 ASP A OD1 51.792 39.69 30.551 1 86 +ATOM O OD2 . ASP A 1 233 . 233 ASP A OD2 53.391 38.33 31.267 1 87 +ATOM H HD2 . ASP A 1 233 . 233 ASP A HD2 53.242 37.968 30.301 1 88 +ATOM N N . HIS A 1 254 . 254 HIS A N 54.208 33.202 30.368 1 89 +ATOM C CA . HIS A 1 254 . 254 HIS A CA 54.927 34.123 29.499 1 90 +ATOM C C . HIS A 1 254 . 254 HIS A C 56.413 34.056 29.773 1 91 +ATOM O O . HIS A 1 254 . 254 HIS A O 57.14 34.979 29.475 1 92 +ATOM C CB . HIS A 1 254 . 254 HIS A CB 54.452 35.553 29.81 1 93 +ATOM C CG . HIS A 1 254 . 254 HIS A CG 53.327 35.912 28.906 1 94 +ATOM C CD2 . HIS A 1 254 . 254 HIS A CD2 52.641 35.25 27.944 1 95 +ATOM N ND1 . HIS A 1 254 . 254 HIS A ND1 52.86 37.207 28.989 1 96 +ATOM C CE1 . HIS A 1 254 . 254 HIS A CE1 51.925 37.328 28.076 1 97 +ATOM N NE2 . HIS A 1 254 . 254 HIS A NE2 51.762 36.17 27.423 1 98 +ATOM H HE2 . HIS A 1 254 . 254 HIS A HE2 51.166 35.957 26.635 1 99 +ATOM N N . ASP A 1 301 . 301 ASP A N 53.083 30.383 25.829 1 100 +ATOM C CA . ASP A 1 301 . 301 ASP A CA 52.283 31.327 25.077 1 101 +ATOM C C . ASP A 1 301 . 301 ASP A C 53.005 31.572 23.755 1 102 +ATOM O O . ASP A 1 301 . 301 ASP A O 53.129 32.705 23.272 1 103 +ATOM C CB . ASP A 1 301 . 301 ASP A CB 51.922 32.633 25.831 1 104 +ATOM C CG . ASP A 1 301 . 301 ASP A CG 50.834 33.465 25.163 1 105 +ATOM O OD1 . ASP A 1 301 . 301 ASP A OD1 50.692 34.706 25.428 1 106 +ATOM O OD2 . ASP A 1 301 . 301 ASP A OD2 50.005 32.875 24.423 1 107 +HETATM C C1 . XDW Z 2 999 . 999 XDW Z C1 45.042 33.978 25.036 1 108 +HETATM O O1 . XDW Z 2 999 . 999 XDW Z O1 45.311 34.827 25.95 1 109 +HETATM O O2 . XDW Z 2 999 . 999 XDW Z O2 45.786 33.714 24.058 1 110 +HETATM ZN ZN1 . XDW Z 2 999 . 999 XDW Z ZN1 47.921 33.281 24.258 1 111 +HETATM ZN ZN2 . XDW Z 2 999 . 999 XDW Z ZN2 46.784 36.133 26.038 1 112 +HETATM P P1 . XDW Z 2 999 . 999 XDW Z P1 48.206 36.471 23.583 1 113 +HETATM C C2 . XDW Z 2 999 . 999 XDW Z C2 48.965 42.104 22.502 1 114 +HETATM N N1 . XDW Z 2 999 . 999 XDW Z N1 49.258 43.51 22.678 1 115 +HETATM O O3 . XDW Z 2 999 . 999 XDW Z O3 48.433 35.234 24.673 1 116 +HETATM C C3 . XDW Z 2 999 . 999 XDW Z C3 48.686 41.32 23.616 1 117 +HETATM O O4 . XDW Z 2 999 . 999 XDW Z O4 48.144 35.581 22.295 1 118 +HETATM C C4 . XDW Z 2 999 . 999 XDW Z C4 48.962 41.544 21.227 1 119 +HETATM O O5 . XDW Z 2 999 . 999 XDW Z O5 46.96 37.068 24.189 1 120 +HETATM C C5 . XDW Z 2 999 . 999 XDW Z C5 48.4 39.985 23.455 1 121 +HETATM O O6 . XDW Z 2 999 . 999 XDW Z O6 49.572 37.177 23.866 1 122 +HETATM C C6 . XDW Z 2 999 . 999 XDW Z C6 48.662 40.21 21.064 1 123 +HETATM O O7 . XDW Z 2 999 . 999 XDW Z O7 48.067 38.123 22.003 1 124 +HETATM C C7 . XDW Z 2 999 . 999 XDW Z C7 48.362 39.37 22.175 1 125 +HETATM O O8 . XDW Z 2 999 . 999 XDW Z O8 49.636 44.151 21.707 1 126 +HETATM C C8 . XDW Z 2 999 . 999 XDW Z C8 47.088 35.617 21.296 1 127 +HETATM O O9 . XDW Z 2 999 . 999 XDW Z O9 49.113 43.998 23.788 1 128 +HETATM C C9 . XDW Z 2 999 . 999 XDW Z C9 50.738 37.072 23.004 1 129 +HETATM C C10 . XDW Z 2 999 . 999 XDW Z C10 47.642 35.036 20.02 1 130 +HETATM C C11 . XDW Z 2 999 . 999 XDW Z C11 51.851 37.799 23.711 1 131 +HETATM H H1 . XDW Z 2 999 . 999 XDW Z H1 46.776 36.647 21.161 1 132 +HETATM H H2 . XDW Z 2 999 . 999 XDW Z H2 46.263 35.023 21.697 1 133 +HETATM H H3 . XDW Z 2 999 . 999 XDW Z H3 50.483 37.512 22.04 1 134 +HETATM H H4 . XDW Z 2 999 . 999 XDW Z H4 50.967 36.011 22.87 1 135 +HETATM H H5 . XDW Z 2 999 . 999 XDW Z H5 48.71 41.774 24.597 1 136 +HETATM H H6 . XDW Z 2 999 . 999 XDW Z H6 49.188 42.172 20.376 1 137 +HETATM H H7 . XDW Z 2 999 . 999 XDW Z H7 48.206 39.361 24.312 1 138 +HETATM H H8 . XDW Z 2 999 . 999 XDW Z H8 48.644 39.776 20.071 1 139 +HETATM H H9 . XDW Z 2 999 . 999 XDW Z H9 49.506 35.064 24.997 1 140 +HETATM H H10 . XDW Z 2 999 . 999 XDW Z H10 46.849 34.99 19.27 1 141 +HETATM H H11 . XDW Z 2 999 . 999 XDW Z H11 48.448 35.662 19.634 1 142 +HETATM H H12 . XDW Z 2 999 . 999 XDW Z H12 48.028 34.027 20.185 1 143 +HETATM H H13 . XDW Z 2 999 . 999 XDW Z H13 52.05 37.359 24.688 1 144 +HETATM H H14 . XDW Z 2 999 . 999 XDW Z H14 52.767 37.746 23.119 1 145 +HETATM H H15 . XDW Z 2 999 . 999 XDW Z H15 51.594 38.847 23.862 1 146 +# diff --git a/tests/data/io/interactions/PTE_group1_theozyme.cif b/tests/data/io/interactions/PTE_group1_theozyme.cif new file mode 100644 index 00000000..11a4c70f --- /dev/null +++ b/tests/data/io/interactions/PTE_group1_theozyme.cif @@ -0,0 +1,161 @@ +data_structure +# +loop_ +_chem_comp_bond.pdbx_ordinal +_chem_comp_bond.comp_id +_chem_comp_bond.atom_id_1 +_chem_comp_bond.atom_id_2 +_chem_comp_bond.value_order +_chem_comp_bond.pdbx_aromatic_flag +_chem_comp_bond.pdbx_stereo_config +1 HIS N CA SING N ? +2 HIS CA C SING N ? +3 HIS CA CB SING N ? +4 HIS C O DOUB N ? +5 HIS CG CB SING N ? +6 HIS CG ND1 SING Y ? +7 HIS CG CD2 DOUB Y ? +8 HIS CE1 ND1 DOUB Y ? +9 HIS ND1 HD1 SING N ? +10 HIS CD2 NE2 SING Y ? +11 HIS CE1 NE2 SING Y ? +12 LYS N CA SING N ? +13 LYS CA C SING N ? +14 LYS CA CB SING N ? +15 LYS C O DOUB N ? +16 LYS CB CG SING N ? +17 LYS CG CD SING N ? +18 LYS CE CD SING N ? +19 LYS NZ CE SING N ? +20 HIS CD2 CG DOUB Y ? +21 HIS NE2 CE1 SING Y ? +22 GLU N CA SING N ? +23 GLU CA C SING N ? +24 GLU CA CB SING N ? +25 GLU C O DOUB N ? +26 GLU CG CB SING N ? +27 GLU CD CG SING N ? +28 GLU CD OE1 DOUB N ? +29 GLU CD OE2 SING N ? +# +loop_ +_atom_site.group_PDB +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.pdbx_PDB_model_num +_atom_site.id +ATOM C CG . HIS A 1 1 . 1 HIS A CG -6.067 1.969 0.64 1 1 +ATOM C CD2 . HIS A 1 1 . 1 HIS A CD2 -5.34 0.811 0.601 1 2 +ATOM C CE1 . HIS A 1 1 . 1 HIS A CE1 -4.081 2.35 -0.269 1 3 +ATOM N NE2 . HIS A 1 1 . 1 HIS A NE2 -4.106 1.06 0.03 1 4 +ATOM N ND1 . HIS A 1 1 . 1 HIS A ND1 -5.251 2.936 0.085 1 5 +ATOM N N . HIS A 1 1 . 1 HIS A N -8.672 0.976 2.825 1 6 +ATOM C CA . HIS A 1 1 . 1 HIS A CA -7.619 1.967 2.629 1 7 +ATOM C C . HIS A 1 1 . 1 HIS A C -7.93 3.257 3.375 1 8 +ATOM O O . HIS A 1 1 . 1 HIS A O -9.095 3.611 3.564 1 9 +ATOM C CB . HIS A 1 1 . 1 HIS A CB -7.427 2.265 1.138 1 10 +ATOM H HD1 . HIS A 1 1 . 1 HIS A HD1 -5.483 3.901 -0.035 1 11 +ATOM C CG . HIS B 2 2 . 2 HIS B CG -4.422 -3.991 -2.106 1 12 +ATOM C CD2 . HIS B 2 2 . 2 HIS B CD2 -4.054 -3.217 -1.039 1 13 +ATOM C CE1 . HIS B 2 2 . 2 HIS B CE1 -3.355 -2.182 -2.817 1 14 +ATOM N NE2 . HIS B 2 2 . 2 HIS B NE2 -3.388 -2.095 -1.496 1 15 +ATOM N ND1 . HIS B 2 2 . 2 HIS B ND1 -3.968 -3.318 -3.226 1 16 +ATOM N N . HIS B 2 2 . 2 HIS B N -6.684 -6.194 -0.504 1 17 +ATOM C CA . HIS B 2 2 . 2 HIS B CA -6.555 -5.237 -1.595 1 18 +ATOM C C . HIS B 2 2 . 2 HIS B C -7.58 -5.507 -2.689 1 19 +ATOM O O . HIS B 2 2 . 2 HIS B O -7.988 -6.649 -2.904 1 20 +ATOM C CB . HIS B 2 2 . 2 HIS B CB -5.143 -5.279 -2.188 1 21 +ATOM H HD1 . HIS B 2 2 . 2 HIS B HD1 -4.072 -3.615 -4.175 1 22 +ATOM N NZ . LYS C 3 3 . 3 LYS C NZ -1.568 2.063 -3.596 1 23 +ATOM C CE . LYS C 3 3 . 3 LYS C CE -1.104 3.392 -3.949 1 24 +ATOM N N . LYS C 3 3 . 3 LYS C N -2.244 7.682 -8.435 1 25 +ATOM C CA . LYS C 3 3 . 3 LYS C CA -1.533 7.261 -7.234 1 26 +ATOM C C . LYS C 3 3 . 3 LYS C C -0.025 7.287 -7.448 1 27 +ATOM O O . LYS C 3 3 . 3 LYS C O 0.456 7.086 -8.564 1 28 +ATOM C CB . LYS C 3 3 . 3 LYS C CB -1.98 5.861 -6.81 1 29 +ATOM C CG . LYS C 3 3 . 3 LYS C CG -1.291 5.332 -5.559 1 30 +ATOM C CD . LYS C 3 3 . 3 LYS C CD -1.787 3.939 -5.202 1 31 +ATOM C CD2 . HIS D 4 4 . 4 HIS D CD2 1.82 4.189 -1.048 1 32 +ATOM N NE2 . HIS D 4 4 . 4 HIS D NE2 1.833 2.876 -0.617 1 33 +ATOM C CG . HIS D 4 4 . 4 HIS D CG 3.083 4.561 -1.424 1 34 +ATOM C CE1 . HIS D 4 4 . 4 HIS D CE1 3.083 2.454 -0.737 1 35 +ATOM N ND1 . HIS D 4 4 . 4 HIS D ND1 3.87 3.446 -1.219 1 36 +ATOM N N . HIS D 4 4 . 4 HIS D N 2.649 8.068 -1.634 1 37 +ATOM C CA . HIS D 4 4 . 4 HIS D CA 3.442 7.021 -1.003 1 38 +ATOM C C . HIS D 4 4 . 4 HIS D C 4.806 7.546 -0.573 1 39 +ATOM O O . HIS D 4 4 . 4 HIS D O 5.353 8.458 -1.192 1 40 +ATOM C CB . HIS D 4 4 . 4 HIS D CB 3.621 5.832 -1.952 1 41 +ATOM H HD1 . HIS D 4 4 . 4 HIS D HD1 4.852 3.385 -1.397 1 42 +ATOM C CG . HIS E 5 5 . 5 HIS E CG 0.251 4.035 3.671 1 43 +ATOM C CD2 . HIS E 5 5 . 5 HIS E CD2 0.571 3.793 2.365 1 44 +ATOM C CE1 . HIS E 5 5 . 5 HIS E CE1 -0.656 2.119 3.013 1 45 +ATOM N NE2 . HIS E 5 5 . 5 HIS E NE2 0.001 2.598 1.961 1 46 +ATOM N ND1 . HIS E 5 5 . 5 HIS E ND1 -0.527 2.964 4.061 1 47 +ATOM N N . HIS E 5 5 . 5 HIS E N 1.222 7.432 3.915 1 48 +ATOM C CA . HIS E 5 5 . 5 HIS E CA 0.101 6.516 4.096 1 49 +ATOM C C . HIS E 5 5 . 5 HIS E C -0.914 7.077 5.085 1 50 +ATOM O O . HIS E 5 5 . 5 HIS E O -0.558 7.82 6.0 1 51 +ATOM C CB . HIS E 5 5 . 5 HIS E CB 0.592 5.148 4.581 1 52 +ATOM H HD1 . HIS E 5 5 . 5 HIS E HD1 -0.927 2.837 4.969 1 53 +ATOM C CD . GLU F 6 6 . 6 GLU F CD -3.012 -1.361 2.585 1 54 +ATOM O OE2 . GLU F 6 6 . 6 GLU F OE2 -1.97 -0.749 2.944 1 55 +ATOM O OE1 . GLU F 6 6 . 6 GLU F OE1 -3.413 -1.489 1.379 1 56 +ATOM C CG . GLU F 6 6 . 6 GLU F CG -3.873 -2.02 3.654 1 57 +ATOM N N . GLU F 6 6 . 6 GLU F N -5.126 -1.505 6.652 1 58 +ATOM C CA . GLU F 6 6 . 6 GLU F CA -4.126 -2.444 6.16 1 59 +ATOM C C . GLU F 6 6 . 6 GLU F C -3.21 -2.913 7.282 1 60 +ATOM O O . GLU F 6 6 . 6 GLU F O -2.962 -2.181 8.242 1 61 +ATOM C CB . GLU F 6 6 . 6 GLU F CB -3.296 -1.803 5.045 1 62 +HETATM ZN ZN1 . YYE Z 7 9 . 9 YYE Z ZN1 -2.598 -0.49 -0.306 1 63 +HETATM ZN ZN2 . YYE Z 7 9 . 9 YYE Z ZN2 0.175 1.606 0.054 1 64 +HETATM C C1 . YYE Z 7 9 . 9 YYE Z C1 -1.538 1.548 -2.333 1 65 +HETATM O O1 . YYE Z 7 9 . 9 YYE Z O1 -1.998 0.376 -2.163 1 66 +HETATM O O2 . YYE Z 7 9 . 9 YYE Z O2 -1.056 2.304 -1.408 1 67 +HETATM O O3 . YYE Z 7 9 . 9 YYE Z O3 -0.815 -0.116 0.62 1 68 +HETATM C C2 . YYE Z 7 9 . 9 YYE Z C2 6.23 -1.15 -0.281 1 69 +HETATM C C3 . YYE Z 7 9 . 9 YYE Z C3 5.45 -0.966 -1.424 1 70 +HETATM C C4 . YYE Z 7 9 . 9 YYE Z C4 5.718 -1.736 0.875 1 71 +HETATM C C5 . YYE Z 7 9 . 9 YYE Z C5 4.118 -1.365 -1.4 1 72 +HETATM C C6 . YYE Z 7 9 . 9 YYE Z C6 4.382 -2.128 0.895 1 73 +HETATM C C7 . YYE Z 7 9 . 9 YYE Z C7 3.584 -1.934 -0.237 1 74 +HETATM O O4 . YYE Z 7 9 . 9 YYE Z O4 -0.133 -2.293 -0.379 1 75 +HETATM O O5 . YYE Z 7 9 . 9 YYE Z O5 1.457 -0.009 -0.455 1 76 +HETATM C C8 . YYE Z 7 9 . 9 YYE Z C8 0.041 -3.389 -1.328 1 77 +HETATM H H1 . YYE Z 7 9 . 9 YYE Z H1 0.927 -3.213 -1.935 1 78 +HETATM H H2 . YYE Z 7 9 . 9 YYE Z H2 -0.848 -3.325 -1.958 1 79 +HETATM O O6 . YYE Z 7 9 . 9 YYE Z O6 1.168 -1.346 1.773 1 80 +HETATM P P1 . YYE Z 7 9 . 9 YYE Z P1 1.02 -1.317 0.175 1 81 +HETATM O O7 . YYE Z 7 9 . 9 YYE Z O7 2.276 -2.359 -0.23 1 82 +HETATM C C9 . YYE Z 7 9 . 9 YYE Z C9 0.581 -2.412 2.584 1 83 +HETATM H H3 . YYE Z 7 9 . 9 YYE Z H3 -0.244 -2.864 2.031 1 84 +HETATM H H4 . YYE Z 7 9 . 9 YYE Z H4 0.16 -1.903 3.449 1 85 +HETATM N N1 . YYE Z 7 9 . 9 YYE Z N1 7.628 -0.698 -0.295 1 86 +HETATM O O8 . YYE Z 7 9 . 9 YYE Z O8 8.315 -0.933 0.697 1 87 +HETATM O O9 . YYE Z 7 9 . 9 YYE Z O9 8.021 -0.099 -1.298 1 88 +HETATM H H5 . YYE Z 7 9 . 9 YYE Z H5 5.897 -0.524 -2.306 1 89 +HETATM H H6 . YYE Z 7 9 . 9 YYE Z H6 6.363 -1.869 1.735 1 90 +HETATM H H7 . YYE Z 7 9 . 9 YYE Z H7 3.481 -1.237 -2.267 1 91 +HETATM H H8 . YYE Z 7 9 . 9 YYE Z H8 3.953 -2.582 1.78 1 92 +HETATM H H9 . YYE Z 7 9 . 9 YYE Z H9 -1.092 -0.31 1.554 1 93 +HETATM C C10 . YYE Z 7 9 . 9 YYE Z C10 0.107 -4.712 -0.593 1 94 +HETATM H H10 . YYE Z 7 9 . 9 YYE Z H10 0.139 -5.532 -1.318 1 95 +HETATM H H11 . YYE Z 7 9 . 9 YYE Z H11 1.002 -4.767 0.032 1 96 +HETATM H H12 . YYE Z 7 9 . 9 YYE Z H12 -0.778 -4.845 0.038 1 97 +HETATM C C11 . YYE Z 7 9 . 9 YYE Z C11 1.625 -3.44 2.972 1 98 +HETATM H H13 . YYE Z 7 9 . 9 YYE Z H13 2.451 -2.971 3.516 1 99 +HETATM H H14 . YYE Z 7 9 . 9 YYE Z H14 1.166 -4.187 3.63 1 100 +HETATM H H15 . YYE Z 7 9 . 9 YYE Z H15 2.024 -3.955 2.094 1 101 +# diff --git a/tests/data/io/interactions/iron_site_9oer.cif.zst b/tests/data/io/interactions/iron_site_9oer.cif.zst new file mode 100644 index 00000000..c3519053 Binary files /dev/null and b/tests/data/io/interactions/iron_site_9oer.cif.zst differ diff --git a/tests/data/io/interactions/nucleic_hbonds/1bna.cif.zst b/tests/data/io/interactions/nucleic_hbonds/1bna.cif.zst new file mode 100644 index 00000000..1c98b902 Binary files /dev/null and b/tests/data/io/interactions/nucleic_hbonds/1bna.cif.zst differ diff --git a/tests/data/io/interactions/nucleic_hbonds/1ehz.cif.zst b/tests/data/io/interactions/nucleic_hbonds/1ehz.cif.zst new file mode 100644 index 00000000..2df14f9b Binary files /dev/null and b/tests/data/io/interactions/nucleic_hbonds/1ehz.cif.zst differ diff --git a/tests/data/io/interactions/pication_1gai.cif.zst b/tests/data/io/interactions/pication_1gai.cif.zst new file mode 100644 index 00000000..59638065 Binary files /dev/null and b/tests/data/io/interactions/pication_1gai.cif.zst differ diff --git a/tests/data/io/regression_tests/104d.pkl b/tests/data/io/regression_tests/104d.pkl new file mode 100644 index 00000000..636b0358 Binary files /dev/null and b/tests/data/io/regression_tests/104d.pkl differ diff --git a/tests/data/io/regression_tests/1a3g.pkl b/tests/data/io/regression_tests/1a3g.pkl new file mode 100644 index 00000000..8c16d5a7 Binary files /dev/null and b/tests/data/io/regression_tests/1a3g.pkl differ diff --git a/tests/data/io/regression_tests/1cbn.pkl b/tests/data/io/regression_tests/1cbn.pkl new file mode 100644 index 00000000..d02341d6 Binary files /dev/null and b/tests/data/io/regression_tests/1cbn.pkl differ diff --git a/tests/data/io/regression_tests/1d3z.pkl b/tests/data/io/regression_tests/1d3z.pkl new file mode 100644 index 00000000..0f0aae11 Binary files /dev/null and b/tests/data/io/regression_tests/1d3z.pkl differ diff --git a/tests/data/io/regression_tests/1d9d.pkl b/tests/data/io/regression_tests/1d9d.pkl new file mode 100644 index 00000000..3b59c038 Binary files /dev/null and b/tests/data/io/regression_tests/1d9d.pkl differ diff --git a/tests/data/io/regression_tests/1fp7.pkl b/tests/data/io/regression_tests/1fp7.pkl index 6387e420..7982f7a6 100644 Binary files a/tests/data/io/regression_tests/1fp7.pkl and b/tests/data/io/regression_tests/1fp7.pkl differ diff --git a/tests/data/io/regression_tests/1ivo.pkl b/tests/data/io/regression_tests/1ivo.pkl new file mode 100644 index 00000000..2b5f181c Binary files /dev/null and b/tests/data/io/regression_tests/1ivo.pkl differ diff --git a/tests/data/io/regression_tests/1j8z.pkl b/tests/data/io/regression_tests/1j8z.pkl index a9ec7187..905a510a 100644 Binary files a/tests/data/io/regression_tests/1j8z.pkl and b/tests/data/io/regression_tests/1j8z.pkl differ diff --git a/tests/data/io/regression_tests/1qh9.pkl b/tests/data/io/regression_tests/1qh9.pkl new file mode 100644 index 00000000..be51e646 Binary files /dev/null and b/tests/data/io/regression_tests/1qh9.pkl differ diff --git a/tests/data/io/regression_tests/1rcq.pkl b/tests/data/io/regression_tests/1rcq.pkl new file mode 100644 index 00000000..e5a86b46 Binary files /dev/null and b/tests/data/io/regression_tests/1rcq.pkl differ diff --git a/tests/data/io/regression_tests/1twr.pkl b/tests/data/io/regression_tests/1twr.pkl index 9eeec3ac..5637a892 100644 Binary files a/tests/data/io/regression_tests/1twr.pkl and b/tests/data/io/regression_tests/1twr.pkl differ diff --git a/tests/data/io/regression_tests/2koc.pkl b/tests/data/io/regression_tests/2koc.pkl new file mode 100644 index 00000000..805424b2 Binary files /dev/null and b/tests/data/io/regression_tests/2koc.pkl differ diff --git a/tests/data/io/regression_tests/3n95.pkl b/tests/data/io/regression_tests/3n95.pkl new file mode 100644 index 00000000..56aca738 Binary files /dev/null and b/tests/data/io/regression_tests/3n95.pkl differ diff --git a/tests/data/io/regression_tests/3ne2.pkl b/tests/data/io/regression_tests/3ne2.pkl new file mode 100644 index 00000000..1c7f60ac Binary files /dev/null and b/tests/data/io/regression_tests/3ne2.pkl differ diff --git a/tests/data/io/regression_tests/3ne7.pkl b/tests/data/io/regression_tests/3ne7.pkl new file mode 100644 index 00000000..0b75c5c1 Binary files /dev/null and b/tests/data/io/regression_tests/3ne7.pkl differ diff --git a/tests/data/io/regression_tests/4aah.pkl b/tests/data/io/regression_tests/4aah.pkl new file mode 100644 index 00000000..2632f951 Binary files /dev/null and b/tests/data/io/regression_tests/4aah.pkl differ diff --git a/tests/data/io/regression_tests/4js1.pkl b/tests/data/io/regression_tests/4js1.pkl new file mode 100644 index 00000000..1b6dfcc3 Binary files /dev/null and b/tests/data/io/regression_tests/4js1.pkl differ diff --git a/tests/data/io/regression_tests/4ndz.pkl b/tests/data/io/regression_tests/4ndz.pkl new file mode 100644 index 00000000..c79eedd4 Binary files /dev/null and b/tests/data/io/regression_tests/4ndz.pkl differ diff --git a/tests/data/io/regression_tests/6dmg.pkl b/tests/data/io/regression_tests/6dmg.pkl new file mode 100644 index 00000000..62aa0471 Binary files /dev/null and b/tests/data/io/regression_tests/6dmg.pkl differ diff --git a/tests/data/io/regression_tests/6h9v.pkl b/tests/data/io/regression_tests/6h9v.pkl new file mode 100644 index 00000000..d5c29e2a Binary files /dev/null and b/tests/data/io/regression_tests/6h9v.pkl differ diff --git a/tests/data/io/regression_tests/6mub.pkl b/tests/data/io/regression_tests/6mub.pkl index ae7ce3f9..a729f8be 100644 Binary files a/tests/data/io/regression_tests/6mub.pkl and b/tests/data/io/regression_tests/6mub.pkl differ diff --git a/tests/data/io/regression_tests/6q9t.pkl b/tests/data/io/regression_tests/6q9t.pkl index c0fc9f45..89a7949b 100644 Binary files a/tests/data/io/regression_tests/6q9t.pkl and b/tests/data/io/regression_tests/6q9t.pkl differ diff --git a/tests/data/ml/3ejj_featurize_msa_like_af3_regression_test.pkl.gz b/tests/data/ml/3ejj_featurize_msa_like_af3_regression_test.pkl.gz index 02c4a611..2a88f33d 100644 Binary files a/tests/data/ml/3ejj_featurize_msa_like_af3_regression_test.pkl.gz and b/tests/data/ml/3ejj_featurize_msa_like_af3_regression_test.pkl.gz differ diff --git a/tests/data/ml/omol_test/metadata.parquet b/tests/data/ml/omol_test/metadata.parquet new file mode 100644 index 00000000..3c2add96 Binary files /dev/null and b/tests/data/ml/omol_test/metadata.parquet differ diff --git a/tests/data/ml/omol_test/shard_00.ciflmdb b/tests/data/ml/omol_test/shard_00.ciflmdb new file mode 100644 index 00000000..aaf25f16 Binary files /dev/null and b/tests/data/ml/omol_test/shard_00.ciflmdb differ diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/experimental/protonation/__init__.py b/tests/experimental/protonation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/experimental/protonation/test_assign.py b/tests/experimental/protonation/test_assign.py new file mode 100644 index 00000000..eacd5c3d --- /dev/null +++ b/tests/experimental/protonation/test_assign.py @@ -0,0 +1,270 @@ +"""assign_hydrogens on trimmed PDB entries: each row is a rule, named by the entry that needs it.""" + +import logging +from pathlib import Path + +import numpy as np +import pytest +from rdkit import Chem + +from atomworks.experimental.protonation import assign_hydrogens +from atomworks.experimental.protonation.external.dimorphite_dl import dimorphite_dl +from atomworks.io import parse +from atomworks.io.config import ParseConfig + +DATA = Path(__file__).parents[2] / "data" / "experimental" / "protonation" +# The documented parse: covalent, metal and disulfide bonds from struct_conn, missing atoms added. +PARSE = { + "hydrogen_policy": "remove", + "add_missing_atoms": True, + "add_bond_types_from_struct_conn": ("covale", "metalc", "disulf"), +} + + +def parse_entry(name, **overrides): + return parse(DATA / f"{name}.cif.zst", config=ParseConfig(**{**PARSE, **overrides}))["asym_unit"][0] + + +# (entry, residue, atom, (charge, hydrogens), parse overrides) +ROWS = [ + # metalc to element X, which has no covalent radius + pytest.param("unknown_atom_on_nickel_1jqk", "UNX", "UNK", (0, 0), {}, id="1JQK UNX UNK"), + pytest.param("sodium_histidine_1vmj", "HIS", "NE2", (0, 0), {}, id="1VMJ HIS NE2"), # on a sodium + # an amide after STA, drawn with its H when read back + pytest.param("pepstatin_after_gap_4ejk", "ALA", "N", (0, 1), {}, id="4EJK ALA N"), + # nitric oxide and peroxide on their metals, which the dictionary draws without hydrogens + pytest.param("nitric_oxide_on_heme_8hbf", "NO", "N", (0, 0), {}, id="8HBF NO N"), + pytest.param("peroxide_on_heme_copper_7ypy", "PER", "O1", (-1, 0), {}, id="7YPY PER O1"), + # the carboxylate's -1 deposited on the O the dictionary draws double-bonded + pytest.param("full_conjugate_9ewf", "SIA", "O1A", (0, 0), {}, id="9EWF SIA O1A"), + # a proline missing its CD: N takes a hydrogen in its place + pytest.param("missing_proline_ring_7no8", "PRO", "N", (0, 2), {"add_missing_atoms": False}, id="7NO8 PRO N"), + # a primary carboxamide N on Fe(III) stays a neutral amide + pytest.param("carboxamide_on_iron_7urh", "ASN", "ND2", (0, 2), {}, id="7URH ASN ND2"), + # a chloride deposited at charge 0 is a chloride + pytest.param("chloride_complex_4hbt", "CL", "CL", (-1, 0), {"add_missing_atoms": False}, id="4HBT CL CL"), + # an unresolved OXT is missing density: the C terminus takes no hydrogen in its place + pytest.param("c_terminus_without_oxt_5ark", "LYS", "C", (0, 0), {"add_missing_atoms": False}, id="5ARK LYS C"), + # a nitro N deposited at charge 0 takes the dictionary's +1, so the peptide's free N terminus is titrated + pytest.param("nitrophenylalanine_peptide_1ytj", "PPN", "N", (1, 3), {"add_missing_atoms": False}, id="1YTJ PPN N"), + # a phosphoryl P is P(V): hypophosphite keeps its two P-H + pytest.param("hypophosphite_5me4", "HP4", "P1", (0, 2), {"remove_ccds": ()}, id="5ME4 HP4 P1"), + # only protons carry over from Dimorphite-DL's redrawn azide + pytest.param("azide_ligand_3wd2", "A1L", "N7", (-1, 0), {"remove_ccds": ()}, id="3WD2 A1L N7"), + # Dimorphite-DL draws perchlorate charge-separated; the Cl keeps the charge its bonds draw + pytest.param("perchlorate_1jfv", "LCP", "CL", (0, 0), {"remove_ccds": ()}, id="1JFV LCP CL"), + # perchlorate keeps the charges its bonds draw; its O takes no proton + pytest.param("perchlorate_1jfv", "LCP", "O1", (0, 0), {"remove_ccds": ()}, id="1JFV LCP O1"), + # free sulfate is a dianion + pytest.param("sulfate_1al1", "SO4", "O3", (-1, 0), {"remove_ccds": ()}, id="1AL1 SO4 O3"), + # bicarbonate keeps its proton (pKa2 10.3) + pytest.param("bicarbonate_5zas", "BCT", "O3", (0, 1), {"remove_ccds": ()}, id="5ZAS BCT O3"), + # a selenol (pKa 5.2) is a selenolate + pytest.param("selenocysteine_5azz", "SEC", "SE", (-1, 0), {"remove_ccds": ()}, id="5AZZ SEC SE"), + # cacodylic acid (pKa 6.3) is cacodylate + pytest.param("cacodylate_8his", "CAD", "O1", (-1, 0), {"remove_ccds": ()}, id="8HIS CAD O1"), + # arsenate is HAsO4 2- + pytest.param("arsenate_3we3", "ART", "O2", (-1, 0), {"remove_ccds": ()}, id="3WE3 ART O2"), + # an amine N-oxide O is no base + pytest.param("amine_oxide_3t2a", "TMO", "OAE", (-1, 0), {"remove_ccds": ()}, id="3T2A TMO OAE"), + # ammonia (pKa 9.25) is ammonium + pytest.param("ammonium_5k8h", "NH4", "N", (1, 4), {"remove_ccds": ()}, id="5K8H NH4 N"), + # free phosphate is HPO4 2- + pytest.param("phosphate_5txd", "PO4", "O2", (0, 1), {"remove_ccds": ()}, id="5TXD PO4 O2"), + # a tertiary amide N is no base + pytest.param("tertiary_amide_4elb", "34S", "N17", (0, 0), {"remove_ccds": ()}, id="4ELB 34S N17"), + # a tertiary sulfonamide N is no base + pytest.param("sulfonamide_4e4l", "0NH", "N5", (0, 0), {"remove_ccds": ()}, id="4E4L 0NH N5"), + # a phosphoramide N is no base + pytest.param("phosphoramide_4p45", "2F9", "N", (0, 1), {"remove_ccds": ()}, id="4P45 2F9 N"), + # a vinamidine takes its proton on the imine N + pytest.param("vinamidine_5oxd", "B2W", "NAE", (1, 1), {"remove_ccds": ()}, id="5OXD B2W NAE"), + # an enamine N is no base + pytest.param("enamine_5cbb", "4ZF", "N08", (0, 1), {"remove_ccds": ()}, id="5CBB 4ZF N08"), + # a neutral histidine nothing decides takes its proton on the tele N (NE2) + pytest.param("6dmz_mod_l", "HIS", "NE2", (0, 1), {}, id="6DMZ HIS NE2"), + pytest.param("6dmz_mod_l", "HIS", "ND1", (0, 0), {}, id="6DMZ HIS ND1"), + # a nitrosamine N is no base + pytest.param("nitrosamine_4eji", "0QA", "N2", (0, 0), {"remove_ccds": ()}, id="4EJI 0QA N2"), + # an OG1 2.98 A from Hg is in contact, not coordinated, and stays an alcohol + pytest.param("metal_contact_5cqo", "THR", "OG1", (0, 1), {"remove_ccds": ()}, id="5CQO THR OG1"), + # a guanidinium (pKa 13.8) on Zn keeps its protons + pytest.param("arginine_on_zinc_7pyk", "ARG", "NH2", (1, 2), {"remove_ccds": ()}, id="7PYK ARG NH2"), + # a ligand without stated bonds keeps its charges and counts + pytest.param("unbonded_ligand_2aj6", "UNL", "O1", (0, 0), {"remove_ccds": ()}, id="2AJ6 UNL O1"), + # a sulfur the dictionary draws at a higher valence keeps its hydrogen + pytest.param("dictionary_valence_2yak", "OSV", "S16", (0, 1), {"remove_ccds": ()}, id="2YAK OSV S16"), +] + + +@pytest.mark.parametrize(("name", "res_name", "atom_name", "expected", "overrides"), ROWS) +def test_an_entry_takes_the_state_its_chemistry_gives(name, res_name, atom_name, expected, overrides): + assigned = assign_hydrogens(parse_entry(name, **overrides)) + + placed = np.isfinite(assigned.coord).all(axis=-1) + atoms = np.flatnonzero((assigned.res_name == res_name) & (assigned.atom_name == atom_name) & placed) + assert len(atoms) + assert {(int(assigned.charge[i]), int(assigned.nhyd[i])) for i in atoms} == {expected} + + +def test_an_internal_phosphodiester_stays_an_anion_below_a_monoester_s_pka(): + """1TTD DT OP2: titrated with its links capped, an internal phosphodiester (pKa ~1.5) stays an anion at pH 6.3; + only the 5' monoester (pKa2 6.5) takes a proton.""" + assigned = assign_hydrogens(parse_entry("na_dna_ttd_1ttd"), ph=6.3) + op2 = assigned.atom_name == "OP2" + first = assigned.res_id[op2].min() + + assert set( + zip( + assigned.charge[op2 & (assigned.res_id > first)].tolist(), + assigned.nhyd[op2 & (assigned.res_id > first)].tolist(), + strict=False, + ) + ) == {(-1, 0)} + assert set( + zip( + assigned.charge[op2 & (assigned.res_id == first)].tolist(), + assigned.nhyd[op2 & (assigned.res_id == first)].tolist(), + strict=False, + ) + ) == {(0, 1)} + + +def test_hydrogens_on_an_unrelated_chain_do_not_change_a_protein_s_counts(): + """3KS3's backbone N-H counts stay the same beside 3C3G's explicitly hydrogenated peptide.""" + protein = parse_entry("zn_tetrahedral_3ks3", add_missing_atoms=False) + peptide = parse_entry("beta_peptide_3c3g", hydrogen_policy="keep", add_missing_atoms=False) + peptide.set_annotation("nhyd", np.zeros(len(peptide), dtype=protein.nhyd.dtype)) + for annotation in ("chain_id", "chain_iid", "pn_unit_id", "pn_unit_iid"): + if annotation in peptide.get_annotation_categories(): + peptide.set_annotation(annotation, np.full(len(peptide), "peptide")) + + alone = assign_hydrogens(protein) + together = assign_hydrogens(protein + peptide)[: len(protein)] + + assert np.array_equal(alone.nhyd, together.nhyd) + assert np.array_equal(alone.charge, together.charge) + + +class _OneProductReaction: + """A reaction that fails the test when a ``RunReactants`` call builds more than one product.""" + + def __init__(self, reaction): + self._reaction = reaction + + def RunReactants(self, *args, **kwargs): # noqa: N802 + products = self._reaction.RunReactants(*args, **kwargs) + if len(products) > 1: + pytest.fail(f"a neutralising reaction built {len(products)} products to keep one") + return products + + +def test_an_atomized_nucleotide_chain_titrates_in_seconds(monkeypatch): + """1QD7's chain of 271 unknown nucleotides is titrated as one atomized molecule. Each phosphate site + resanitized all of it (50 s), and protonating the output again built every product of each neutralising + reaction to keep the first (over 30 min).""" + calls = [] + sanitize = Chem.SanitizeMol + monkeypatch.setattr(Chem, "SanitizeMol", lambda *args, **kwargs: calls.append(1) or sanitize(*args, **kwargs)) + reactions = tuple((query, _OneProductReaction(r)) for query, r in dimorphite_dl._NEUTRALIZING_REACTIONS) + monkeypatch.setattr(dimorphite_dl, "_NEUTRALIZING_REACTIONS", reactions) + + assigned = assign_hydrogens(parse_entry("atomized_nucleotide_chain_1qd7")) + assert len(calls) < (assigned.element == "P").sum() == 271 + assign_hydrogens(assigned) + + +def test_only_placed_input_hydrogens_are_reported_dropped(caplog): + """6FPI kept with its hydrogens: the two deposited EJ2 hydrides are dropped, not the unplaced template ones.""" + heavy = parse(DATA / "nife_hydride_6fpi.cif.zst", config=ParseConfig(hydrogen_policy="keep", remove_ccds=())) + with caplog.at_level(logging.WARNING, logger="atomworks.ml"): + assign_hydrogens(heavy["asym_unit"][0]) + + assert "Dropped 2 placed input hydrogens" in caplog.text + + +def molecule_from_smiles(smiles): + """A resolved non-polymer with explicit connectivity, independent of CCD entries.""" + from atomworks.enums import ChainType + from atomworks.io.tools.rdkit import atom_array_from_rdkit + + atoms = atom_array_from_rdkit(Chem.MolFromSmiles(smiles)) + atoms.coord = np.arange(len(atoms) * 3, dtype=np.float32).reshape(-1, 3) / 4 + atoms.chain_id[:] = "A" + atoms.res_id[:] = 1 + atoms.set_annotation("is_polymer", np.zeros(len(atoms), dtype=bool)) + atoms.set_annotation("chain_type", np.full(len(atoms), int(ChainType.NON_POLYMER))) + atoms.set_annotation("pn_unit_iid", np.zeros(len(atoms), dtype=int)) + return atoms + + +@pytest.mark.parametrize("element", ["P", "As", "Sb"]) +def test_declared_hydrogens_preserve_five_coordinate_chemistry(element): + atoms = molecule_from_smiles(f"[{element}H](C)(C)(C)C") + assigned = assign_hydrogens(atoms, hydrogens=atoms.nhyd) + + np.testing.assert_array_equal(assigned.nhyd, atoms.nhyd) + np.testing.assert_array_equal(assigned.charge, atoms.charge) + assert "skip_hydrogen_placement" in assigned.get_annotation_categories() + assert not assigned.skip_hydrogen_placement.any() + + +@pytest.mark.parametrize("on_metal", [False, True]) +def test_inconsistent_declared_valence_is_rejected_with_or_without_coordination(on_metal): + import biotite.structure as struc + + atoms = molecule_from_smiles("CC=O.[Mg+2]" if on_metal else "CC=O") + declared = atoms.nhyd.copy() + declared[1] = 2 # The carbonyl carbon has room for one H, independent of coordination. + if on_metal: + atoms.res_id[-1], atoms.res_name[-1], atoms.atom_name[-1] = 2, "MG", "MG" + atoms.pn_unit_iid[-1] = 1 + atoms.bonds.add_bond(1, len(atoms) - 1, struc.BondType.COORDINATION) + + with pytest.raises(ValueError, match="Declared hydrogen count exceeds chemical valence"): + assign_hydrogens(atoms, hydrogens=declared) + + +@pytest.mark.parametrize(("code", "element"), [("F", "F"), ("CL", "Cl"), ("BR", "Br"), ("IOD", "I")]) +def test_monatomic_halides_follow_ccd_chemistry_but_covalent_halogens_do_not(code, element, caplog): + from atomworks.io.utils.ccd import atom_array_from_ccd_code + + template = atom_array_from_ccd_code(code) + atoms = molecule_from_smiles(f"[{element}-]") + atoms.res_name[:] = code + atoms.atom_name[:] = template.atom_name + atoms.charge[:] = 0 # Missing atom_site charges are read as zero. + assigned = assign_hydrogens(atoms) + assert assigned.charge.tolist() == [-1] + assert assigned.nhyd.tolist() == [0] + assert "Zero also represents missing charge" in caplog.text + + covalent = assign_hydrogens(molecule_from_smiles(f"C{element}")) + assert covalent.charge[1] == 0 + assert covalent.nhyd[1] == 0 + + +@pytest.mark.parametrize(("code", "element"), [("F", "F"), ("CL", "Cl"), ("BR", "Br"), ("IOD", "I")]) +@pytest.mark.parametrize("hydrogens", [0, 1]) +def test_declared_monatomic_states_override_the_ccd_charge(code, element, hydrogens): + from atomworks.io.utils.ccd import atom_array_from_ccd_code + + atoms = molecule_from_smiles(f"[{element}]") + atoms.res_name[:] = code + atoms.atom_name[:] = atom_array_from_ccd_code(code).atom_name + assigned = assign_hydrogens(atoms, hydrogens=np.array([hydrogens])) + assert assigned.charge.tolist() == [0] + assert assigned.nhyd.tolist() == [hydrogens] + assert atoms.charge.tolist() == [0] + + +def test_custom_monatomic_component_defines_its_own_charge(): + from atomworks.io.utils.ccd import custom_ccd_residues + + # An explicit neutral chlorine atom is distinct from the CCD's chloride ion. + atoms = molecule_from_smiles("[Cl]") + atoms.res_name = np.full(len(atoms), "CUSTOM_CL") + with custom_ccd_residues({"CUSTOM_CL": atoms}): + assigned = assign_hydrogens(atoms) + assert assigned.charge.tolist() == [0] + assert assigned.nhyd.tolist() == [0] diff --git a/tests/experimental/protonation/test_regressions.py b/tests/experimental/protonation/test_regressions.py new file mode 100644 index 00000000..26173c24 --- /dev/null +++ b/tests/experimental/protonation/test_regressions.py @@ -0,0 +1,349 @@ +"""End-to-end protonation of trimmed PDB entries, each test or row named by the entry whose rule it guards. + +Each parses an entry as documented, runs ``assign_hydrogens`` then ``add_hydrogens``, and checks the named atoms. +""" + +import biotite.structure as struc +import biotite.structure.info as info +import numpy as np +import pytest + +from atomworks.experimental.protonation import assign_hydrogens, place_hydrogens +from atomworks.experimental.protonation._assign import template_hydrogen_names +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from tests.experimental.protonation.test_assign import DATA, ROWS, parse_entry + + +def protonate_array(atom_array, **kwargs): + return place_hydrogens(assign_hydrogens(atom_array, **kwargs)) + + +def protonate(name, **overrides): + return protonate_array(parse_entry(name, **overrides)) + + +def is_h(atom_array): + return np.isin(atom_array.element, ("H", "D")) + + +def hydrogens_on(atom_array, index): + neighbours, _ = atom_array.bonds.get_bonds(int(index)) + return neighbours[is_h(atom_array)[neighbours]] + + +def residue_key(atom_array, index): + return str(atom_array.chain_id[index]), int(atom_array.res_id[index]), str(atom_array.res_name[index]) + + +def hydrogen_positions(atom_array): + """Each hydrogen's position keyed by chain, residue number and name.""" + return { + residue_key(atom_array, i)[:2] + (str(atom_array.atom_name[i]),): atom_array.coord[i] + for i in np.flatnonzero(is_h(atom_array)) + } + + +@pytest.mark.parametrize(("name", "atoms_too"), [("6dmz_mod_l", False), ("missing_ligand_carbon_5hs6", True)]) +def test_hydrogens_do_not_depend_on_atom_order(name, atoms_too): + """Bug 3: an amide H was built against whichever bonded residue came earlier in the array, and + 5HS6's J3Z HO3 against whichever of two carbons did.""" + heavy = parse_entry(name) + starts = struc.get_residue_starts(heavy, add_exclusive_stop=True) + if atoms_too: + reverse = np.arange(len(heavy))[::-1] + else: + reverse = np.concatenate([np.arange(a, b) for a, b in zip(starts[-2::-1], starts[:0:-1], strict=True)]) + reordered = heavy[reverse] + reordered.bonds = struc.BondList(len(reordered), reordered.bonds.as_array()[::-1]) + forward, backward = hydrogen_positions(protonate_array(heavy)), hydrogen_positions(protonate_array(reordered)) + + assert forward.keys() == backward.keys() + assert max(float(np.linalg.norm(forward[k] - backward[k])) for k in forward) < 1e-4 + + +@pytest.mark.parametrize("name", ["6dmz_mod_l", "6dmz_mod_d"]) +def test_an_amide_hydrogen_lies_in_the_peptide_plane_whatever_the_handedness(name): + """Bug 7: a D residue after GLY took the tetrahedral position, 54.7 degrees out of the plane.""" + protonated = protonate(name) + angles = [] + for n in np.flatnonzero(protonated.atom_name == "N"): + neighbours, _ = protonated.bonds.get_bonds(int(n)) + heavy = {str(protonated.atom_name[j]): j for j in neighbours if not is_h(protonated)[j]} + hydrogens = hydrogens_on(protonated, n) + if set(heavy) != {"C", "CA"} or len(hydrogens) != 1: + continue + normal = np.cross(*(protonated.coord[heavy[a]] - protonated.coord[n] for a in ("CA", "C"))) + bond = protonated.coord[hydrogens[0]] - protonated.coord[n] + angles.append(np.degrees(np.arcsin(abs(normal @ bond) / np.linalg.norm(normal) / np.linalg.norm(bond)))) + + assert len(angles) > 40 + assert max(angles) < 0.5 + + +def test_a_hydroxyl_hydrogen_is_not_placed_on_another_residue_s_hydrogen(): + """Bug 8: 4NDZ's GLC HO4, 2.4 A from ARG B66 NH2, took its template torsion 0.84 A from HH21.""" + protonated = protonate("terminal_and_linked_glycans_4ndz") + o4 = np.flatnonzero((protonated.res_name == "GLC") & (protonated.atom_name == "O4")) + nh2 = np.flatnonzero((protonated.res_name == "ARG") & (protonated.atom_name == "NH2")) + distances = np.linalg.norm(protonated.coord[o4, None] - protonated.coord[None, nh2], axis=-1) + contacts = np.argwhere(distances < 2.6) + + assert len(contacts) + for g, a in contacts: + (ho4,) = hydrogens_on(protonated, o4[g]) + arg = (protonated.chain_id == protonated.chain_id[nh2[a]]) & (protonated.res_id == protonated.res_id[nh2[a]]) + assert np.linalg.norm(protonated.coord[arg & is_h(protonated)] - protonated.coord[ho4], axis=-1).min() > 1.5 + + +def test_a_three_prime_terminal_nucleotide_names_its_prochiral_hydrogens_as_the_internal_ones(): + """Bug 9: 1D17's 3'-terminal DG swapped H2'/H2'' and H21/H22 against every internal residue.""" + protonated = protonate("na_dna_5mc_1d17") + senses, amines = {}, {} + for res_id in np.unique(protonated.res_id): + in_residue = protonated.res_id == res_id + xyz = dict(zip(protonated.atom_name[in_residue], protonated.coord[in_residue], strict=True)) + code = f"{protonated.res_name[in_residue][0]}{res_id}" + if "H2''" in xyz and code[:2] in ("DG", "DA", "DT", "DC"): + senses[code] = np.linalg.det(np.stack([xyz[a] - xyz["C2'"] for a in ("C1'", "C3'", "H2'")])) > 0 + if "H21" in xyz: + axis = (xyz["C2"] - xyz["N2"]) / np.linalg.norm(xyz["C2"] - xyz["N2"]) + + def across(v, axis=axis): + return v - (v @ axis) * axis + + amines[code] = across(xyz["H21"] - xyz["N2"]) @ across(xyz["N1"] - xyz["C2"]) > 0 + + assert {"DG2", "DG6"} <= set(senses) & set(amines) + assert all(senses.values()) and all(amines.values()), (senses, amines) + + +@pytest.mark.parametrize(("name", "res_name", "atom_name", "expected", "overrides"), ROWS) +def test_a_sweep_entry_takes_the_state_its_chemistry_gives_and_keeps_it(name, res_name, atom_name, expected, overrides): + """Entries the whole-PDB and TMol sweeps failed on: the atom takes *expected* as ``(charge, hydrogens)``, and + protonating the output again keeps it. The output is what TMol reads: each added hydrogen bonded once, to + its heavy atom; the input atoms' atom_id carried through; a hydrogen without a position kept as an atom.""" + heavy = parse_entry(name, **overrides) + heavy.set_annotation("atom_id", np.arange(len(heavy))) + assigned = assign_hydrogens(heavy) + protonated = place_hydrogens(assigned) + + h = is_h(protonated) + bonds = protonated.bonds.as_array()[:, :2] + assert (np.bincount(bonds.ravel(), minlength=len(protonated))[h] == 1).all() + assert not h[bonds].all(axis=1).any() + np.testing.assert_array_equal(np.sort(protonated.atom_id[~h]), np.sort(assigned.atom_id[~is_h(assigned)])) + built = assigned.nhyd[~assigned.skip_hydrogen_placement & ~is_h(assigned)].sum() + assert h.sum() == is_h(assigned).sum() + built + assert {"charge", "tautomer_free"} <= set(assigned.get_annotation_categories()) + + for structure in (protonated, protonate_array(protonated)): + placed = np.isfinite(structure.coord).all(axis=-1) + atoms = np.flatnonzero((structure.res_name == res_name) & (structure.atom_name == atom_name) & placed) + assert {(int(structure.charge[i]), len(hydrogens_on(structure, i))) for i in atoms} == {expected} + + +def test_a_nucleotide_missing_a_sugar_carbon_keeps_the_dictionary_s_chirality(): + """7N5V DT H1': with C2' unresolved, C1' keeps the hand the dictionary gives it.""" + + def hand(xyz): + return np.sign(np.linalg.det(np.stack([xyz[a] - xyz["C1'"] for a in ("O4'", "N1", "H1'")]))) + + protonated = protonate("missing_sugar_carbon_7n5v") + template = info.residue("DT") + expected = hand(dict(zip(template.atom_name, template.coord, strict=True))) + hands = [] + for atoms in struc.residue_iter(protonated): + if atoms.res_name[0] == "DT" and {"C1'", "O4'", "N1", "H1'"} <= set(atoms.atom_name): + hands.append(hand(dict(zip(atoms.atom_name, atoms.coord, strict=True)))) + + assert len(hands) > 1 + assert set(hands) == {expected} + + +def test_a_generated_hydrogen_name_avoids_the_names_the_dictionary_gives_other_atoms(): + """6Q9T QUK: a hydrogen the dictionary does not name takes no name it gives another atom's hydrogen.""" + protonated = protonate("modified_components_6q9t") + declared = template_hydrogen_names("QUK") + quk = np.flatnonzero((protonated.res_name == "QUK") & is_h(protonated)) + + assert len(quk) + for h in quk: + (parent,) = [j for j in protonated.bonds.get_bonds(int(h))[0] if not is_h(protonated)[j]] + others = {n for p, names in declared.items() if p != protonated.atom_name[parent] for n in names} + assert protonated.atom_name[h] not in others, protonated.atom_name[h] + + +def test_assembly_copies_of_a_chain_each_keep_their_n_terminus(): + """1AWD's assembly repeats chain A: the second copy's TYR 1 does not follow the first copy's last residue.""" + config = ParseConfig(hydrogen_policy="remove", add_missing_atoms=True, build_assembly="first") + assembly = next(iter(parse(DATA / "assembly_copies_1awd.cif.zst", config=config)["assemblies"].values()))[0] + protonated = protonate_array(assembly) + + n = np.flatnonzero((protonated.res_name == "TYR") & (protonated.res_id == 1) & (protonated.atom_name == "N")) + assert len(n) == 2 + assert {(int(protonated.charge[i]), len(hydrogens_on(protonated, i))) for i in n} == {(1, 3)} + aromatic = assembly.copy() + bonds = aromatic.bonds.as_array() + bonds[bonds[:, 2] == struc.BondType.DOUBLE, 2] = struc.BondType.AROMATIC + aromatic.bonds = struc.BondList(len(aromatic), bonds) + with pytest.raises(ValueError, match="unstated order"): + assign_hydrogens(aromatic) + + +def test_a_histidine_bridging_copper_and_zinc_is_the_imidazolate(): + """1SPD HIS 63 bridges superoxide dismutase's Cu and Zn through both ring nitrogens.""" + protonated = protonate("bridging_imidazolate_1spd", remove_ccds=()) + + ring = np.flatnonzero((protonated.res_name == "HIS") & np.isin(protonated.atom_name, ["ND1", "NE2"])) + assert (-1, 0) in {(int(protonated.charge[i]), len(hydrogens_on(protonated, i))) for i in ring} + + +@pytest.mark.parametrize(("element", "length"), [("NA", 2.4), ("K", 2.8)]) +def test_a_histidine_bridging_zinc_and_an_alkali_ion_keeps_its_proton_off_the_zinc(element, length): + """3KS3 HIS 94 NE2 is on Zn; an alkali ion on ND1 lowers no water's pKa, so ND1 keeps the proton.""" + heavy = parse_entry("zn_tetrahedral_3ks3") + his = (heavy.chain_id == "A") & (heavy.res_id == 94) & (heavy.res_name == "HIS") + (nd1,) = np.flatnonzero(his & (heavy.atom_name == "ND1")) + ring = his & np.isin(heavy.atom_name, ["CG", "ND1", "CE1", "NE2", "CD2"]) + outward = heavy.coord[nd1] - heavy.coord[ring].mean(axis=0) + ion = heavy[[nd1]] + ion.res_name[:], ion.atom_name[:], ion.element[:] = element, element, element + ion.chain_id[:], ion.res_id[:], ion.charge[:], ion.hetero[:] = "Z", 1, 1, True + for name in ("pn_unit_iid", "chain_iid", "pn_unit_id"): + if name in ion.get_annotation_categories(): + ion.get_annotation(name)[:] = "Z_1" + ion.coord += outward / np.linalg.norm(outward) * length + site = heavy + ion + site.bonds.add_bond(int(nd1), len(heavy), struc.BondType.COORDINATION) + + protonated = protonate_array(site) + + names = protonated.atom_name + ring_n = [ + np.flatnonzero((protonated.res_id == 94) & (protonated.chain_id == "A") & (names == n))[0] + for n in ("ND1", "NE2") + ] + assert [len(hydrogens_on(protonated, i)) for i in ring_n] == [1, 0] + + +def test_counts_declared_from_a_polar_hydrogen_input_outrank_the_ph(): + """PDBbind 10GS (the pocket around its ligand) draws polar hydrogens only (backbone N-H, ARG HE, LYS HZ1-3). + TMol declares the counts drawn in each residue that draws any, and assign_hydrogens keeps every one of them.""" + atoms = parse( + DATA / "polar_hydrogens_10gs.pdb.zst", config=ParseConfig(hydrogen_policy="keep", add_missing_atoms=False) + )["asym_unit"][0] + h = is_h(atoms) + bonds = atoms.bonds.as_array()[:, :2] + drawn = np.bincount(bonds[h[bonds[:, ::-1]]], minlength=len(atoms)) + residue = struc.get_all_residue_positions(atoms) + draws_h = (np.bincount(residue, weights=h) > 0)[residue] + declared = np.where(draws_h & np.isin(atoms.element, ["N", "O"]), drawn, -1) + + protonated = place_hydrogens(assign_hydrogens(atoms, hydrogens=declared)) + + heavy = np.flatnonzero(~h) + kept = { + (atoms.chain_id[i], atoms.res_id[i], atoms.atom_name[i]): int(declared[i]) for i in heavy if declared[i] >= 0 + } + found = { + (protonated.chain_id[i], protonated.res_id[i], protonated.atom_name[i]): len(hydrogens_on(protonated, i)) + for i in np.flatnonzero(~is_h(protonated)) + } + assert any(count == 0 for count in kept.values()) + assert {key: found[key] for key in kept} == kept + (nz,) = np.flatnonzero((protonated.res_name == "LYS") & (protonated.res_id == 44) & (protonated.atom_name == "NZ")) + assert (len(hydrogens_on(protonated, nz)), int(protonated.charge[nz])) == (3, 1) + + +def test_unsupported_placement_geometry_preserves_the_assigned_state(): + from tests.experimental.protonation.test_assign import molecule_from_smiles + + atoms = molecule_from_smiles("[PH](C)(C)(C)C") + assigned = assign_hydrogens(atoms, hydrogens=atoms.nhyd) + expected_counts = assigned.nhyd.copy() + with pytest.raises(ValueError, match="more hydrogens than free directions"): + place_hydrogens(assigned) + np.testing.assert_array_equal(assigned.nhyd, expected_counts) + + +@pytest.mark.parametrize("smiles", ["CC[NH3+]", "CC[NH2+]C", "CC[NH+](C)C", "CC"]) +def test_metal_contacts_do_not_displace_a_saturated_atoms_hydrogens(smiles, caplog): + from tests.experimental.protonation.test_assign import molecule_from_smiles + + atoms = molecule_from_smiles(f"{smiles}.[Mg+2]") + atoms.res_id[-1], atoms.res_name[-1], atoms.atom_name[-1] = 2, "MG", "MG" + atoms.pn_unit_iid[-1] = 1 + parent = 2 if "N" in smiles else 1 + atoms.bonds.add_bond(parent, len(atoms) - 1, struc.BondType.COORDINATION) + assigned = assign_hydrogens(atoms, hydrogens=atoms.nhyd) + placed = place_hydrogens(assigned) + + kept = placed.atom_id < len(assigned) + np.testing.assert_array_equal(placed.coord[kept], assigned.coord) + np.testing.assert_array_equal(placed.charge[kept], assigned.charge) + np.testing.assert_array_equal(placed[kept].bonds.as_array(), assigned.bonds.as_array()) + (placed_parent,) = np.flatnonzero(placed.atom_id == parent) + assert len(hydrogens_on(placed, placed_parent)) == int(assigned.nhyd[parent]) + assert np.isfinite(placed.coord).all() + assert "Ignored metal contacts as hydrogen-placement constraints" in caplog.text + + +@pytest.mark.parametrize("keep_hydrogens", [False, True]) +def test_3bpc_lysine_keeps_three_hydrogens_despite_its_recorded_magnesium_contact(keep_hydrogens, caplog): + atoms = parse( + DATA / "protonated_lysine_mg_3bpc.pdb", + config=ParseConfig(hydrogen_policy="keep", add_missing_atoms=False), + )["asym_unit"][0] + atoms = atoms[~is_h(atoms) | (keep_hydrogens & (atoms.atom_name == "HZ1"))] + (nz,) = np.flatnonzero(atoms.atom_name == "NZ") + (mg,) = np.flatnonzero(atoms.element == "MG") + neighbours, bond_types = atoms.bonds.get_bonds(nz) + assert bond_types[neighbours == mg].tolist() == [struc.BondType.COORDINATION] + declared = np.full(len(atoms), -1) + declared[nz] = 3 + assigned = assign_hydrogens(atoms, hydrogens=declared) + placed = place_hydrogens(assigned) + + kept = np.array([np.flatnonzero(placed.atom_id == atom_id)[0] for atom_id in assigned.atom_id]) + np.testing.assert_array_equal(placed.coord[kept], assigned.coord) + np.testing.assert_array_equal(placed.charge[kept], assigned.charge) + np.testing.assert_array_equal(placed[kept].bonds.as_array(), assigned.bonds.as_array()) + (nz,) = np.flatnonzero(placed.atom_name == "NZ") + assert placed.charge[nz] == 1 + assert len(hydrogens_on(placed, nz)) == 3 + assert np.isfinite(placed.coord).all() + assert "four covalent neighbours" in caplog.text + + +def test_metal_hydride_counts_remain_explicitly_unplaced(caplog): + import logging + + atoms = parse_entry("nife_hydride_6fpi", remove_ccds=()) + with caplog.at_level(logging.WARNING): + assigned = assign_hydrogens(atoms) + hydrides = np.isin(assigned.element, ["FE", "NI"]) & (assigned.nhyd > 0) + assert hydrides.sum() == 4 + assert assigned.skip_hydrogen_placement[hydrides].all() + assert "hydride placement is unsupported" in caplog.text + + placed = place_hydrogens(assigned) + np.testing.assert_array_equal(placed.nhyd[~is_h(placed)][hydrides], assigned.nhyd[hydrides]) + + +def test_hydrogen_placement_preserves_pair_annotations_across_residue_reordering(): + from atomworks.io.utils.atom_array_plus import AnnotationList2D, as_atom_array_plus + from tests.experimental.protonation.test_assign import molecule_from_smiles + + atoms = as_atom_array_plus(molecule_from_smiles("CC.O")) + atoms.res_id[-1], atoms.res_name[-1], atoms.atom_name[-1] = 2, "HOH", "O" + atoms.pn_unit_iid[-1] = 1 + atoms.set_annotation("atom_id", np.arange(len(atoms))) + atoms.set_annotation("restraint", AnnotationList2D(len(atoms), [[0, 2]], np.array([1.7])), n_body=2) + + placed = place_hydrogens(assign_hydrogens(atoms)) + annotation = placed.get_annotation("restraint", n_body=2) + np.testing.assert_array_equal(placed.atom_id[annotation.pairs], [[0, 2]]) + np.testing.assert_array_equal(annotation.values, [1.7]) + assert annotation.n_atoms == len(placed) + np.testing.assert_array_equal(atoms.get_annotation("restraint", n_body=2).pairs, [[0, 2]]) diff --git a/tests/experimental/protonation/test_structures.py b/tests/experimental/protonation/test_structures.py new file mode 100644 index 00000000..0843f1ea --- /dev/null +++ b/tests/experimental/protonation/test_structures.py @@ -0,0 +1,457 @@ +"""Protonation of deposited structures, parsed and protonated end to end. + +The fixtures are the metal sites, histidines, ligands, covalent attachments and +non-canonical residues TMol checks its own protonation against, cropped to the +residues around each site. Residue numbers are ``label_seq_id``. +""" + +import functools +from pathlib import Path + +import biotite.structure as struc +import numpy as np +import pytest + +from atomworks.constants import METAL_ELEMENTS +from atomworks.experimental.protonation import assign_hydrogens, place_hydrogens +from atomworks.experimental.protonation._assign import AQUA_PKA, DEFAULT_AQUA_PKA, WATER_PKA +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from tests.experimental.protonation.test_regressions import DATA, hydrogens_on, is_h, protonate_array, residue_key + + +def atoms_named(atom_array, res_name, atom_name): + return np.flatnonzero((atom_array.res_name == res_name) & (atom_array.atom_name == atom_name)) + + +def partners_outside(atom_array, index): + """Heavy atoms bonded to *index* in another residue.""" + neighbours, _ = atom_array.bonds.get_bonds(int(index)) + own = residue_key(atom_array, index) + return [int(n) for n in neighbours if not is_h(atom_array)[n] and residue_key(atom_array, n) != own] + + +_TESTS = Path(__file__).parents[2] +# Fixtures other tests already keep, read from their files. +SHARED = { + "acetylated_peptide_1j8z": _TESTS / "io" / "test_outputs" / "1j8z_new.cif", + "af3_cyclic_peptide_7ubd": _TESTS / "data" / "io" / "7ubd_from_af3.cif", + "conditional_generation": _TESTS / "data" / "io" / "example_conditional_generation_output.cif", + "plp_enzyme_7mkv": _TESTS / "data" / "io" / "interactions" / "7MKV_UstD_2_dimer.cif", + "repeated_glycans_6mub": _TESTS / "io" / "test_outputs" / "6mub_new.cif", + "unknown_heavy_atom_1a8o": _TESTS / "data" / "io" / "1a8o_modified.cif", + "unresolved_unl": _TESTS / "data" / "io" / "test_unl_ligand_with_bonds.cif", +} +# 1QD7 resolves only its phosphorus trace. +UNPROTONATABLE = {"atomized_nucleotide_chain_1qd7", "ccd_model_coords_3uq", "unknown_heavy_atom_1a8o", "unresolved_unl"} +FIXTURES = sorted(({p.name.split(".cif")[0] for p in DATA.glob("*.cif.zst")} | set(SHARED)) - UNPROTONATABLE) + + +def _path(name: str) -> Path: + return SHARED.get(name, DATA / f"{name}.cif.zst") + + +def parse_fixture(name: str, **overrides): + """A fixture parsed without hydrogens, completed, with covalent and metal bonds from struct_conn.""" + config = { + "hydrogen_policy": "remove", + "add_missing_atoms": True, + "add_bond_types_from_struct_conn": ("covale", "metalc"), + **overrides, + } + return parse(_path(name), config=ParseConfig(**config))["asym_unit"][0] + + +@functools.cache +def _protonated(name: str, overrides: tuple): + return protonate_array(parse_fixture(name, **dict(overrides))) + + +def protonate(name: str, **overrides): + """A fixture parsed and protonated at pH 7.4 (cached; a copy is returned).""" + return _protonated(name, tuple(sorted(overrides.items()))).copy() + + +# Parsing drops crystallization aids such as PO4 and GOL by default. +_KEEP_AIDS = {"remove_ccds": ()} +OVERRIDES = {"phosphate_charge_4js1": _KEEP_AIDS} +# A metal takes the proton's place on these donors. +PROTON_DONORS = {"HIS": {"ND1", "NE2"}, "CYS": {"SG"}, "ASP": {"OD1", "OD2"}, "GLU": {"OE1", "OE2"}} +# The histidine and cysteine atoms each metal holds, as TMol's metal fixtures list them. +METAL_DONORS = { + "zn_tetrahedral_3ks3": {"A:HIS94.NE2", "A:HIS96.NE2", "A:HIS119.ND1"}, + "cu_blue_copper_2ov0": {"A:HIS53.ND1", "A:HIS95.ND1", "A:CYS92.SG"}, + "cu_zn_sod_3f7l": {"A:HIS44.ND1", "A:HIS46.NE2", "A:HIS118.NE2", "A:HIS61.ND1", "A:HIS69.ND1", "A:HIS78.ND1"}, + "feo_myohemerythrin_2mhr": {f"A:HIS{n}.NE2" for n in (25, 54, 73, 77, 106)}, + "cua_ba3_2cua": { + f"{chain}:{atom}" + for chain in "AB" + for atom in ("HIS81.ND1", "HIS124.ND1", "HIS84.NE2", "CYS116.SG", "CYS120.SG") + }, + "heme_myoglobin_5yce": {"A:HIS94.NE2"}, + "fe_rubredoxin_30oh": {f"A:CYS{n}.SG" for n in (10, 13, 43, 46)}, + "fes_ferredoxin_6e6r": {f"A:CYS{n}.SG" for n in (4, 6, 38, 41)}, + "sf4_ferredoxin_1fdn": {f"A:CYS{n}.SG" for n in (8, 11, 14, 18, 37, 40, 43, 47)}, + "sf4_ferredoxin_2fdn": {f"A:CYS{n}.SG" for n in (8, 11, 14, 18, 37, 40, 43, 47)}, + "clf_nitrogenase_7adr": {"A:CYS49.SG", "A:CYS75.SG", "A:CYS138.SG", "B:CYS31.SG", "B:CYS56.SG", "B:CYS115.SG"}, +} +# (fixture, residue, atoms or None for all, bonded to another residue or None for either, +# hydrogens, formal charge or None for any[, parse overrides]) +ATOM_STATES = [ + ("sf4_ferredoxin_1fdn", "SF4", None, None, 0, 0), + ("sf4_ferredoxin_2fdn", "SF4", None, None, 0, 0), + ("clf_nitrogenase_7adr", "CLF", None, None, 0, 0), + ("fes_ferredoxin_6e6r", "FES", None, None, 0, 0), + ("feo_myohemerythrin_2mhr", "FEO", None, None, 0, 0), + ("cua_ba3_2cua", "CUA", None, None, 0, 0), + ("mgf_phosphoglucomutase_6h8z", "MGF", ("F1", "F2", "F3"), None, 0, 0), + ("mgf_phosphoglucomutase_6h8z", "MGF", ("MG",), None, 0, -1), + ("nco_zdna_1dn8", "NCO", ("N1", "N2", "N3", "N4", "N5", "N6"), None, 3, 0), + ("nco_zdna_1dn8", "NCO", ("CO",), None, 0, 3), + ("heme_myoglobin_5yce", "HEM", ("NA", "NB", "NC", "ND", "FE"), None, 0, 0), + ("chloride_complex_4hbt", "CL", ("CL",), None, 0, -1), + # An acylated lysine is a neutral amide. + ("lys_biotin_1bdo", "LYS", ("NZ",), True, 1, 0), + ("lys_biotin_1bdo", "LYS", ("NZ",), False, 3, 1), + # An attachment takes the place of one hydrogen. + ("nglycan_tree_1ax2", "ASN", ("ND2",), True, 1, 0), + ("oglycan_sia_1g1s", "THR", ("OG1",), True, 0, 0), + ("terminal_asj_glycans_1iau", "ASJ", ("C",), True, 1, 0), + ("hydrolase_intermediate_1tqh", "SER", ("OG",), True, 0, 0), + # 1TQH's tetrahedral intermediate, read as an oxyanion, is prepared as an alcohol, as TMol does. + ("hydrolase_intermediate_1tqh", "4PA", ("OAD",), None, 1, 0), + ("palmitoyl_ester_thioester_8trb", "SER", ("OG",), True, 0, 0), + ("palmitoyl_ester_thioester_8trb", "CYS", ("SG",), True, 0, 0), + ("palmitoyl_ester_thioester_8trb", "PLM", ("C1", "O2"), None, 0, 0), + ("sulfur_attachments_3t14", "H2S", ("S",), True, 1, 0), + ("sulfur_attachments_3t14", "S2H", ("S1",), True, 0, 0), + ("sulfur_attachments_3t14", "S2H", ("S2",), False, 1, 0), + ("sulfur_attachments_3t14", "CYS", ("SG",), True, 0, 0), + ("collagen_hyp_1bkv", "HYP", ("N",), True, 0, 0), + # 1J8Z states its ACE C at +1, which its CH3, =O and peptide N leave no room for. + ("acetylated_peptide_1j8z", "ACE", ("C",), None, 0, 0, {"add_missing_atoms": False}), + ("phosphopeptide_5ema", "SEP", ("O1P", "O2P", "O3P"), None, 0, None), + # 8CH1's VDF OP3 esterifies FRU in altloc B (altloc A holds LAO on the other FRU). + ("phosphate_attachment_8ch1", "VDF", ("OP3",), None, 0, None, {"altloc": "B"}), + ("phosphate_charge_4js1", "PO4", ("O2",), None, 1, 0, _KEEP_AIDS), + ("phosphate_charge_4js1", "PO4", ("O1", "O3", "O4"), None, 0, None, _KEEP_AIDS), + # 183D's 8OG flags OP2 and OP3 as leaving; the O3' bond takes OP3, and OP2 is the phosphodiester's anion. + ("na_dna_8og_183d", "8OG", ("P",), None, 0, None), + ("na_dna_8og_183d", "8OG", ("OP2",), None, 0, -1), + # 5XAG's IMD keeps N1's H whether or not N3 is bonded to a GOL. + ("free_and_attached_solutes_5xag", "IMD", ("N1",), None, 1, None, _KEEP_AIDS), + ("free_and_attached_solutes_5xag", "IMD", ("N3",), None, 0, None, _KEEP_AIDS), +] +# (fixture, residue, net formal charge, hydrogens or None[, parse overrides]) of every resolved copy +RESIDUE_STATES = [ + ("heme_myoglobin_5yce", "HEM", -2, 30), + ("phosphate_charge_4js1", "PO4", -2, 1, _KEEP_AIDS), + ("phosphopeptide_5ema", "SEP", -2, 4), + ("free_and_attached_solutes_5xag", "GOL", 0, None, _KEEP_AIDS), +] +# S-H 1.34 A, P-H 1.42 A (5ME4 hypophosphite), other X-H 0.97-1.09 A +BOND_LENGTHS = {"S": (1.2, 1.5), "P": (1.3, 1.5)} + + +def _holds_protons(atom_array): + elements = np.char.upper(atom_array.element.astype(str)) + return np.isin(elements, sorted(m for m in METAL_ELEMENTS if AQUA_PKA.get(m, DEFAULT_AQUA_PKA) < WATER_PKA)) + + +def _state(atom_array, index): + """Hydrogen count, formal charge, covalent bond to another residue and bond to a metal of one atom.""" + neighbours, _ = atom_array.bonds.get_bonds(int(index)) + heavy = neighbours[~is_h(atom_array)[neighbours]] + elsewhere = np.array([residue_key(atom_array, n) != residue_key(atom_array, index) for n in heavy], dtype=bool) + on_metal = _holds_protons(atom_array)[heavy] + n_h = len(neighbours) - len(heavy) + return n_h, int(atom_array.charge[index]), bool((elsewhere & ~on_metal).any()), bool(on_metal.any()) + + +def _residues(atom_array, res_name): + """Heavy-atom indices of each resolved residue called *res_name*.""" + mask = (atom_array.res_name == res_name) & ~is_h(atom_array) & np.isfinite(atom_array.coord).all(-1) + keys = sorted({residue_key(atom_array, i)[:2] for i in np.flatnonzero(mask)}) + return [np.flatnonzero(mask & (atom_array.chain_id == c) & (atom_array.res_id == r)) for c, r in keys] + + +def _label(atom_array, i): + return f"{atom_array.chain_id[i]}:{atom_array.res_name[i]}{atom_array.res_id[i]}.{atom_array.atom_name[i]}" + + +def _padded(rows, width): + return [(*row, {}) if len(row) < width else row for row in rows] + + +@pytest.mark.parametrize("name", FIXTURES) +def test_every_hydrogen_is_placed_once_at_a_bond_length_on_a_sane_charge(name): + protonated = protonate(name, **OVERRIDES.get(name, {})) + h = is_h(protonated) + bonds = protonated.bonds.as_array() + degree = np.bincount(bonds[:, :2].ravel(), minlength=protonated.array_length()) + metal = np.isin(np.char.upper(protonated.element.astype(str)), sorted(METAL_ELEMENTS)) + to_h = bonds[h[bonds[:, 0]] != h[bonds[:, 1]], :2] + heavy = np.where(h[to_h[:, 0]], to_h[:, 1], to_h[:, 0]) + lengths = np.linalg.norm(protonated.coord[to_h[:, 0]] - protonated.coord[to_h[:, 1]], axis=-1) + low, high = np.array([BOND_LENGTHS.get(str(e).upper(), (0.9, 1.15)) for e in protonated.element[heavy]]).T + + assert h.any() + assert np.isfinite(protonated.coord[h]).all() + assert (degree[h] == 1).all() + assert ((lengths >= low) & (lengths <= high)).all(), [_label(protonated, i) for i in heavy[lengths > high]] + assert (np.abs(protonated.charge[~metal]) <= 1).all() + + +@pytest.mark.parametrize( + "name", ["zn_tetrahedral_3ks3", "phosphopeptide_5ema", "nglycan_tree_1ax2", "heme_myoglobin_5yce"] +) +def test_the_state_counts_the_hydrogens_add_hydrogens_places(name): + heavy = parse_fixture(name) + assigned = assign_hydrogens(heavy) + protonated = place_hydrogens(assigned) + h = is_h(protonated) + to_h = protonated.bonds.as_array()[:, :2] + to_h = to_h[h[to_h[:, 0]] != h[to_h[:, 1]]] + placed = np.bincount(np.where(h[to_h[:, 0]], to_h[:, 1], to_h[:, 0]), minlength=len(h)) + + assert not is_h(assigned).any() + np.testing.assert_array_equal(assigned.atom_name, heavy.atom_name) + np.testing.assert_array_equal(assigned.coord, heavy.coord) + np.testing.assert_array_equal(assigned.charge, protonated.charge[~h]) + np.testing.assert_array_equal(assigned.nhyd, placed[~h] + protonated.nhyd[~h]) + + +@pytest.mark.parametrize("name", sorted(METAL_DONORS)) +def test_each_metal_holds_the_donors_tmol_lists_and_takes_their_protons(name): + """A donor on the metal has no H; a cysteine there is a thiolate, and a histidine protonates its other N.""" + protonated = protonate(name) + holds = _holds_protons(protonated) + donors = [ + donor + for left, right, _ in protonated.bonds.as_array() + for donor, metal in ((left, right), (right, left)) + if holds[metal] + and not holds[donor] + and str(protonated.atom_name[donor]) in PROTON_DONORS.get(str(protonated.res_name[donor]), ()) + ] + candidates = np.isin(protonated.res_name, ["HIS", "CYS"]) & np.isin(protonated.atom_name, ["ND1", "NE2", "SG"]) + + assert {_label(protonated, i) for i in np.flatnonzero(candidates) if _state(protonated, i)[3]} == METAL_DONORS[name] + assert donors + assert all(len(hydrogens_on(protonated, donor)) == 0 for donor in donors) + + +def test_a_cysteine_bridging_two_irons_of_the_p_cluster_binds_both(): + protonated = protonate("clf_nitrogenase_7adr") + for chain, res_id in (("A", 75), ("B", 56)): + (sg,) = np.flatnonzero( + (protonated.chain_id == chain) & (protonated.res_id == res_id) & (protonated.atom_name == "SG") + ) + neighbours, _ = protonated.bonds.get_bonds(int(sg)) + assert np.count_nonzero(protonated.element[neighbours] == "FE") == 2 + + +@pytest.mark.parametrize("name", FIXTURES) +def test_standard_side_chains_take_their_state_at_ph_7_4(name): + """Free lysines and arginines are cations, free carboxylates anions, free thiols, phenols and imidazoles neutral. + + A donor on a metal takes the form that donates: a thiolate, a carboxylate, a + phenolate, or a neutral imidazole with its proton on the free nitrogen. A + histidine is an imidazolate only where it bridges two metals (1SPD HIS 63). + """ + protonated = protonate(name, **OVERRIDES.get(name, {})) + wrong = [] + for res in _residues(protonated, "LYS"): + for i in res[protonated.atom_name[res] == "NZ"]: + n_h, charge, linked, on_metal = _state(protonated, i) + if not linked and not on_metal and (n_h, charge) != (3, 1): + wrong.append(i) + for res_name, atoms, expected in ( + ("ARG", ("NE", "NH1", "NH2"), (5, 1)), + ("ASP", ("OD1", "OD2"), (0, -1)), + ("GLU", ("OE1", "OE2"), (0, -1)), + ): + for res in _residues(protonated, res_name): + states = [_state(protonated, i) for i in res[np.isin(protonated.atom_name[res], atoms)]] + total = (sum(s[0] for s in states), sum(s[1] for s in states)) + if len(states) == len(atoms) and not any(s[2] for s in states) and total != expected: + wrong.append(res[0]) + for res_name, atom in (("CYS", "SG"), ("TYR", "OH")): + for res in _residues(protonated, res_name): + for i in res[protonated.atom_name[res] == atom]: + n_h, charge, linked, on_metal = _state(protonated, i) + if (n_h, charge) != ((0, -1) if on_metal else (0, 0) if linked else (1, 0)): + wrong.append(i) + for res in _residues(protonated, "HIS"): + ring = res[np.isin(protonated.atom_name[res], ("ND1", "NE2"))] + if len(ring) != 2: + continue + states = [_state(protonated, i) for i in ring] + n_h, charge = sum(s[0] for s in states), sum(s[1] for s in states) + bound = sum(s[2] or s[3] for s in states) + on_one_metal = any(s[3] for s in states) and bound == 1 + bridging = all(s[3] for s in states) and (n_h, charge) == (0, -1) + if not bridging and ( + n_h + bound == 0 or charge < 0 or ((on_one_metal or not bound) and (n_h, charge) != (1, 0)) + ): + wrong.append(ring[0]) + + assert not [_label(protonated, i) for i in wrong] + + +@pytest.mark.parametrize( + ("name", "res_name", "atoms", "linked", "n_h", "charge", "overrides"), + _padded(ATOM_STATES, 7), + ids=[ + f"{r[0]}-{r[1]}-{'+'.join(r[2] or ('all',))}{ {None: '', True: '-linked', False: '-free'}[r[3]]}" + for r in ATOM_STATES + ], +) +def test_an_atom_takes_the_state_its_chemistry_gives_it(name, res_name, atoms, linked, n_h, charge, overrides): + protonated = protonate(name, **overrides) + selected = [ + (str(protonated.atom_name[i]), _state(protonated, i)[:2]) + for res in _residues(protonated, res_name) + for i in res + if (atoms is None or str(protonated.atom_name[i]) in atoms) and linked in (None, _state(protonated, i)[2]) + ] + + assert selected + assert all(state[0] == n_h and charge in (None, state[1]) for _, state in selected), selected + + +@pytest.mark.parametrize( + ("name", "res_name", "charge", "n_h", "overrides"), + _padded(RESIDUE_STATES, 5), + ids=[f"{r[0]}-{r[1]}" for r in RESIDUE_STATES], +) +def test_a_residue_carries_its_net_charge_at_ph_7_4(name, res_name, charge, n_h, overrides): + protonated = protonate(name, **overrides) + residues = _residues(protonated, res_name) + + assert residues + for res in residues: + assert int(protonated.charge[res].sum()) == charge + assert n_h in (None, sum(len(hydrogens_on(protonated, i)) for i in res)) + + +def test_the_parse_reads_a_tetrahedral_intermediate_as_a_serine_bonded_oxyanion(): + heavy = parse_fixture("hydrolase_intermediate_1tqh") + (oad,) = atoms_named(heavy, "4PA", "OAD") + linked = [p for i in np.flatnonzero(heavy.res_name == "4PA") for p in partners_outside(heavy, i)] + + assert heavy.charge[oad] == -1 + assert any(heavy.res_name[p] == "SER" and heavy.atom_name[p] == "OG" for p in linked) + + +def test_a_phosphate_bridging_to_a_sugar_leaves_the_sugar_saturated(): + protonated = protonate("phosphate_attachment_8ch1") + degree = np.bincount(protonated.bonds.as_array()[:, :2].ravel(), minlength=protonated.array_length()) + carbons = np.flatnonzero(np.isin(protonated.res_name, ["GLC", "FRU"]) & (protonated.element == "C")) + + assert len(carbons) + assert (protonated.charge[carbons] == 0).all() + assert (degree[carbons] == 4).all() + + +@pytest.mark.parametrize("name", ["terminal_and_linked_glycans_1en2", "terminal_and_linked_glycans_4ndz"]) +def test_a_glycosidic_bond_displaces_o1_and_a_reducing_end_keeps_it(name): + protonated = protonate(name) + sugars = {"BGC", "BMA", "FRU", "FUC", "GAL", "GLC", "MAN", "NAG", "NGA"} & set(protonated.res_name) + residues = [res for sugar in sorted(sugars) for res in _residues(protonated, sugar)] + + assert residues + for res in residues: + chain, res_id, _ = residue_key(protonated, res[0]) + names = protonated.atom_name[(protonated.chain_id == chain) & (protonated.res_id == res_id)] + c1 = res[protonated.atom_name[res] == "C1"] + assert protonated.charge[res].sum() == 0 + if len(c1): + assert ("O1" in names) != bool(partners_outside(protonated, c1[0])) + + +def test_an_n_terminal_proline_missing_ring_atoms_still_takes_its_hydrogen(): + protonated = protonate("missing_proline_ring_7no8") + terminal = [i for i in atoms_named(protonated, "PRO", "N") if not partners_outside(protonated, i)] + + assert terminal + for n in terminal: + assert len(hydrogens_on(protonated, n)) + + +def test_a_nucleotide_listing_two_leaving_oxygens_keeps_the_one_it_resolves(): + assert not len(atoms_named(protonate("na_dna_8og_183d"), "8OG", "OP3")) + + +def test_what_protonation_cannot_read_is_left_or_rejected(): + heavy = parse_fixture("unresolved_unl") + protonated = protonate("unresolved_unl") + + assert (heavy.res_name == "UNL").any() + assert not np.isfinite(heavy.coord).all(axis=-1).any() + np.testing.assert_array_equal(protonated.atom_name, heavy.atom_name) + np.testing.assert_array_equal(protonated.charge, heavy.charge) + with pytest.raises(ValueError, match="XYZ"): + parse_fixture("unknown_heavy_atom_1a8o") + + +@pytest.mark.parametrize( + ("name", "res_ids"), [("nmr_his_cation_1r21", [27, 56, 62, 69, 92]), ("beta_peptide_3c3g", None)] +) +def test_authored_hydrogens_on_both_ring_nitrogens_make_a_histidine_cation(name, res_ids): + drawn = parse_fixture(name, hydrogen_policy="keep") + authored = {residue_key(drawn, i) for i in atoms_named(drawn, "HIS", "HD1")} + authored &= {residue_key(drawn, i) for i in atoms_named(drawn, "HIS", "HE2")} + protonated = protonate(name, hydrogen_policy="keep") + + assert authored + assert res_ids is None or sorted(key[1] for key in authored) == res_ids + for res in _residues(protonated, "HIS"): + if residue_key(protonated, res[0]) in authored: + ring = [_state(protonated, i)[:2] for i in res[np.isin(protonated.atom_name[res], ("ND1", "NE2"))]] + assert [n_h for n_h, _ in ring] == [1, 1] + assert sum(charge for _, charge in ring) == 1 + + +def test_an_authored_hydrogen_bonded_to_nothing_is_rebuilt_on_its_atom(): + authored = protonate("orphan_hydrogen_9ewf", hydrogen_policy="keep") + degree = np.bincount(authored.bonds.as_array()[:, :2].ravel(), minlength=authored.array_length()) + + assert (degree[is_h(authored)] == 1).all() + assert np.count_nonzero(is_h(authored)) == np.count_nonzero(is_h(protonate("orphan_hydrogen_9ewf"))) + + +def test_a_water_on_a_metal_is_trigonal_and_one_bridging_two_zinc_is_a_hydroxide(): + """1HZY's phosphotriesterase site: the water bridging both Zn is the hydroxide, its H on the bisector, and + the water on one Zn lies in a plane with it, the metal on the bisector of H-O-H (M-O-H 125.3 deg).""" + protonated = protonate("zn_hydroxide_bridge_1hzy", remove_waters=False) + metal = np.isin(protonated.element, list(METAL_ELEMENTS)) + waters = {} + for o in np.flatnonzero((protonated.res_name == "HOH") & (protonated.element == "O")): + neighbours, _ = protonated.bonds.get_bonds(int(o)) + if metal[neighbours].any(): + waters[o] = neighbours[metal[neighbours]] + angles = { + o: [ + np.degrees(struc.angle(protonated.coord[h], protonated.coord[o], protonated.coord[m])) + for h in hydrogens_on(protonated, o) + for m in metals + ] + for o, metals in waters.items() + } + + assert sorted( + (len(metals), protonated.charge[o], len(hydrogens_on(protonated, o))) for o, metals in waters.items() + ) == [ + (1, 0, 2), + (2, -1, 1), + ] + for o, metals in waters.items(): + h = hydrogens_on(protonated, o) + if len(metals) == 1: + assert angles[o] == pytest.approx([125.3, 125.3], abs=0.1) + assert np.degrees(struc.angle(*protonated.coord[[h[0], o, h[1]]])) == pytest.approx(109.5, abs=0.1) + dihedral = struc.dihedral(*protonated.coord[[h[0], o, metals[0], h[1]]]) + assert abs(np.degrees(dihedral)) == pytest.approx(180.0, abs=0.1) + else: + assert min(angles[o]) > 115.0 diff --git a/tests/io/components/test_add_charge_from_ccd.py b/tests/io/components/test_add_charge_from_ccd.py index 20ce36c2..6c112999 100644 --- a/tests/io/components/test_add_charge_from_ccd.py +++ b/tests/io/components/test_add_charge_from_ccd.py @@ -4,14 +4,14 @@ import pytest from atomworks.io.parser import parse -from atomworks.io.transforms.atom_array import add_charge_from_ccd_codes +from atomworks.io.utils.ccd import add_annotations_from_ccd from tests.io.conftest import get_pdb_path TEST_CASES = [{"pdb_id": "1jj8", "charge_sum": 7}, {"pdb_id": "2r5z", "charge_sum": 32}] @pytest.mark.parametrize("test_case", TEST_CASES) -def test_add_charge_from_ccd_codes(test_case: dict[str, Any]): +def test_add_charge_from_ccd(test_case: dict[str, Any]): path = get_pdb_path(test_case["pdb_id"]) result = parse( @@ -23,12 +23,13 @@ def test_add_charge_from_ccd_codes(test_case: dict[str, Any]): atom_array = result["assemblies"]["1"][0] # First bioassembly, first model has_resolved_coordinates = ~np.isnan(atom_array.coord).any(axis=-1) non_nan_array = atom_array[has_resolved_coordinates] - non_nan_array.del_annotation("charge") - non_nan_array = add_charge_from_ccd_codes(non_nan_array) - charge_sum = np.sum(non_nan_array.charge) - assert charge_sum == test_case["charge_sum"] + # parse() should already have applied CCD charges via its annotation pipeline. + assert np.sum(non_nan_array.charge) == test_case["charge_sum"] + # add_annotations_from_ccd should reproduce the same sum from scratch. + non_nan_array.del_annotation("charge") -if __name__ == "__main__": - pytest.main([__file__]) + # check that we can add charge back in and get the same sum + non_nan_array = add_annotations_from_ccd(non_nan_array, annotations=["charge"]) + assert np.sum(non_nan_array.charge) == test_case["charge_sum"] diff --git a/tests/io/components/test_arginine_renaming.py b/tests/io/components/test_arginine_renaming.py index ba22981a..189ff0db 100644 --- a/tests/io/components/test_arginine_renaming.py +++ b/tests/io/components/test_arginine_renaming.py @@ -1,5 +1,6 @@ +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import TEST_DATA_IO, get_pdb_path @@ -7,10 +8,10 @@ def test_arginine_renaming(): correct_file = get_pdb_path("101m") renamed_file = TEST_DATA_IO / "101m_arginine_nh1nh2_swapped.cif" # Manually renamed the arginine atoms. - result1 = parse(filename=correct_file, build_assembly=None) - result2 = parse(filename=renamed_file, build_assembly=None, fix_arginines=True) + result1 = parse(correct_file, config=ParseConfig(build_assembly=None)) + result2 = parse(renamed_file, config=ParseConfig(build_assembly=None, fix_arginines=True)) - assert_same_atom_array( + assert_same_atom_array_or_stack( result1["asym_unit"][0], result2["asym_unit"][0], annotations_to_compare=["chain_id", "res_name", "res_id", "atom_name", "element"], diff --git a/tests/io/components/test_atom_order.py b/tests/io/components/test_atom_order.py index 7c8a3a29..5f683249 100644 --- a/tests/io/components/test_atom_order.py +++ b/tests/io/components/test_atom_order.py @@ -1,5 +1,6 @@ import pytest +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse from tests.io.conftest import get_pdb_path @@ -17,5 +18,5 @@ @pytest.mark.parametrize("pdb_id", TEST_CASES) def test_atom_order(pdb_id: str): path = get_pdb_path(pdb_id) - result = parse(filename=path, add_missing_atoms=True, build_assembly=None) + result = parse(path, config=ParseConfig(add_missing_atoms=True, build_assembly=None)) assert result is not None diff --git a/tests/io/components/test_bioassemblies.py b/tests/io/components/test_bioassemblies.py index f9aa83e3..f6f542ad 100644 --- a/tests/io/components/test_bioassemblies.py +++ b/tests/io/components/test_bioassemblies.py @@ -1,9 +1,10 @@ import pytest from biotite.structure.io import pdbx +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse from atomworks.io.utils import io_utils -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import get_pdb_path MULTIPLE_ASSEMBLY_TEST_CASES = [ @@ -24,14 +25,15 @@ def test_assembly_counts(test_case: dict): filename = get_pdb_path(pdbid) # test the different build_assembly options - # build_assembly=None creates an identity assembly with IID annotations - out_no_assembly = parse(filename=filename, build_assembly=None, remove_ccds=[]) - assert len(out_no_assembly["assemblies"]) == 1 + # build_assembly=None returns no assemblies (only asymmetric unit) + out_no_assembly = parse(filename, config=ParseConfig(build_assembly=None, remove_ccds=[])) + assert len(out_no_assembly["assemblies"]) == 0 - out_first = parse(filename=filename, build_assembly="first", remove_ccds=[]) + # build_assembly="first" builds the first assembly with IID annotations + out_first = parse(filename, config=ParseConfig(build_assembly="first", remove_ccds=[])) assert len(out_first["assemblies"]) == 1 - out_all = parse(filename=filename, build_assembly="all", remove_ccds=[]) + out_all = parse(filename, config=ParseConfig(build_assembly="all", remove_ccds=[])) assert len(out_all["assemblies"]) == n_assemblies @@ -52,20 +54,22 @@ def test_assembly_atom_coordinates(pdb_id: str): ], model=1, ) - resolved_biotite_assembly = biotite_assembly[(biotite_assembly.occupancy > 0) & (biotite_assembly.element != "H")] + # Water molecules are unreliable; to match them we would need to infer residue bounudaries from atom names + resolved_biotite_assembly = biotite_assembly[ + (biotite_assembly.occupancy > 0) & (biotite_assembly.element != "H") & (biotite_assembly.res_name != "HOH") + ] assembly = parse( filename=path, build_assembly="first", fix_arginines=False, - remove_waters=False, + remove_waters=True, hydrogen_policy="remove", remove_ccds=[], # Do not remove crystallization solvents - ccd_mirror_path=None, # Use Biotite's CCD mirror )["assemblies"]["1"][0] resolved_assembly = assembly[assembly.occupancy > 0] - assert_same_atom_array( + assert_same_atom_array_or_stack( resolved_biotite_assembly, resolved_assembly, annotations_to_compare=["chain_id", "res_name", "atom_name"], diff --git a/tests/io/components/test_cache_atomic.py b/tests/io/components/test_cache_atomic.py new file mode 100644 index 00000000..738e0719 --- /dev/null +++ b/tests/io/components/test_cache_atomic.py @@ -0,0 +1,27 @@ +"""Regression coverage for interrupted parser cache writes.""" + +from pathlib import Path + +import pytest + +import atomworks.io.parser as parser +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from tests.conftest import TEST_DATA_DIR + + +def test_cache_write_is_atomic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An interrupted write leaves neither a cache entry nor a temporary file behind.""" + + def failing_to_pickle(obj, path, *args, **kwargs): + Path(path).write_bytes(b"partial") + raise KeyboardInterrupt("interrupted while writing the cache") + + monkeypatch.setattr(parser.pd, "to_pickle", failing_to_pickle) + config = ParseConfig(cache_dir=str(tmp_path), save_to_cache=True, load_from_cache=False) + with pytest.raises(KeyboardInterrupt, match="interrupted while writing the cache"): + parse(TEST_DATA_DIR / "io" / "2hhb.cif.gz", config=config) + + assert not any( + path.is_file() for path in tmp_path.rglob("*") + ), "an interrupted write left a cache or temporary file" diff --git a/tests/io/components/test_caching.py b/tests/io/components/test_caching.py index a6759220..d9a0c9ad 100644 --- a/tests/io/components/test_caching.py +++ b/tests/io/components/test_caching.py @@ -1,19 +1,43 @@ +import io +import os +import tempfile import time +from unittest.mock import patch import pytest -from atomworks.io.parser import parse -from atomworks.io.utils.testing import assert_same_atom_array +import atomworks.io.parser as parser +from atomworks.io.config import ParseConfig +from atomworks.io.parser import _build_cache_file_path, parse +from atomworks.io.utils.testing import assert_same_atom_array_or_stack +from tests.conftest import TEST_DATA_DIR from tests.io.conftest import get_pdb_path + +# Skip this test when running with pytest-xdist (multiprocessing) +# Timing-based tests are unreliable with parallel execution due to variable system load +def pytest_configure(config): + """Register custom marker for xdist detection.""" + config.addinivalue_line("markers", "no_xdist: skip test when running with pytest-xdist") + + +def is_xdist_worker(request): + """Check if test is running in a pytest-xdist worker process.""" + return hasattr(request.config, "workerinput") + + TEST_CASES = [ "4NDZ", # 29K atoms, large enough to test caching without too much variance ] -@pytest.mark.xdist_group(name="test_caching") # Ensure if running tests in parallel, they are run in the same group @pytest.mark.parametrize("pdb_id", TEST_CASES) -def test_caching(pdb_id: str, tmp_path): +def test_caching(pdb_id: str, tmp_path, request): + # Skip when running with pytest-xdist (multiprocessing) + # Timing assertions are unreliable with parallel execution + if is_xdist_worker(request): + pytest.skip("Caching test disabled in multiprocessing mode (timing-based assertions are unreliable)") + path = get_pdb_path(pdb_id) # First, we load normally, tracking how long it takes @@ -89,7 +113,7 @@ def different_args_parse(): # Assert that the assembly data is the same annotations_to_compare = ["chain_id", "res_name", "res_id", "atom_name", "chain_iid", "pn_unit_id", "pn_unit_iid"] for assembly_id in normal_result["assemblies"]: - assert_same_atom_array( + assert_same_atom_array_or_stack( normal_result["assemblies"][assembly_id], cached_result["assemblies"][assembly_id], annotations_to_compare ) @@ -100,5 +124,73 @@ def different_args_parse(): assert abs(different_args_elapsed_time - normal_elapsed_time) < normal_elapsed_time * 0.8 +def test_cache_key_disambiguates_files_with_same_stem(tmp_path): + """Two distinct files with the same stem must map to distinct cache files.""" + cfg = ParseConfig(cache_dir=str(tmp_path), save_to_cache=True, load_from_cache=True) + + file_a = tmp_path / "source_a" / "system.cif" + file_b = tmp_path / "source_b" / "system.cif" + file_c = tmp_path / "source_c" / "protein.cif.gz" + + p1 = _build_cache_file_path(tmp_path, file_a, cfg) + p2 = _build_cache_file_path(tmp_path, file_b, cfg) + p1_again = _build_cache_file_path(tmp_path, file_a, cfg) + + assert p1 != p2, "same stem but different paths must produce different cache keys" + assert p1 == p1_again, "same path must hash to the same cache key" + + # Unique-stem case must also remain idempotent. + q1 = _build_cache_file_path(tmp_path, file_c, cfg) + q2 = _build_cache_file_path(tmp_path, file_c, cfg) + assert q1 == q2 + + +def test_cache_dir_defaults_to_tempdir_when_enabled(): + """Enabling caching without a path defaults cache_dir to the system tempdir; off otherwise.""" + assert ParseConfig().cache_dir is None + cfg = ParseConfig(save_to_cache=True, load_from_cache=True) + assert cfg.cache_dir == os.path.join(tempfile.gettempdir(), "atomworks_parse_cache") + + +@pytest.mark.parametrize( + "filename,buffer_type", [("101m_arginine_nh1nh2_swapped.cif", io.StringIO), ("6lyz.bcif", io.BytesIO)] +) +def test_buffer_cache(filename, buffer_type, tmp_path): + path = TEST_DATA_DIR / "io" / filename + content = path.read_text() if buffer_type is io.StringIO else path.read_bytes() + config = ParseConfig.from_preset("minimal", cache_dir=str(tmp_path), save_to_cache=True, load_from_cache=False) + source = buffer_type(content) + original = parse(source, config=config) + position = source.tell() + cache_path = next(tmp_path.rglob("*.pkl.zst")) + assert cache_path.read_bytes().startswith(b"\x28\xb5\x2f\xfd") + cached_bytes = cache_path.read_bytes() + with ( + patch.object(parser.os, "replace", side_effect=OSError("replace failed")), + pytest.raises(OSError, match="replace failed"), + ): + parse(buffer_type(content), config=config) + assert cache_path.read_bytes() == cached_bytes + assert not list(tmp_path.rglob("*.tmp.*")) + parser.pd.to_pickle(parser.pd.read_pickle(cache_path), cache_path.with_suffix(".gz")) + config = config.replace(save_to_cache=False, load_from_cache=True) + with patch.object(parser, "load_cif", side_effect=AssertionError("cache miss")): + source = buffer_type(content) + cached = parse(source, config=config) + assert source.tell() == position + assert cached.keys() == original.keys() + assert cached["metadata"] == original["metadata"] + assert_same_atom_array_or_stack(cached["asym_unit"], original["asym_unit"]) + assert cached["assemblies"].keys() == original["assemblies"].keys() + for name in original["assemblies"]: + assert_same_atom_array_or_stack(cached["assemblies"][name], original["assemblies"][name]) + cached["asym_unit"].coord[:] = 0 + fresh = parse(buffer_type(content), config=config) + assert_same_atom_array_or_stack(fresh["asym_unit"], original["asym_unit"]) + cache_path.unlink() + with pytest.raises(AssertionError, match="cache miss"): + parse(buffer_type(content), config=config) + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/io/components/test_chain_types.py b/tests/io/components/test_chain_types.py index 67192ebb..7c47c1e6 100644 --- a/tests/io/components/test_chain_types.py +++ b/tests/io/components/test_chain_types.py @@ -2,8 +2,6 @@ Tests for chain type assignment and annotation. """ -from __future__ import annotations - from typing import Any import numpy as np @@ -11,7 +9,7 @@ from atomworks.enums import ChainType from atomworks.io.parser import parse -from tests.io.conftest import CHAIN_TYPE_TEST_CASES, get_pdb_path +from tests.io.conftest import CHAIN_TYPE_TEST_CASES, TEST_DATA_IO, get_pdb_path # General Enum tests @@ -71,3 +69,23 @@ def test_chain_types(test_case: dict[str, Any]): assert ( got_is_polymer == expected_is_polymer ), f"Mismatch for {pn_unit_id=}: {got_is_polymer=}, {expected_is_polymer=}" + + +def test_pdb_with_same_chain_poly_non_poly(): + result = parse( + filename=TEST_DATA_IO / "1qfe.pdb", + hydrogen_policy="remove", + ) + # Check if processing runs through + assert result is not None + + # Check that we don't have any chains with polymeric and non-polymeric residues + atom_array = result["assemblies"]["1"][0] + polymer_chain_ids = np.unique(atom_array.chain_id[atom_array.is_polymer]) + non_polymer_chain_ids = np.unique(atom_array.chain_id[~atom_array.is_polymer]) + assert len(set(polymer_chain_ids).intersection(non_polymer_chain_ids)) == 0 + + # Assert that all residues have coordinates + if not np.all(np.isfinite(atom_array.coord)): + culprits = atom_array[(~np.isfinite(atom_array.coord)).any(axis=1)] + raise RuntimeError(f"Some residues are missing coordinates: \n{culprits}") diff --git a/tests/io/components/test_charges.py b/tests/io/components/test_charges.py deleted file mode 100644 index a977ec37..00000000 --- a/tests/io/components/test_charges.py +++ /dev/null @@ -1,21 +0,0 @@ -import numpy as np - -from atomworks.io.parser import parse -from tests.io.conftest import get_pdb_path - - -def test_charges(): - pdb_path = get_pdb_path("6lyz") - - result = parse(filename=pdb_path, build_assembly=None, add_missing_atoms=True, fix_formal_charges=True) - atom_array = result["asym_unit"][0] - - # ... 6lyz is a pure protein backbone, so no charges should be present - assert np.all(atom_array[atom_array.is_backbone_atom].charge == 0) - - # ... but the sidechains should have charges - assert np.any(atom_array[~atom_array.is_backbone_atom].charge != 0) - - # ... assert that the charges are within the expected range - assert np.all(atom_array.charge >= -1) - assert np.all(atom_array.charge <= 1) diff --git a/tests/io/components/test_deserialize_assembly.py b/tests/io/components/test_deserialize_assembly.py index 4289749a..12ec10ea 100644 --- a/tests/io/components/test_deserialize_assembly.py +++ b/tests/io/components/test_deserialize_assembly.py @@ -1,5 +1,6 @@ import pytest +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse from tests.io.conftest import get_pdb_path @@ -14,5 +15,5 @@ @pytest.mark.parametrize("pdb_id", TEST_CASES) def test_deserialize_assembly(pdb_id: str): digs_path = get_pdb_path(pdb_id) - result = parse(filename=digs_path, build_assembly="first") + result = parse(digs_path, config=ParseConfig(build_assembly="first")) assert result is not None diff --git a/tests/io/components/test_entity_annotations.py b/tests/io/components/test_entity_annotations.py index 00b19462..ea5a1688 100644 --- a/tests/io/components/test_entity_annotations.py +++ b/tests/io/components/test_entity_annotations.py @@ -3,6 +3,7 @@ from biotite.structure import AtomArray from atomworks.common import not_isin +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse from atomworks.io.transforms.atom_array import annotate_entities from tests.io.conftest import get_pdb_path @@ -69,8 +70,8 @@ def validate_molecule_entity_annotations(atom_array: AtomArray, test_case: dict) def test_add_molecule_entity_annotation(test_case: dict): path = get_pdb_path(test_case["pdb_id"]) result = parse( - filename=path, - build_assembly="all", + path, + config=ParseConfig(build_assembly="all"), ) assert result is not None assembly_atom_array = result["assemblies"]["1"][0] # Check the first model of the first assembly @@ -86,8 +87,8 @@ def test_add_molecule_entity_annotation_on_modified_pdb(): pdb_id = "1hge" path = get_pdb_path(pdb_id) result = parse( - filename=path, - build_assembly="all", + path, + config=ParseConfig(build_assembly="all"), ) atom_array = result["assemblies"]["1"][0] # First model @@ -166,7 +167,7 @@ def test_regenerate_and_add_chain_entity_annotation(test_case): - Add the chain entity annotation to the atom array """ path = get_pdb_path(test_case["pdb_id"]) - result = parse(filename=path, hydrogen_policy="remove") + result = parse(path, config=ParseConfig(hydrogen_policy="remove")) atom_array = result["assemblies"]["1"][0] # First model, first assembly for equivalent_chains in test_case["equivalent_chains"]: diff --git a/tests/io/components/test_fix_ligands_at_symmetry_centers.py b/tests/io/components/test_fix_ligands_at_symmetry_centers.py index 8d234f90..ca9446c9 100644 --- a/tests/io/components/test_fix_ligands_at_symmetry_centers.py +++ b/tests/io/components/test_fix_ligands_at_symmetry_centers.py @@ -1,6 +1,7 @@ import numpy as np import pytest +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse from tests.io.conftest import get_pdb_path @@ -25,7 +26,9 @@ def test_patch_symmetry_centers(test_case: dict): # Parse the file filename = get_pdb_path(pdbid) - out = parse(filename=filename, build_assembly="first", remove_waters=True, fix_ligands_at_symmetry_centers=True) + out = parse( + filename, config=ParseConfig(build_assembly="first", remove_waters=True, fix_ligands_at_symmetry_centers=True) + ) chain_iids = np.unique(out["assemblies"]["1"][0].chain_iid.astype(str)).tolist() # Ensure that we excluded clashing chains diff --git a/tests/io/components/test_get_ph.py b/tests/io/components/test_get_ph.py index 50bd776e..0e14ed11 100644 --- a/tests/io/components/test_get_ph.py +++ b/tests/io/components/test_get_ph.py @@ -20,7 +20,6 @@ def test_get_ph(test_case: dict): filename=path, add_missing_atoms=False, remove_ccds=[], - hydrogen_policy="infer", ) assert result["metadata"]["crystallization_details"]["pH"] == test_case["pH"] diff --git a/tests/io/components/test_hydrogen_addition_conflict_with_resolving_resnums.py b/tests/io/components/test_hydrogen_addition_conflict_with_resolving_resnums.py index 8ceb03e6..3d8d6079 100644 --- a/tests/io/components/test_hydrogen_addition_conflict_with_resolving_resnums.py +++ b/tests/io/components/test_hydrogen_addition_conflict_with_resolving_resnums.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest @@ -14,7 +12,7 @@ @pytest.mark.parametrize("pdbid", TEST_CASES) def test_resnum_duplication_resolve(pdbid: str): # Not excluding crystallization aids - out1 = parse(filename=get_pdb_path(pdbid), hydrogen_policy="infer") + out1 = parse(get_pdb_path(pdbid)) out1 = out1["assemblies"]["1"][0] ids = out1[out1.res_name == "NAG"].res_id print(ids) diff --git a/tests/io/components/test_hydrogen_policy.py b/tests/io/components/test_hydrogen_policy.py deleted file mode 100644 index 1906860d..00000000 --- a/tests/io/components/test_hydrogen_policy.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import Any - -import numpy as np -import pytest - -from atomworks.io.parser import parse -from atomworks.io.transforms.atom_array import add_hydrogen_atom_positions -from atomworks.io.utils.testing import has_ambiguous_annotation_set -from tests.io.conftest import get_pdb_path - -TEST_CASES = [{"pdb_id": "1jj8", "count": 705}, {"pdb_id": "3kz8", "count": 6258}, {"pdb_id": "2r5z", "count": 1632}] - - -@pytest.mark.parametrize("test_case", TEST_CASES) -def test_add_hydrogen_atom_positions(test_case: dict[str, Any]): - path = get_pdb_path(test_case["pdb_id"]) - - # Load anf infer hydrogens... - result_added_hydrogens = parse( - filename=path, - build_assembly="all", - hydrogen_policy="infer", - ) - - # ...assert that there are hydrogens - atom_array_added_hydrogens = result_added_hydrogens["assemblies"]["1"][0] # First bioassembly, first model - has_resolved_coordinates = ~np.isnan(atom_array_added_hydrogens.coord).any(axis=-1) - non_nan_array = atom_array_added_hydrogens[has_resolved_coordinates] - final_h_count = np.sum(non_nan_array.atomic_number == 1) - assert final_h_count == test_case["count"] - - -def test_add_hydrogens_with_nan_coords(): - # Parse the AtomArray from CIF without adding hydrogens -- this results in some NaN coordinates - result_without_adding_hydrogens = parse( - filename=get_pdb_path("1jj8"), - build_assembly="all", - hydrogen_policy="keep", - ) - atom_array_without_adding_hydrogens = result_without_adding_hydrogens["assemblies"]["1"][0] - - # Check for ambiguous annotations in the original AtomArray - unique_atom_labels = ["chain_id", "res_id", "res_name", "atom_name"] - assert not has_ambiguous_annotation_set(atom_array_without_adding_hydrogens, unique_atom_labels) - - # Get nan information for the original AtomArray - original_nan_coords_mask = np.any(np.isnan(atom_array_without_adding_hydrogens.coord), axis=1) - original_hydrogens_mask = atom_array_without_adding_hydrogens.element == "H" - - # Add hydrogens - atom_array_added_hydrogens = add_hydrogen_atom_positions(atom_array_without_adding_hydrogens) - - # Check for ambiguous annotations in the output AtomArray - assert not has_ambiguous_annotation_set(atom_array_added_hydrogens, unique_atom_labels) - - # Get nan information for the output AtomArray - result_nan_coords_mask = np.any(np.isnan(atom_array_added_hydrogens.coord), axis=1) - heavy_atom_nan_idces = np.where(result_nan_coords_mask & ~(atom_array_added_hydrogens.element == "H"))[0] - - # Check that the number of heavy atoms with nan coordinates is unchanged - assert len(heavy_atom_nan_idces) == sum(original_nan_coords_mask & ~original_hydrogens_mask) - - # Check the bonded hydrogens for each heavy atom with nan coordinates - for idx in heavy_atom_nan_idces: - bonded_atoms = atom_array_added_hydrogens.bonds.get_bonds(idx)[0] - bonded_h_atoms = bonded_atoms[atom_array_added_hydrogens[bonded_atoms].element == "H"] - for h_atom in bonded_h_atoms: - # Find the corresponding hydrogen atom in the original array, if present - h_atom_obj = atom_array_added_hydrogens[h_atom] - matching_mask = original_hydrogens_mask - for annot in unique_atom_labels: - annot_mask = getattr(atom_array_without_adding_hydrogens, annot) == getattr(h_atom_obj, annot) - matching_mask &= annot_mask - - # Newly-added hydrogens should have NaN coordinates - if not np.any(matching_mask): - assert np.all(np.isnan(h_atom_obj.coord)) - - # Previously-existing hydrogens should have the same coordinates as before - else: - original_atom = atom_array_without_adding_hydrogens[matching_mask] - assert len(original_atom) == 1 - assert np.allclose(h_atom_obj.coord, original_atom.coord, equal_nan=True) - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/io/components/test_ignore_residues.py b/tests/io/components/test_ignore_residues.py index 6436d1cd..73163875 100644 --- a/tests/io/components/test_ignore_residues.py +++ b/tests/io/components/test_ignore_residues.py @@ -1,25 +1,54 @@ -from __future__ import annotations - +import biotite.structure as struc import numpy as np import pytest from atomworks.constants import CRYSTALLIZATION_AIDS +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse +from atomworks.io.utils.atom_array import remove_components +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import get_pdb_path -CRYSTALLIZATION_AIDS_PDB_IDS_TO_TEST = ["101M", "1AH8", "1ATG", "1ARX"] +CRYSTALLIZATION_AIDS_PDB_IDS_TO_TEST = ["101M", "1AH8", "1ATG", "1ARX", "5XAG"] @pytest.mark.parametrize("pdbid", CRYSTALLIZATION_AIDS_PDB_IDS_TO_TEST) def test_remove_crystallization_aids(pdbid: str): - # Not excluding crystallization aids - out1 = parse(filename=get_pdb_path(pdbid), remove_ccds=[]) - assert np.any( - np.isin(out1["asym_unit"].res_name, CRYSTALLIZATION_AIDS) - ), "No crystallization aids found when not excluding." - - # Excluding crystallization aids - out2 = parse(filename=get_pdb_path(pdbid), remove_ccds=CRYSTALLIZATION_AIDS) - assert not np.any( - np.isin(out2["asym_unit"].res_name, CRYSTALLIZATION_AIDS) - ), "Crystallization aids found when excluding." + """Remove aid-only molecules while preserving atoms and bonds of molecules containing non-aids.""" + original = parse(get_pdb_path(pdbid), config=ParseConfig(remove_ccds=[]))["asym_unit"] + is_aid = np.isin(original.res_name, CRYSTALLIZATION_AIDS) + assert is_aid.any(), "No crystallization aids found when not excluding." + + # Use unfiltered covalent molecules so deleting every aid cannot satisfy the test. + bonds = original.bonds.as_array() + covalent = struc.BondList(original.array_length(), bonds[bonds[:, 2] != struc.BondType.COORDINATION]) + keep = np.zeros(original.array_length(), dtype=bool) + for molecule in struc.get_molecule_indices(covalent): + keep[molecule] = not is_aid[molecule].all() + + result = parse(get_pdb_path(pdbid), config=ParseConfig(remove_ccds=CRYSTALLIZATION_AIDS)) + assert_same_atom_array_or_stack( + result["asym_unit"], + original[:, keep], + annotations_to_compare=["chain_id", "res_id", "res_name", "atom_name", "element", "charge"], + ) + assert set(result["asym_unit"].chain_id) == set(result["chain_info"]) + + +@pytest.mark.parametrize("bond_type", [struc.BondType.SINGLE, struc.BondType.COORDINATION]) +@pytest.mark.parametrize("stack", [False, True]) +def test_remove_aids_by_covalent_connectivity(bond_type, stack): + """Preserve whole, transitively linked residues; remove free and aid-only molecules, ignoring coordination.""" + atoms = struc.AtomArray(12) + atoms.res_name = np.repeat(["IMD", "GOL", "EDO", "GOL", "EDO", "GOL"], 2) + atoms.res_id = np.repeat(np.arange(6), 2) + # Only inter-residue bonds: IMD–GOL–EDO, GOL–EDO, and free GOL. + atoms.bonds = struc.BondList(12, np.array([[0, 2, bond_type], [3, 4, bond_type], [6, 8, bond_type]])) + if stack: + atoms = struc.stack([atoms, atoms]) + + filtered = remove_components(atoms, remove_ccds=("GOL", "EDO")) + expected = ["IMD", "GOL", "EDO"] if bond_type == struc.BondType.SINGLE else ["IMD"] + assert filtered.res_name.tolist() == np.repeat(expected, 2).tolist() + assert atoms.array_length() == 12 + assert atoms.bonds.get_bond_count() == 3 diff --git a/tests/io/components/test_mse_to_met.py b/tests/io/components/test_mse_to_met.py index 37114f67..d52d8a02 100644 --- a/tests/io/components/test_mse_to_met.py +++ b/tests/io/components/test_mse_to_met.py @@ -1,30 +1,27 @@ -import os - import numpy as np import pytest from atomworks.common import not_isin -from atomworks.constants import CCD_MIRROR_PATH, HYDROGEN_LIKE_SYMBOLS +from atomworks.constants import HYDROGEN_LIKE_SYMBOLS from atomworks.io.parser import parse from atomworks.io.transforms.atom_array import mse_to_met from atomworks.io.utils.ccd import atom_array_from_ccd_code -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import get_pdb_path TEST_CASES = ["1aqc"] -@pytest.mark.parametrize("ccd_mirror_path", [CCD_MIRROR_PATH, None]) -def test_mse_to_met_residue(ccd_mirror_path: os.PathLike | None): +def test_mse_to_met_residue(): # Test with local CCD data - mse = atom_array_from_ccd_code("MSE", ccd_mirror_path=ccd_mirror_path) - met = atom_array_from_ccd_code("MET", ccd_mirror_path=ccd_mirror_path) + mse = atom_array_from_ccd_code("MSE") + met = atom_array_from_ccd_code("MET") # Set coordinates to `nan` to avoid comparing coordinates mse.coord[:] = np.nan met.coord[:] = np.nan is_heavy = lambda x: not_isin(x.element, HYDROGEN_LIKE_SYMBOLS) mse_converted = mse_to_met(mse) - assert_same_atom_array( + assert_same_atom_array_or_stack( mse_converted[is_heavy(mse_converted)], met[is_heavy(met)], annotations_to_compare=["chain_id", "res_name", "res_id", "atom_name", "element"], @@ -34,6 +31,7 @@ def test_mse_to_met_residue(ccd_mirror_path: os.PathLike | None): @pytest.mark.parametrize("pdb_id", TEST_CASES) def test_mse_to_met_pdb(pdb_id: str): + """Test MSE to MET conversion via parser with chain_info updates.""" path = get_pdb_path(pdb_id) result = parse( filename=path, @@ -44,9 +42,14 @@ def test_mse_to_met_pdb(pdb_id: str): fix_arginines=True, convert_mse_to_met=True, ) - assert result is not None # Check if processing runs through + assert result is not None assert "MSE" not in result["asym_unit"].res_name + # Verify chain_info was also updated + for chain_data in result["chain_info"].values(): + if "res_name" in chain_data: + assert "MSE" not in chain_data["res_name"] + result_unconverted = parse( filename=path, add_missing_atoms=True, diff --git a/tests/io/components/test_non_covalent_bond_parsing.py b/tests/io/components/test_non_covalent_bond_parsing.py index 79c3b4ac..e23469da 100644 --- a/tests/io/components/test_non_covalent_bond_parsing.py +++ b/tests/io/components/test_non_covalent_bond_parsing.py @@ -153,12 +153,12 @@ def test_bond_type_filtering(test_case: tuple): # Record the total number of bonds present total_num_bonds = atom_array_all_bonds.bonds.as_array().shape[0] - # Check the number of coordination bonds - # NOTE: This is done only as a sanity check. Since the `chem_comp_bond` field (used for intra-residue bonds) - # does not distinguish coordinate bonds from covalent bonds, and since no canonical inter-residue bonds are - # coordination bonds, any bonds marked as coordination bonds must originate from the `struct_conn` category. - num_coord_bonds_in_atom_array = sum(atom_array_all_bonds.bonds.as_array()[:, 2] == struc.BondType.COORDINATION) - assert num_coord_bonds_in_atom_array == num_coord_bonds_struct_conn + # Count coordination bonds. + # NOTE: Parsing retypes every metal-incident bond to COORDINATION regardless of its source, so this total + # is *not* the `struct_conn` count -- intra-residue metal bonds from `chem_comp_bond` (e.g. the heme Fe-N + # bonds in 1n45) are coordination bonds too. What `struct_conn` filtering controls is the *difference* + # between the two parses, which is asserted below. + num_coord_bonds_all = sum(atom_array_all_bonds.bonds.as_array()[:, 2] == struc.BondType.COORDINATION) # Parse AtomArray from CIF, including only covalent bonds from `struct_conn` result = parse( @@ -174,9 +174,10 @@ def test_bond_type_filtering(test_case: tuple): # Record the total number of bonds present total_num_bonds_no_struct_conn_coord_or_disulfide = atom_array_covalent_only.bonds.as_array().shape[0] - # Check that there are no parsed coordination bonds - num_coord_bonds_in_atom_array = sum(atom_array_covalent_only.bonds.as_array()[:, 2] == struc.BondType.COORDINATION) - assert num_coord_bonds_in_atom_array == 0 + # Dropping `metalc` removes exactly the struct_conn coordination bonds; metal bonds inferred from the + # CCD remain and stay typed COORDINATION. + num_coord_bonds_covalent_only = sum(atom_array_covalent_only.bonds.as_array()[:, 2] == struc.BondType.COORDINATION) + assert num_coord_bonds_all - num_coord_bonds_covalent_only == num_coord_bonds_struct_conn # We cannot directly detect disulfides, but we can infer how many were filtered out num_filtered_disulfide_bonds = ( diff --git a/tests/io/components/test_non_rcsb_functionality.py b/tests/io/components/test_non_rcsb_functionality.py deleted file mode 100644 index e7a68679..00000000 --- a/tests/io/components/test_non_rcsb_functionality.py +++ /dev/null @@ -1,112 +0,0 @@ -import numpy as np -import pytest - -from atomworks.enums import ChainType -from atomworks.io.parser import parse -from atomworks.io.utils.non_rcsb import initialize_chain_info_from_atom_array -from tests.io.conftest import CHAIN_TYPE_TEST_CASES, TEST_DATA_IO, get_pdb_path - -CIF_PATHS = [TEST_DATA_IO / "example_distillation_output.cif"] - - -@pytest.mark.parametrize("path", CIF_PATHS) -def test_load_with_all_resolved(path: str): - result = parse( - filename=path, - add_missing_atoms=True, - remove_ccds=[], - hydrogen_policy="remove", - ) - # Check if processing runs through - assert result is not None - - # Check if the extra metadata is present (from the custom `_extra_metadata` CIFCategory) - assert result["metadata"]["extra_metadata"] is not None - - -def test_af2_predicted_pdb_example(): - result = parse( - filename=TEST_DATA_IO / "UniRef50_A0A0S8JQ92_AF2_predicted.pdb", - remove_waters=True, - remove_ccds=[], - ) - # Check if processing runs through - assert result is not None - - -def test_af3_cond_design_cif_example(): - result = parse( - filename=TEST_DATA_IO / "example_conditional_generation_output.cif", - add_missing_atoms=False, - extra_fields="all", - ) - # Check if processing runs through - assert result is not None - - atom_array = result["assemblies"]["1"][0] - assert atom_array.condition_motif_index is not None - assert atom_array.condition_coords_gt_x is not None - - result = parse( - filename=TEST_DATA_IO / "example_conditional_generation_output.cif", - add_missing_atoms=False, - extra_fields=["condition_coords_gt_x"], - ) - atom_array = result["assemblies"]["1"][0] - assert atom_array.condition_coords_gt_x is not None - assert "condition_coords_gt_y" not in atom_array._annot - - -def test_bcif_example(): - result = parse( - filename=TEST_DATA_IO / "6lyz.bcif", - ) - # Check if processing runs through - assert result is not None - - -def test_pdb_with_same_chain_poly_non_poly(): - result = parse( - filename=TEST_DATA_IO / "1qfe.pdb", - hydrogen_policy="remove", - ) - # Check if processing runs through - assert result is not None - - # Check that we don't have any chains with polymeric and non-polymeric residues - atom_array = result["assemblies"]["1"][0] - polymer_chain_ids = np.unique(atom_array.chain_id[atom_array.is_polymer]) - non_polymer_chain_ids = np.unique(atom_array.chain_id[~atom_array.is_polymer]) - assert len(set(polymer_chain_ids).intersection(non_polymer_chain_ids)) == 0 - - # Assert that all residues have coordinates - if not np.all(np.isfinite(atom_array.coord)): - culprits = atom_array[(~np.isfinite(atom_array.coord)).any(axis=1)] - raise RuntimeError(f"Some residues are missing coordinates: \n{culprits}") - - -@pytest.mark.parametrize("test_case", CHAIN_TYPE_TEST_CASES) -def test_infer_chain_info_from_atom_array(test_case: dict): - cif_path = get_pdb_path(test_case["pdb_id"]) - atom_array = parse( - filename=cif_path, - add_missing_atoms=False, - remove_waters=True, - )["asym_unit"][0] - - chain_info = initialize_chain_info_from_atom_array(atom_array) - - for chain_id, info_dict in chain_info.items(): - got = info_dict["chain_type"] - expected = ChainType.as_enum(test_case["chain_types"][chain_id]) - - if got.is_non_polymer(): - # We allow all non-polymers to be interchanged - assert expected.is_non_polymer() - else: - # Enforce strict equality for polymers - assert got == expected - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/io/components/test_parse_invariants.py b/tests/io/components/test_parse_invariants.py new file mode 100644 index 00000000..4efa3e11 --- /dev/null +++ b/tests/io/components/test_parse_invariants.py @@ -0,0 +1,70 @@ +import pytest + +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from tests.io.conftest import get_pdb_path +from tests.io.utils.charge_invariants import check_charge_invariants + +TEST_CASES = [ + "5e5j", # Comes from more than 1 experimental method (X-ray & neutron scattering) + "1j8z", # Contains misordered atoms in a residue + "2fs3", # Contains an unusual operation expression for assembly building + "1fp7", # Contains bonds between crystallization aids in struct_conn + "6lzb", # Multiple occupancy; some prior issues with `struct_conn` (since fixed in latest PDB updates) + "5t39", # Contains misordered atoms in a residue (`SAH`) + "1nci", # Issues with arginine resolving, seems to have differing number of NH1/NH2 + "1aym", # Issues with patching symmetric ligands (invalid literal) + "8bc3", # Covalent bond involving chains that were removed during cleaning + "1twr", # Residue name not in biotite's CCD + "6q9t", # Contains residue `QUK` which uses a mix of `std` and `alt` atom ids + "1xvk", # Final boss of PDB parsing: non-canonical macrocycle with disulfide bond and multiple occupancy; cannot infer standard inter-residue bonds + "1qfe", # A170 NZ incorrectly assigned charge +2 unless we remove a hydrogen as a leaving atom (there should be no protonated amides in the final structure; Lysine NZ becomes part of an amide bond to small molecule DHS) + "1tqh", # Serine hydrolase intermediate requires converting C=O to C-O(-) to satisfy valence requirements + "1dpn", # A single P-O link replaces a double-bond leaving group and restores an equivalent terminal P=O + "6h9v", # Includes isopeptide bond with IAS NCAA where naively building N-CA-C backbone would create too many bonds + "1rcq", # Enzyme active site where PLP exists in two partially occupied states, one with a covalent bond to a lysine and one without + # Additional charge edge cases + "6lyz", # Pure protein + "1d9d", # DNA/RNA hybrid + protein + "1ivo", # Oligosaccharide + "6qhp", # Simple protein +] + + +@pytest.mark.parametrize("pdb_id", TEST_CASES) +@pytest.mark.parametrize("hydrogen_policy", ["keep", "remove"], ids=["with_h", "no_h"]) +def test_parse_invariants(pdb_id: str, hydrogen_policy: str): + path = get_pdb_path(pdb_id) + result = parse( + filename=path, + hydrogen_policy=hydrogen_policy, + ) + assert result is not None # Check if processing runs through + + atom_array = result["asym_unit"][0] + + check_charge_invariants(pdb_id, atom_array) + + +# PDB IDs that are malformed or contain unresolvable data, and should raise +# specific errors during parsing. +ERROR_TEST_CASES = [ + # 8cuy contains UNL (unknown ligand) residues — free-floating lipids with no CCD + # template. Atoms have inter-residue bonds but zero intra-residue bonds. + ("8cuy", ValueError, "missing CCD template"), + # 4v4s authors both O3'-P and O3'-OP2 links from A36 to YYG37; reject the three-bond oxygen. + ("4v4s", ValueError, r"Unresolved link valence at C/36/A/O3'.*bond-order sum=3"), +] + + +@pytest.mark.parametrize("pdb_id,expected_error,match", ERROR_TEST_CASES) +@pytest.mark.parametrize("hydrogen_policy", ["keep", "remove"], ids=["with_h", "no_h"]) +def test_parse_expected_errors(pdb_id: str, expected_error: type, match: str, hydrogen_policy: str): + """PDB structures that should raise specific errors during parsing.""" + path = get_pdb_path(pdb_id) + with pytest.raises(expected_error, match=match): + parse(path, config=ParseConfig(hydrogen_policy=hydrogen_policy)) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/io/components/test_prior_bugs.py b/tests/io/components/test_prior_bugs.py deleted file mode 100644 index 045fd5e0..00000000 --- a/tests/io/components/test_prior_bugs.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest - -from atomworks.io.parser import parse -from tests.io.conftest import get_pdb_path - -TEST_CASES = [ - "5e5j", # Comes from more than 1 experimental method (X-ray & neutron scattering) - "1j8z", # Contains misordered atoms in a residue - "2fs3", # Contains an unusual operation expression for assembly building - "1fp7", # Contains bonds between crystallization aids in struct_conn - "6lzb", # Duplicate index problem with struct_conn (? - presumably crystallization aids/water) - "5t39", # Contains misordered atoms in a residue (`SAH`) - "1nci", # Issues with arginine resolving, seems to have differing number of NH1/NH2 - "1aym", # Issues with patching symmetric ligands (invalid literal) - "8bc3", # Covalent bond involving chains that were removed during cleaning - "1twr", # Residue name not in biotite's CCD - "6q9t", # Contains residue `QUK` which uses a mix of `std` and `alt` atom ids - "8cuy", # Raises error when parsing CCD CIF's ('NoneType' object has no attribute 'row_count') due to UNL - "1xvk", # Raises error when parsing struct_conn record (index 0 is out of bounds for axis 0 with size 0) -] - - -@pytest.mark.parametrize("pdb_id", TEST_CASES) -def test_prior_bugs(pdb_id: str): - path = get_pdb_path(pdb_id) - result = parse( - filename=path, - ) - assert result is not None # Check if processing runs through - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/io/components/test_regression.py b/tests/io/components/test_regression.py index bd1e9d92..4b6d9b85 100644 --- a/tests/io/components/test_regression.py +++ b/tests/io/components/test_regression.py @@ -1,145 +1,284 @@ """Regression tests for complex cases to ensure consistent behavior.""" import pickle -from pathlib import Path # noqa: F401 +import tempfile +from dataclasses import replace +from pathlib import Path import numpy as np import pytest +from biotite.structure import AtomArray, AtomArrayStack -from atomworks.constants import CRYSTALLIZATION_AIDS +from atomworks.constants import ATOMWORKS_COMMON_ANNOTATIONS, CRYSTALLIZATION_AIDS +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse from atomworks.io.transforms import atom_array as ta -from atomworks.io.utils.io_utils import to_cif_file # noqa: F401 -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.transforms.categories import category_to_dict +from atomworks.io.utils.io_utils import to_cif_file +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import TEST_DATA_IO, get_pdb_path TEST_CASES = [ "6mub", # Symmetry center clash "1j8z", # Contains misordered atoms in a residue "1fp7", # Contains bonds between crystallization aids in struct_conn - "1twr", # Residue name not in biotite's CCD + "1twr", # Residue VER provided with custom bonds (aromatic in crystal structure but not in CCD) "6q9t", # Contains residue `QUK` which uses a mix of `std` and `alt` atom ids; also contains various unusual ligands and NCAA's + "1d9d", # DNA_RNA_HYBRID + protein + "1ivo", # protein + oligosaccharides (branched) + "4js1", # Protein-glycosylation with covalent bonds + "4ndz", # Large assembly with ligands covalently bound + "3ne7", # Small assembly with two ligands covalently bound + "1rcq", # PLP,DLY ligands with 2 transformations + "1qh9", # 2 transformations + LAC (lactose) + "1cbn", # Sequence heterogeneity in residue 22 of chain A + "6h9v", # Partial hydrogens cause fast-path miscount in add_missing_atoms + "1d3z", # NMR protein (ubiquitin), 10 models + "2koc", # NMR RNA hairpin, 20 models + "3ne2", # Two simple proteins + "6dmg", # Multiconformer ligand + "104d", # DNA with MG ions + "1a3g", # Covalent modification, protein-ligand + "3n95", # Terminal VAL-NH2 amide inferred from polymer sequence without an authored link. + "4aah", # Vicinal Cys103-Cys104 disulfide must not suppress the C103-N104 backbone bond. ] +ANNOTATIONS_TO_EXCLUDE = { + "ins_code", # Legacy PDB artifact + "label_alt_id", # Altloc selection may not round-trip +} -@pytest.mark.parametrize("pdb_id", TEST_CASES) -def test_regression_against_stored_result(pdb_id: str): - path = get_pdb_path(pdb_id) - result = parse( - filename=path, - add_missing_atoms=True, - remove_waters=True, - remove_ccds=CRYSTALLIZATION_AIDS, - fix_ligands_at_symmetry_centers=True, - build_assembly="all", - fix_arginines=True, - convert_mse_to_met=True, - hydrogen_policy="keep", - model=None, - ) - assert result is not None # Check if processing runs through +# Additional annotations to exclude only for CIF roundtrip tests +ROUNDTRIP_ANNOTATIONS_TO_EXCLUDE = { + "chain_type", # CIF roundtrip collapses non-polymer types (BRANCHED→NON_POLYMER, etc.) +} - regression_dir = TEST_DATA_IO / "regression_tests" - regression_dir.mkdir(parents=True, exist_ok=True) - pickle_path = regression_dir / f"{pdb_id}.pkl" +# Common parse config used in regression tests +REGRESSION_PARSE_CONFIG = ParseConfig( + add_missing_atoms=True, + remove_waters=True, + remove_ccds=CRYSTALLIZATION_AIDS, + fix_ligands_at_symmetry_centers=True, + build_assembly="all", + fix_arginines=True, + convert_mse_to_met=True, + hydrogen_policy="keep", + model=None, + return_atom_array_plus=True, +) - # Uncomment the following lines to create the pickle file - # with pickle_path.open("wb") as f: - # import atomworks.io - # result["atomworks.version"] = atomworks.__version__ - # pickle.dump(result, f) +@pytest.fixture +def regression_config(pdb_id: str) -> ParseConfig: + # Include disulfides to exercise 4AAH's adjacent-cysteine backbone attachment. + if pdb_id == "4aah": + return replace(REGRESSION_PARSE_CONFIG, add_bond_types_from_struct_conn=("covale", "disulf")) + return REGRESSION_PARSE_CONFIG - with pickle_path.open("rb") as f: - expected_result = pickle.load(f) - expected_result["asym_unit"] = ta.remove_hydrogens(expected_result["asym_unit"]) - result["asym_unit"] = ta.remove_hydrogens(result["asym_unit"]) - - # Save output into test_output - # to_cif_file( - # result["asym_unit"][0], - # f"{Path(__file__).parents[1]}/test_outputs/{pdb_id}_new.cif", - # id=f"{pdb_id}_new", - # date=True, - # time=True, - # ) - # to_cif_file( - # expected_result["asym_unit"][0], - # f"{Path(__file__).parents[1]}/test_outputs/{pdb_id}_old.cif", - # id=f"{pdb_id}_old", - # date=True, - # time=True, - # ) - - # ## FOR DEBUGGING REGRESSION TESTS UNCOMMENT: - from atomworks.common import sum_string_arrays - - def get_atom_identifiers(atom_array): - return sum_string_arrays( - atom_array.chain_id, "-", atom_array.res_name, "-", atom_array.res_id.astype(str), "-", atom_array.atom_name - ) +def get_annotations_to_compare( + atom_array1: AtomArray | AtomArrayStack, + atom_array2: AtomArray | AtomArrayStack, + extra_exclude: set[str] | None = None, +): + """Get the set of annotations to compare between atom arrays.""" + annots1 = set(ATOMWORKS_COMMON_ANNOTATIONS) & set(atom_array1.get_annotation_categories()) + annots2 = set(ATOMWORKS_COMMON_ANNOTATIONS) & set(atom_array2.get_annotation_categories()) + annotations = annots1 & annots2 + + exclude = ANNOTATIONS_TO_EXCLUDE + if extra_exclude: + exclude = exclude | extra_exclude + + return annotations - exclude + + +def _compare_parse_results( + result: dict, + expected_result: dict, + remove_hydrogens: bool = True, + extra_annotations_to_exclude: set[str] | None = None, + metadata_keys_to_compare: set[str] | None = None, + chain_info_fields_to_skip: set[str] | None = None, +) -> None: + """Compare two parse results for structural and metadata equality. + + Compares: + - Asymmetric unit atoms and annotations + - All assemblies atoms and annotations + - Metadata sections specified by metadata_keys_to_compare + """ + # +----- Asymmetric unit -----+ + # ... remove hydrogens, if requested + if remove_hydrogens: + expected_result["asym_unit"] = ta.remove_hydrogens(expected_result["asym_unit"]) + result["asym_unit"] = ta.remove_hydrogens(result["asym_unit"]) - # a = result["asym_unit"][0] - # b = expected_result["asym_unit"][0] - a_id = get_atom_identifiers(result["asym_unit"][0]) - b_id = get_atom_identifiers(expected_result["asym_unit"][0]) - # In a but not in b - print("in new but not in old:", np.setdiff1d(a_id, b_id)) - # In b but not in a - print("in old but not in new:", np.setdiff1d(b_id, a_id)) - ### - - # Check the asymmetric unit... - assert_same_atom_array( + assert_same_atom_array_or_stack( result["asym_unit"], expected_result["asym_unit"], - annotations_to_compare=["chain_id", "res_name", "res_id", "atom_name", "charge"], + annotations_to_compare=list( + get_annotations_to_compare( + result["asym_unit"], + expected_result["asym_unit"], + extra_exclude=extra_annotations_to_exclude, + ) + ), compare_coords=True, compare_bonds=True, + enforce_order=False, + cast_to_common_dtype=True, ) - # ... the assemblies + # +----- Assemblies -----+ for assembly_id in result["assemblies"]: - result["assemblies"][assembly_id] = ta.remove_hydrogens(result["assemblies"][assembly_id]) - expected_result["assemblies"][assembly_id] = ta.remove_hydrogens(expected_result["assemblies"][assembly_id]) - assert_same_atom_array( - result["assemblies"][assembly_id], - expected_result["assemblies"][assembly_id], - annotations_to_compare=["chain_id", "res_name", "res_id", "atom_name", "charge"], + # ... remove hydrogens, if requested + if remove_hydrogens: + result["assemblies"][assembly_id] = ta.remove_hydrogens(result["assemblies"][assembly_id]) + expected_result["assemblies"][assembly_id] = ta.remove_hydrogens(expected_result["assemblies"][assembly_id]) + + result_asm = result["assemblies"][assembly_id] + expected_asm = expected_result["assemblies"][assembly_id] + + assert_same_atom_array_or_stack( + result_asm, + expected_asm, + annotations_to_compare=list( + get_annotations_to_compare( + result_asm, + expected_asm, + extra_exclude=extra_annotations_to_exclude, + ) + ), compare_coords=True, compare_bonds=True, + enforce_order=False, + cast_to_common_dtype=True, ) + # +----- Metadata -----+ + # Determine which metadata keys to compare (default = all) + if metadata_keys_to_compare is None: + metadata_keys_to_compare = {"ligand_info", "chain_info", "extra_info", "metadata"} + # ... the ligand of interest information - assert result["ligand_info"] == expected_result["ligand_info"] + if "ligand_info" in metadata_keys_to_compare: + assert result["ligand_info"] == expected_result["ligand_info"] # ... the chain information - assert set(result["chain_info"].keys()) == set(expected_result["chain_info"].keys()) - for chain in result["chain_info"]: - got = result["chain_info"][chain]["chain_type"] - expected = expected_result["chain_info"][chain]["chain_type"] - assert got == expected, f"Chain info for {chain=} does not match: {got} != {expected}" + if "chain_info" in metadata_keys_to_compare: + assert set(result["chain_info"].keys()) == set(expected_result["chain_info"].keys()) - got = result["chain_info"][chain]["res_name"] - expected = expected_result["chain_info"][chain]["res_name"] - assert np.array_equal(got, expected), f"Chain info for {chain=} does not match: {got} != {expected}" + # Initialize skip set + if chain_info_fields_to_skip is None: + chain_info_fields_to_skip = set() - got = result["chain_info"][chain]["res_id"] - expected = expected_result["chain_info"][chain]["res_id"] - assert np.array_equal(got, expected), f"Chain info for {chain=} does not match: {got} != {expected}" + for chain in result["chain_info"]: + # Only compare fields that aren't in the skip set + if "chain_type" not in chain_info_fields_to_skip: + got = result["chain_info"][chain]["chain_type"] + expected = expected_result["chain_info"][chain]["chain_type"] + assert got == expected, f"Chain info for {chain=} does not match: {got} != {expected}" - got = result["chain_info"][chain]["is_polymer"] - expected = expected_result["chain_info"][chain]["is_polymer"] - assert got == expected, f"Chain info for {chain=} does not match: {got} != {expected}" + if "res_name" not in chain_info_fields_to_skip: + got = result["chain_info"][chain]["res_name"] + expected = expected_result["chain_info"][chain]["res_name"] + assert np.array_equal(got, expected), f"Chain info for {chain=} does not match: {got} != {expected}" + + if "res_id" not in chain_info_fields_to_skip: + got = result["chain_info"][chain]["res_id"] + expected = expected_result["chain_info"][chain]["res_id"] + assert np.array_equal(got, expected), f"Chain info for {chain=} does not match: {got} != {expected}" + + if "is_polymer" not in chain_info_fields_to_skip: + got = result["chain_info"][chain]["is_polymer"] + expected = expected_result["chain_info"][chain]["is_polymer"] + assert got == expected, f"Chain info for {chain=} does not match: {got} != {expected}" # ... the extra information - assert result["extra_info"] == expected_result["extra_info"] + if "extra_info" in metadata_keys_to_compare: + assert result["extra_info"] == expected_result["extra_info"] + + # ... any additional metadata keys + if "metadata" in metadata_keys_to_compare: + for key in expected_result.get("metadata", {}): + assert key in result["metadata"], f"Missing metadata key: {key}" + assert result["metadata"][key] == expected_result["metadata"][key] + + +@pytest.mark.parametrize("pdb_id", TEST_CASES) +@pytest.mark.parametrize("remove_hydrogens", [True, False], ids=["no_h", "with_h"]) +def test_regression_against_stored_result(pdb_id: str, remove_hydrogens: bool, regression_config: ParseConfig): + """Test that parse output matches stored regression baseline.""" + regression_dir = TEST_DATA_IO / "regression_tests" + pickle_path = regression_dir / f"{pdb_id}.pkl" - # ... the metadata - for key in expected_result.get("metadata", {}): - assert key in result["metadata"], f"Missing metadata key: {key}" - assert result["metadata"][key] == expected_result["metadata"][key] + with pickle_path.open("rb") as f: + expected_result = pickle.load(f) + + result = parse(get_pdb_path(pdb_id), config=regression_config) + assert result is not None + + _compare_parse_results(result, expected_result, remove_hydrogens=remove_hydrogens) + + +@pytest.mark.parametrize("pdb_id", TEST_CASES) +@pytest.mark.parametrize("remove_hydrogens", [True, False], ids=["no_h", "with_h"]) +@pytest.mark.parametrize("file_type", ["cif", "bcif.zst"]) +def test_regression_cif_roundtrip(pdb_id: str, remove_hydrogens: bool, file_type: str, regression_config: ParseConfig): + """Test CIF/BCIF roundtrip. + + Gold-standard test that ensures: + (a) Saving to CIF preserves all atoms + (b) struct_oper categories are preserved + (c) Assemblies can be rebuilt from saved struct_oper + (d) Roundtrip produces same result as original + """ + regression_dir = TEST_DATA_IO / "regression_tests" + pickle_path = regression_dir / f"{pdb_id}.pkl" + + with pickle_path.open("rb") as f: + expected_result = pickle.load(f) + + result = parse(get_pdb_path(pdb_id), config=regression_config) + + with tempfile.TemporaryDirectory() as tmp_dir: + cif_path = Path(tmp_dir) / f"{pdb_id}_roundtrip.{file_type}" + + extra_cats = { + "pdbx_struct_oper_list": category_to_dict(result["extra_info"]["struct_oper_category"]), + "pdbx_struct_assembly_gen": category_to_dict(result["extra_info"]["assembly_gen_category"]), + } + + to_cif_file( + result["asym_unit"], + cif_path, + include_entity_categories=True, + extra_categories=extra_cats, + ) + + reloaded_result = parse( + cif_path, + config=ParseConfig( + add_bond_types_from_struct_conn=regression_config.add_bond_types_from_struct_conn, + add_missing_atoms=False, + remove_waters=False, + remove_ccds=None, + fix_ligands_at_symmetry_centers=False, + build_assembly="all", + convert_mse_to_met=False, + hydrogen_policy="keep", + ), + ) + + _compare_parse_results( + reloaded_result, + expected_result, + remove_hydrogens=remove_hydrogens, + extra_annotations_to_exclude=ROUNDTRIP_ANNOTATIONS_TO_EXCLUDE, + metadata_keys_to_compare={"chain_info"}, + chain_info_fields_to_skip={"chain_type"}, + ) if __name__ == "__main__": diff --git a/tests/io/components/test_remove_hydrogens.py b/tests/io/components/test_remove_hydrogens.py index 170057f7..0caffddf 100644 --- a/tests/io/components/test_remove_hydrogens.py +++ b/tests/io/components/test_remove_hydrogens.py @@ -1,8 +1,9 @@ import numpy as np import pytest +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import get_pdb_path TEST_CASES = ["2w3o"] @@ -14,9 +15,8 @@ def test_remove_hydrogens(pdb_id: str): # First, we load without hydrogens... result_no_hydrogens = parse( - filename=path, - build_assembly="all", - hydrogen_policy="remove", + path, + config=ParseConfig(build_assembly="all", hydrogen_policy="remove"), ) atom_array_no_hydrogens = result_no_hydrogens["assemblies"]["1"][0] # First bioassembly, first model @@ -25,9 +25,8 @@ def test_remove_hydrogens(pdb_id: str): # Then, we load with hydrogens... result_with_hydrogens = parse( - filename=path, - build_assembly="all", - hydrogen_policy="keep", + path, + config=ParseConfig(build_assembly="all", hydrogen_policy="keep"), ) # ...assert that there are hydrogens @@ -38,7 +37,7 @@ def test_remove_hydrogens(pdb_id: str): atom_array_with_hydrogens_filtered = atom_array_with_hydrogens[atom_array_with_hydrogens.atomic_number != 1] # ...and assert that the atom arrays are the same - assert_same_atom_array( + assert_same_atom_array_or_stack( atom_array_no_hydrogens, atom_array_with_hydrogens_filtered, annotations_to_compare=["chain_id", "res_name", "res_id", "atom_name", "element"], diff --git a/tests/io/components/test_struct_conn_from_atom_array.py b/tests/io/components/test_struct_conn_from_atom_array.py deleted file mode 100644 index b103efff..00000000 --- a/tests/io/components/test_struct_conn_from_atom_array.py +++ /dev/null @@ -1,120 +0,0 @@ -import numpy as np -import pytest - -from atomworks.io.parser import parse -from atomworks.io.transforms.categories import category_to_dict -from atomworks.io.utils.bonds import get_struct_conn_dict_from_atom_array -from atomworks.io.utils.io_utils import read_any -from tests.io.conftest import get_pdb_path - -# (pdb_id, assembly_id) -TEST_CASES = [ - ("2k33", "1"), # Contains only covalent bonds in struct_conn - ("3u8v", "3"), # Contains only metal bonds in struct_conn - ("7cu5", "1"), # Contains a mix of covalent and disulfide bonds in struct_conn - ("1iv9", "1"), # Does not contain struct_conn field -] - - -def sort_bond_array(bond_array): - """Sort a 2D array of dimension (2, n_bonds), preserving the values within each column (bond)""" - - # Sort the elements within each bond - bond_array = np.sort(bond_array, axis=0) - - # Sort the bonds themselves - lex_key = np.flipud(bond_array) - sorted_indices = np.lexsort(lex_key) - bond_array = bond_array[:, sorted_indices] - - return bond_array - - -@pytest.mark.parametrize("test_case", TEST_CASES) -def test_get_struct_conn_dict_from_atom_array(test_case: str): - pdb_id, assembly_id = test_case - path = get_pdb_path(pdb_id) - - # Parse AtomArray from CIF - result = parse( - filename=path, - build_assembly=[assembly_id], - add_bond_types_from_struct_conn=[ - "covale", - "metalc", - "disulf", - ], - ) - atom_array = result["assemblies"][assembly_id][0] - - # Get the struct_conn_dict from the CIF file - # NOTE: This will not subset by assembly, so the test assembly must contain all struct_conn bonds in the CIF file - cif_file = read_any(path) - struct_conn_dict_from_cif = category_to_dict(cif_file.block, "struct_conn") - - # Get the struct_conn_dict from the AtomArray - struct_conn_dict_from_atom_array = get_struct_conn_dict_from_atom_array(atom_array) - - # Compare the two struct_conn_dicts - for key in struct_conn_dict_from_atom_array: - assert key in struct_conn_dict_from_cif - - # Get the corresponding partner annotation - if "ptnr1" in key: - # Sort the bond arrays, since they may be reordered during parsing - partner_key = key.replace("ptnr1", "ptnr2") - arr_from_cif = np.stack( - ( - struct_conn_dict_from_cif[key], - struct_conn_dict_from_cif[partner_key], - ), - axis=0, - ) - - # Convert missing or inapplicable ins_codes to empty strings, as is done - # downstream in get_struct_conn_bonds. This is done here to facilitate testing - if key.endswith("PDB_ins_code"): - mask = np.isin(arr_from_cif, np.array([".", "?"])) - arr_from_cif[mask] = "" - - arr_from_cif = sort_bond_array(arr_from_cif) - - arr_from_atom_array = np.stack( - ( - struct_conn_dict_from_atom_array[key], - struct_conn_dict_from_atom_array[partner_key], - ), - axis=0, - ) - arr_from_atom_array = sort_bond_array(arr_from_atom_array) - elif "ptnr2" in key: - continue - else: - # We convert disulfides to covalent if parsed - if key == "conn_type_id": - arr_from_cif = np.sort(np.char.replace(struct_conn_dict_from_cif[key], "disulf", "covale")) - else: - arr_from_cif = np.sort(struct_conn_dict_from_cif[key]) - - arr_from_atom_array = np.sort(struct_conn_dict_from_atom_array[key]) - - # Handle the known issue where the label_seq_id is uninformative for non-polymers - # This leads to mismatches since we sometimes re-label these cases during CIF parsing - # This makes sorting unreliable, so we fall back to an isin assertion - if key.endswith("label_seq_id"): - flattened_arr_from_cif = arr_from_cif.flatten() - flattened_arr_from_atom_array = arr_from_atom_array.flatten() - mask = flattened_arr_from_cif != "." - assert np.isin( - flattened_arr_from_cif[mask], - flattened_arr_from_atom_array, - ).all(), f"Mismatch in {key} for {test_case}" - else: - assert np.array_equal( - arr_from_atom_array, - arr_from_cif, - ), f"Mismatch in {key} for {test_case}" - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/io/components/test_structure_with_non_ccd_ligand.py b/tests/io/components/test_structure_with_non_ccd_ligand.py index 817a9c16..714a7a62 100644 --- a/tests/io/components/test_structure_with_non_ccd_ligand.py +++ b/tests/io/components/test_structure_with_non_ccd_ligand.py @@ -1,20 +1,34 @@ -from atomworks.io import parse -from tests.io.conftest import TEST_DATA_IO - +import numpy as np +import pytest +from biotite.structure import BondType -def test_structure_with_non_ccd_ligand(): - """Test parsing a structure containing a non-CCD ligand.""" - # Fetch the test structure - cif_path = TEST_DATA_IO / "9cox_with_unknown_ccd.cif" +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from atomworks.io.utils.io_utils import read_any +from tests.conftest import TEST_DATA_DIR - # Parse the structure without CCD mirror path - structure = parse(cif_path, ccd_mirror_path=None) - # Basic validation that we got a structure - assert structure is not None - assert "asym_unit" in structure - assert len(structure["asym_unit"]) > 0 - assert "UNKNOWN_CCD" in structure["asym_unit"][0].res_name +def test_structure_with_non_ccd_ligand(tmp_path): + """Authored ligand bonds survive absent altlocs; incomplete bond tables raise instead of falling back.""" + source = TEST_DATA_DIR / "io" / "9cox_with_unknown_ccd.cif" + cif = read_any(source) + del cif.block["atom_site"]["label_alt_id"] + path = tmp_path / "ligand.cif" + cif.write(path) + config = ParseConfig.from_preset("minimal", altloc="random_clash_aware") + atoms = parse(path, config=config)["asym_unit"][0] + ligand = atoms[atoms.res_name == "UNKNOWN_CCD"] + assert len(ligand) == 43 + i, j = (np.flatnonzero(ligand.atom_name == name).item() for name in ("C26", "C25")) + neighbors, orders = ligand.bonds.get_bonds(i) + assert orders[neighbors == j].item() == BondType.AROMATIC_DOUBLE - # Optional: Uncomment for visual inspection during development - # view(structure["asym_unit"][0]) + for column in ("value_order", "pdbx_aromatic_flag"): + malformed = read_any(source) + del malformed.block["chem_comp_bond"][column] + for category in (None, "chem_comp_atom"): + if category is not None: + del malformed.block[category] + malformed.write(path) + with pytest.raises(KeyError, match=column): + parse(path, config=config) diff --git a/tests/io/conftest.py b/tests/io/conftest.py index 1022e69f..8f29c4be 100644 --- a/tests/io/conftest.py +++ b/tests/io/conftest.py @@ -1,11 +1,35 @@ """IO-specific test fixtures and utilities for atomworks.io tests.""" +import gzip + +import pytest +import zstandard as zstd + from atomworks.enums import ChainType +from atomworks.io.utils.ccd import custom_ccd_residues from atomworks.io.utils.testing import get_pdb_path # noqa: F401 from tests.conftest import TEST_DATA_DIR TEST_DATA_IO = TEST_DATA_DIR / "io" + +@pytest.fixture(params=["", ".gz", ".gzip", ".zst"]) +def compressed_example(request, tmp_path): + """Make independently compressed copies of existing structure fixtures.""" + + def compress(path): + if not request.param: + return path + destination = tmp_path / (path.name + request.param) + data = path.read_bytes() + destination.write_bytes( + zstd.ZstdCompressor(level=3).compress(data) if request.param == ".zst" else gzip.compress(data) + ) + return destination + + return compress + + CHAIN_TYPE_TEST_CASES = [ { # Simple polymer & non-polymers @@ -76,3 +100,14 @@ }, }, ] + + +@pytest.fixture +def cleanup_registry(): + """Clean up CCD registry after test, restoring original state. + + Uses the custom_ccd_residues context manager with an empty dict + to save/restore registry state without registering new entries. + """ + with custom_ccd_residues({}): + yield diff --git a/tests/io/test_altloc.py b/tests/io/test_altloc.py new file mode 100644 index 00000000..4f8ef04b --- /dev/null +++ b/tests/io/test_altloc.py @@ -0,0 +1,228 @@ +"""Tests for altloc edge cases on difficult PDB structures.""" + +import dataclasses +import io + +import biotite.structure.io.pdbx as pdbx +import numpy as np +import pytest +from biotite.structure import AtomArray, AtomArrayStack +from scipy.spatial import cKDTree + +from atomworks.constants import ALTLOC_DEFAULT_IDS +from atomworks.io.parser import ParseConfig, parse +from atomworks.io.utils.altloc import ( + _build_constraints, + _identify_altloc_groups, + select_altlocs_clash_aware, +) +from atomworks.io.utils.io_utils import read_any +from atomworks.io.utils.selection import get_annotation +from atomworks.io.utils.testing import get_pdb_path_or_buffer + +_ALTLOC_EDGE_CASE_IDS = [ + "1cbn", # 14 altloc residues with intra-chain clashes between combinations + "1xvk", # struct_conn altloc-dependent disulfide bond (NYC-N2C); cyclic peptide with many close contacts + "1rcq", # PLP in two partially occupied active-site states; struct_conn in one conformation only + "145d", # DNA structure with chain-level altlocs + "1aym", # 11 altloc residues, 29 incompatibilities — heavy constraint stress test +] + +_CLASH_AWARE_CONFIG = ParseConfig( + altloc="random_clash_aware", + build_assembly=None, + add_missing_atoms=False, +) + + +def assert_no_inter_residue_altloc_clashes( + arr: AtomArray | AtomArrayStack, + clash_distance: float = 1.5, + label: str = "", +) -> None: + """Assert no unbonded inter-residue steric clashes among altloc atoms.""" + if isinstance(arr, AtomArrayStack): + arr = arr[0] + + if not hasattr(arr, "altloc_id"): + return + + # Only check atoms with non-default altloc IDs + altloc_mask = ~np.isin(arr.altloc_id, ALTLOC_DEFAULT_IDS) + altloc_idx = np.where(altloc_mask & ~np.any(np.isnan(arr.coord), axis=1))[0] + if len(altloc_idx) < 2: + return + + tree = cKDTree(arr.coord[altloc_idx]) + close_pairs = tree.query_pairs(r=clash_distance, output_type="ndarray") + if len(close_pairs) == 0: + return + + # Map back to original indices + pairs_orig = altloc_idx[close_pairs] + + # Remove intra-residue pairs + same_res = (arr.chain_id[pairs_orig[:, 0]] == arr.chain_id[pairs_orig[:, 1]]) & ( + arr.res_id[pairs_orig[:, 0]] == arr.res_id[pairs_orig[:, 1]] + ) + pairs_orig = pairs_orig[~same_res] + if len(pairs_orig) == 0: + return + + # Remove bonded pairs + if arr.bonds is not None: + bond_arr = arr.bonds.as_array()[:, :2] + bonded = set(map(tuple, bond_arr)) | set(map(tuple, bond_arr[:, ::-1])) + not_bonded = np.array([tuple(p) not in bonded for p in pairs_orig]) + pairs_orig = pairs_orig[not_bonded] + + assert len(pairs_orig) == 0, ( + f"Found {len(pairs_orig)} inter-residue altloc clashes{f' ({label})' if label else ''}:\n" + + "\n".join( + f" {arr.chain_id[i]}:{arr.res_name[i]}{arr.res_id[i]}:{arr.atom_name[i]} <-> " + f"{arr.chain_id[j]}:{arr.res_name[j]}{arr.res_id[j]}:{arr.atom_name[j]} " + f"dist={np.linalg.norm(arr.coord[i] - arr.coord[j]):.2f}A" + for i, j in pairs_orig[:5] + ) + ) + + +@pytest.mark.parametrize("pdb_id", _ALTLOC_EDGE_CASE_IDS) +@pytest.mark.parametrize("add_missing_atoms", [True, False]) +def test_no_clashes_and_coordinate_diversity(pdb_id: str, add_missing_atoms: bool): + """Clash-aware altloc selection produces no clashes and diverse coordinates across seeds.""" + path = get_pdb_path_or_buffer(pdb_id) + coord_hashes = set() + for seed in range(3): + config = dataclasses.replace( + _CLASH_AWARE_CONFIG, + altloc_seed=seed, + add_missing_atoms=add_missing_atoms, + ) + asym = parse(path, config=config)["asym_unit"] + assert_no_inter_residue_altloc_clashes( + asym, + label=f"{pdb_id} seed={seed} add_missing={add_missing_atoms}", + ) + coord_hashes.add(asym.coord.tobytes()) + + assert ( + len(coord_hashes) > 1 + ), f"{pdb_id} add_missing={add_missing_atoms}: all 3 seeds produced identical coordinates" + + +def test_random_per_chain(): + """random_per_chain: one altloc letter per chain, different seeds produce diversity.""" + path = get_pdb_path_or_buffer("1cbn") + coord_hashes = set() + for seed in range(5): + config = ParseConfig(altloc="random_per_chain", altloc_seed=seed, build_assembly=None) + asym = parse(path, config=config)["asym_unit"] + + # Only one altloc letter per chain (plus defaults) + altloc_ids = get_annotation(asym, "label_alt_id") + if altloc_ids is not None: + for chain_id in np.unique(asym.chain_id): + chain_alts = altloc_ids[asym.chain_id == chain_id] + letters = {a for a in chain_alts if a not in ALTLOC_DEFAULT_IDS} + assert len(letters) <= 1, f"seed={seed} chain={chain_id}: multiple altlocs {letters}" + + coord_hashes.add(asym.coord.tobytes()) + + assert len(coord_hashes) > 1, "All 5 seeds produced identical coordinates" + + +def test_6lzb_cross_chain_altlocs_linked(): + """6LZB: cross-chain proximal residues with shared altloc letters are consistently linked.""" + path = get_pdb_path_or_buffer("6lzb") + cif = read_any(path) + raw = pdbx.get_structure(cif, extra_fields=["label_alt_id"], altloc="all", use_author_fields=False) + if hasattr(raw, "stack_depth"): + raw = raw[0] + + altloc_ids = get_annotation(raw, "altloc_id") + defaults = np.isin(altloc_ids, ALTLOC_DEFAULT_IDS) + groups = _identify_altloc_groups(raw, altloc_ids, defaults) + _, preferences = _build_constraints(raw, groups, clash_distance=1.5, proximity_distance=5.0) + assert len(preferences) > 0, "6LZB: expected cross-chain proximity preferences" + + for seed in range(5): + result = select_altlocs_clash_aware(raw, seed=seed) + result_altlocs = get_annotation(result, "altloc_id") + assert_no_inter_residue_altloc_clashes(result, label=f"6lzb seed={seed}") + + for res_i, res_j in preferences: + mask_i = (result.chain_id == res_i[0]) & (result.res_id == res_i[1]) + mask_j = (result.chain_id == res_j[0]) & (result.res_id == res_j[1]) + letters_i = set(result_altlocs[mask_i]) - set(ALTLOC_DEFAULT_IDS) + letters_j = set(result_altlocs[mask_j]) - set(ALTLOC_DEFAULT_IDS) + if letters_i and letters_j: + assert letters_i == letters_j, f"seed={seed}: {res_i}<->{res_j} got {letters_i} vs {letters_j}" + + +def test_6qhp_mutually_exclusive_catalytic_states(): + """6QHP: ASB/FAH are mutually exclusive catalytic states; ASB falls back to parent ASP.""" + path = get_pdb_path_or_buffer("6qhp") + asp_template_atoms = {"C", "CA", "CB", "CG", "N", "O", "OD1", "OD2"} + + states_seen: set[tuple[bool, bool]] = set() + fallback_verified = False + for seed in range(3): + config = dataclasses.replace(_CLASH_AWARE_CONFIG, altloc_seed=seed) + asym = parse(path, config=config)["asym_unit"] + assert_no_inter_residue_altloc_clashes(asym, label=f"6qhp seed={seed}") + + has_asb = bool(np.any(asym.res_name == "ASB")) + has_fah_c = bool(np.any((asym.res_name == "FAH") & (asym.chain_id == "C"))) + assert not (has_asb and has_fah_c), f"seed={seed}: ASB and FAH(C) both present" + + res112_mask = (asym.chain_id == "A") & (asym.res_id == 112) + if has_fah_c: + assert np.any(res112_mask), f"seed={seed}: res 112 absent — parent fallback failed" + assert ( + asym.res_name[res112_mask][0] == "ASP" + ), f"seed={seed}: expected ASP, got {asym.res_name[res112_mask][0]}" + actual_atoms = set(asym.atom_name[res112_mask]) + assert ( + actual_atoms <= asp_template_atoms + ), f"seed={seed}: unexpected atoms: {actual_atoms - asp_template_atoms}" + assert len(actual_atoms) >= 7, f"seed={seed}: too few atoms: {actual_atoms}" + arr = asym[0] if hasattr(asym, "stack_depth") else asym + assert not np.any(np.isnan(arr.coord[res112_mask])), f"seed={seed}: NaN in fallback coords" + fallback_verified = True + + states_seen.add((has_asb, has_fah_c)) + + assert (True, False) in states_seen, "Never sampled post-reaction state (ASB present)" + assert (False, True) in states_seen, "Never sampled pre-reaction state (FAH present)" + assert fallback_verified, "No seed triggered parent fallback" + + +def test_4hbt_independent_altloc_sampling(): + """4HBT: 9 unconstrained altloc residues — 4 target residues sampled independently.""" + path = get_pdb_path_or_buffer("4hbt") + target_res_ids = [14, 68, 73, 246] + combos_seen: set[tuple[str, ...]] = set() + + for seed in range(3): + if isinstance(path, io.StringIO): + path.seek(0) + config = dataclasses.replace(_CLASH_AWARE_CONFIG, altloc_seed=seed) + asym = parse(path, config=config)["asym_unit"] + assert_no_inter_residue_altloc_clashes(asym, label=f"4hbt seed={seed}") + + altloc_ids = get_annotation(asym, "altloc_id") + chain_mask = asym.chain_id == "A" + + letters = [] + for rid in target_res_ids: + res_mask = chain_mask & (asym.res_id == rid) + non_default = ~np.isin(altloc_ids[res_mask], ALTLOC_DEFAULT_IDS) + res_letters = set(altloc_ids[res_mask][non_default]) + assert len(res_letters) == 1, f"seed={seed}, res_id={rid}: expected 1 altloc, got {res_letters}" + letters.append(res_letters.pop()) + + combos_seen.add(tuple(letters)) + + # With 5 seeds and 2^4=16 combos, we won't see all 16 — just verify diversity + assert len(combos_seen) > 1, f"All 5 seeds produced identical altloc combinations: {combos_seen}" diff --git a/tests/io/test_hydrogen_policy.py b/tests/io/test_hydrogen_policy.py new file mode 100644 index 00000000..4e476625 --- /dev/null +++ b/tests/io/test_hydrogen_policy.py @@ -0,0 +1,196 @@ +"""Checks for hydrogen accounting consistency between "keep" and "remove" paths.""" + +import biotite.structure as struc +import numpy as np +import pytest +from biotite.structure.io import pdbx + +from atomworks.constants import STANDARD_AA +from atomworks.experimental.protonation import assign_hydrogens, place_hydrogens +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from atomworks.io.utils.atom_array import ( + annotate_hydrogens, + count_bonded_hydrogens, + get_bond_degree_per_atom, + remove_hydrogens, +) +from atomworks.io.utils.io_utils import to_cif_file +from atomworks.io.utils.testing import assert_same_atom_array_or_stack, get_pdb_path + + +@pytest.mark.parametrize("pdb_id", ["1a8o", "6lyz", "1a1e"]) +def test_nhyd_invariant(pdb_id): + """nhyd absent ↔ explicit H mode; nhyd present ↔ implicit H (no explicit H atoms).""" + path = get_pdb_path(pdb_id) + + # hydrogen_policy="keep": nhyd must NOT be present + aa_keep = parse(path, config=ParseConfig(hydrogen_policy="keep", add_missing_atoms=True))["asym_unit"][0] + assert ( + "nhyd" not in aa_keep.get_annotation_categories() + ), f"{pdb_id} keep: 'nhyd' should not be set when explicit H atoms are present" + + # hydrogen_policy="remove": nhyd must BE present and no explicit H atoms + aa_remove = parse(path, config=ParseConfig(hydrogen_policy="remove", add_missing_atoms=True))["asym_unit"][0] + assert ( + "nhyd" in aa_remove.get_annotation_categories() + ), f"{pdb_id} remove: 'nhyd' should be set when H atoms are implicit" + has_explicit_h = np.isin(aa_remove.element, ["H", "D"]).any() + assert not has_explicit_h, f"{pdb_id} remove: explicit H atoms found despite nhyd being set" + + +def test_nhyd_explicit_h_double_count_guard(): + """nhyd consumers must raise if an atom carries both nhyd>0 and an explicit bonded H.""" + # CH4: carbon with four explicit bonded H atoms. + ch4 = struc.array( + [ + struc.Atom([0.0, 0.0, 0.0], element="C", atom_name="C"), + *[struc.Atom([1.0, 0.0, 0.0], element="H", atom_name=f"H{i}") for i in range(4)], + ] + ) + ch4.bonds = struc.BondList(5, np.array([[0, i, 1] for i in range(1, 5)])) + + # annotate_hydrogens sets nhyd=4 on C *without* removing the explicit H -> illegal both-present state. + both = annotate_hydrogens(ch4) + assert both.nhyd[0] == 4 and np.isin(both.element, ["H", "D"]).any() + + with pytest.raises(AssertionError): + get_bond_degree_per_atom(both) + with pytest.raises(AssertionError): + count_bonded_hydrogens(both, 0, include_implicit=True) + + # An all-zero nhyd annotation alongside explicit H is legal (adding zero is a no-op). + ch4.set_annotation("nhyd", np.zeros(ch4.array_length(), dtype=np.int8)) + np.testing.assert_array_equal(get_bond_degree_per_atom(ch4), [4, 1, 1, 1, 1]) + + +@pytest.mark.parametrize("pdb_id", ["1a8o", "6lyz", "1a1e"]) +def test_atom_array_equivalence_keep_vs_remove(pdb_id): + """Heavy-atom array from 'keep'→annotate→strip should equal 'remove' path.""" + path = get_pdb_path(pdb_id) + + # Path 1: keep H atoms, annotate nhyd from bond graph, then strip H + aa_keep = parse(path, config=ParseConfig(hydrogen_policy="keep", add_missing_atoms=True))["asym_unit"][0] + aa_keep = annotate_hydrogens(aa_keep) + aa_keep_heavy = remove_hydrogens(aa_keep) + + # Path 2: H atoms never added; nhyd sourced from CCD template + aa_remove = parse(path, config=ParseConfig(hydrogen_policy="remove", add_missing_atoms=True))["asym_unit"][0] + + annotations_to_compare = sorted( + set(aa_keep_heavy.get_annotation_categories()) & set(aa_remove.get_annotation_categories()) + ) + assert_same_atom_array_or_stack( + aa_keep_heavy, + aa_remove, + compare_coords=False, + compare_bonds=True, + annotations_to_compare=annotations_to_compare, + ) + + +@pytest.mark.parametrize("pdb_id", ["1a8o", "6lyz", "1a1e"]) +@pytest.mark.parametrize("missing_value", [None, "?", "."]) +@pytest.mark.parametrize("extra_fields", [None, "all"]) +def test_nhyd_roundtrip_via_cif(pdb_id, missing_value, extra_fields, tmp_path): + """Ensure nhyd survives a save→reload cycle.""" + path = get_pdb_path(pdb_id) + + aa = parse(path, config=ParseConfig(hydrogen_policy="remove", add_missing_atoms=True))["asym_unit"][0] + + cif_path = tmp_path / f"{pdb_id}_nhyd.cif" + to_cif_file(aa, cif_path, extra_fields=["nhyd"]) + expected = aa.nhyd.copy() + if missing_value is not None: + cif = pdbx.CIFFile.read(cif_path) + counts = cif.block["atom_site"]["nhyd"].as_array(str) + index = np.flatnonzero(aa.atom_name == "CB")[0] + counts[index] = missing_value + cif.block["atom_site"]["nhyd"] = pdbx.CIFColumn(counts) + cif.write(cif_path) + expected[index] = 0 + + aa_reloaded = parse( + cif_path, + config=ParseConfig(hydrogen_policy="remove", add_missing_atoms=False, extra_fields=extra_fields), + )["asym_unit"][0] + + assert "nhyd" in aa_reloaded.get_annotation_categories() + assert not np.isin(aa_reloaded.element, ["H", "D"]).any() + np.testing.assert_array_equal(expected, aa_reloaded.nhyd) + + +def _bonded_h_count(atom_array): + """Number of *explicit* bonded H per atom.""" + return annotate_hydrogens(atom_array.copy()).nhyd + + +def _is_non_terminal_backbone_amide_n(atom_array): + """Mask of backbone N that should carry exactly one amide H (standard AA, not Pro, resolved).""" + bonds = atom_array.bonds.as_array() + i, j = bonds[:, 0], bonds[:, 1] + is_n, is_c = atom_array.atom_name == "N", atom_array.atom_name == "C" + # A preceding residue's C is bonded to this N, i.e. the N is not a chain start + inter_res = atom_array.res_id[i] != atom_array.res_id[j] + has_prev_c = np.zeros(len(atom_array), dtype=bool) + has_prev_c[i[is_n[i] & is_c[j] & inter_res]] = True + has_prev_c[j[is_n[j] & is_c[i] & inter_res]] = True + + return ( + has_prev_c + & np.isin(atom_array.res_name, STANDARD_AA) + & (atom_array.res_name != "PRO") # tertiary amide, no N-H + & ~np.isnan(atom_array.coord).any(axis=-1) # unresolved atoms are never protonated + ) + + +@pytest.mark.parametrize("pdb_id", ["1a8o", "6lyz", "1a1e"]) +def test_protonation_gives_backbone_amide_nitrogens_their_hydrogen(pdb_id): + """Backbone amide N must get its H from implicit-H input, identically to explicit-H input.""" + path = get_pdb_path(pdb_id) + config = {"add_missing_atoms": True} + + # The production path: H are implicit (carried in nhyd), no explicit H atoms + aa_implicit = parse(path, config=ParseConfig(hydrogen_policy="remove", **config))["asym_unit"][0] + from_implicit = place_hydrogens(assign_hydrogens(aa_implicit)) + + backbone_n = _is_non_terminal_backbone_amide_n(from_implicit) + n_h = _bonded_h_count(from_implicit)[backbone_n] + assert (n_h == 1).all(), f"{pdb_id}: {(n_h == 0).sum()} of {backbone_n.sum()} backbone amide N have no H" + + # Explicit-H input gives the same protonation, heavy atom for heavy atom, except where it states + # a tautomer the implicit input leaves free (1A1E HIS62 is drawn as a cation) + aa_explicit = parse(path, config=ParseConfig(hydrogen_policy="keep", **config))["asym_unit"][0] + from_explicit = place_hydrogens(assign_hydrogens(aa_explicit)) + free = from_implicit.tautomer_free[~np.isin(from_implicit.element, ["H", "D"])] + per_heavy = [] + for protonated in (from_implicit, from_explicit): + heavy = ~np.isin(protonated.element, ["H", "D"]) + per_heavy.append(np.where(free, -1, _bonded_h_count(protonated)[heavy])) + np.testing.assert_array_equal(*per_heavy) + + +@pytest.mark.parametrize("pdb_id", ["1a8o", "6lyz", "1a1e"]) +def test_added_hydrogens_survive_cif_roundtrip(pdb_id, tmp_path): + """Added H come back unchanged from a save->reload cycle.""" + aa = parse(get_pdb_path(pdb_id), config=ParseConfig(hydrogen_policy="remove", add_missing_atoms=True))["asym_unit"][ + 0 + ] + protonated = place_hydrogens(assign_hydrogens(aa)) + assert np.isin(protonated.element, ["H", "D"]).any(), f"{pdb_id}: nothing was protonated" + + cif_path = tmp_path / f"{pdb_id}_protonated.cif" + to_cif_file(protonated, cif_path) + reloaded = parse(cif_path, config=ParseConfig(hydrogen_policy="keep", add_missing_atoms=False))["asym_unit"][0] + + # Bonds are excluded: H carry generated names that no CCD template knows, so the reader + # cannot rebuild their bonds from the file. + assert_same_atom_array_or_stack( + protonated, + reloaded, + compare_bonds=False, + cast_to_common_dtype=True, + annotations_to_compare=sorted( + set(protonated.get_annotation_categories()) & set(reloaded.get_annotation_categories()) + ), + ) diff --git a/tests/io/test_insertion_codes.py b/tests/io/test_insertion_codes.py new file mode 100644 index 00000000..e564314f --- /dev/null +++ b/tests/io/test_insertion_codes.py @@ -0,0 +1,35 @@ +import biotite.structure as struc +import biotite.structure.io.pdb as pdb +import numpy as np + +from atomworks.io.parser import parse + + +def test_add_missing_atoms_keeps_residues_sharing_a_number(tmp_path): + """PDB insertion codes repeat a number (1PPB chain L runs 1H, 1G, ..., 1) and must not collide.""" + residues = [(1, "H", "THR"), (1, "G", "PHE"), (1, "F", "GLY"), (1, "", "CYS"), (2, "", "GLY")] + atoms = struc.array( + [ + struc.Atom( + [3.8 * i + x, 0.0, 0.0], + chain_id="L", + res_id=res_id, + ins_code=ins_code, + res_name=res_name, + atom_name=atom_name, + element=atom_name[0], + ) + for i, (res_id, ins_code, res_name) in enumerate(residues) + for atom_name, x in (("N", 0.0), ("CA", 1.46)) + ] + ) + path = tmp_path / "insertion_codes.pdb" + file = pdb.PDBFile() + file.set_structure(atoms) + file.write(path) + + parsed = parse(str(path))["asym_unit"][0] + + starts = struc.get_residue_starts(parsed) + assert list(zip(parsed.res_id[starts], parsed.ins_code[starts], strict=True)) == [(r, i) for r, i, _ in residues] + assert (~np.isnan(parsed.coord).any(axis=1)).sum() == 2 * len(residues) diff --git a/tests/io/test_resid_ordering.py b/tests/io/test_resid_ordering.py new file mode 100644 index 00000000..80438bf0 --- /dev/null +++ b/tests/io/test_resid_ordering.py @@ -0,0 +1,34 @@ +import pytest +from biotite.structure import AtomArray +from biotite.structure.io import pdbx + +from atomworks.io._pipeline import _check_resid_ordering +from atomworks.io.utils.io_utils import _validate_label_seq_ids + + +def test_check_resid_ordering_raises_on_decreasing(): + atoms = AtomArray(3) + atoms.chain_id[:] = "A" + atoms.res_id[:] = [1, 3, 2] + + with pytest.raises(ValueError, match="chain 'A'"): + _check_resid_ordering(atoms) + + +def test_cif_identity_invariants(): + block = pdbx.CIFBlock() + block["atom_site"] = pdbx.CIFCategory( + {"label_asym_id": ["A", "A"], "label_seq_id": ["1", "."], "label_entity_id": ["1", "1"]} + ) + with pytest.raises(ValueError, match="mixes defined and missing label_seq_id"): + _validate_label_seq_ids(block) + + block["atom_site"]["label_seq_id"] = [".", "."] + block["entity"] = pdbx.CIFCategory({"id": ["1"], "type": ["polymer"]}) + with pytest.raises(ValueError, match="including noncanonical monomers"): + _validate_label_seq_ids(block) + + block["atom_site"]["label_seq_id"] = ["1", "2"] + block["atom_site"]["label_entity_id"] = ["1", "2"] + with pytest.raises(ValueError, match="maps to multiple label_entity_id"): + _validate_label_seq_ids(block) diff --git a/tests/io/tools/test_ase_ccd_pipeline.py b/tests/io/tools/test_ase_ccd_pipeline.py new file mode 100644 index 00000000..93359a41 --- /dev/null +++ b/tests/io/tools/test_ase_ccd_pipeline.py @@ -0,0 +1,32 @@ +"""Tests for the ASE -> RDKit -> AtomArray pipeline against CCD reference.""" + +import pytest + +pytest.importorskip("ase") + +from atomworks.io.tools.rdkit import atom_array_from_rdkit, atom_array_to_rdkit +from atomworks.io.utils.ase_conversions import ase_to_atom_array, atom_array_to_ase +from atomworks.io.utils.ccd import atom_array_from_ccd_code +from atomworks.io.utils.testing import assert_same_atom_array_or_stack + + +@pytest.mark.parametrize("ccd_code", ["H5C", "ALA", "GLY", "NAG", "ATP"]) +def test_ase_pipeline_matches_ccd(ccd_code): + """ASE -> RDKit -> AtomArray pipeline recovers the CCD reference structure.""" + ccd_arr = atom_array_from_ccd_code(ccd_code) + + # ASE -> RDKit -> AtomArray pipeline (simulates what happens with OMOL/ASE data) + ase_atoms = atom_array_to_ase(ccd_arr) + bare = ase_to_atom_array(ase_atoms) + mol = atom_array_to_rdkit(bare, infer_bonds=True, hydrogen_policy="keep", system_charge=0) + result = atom_array_from_rdkit(mol, remove_hydrogens=False) + + assert_same_atom_array_or_stack( + ccd_arr, + result, + compare_bonds=True, + compare_bond_order=False, + compare_coords=True, + enforce_order=False, + annotations_to_compare=["element", "charge"], + ) diff --git a/tests/io/tools/test_fasta.py b/tests/io/tools/test_fasta.py index e69de29b..7e715807 100644 --- a/tests/io/tools/test_fasta.py +++ b/tests/io/tools/test_fasta.py @@ -0,0 +1,48 @@ +"""Tests for FASTA utilities.""" + +import pytest + +from atomworks.enums import ChainType +from atomworks.io.tools.fasta import infer_chain_type_from_one_letter + + +@pytest.mark.parametrize( + "seq,expected", + [ + # Simple one-letter protein + ("ACDEFG", ChainType.POLYPEPTIDE_L), + ( + "MAEGEITTFTALTEKFNLPPGNYKKPKLLYCSNGGHFLRILPDGTVDGTRDRSDQHIQLQLSAESVGEVYIKSTET", + ChainType.POLYPEPTIDE_L, + ), + # Simple one-letter DNA + ("ATGC", ChainType.DNA), + ("CGCGAATTCGCG", ChainType.DNA), + # Simple one-letter RNA + ("ACGU", ChainType.RNA), + ("CGCGAAUUCGCG", ChainType.RNA), + # Parenthesized notation - DNA + ("(DA)(DT)(DG)(DC)", ChainType.DNA), + (["(DA)", "(DT)", "(DG)", "(DC)"], ChainType.DNA), + # Parenthesized notation - RNA + ("(A)(U)(G)(C)", ChainType.RNA), + # Mixed protein with non-standard - unambiguous protein codes dominate + ("ACDE(SEP)FG", ChainType.POLYPEPTIDE_L), + # List format + (["A", "C", "D", "E", "F"], ChainType.POLYPEPTIDE_L), + (["A", "T", "G", "C"], ChainType.DNA), + # Contains valid amino acid Y (tyrosine), so infers as protein + ("XYZ", ChainType.POLYPEPTIDE_L), + ], +) +def test_infer_chain_type_from_one_letter(seq, expected): + """Test chain type inference from sequence notation.""" + result = infer_chain_type_from_one_letter(seq) + assert result == expected + + +def test_infer_chain_type_from_one_letter_raises_on_ambiguous(): + """Test that inference raises ValueError for truly ambiguous sequences.""" + # All letters A, C, G are ambiguous (could be protein or nucleotide) + with pytest.raises(ValueError, match="Could not infer chain type"): + infer_chain_type_from_one_letter(["A", "C", "(SEP)", "G"]) diff --git a/tests/io/tools/test_inchi.py b/tests/io/tools/test_inchi.py new file mode 100644 index 00000000..3b0eafd2 --- /dev/null +++ b/tests/io/tools/test_inchi.py @@ -0,0 +1,117 @@ +"""Tests for InChI support.""" + +import os +import random + +import pytest +from biotite.structure import AtomArray +from rdkit.Chem.inchi import MolToInchi + +from atomworks.constants import CCD_MIRROR_PATH +from atomworks.io.tools.rdkit import atom_array_to_rdkit, inchi_to_rdkit +from atomworks.io.utils.ccd import ( + atom_array_from_ccd_code, + get_available_ccd_codes_in_mirror, + get_ccd_descriptors, +) +from atomworks.ml.utils.rng import create_rng_state_from_seeds, rng_state +from tests.conftest import skip_if_no_ccd_mirror + + +def _inchi_from_ccd_array(arr: AtomArray) -> str | None: + """Convert a CCD AtomArray (with 3D coords) to a canonical InChI string.""" + heavy = arr[arr.element != "H"] + mol = atom_array_to_rdkit(heavy, hydrogen_policy="remove", sanitize=True) + return MolToInchi(mol) + + +def _inchi_from_inchi_string(inchi: str) -> str | None: + """Round-trip an InChI string through RDKit to get a canonical form.""" + mol = inchi_to_rdkit(inchi, generate_conformers=False) + return MolToInchi(mol) + + +@skip_if_no_ccd_mirror +class TestMolecularEquivalence: + """Tests verifying molecular equivalence between InChI and CCD atom array paths.""" + + @pytest.mark.parametrize( + "ccd_code", + [ + "ATP", # Adenosine triphosphate + "NAG", # N-acetylglucosamine + "ALA", # Alanine + "GLY", # Glycine + "GDP", # Guanosine diphosphate + ], + ) + def test_inchi_vs_ccd_atom_array_equivalence(self, ccd_code: str): + """Test that InChI -> AtomArray produces same heavy atoms as CCD -> AtomArray.""" + descriptors = get_ccd_descriptors(ccd_code) + inchi = descriptors.get("InChI") + assert inchi is not None, f"No InChI descriptor for {ccd_code}" + + ccd_arr = atom_array_from_ccd_code(ccd_code) + inchi_from_ccd = _inchi_from_ccd_array(ccd_arr) + inchi_canonical = _inchi_from_inchi_string(inchi) + + assert inchi_from_ccd == inchi_canonical, ( + f"Canonical InChI mismatch for {ccd_code}:\n" + f" from CCD: {inchi_from_ccd}\n" + f" from InChI: {inchi_canonical}" + ) + + @pytest.fixture(scope="class") + def random_ccd_codes_with_inchi(self): + """Get random CCD codes that have InChI descriptors.""" + if not CCD_MIRROR_PATH or not os.path.isdir(CCD_MIRROR_PATH): + pytest.skip("CCD_MIRROR_PATH not set or not a valid directory") + + available_codes = list(get_available_ccd_codes_in_mirror(CCD_MIRROR_PATH)) + if len(available_codes) == 0: + pytest.skip("No CCD codes available in mirror") + + with rng_state(create_rng_state_from_seeds(py_seed=42)): + sample_size = min(500, len(available_codes)) + sampled_codes = random.sample(available_codes, sample_size) + + valid_codes = [] + for code in sampled_codes: + if len(valid_codes) >= 100: + break + descriptors = get_ccd_descriptors(code) + if descriptors.get("InChI"): + valid_codes.append((code, descriptors["InChI"])) + + if len(valid_codes) < 10: + pytest.skip("Not enough CCD codes with InChI descriptors") + + return valid_codes + + def test_random_ccd_inchi_equivalence(self, random_ccd_codes_with_inchi): + """Test molecular equivalence for random CCD codes. + + Allows up to 10% failure rate for edge cases (metal complexes, exotic coordination, etc.). + """ + codes = random_ccd_codes_with_inchi + failures = [] + + for ccd_code, inchi in codes: + try: + ccd_arr = atom_array_from_ccd_code(ccd_code) + inchi_from_ccd = _inchi_from_ccd_array(ccd_arr) + inchi_canonical = _inchi_from_inchi_string(inchi) + assert inchi_from_ccd == inchi_canonical, ( + f"Canonical InChI mismatch:\n" + f" from CCD: {inchi_from_ccd}\n" + f" from InChI: {inchi_canonical}" + ) + except Exception as e: + failures.append(f"{ccd_code}: {e}") + + max_failures = max(1, int(len(codes) * 0.1)) # Allow up to 10% failures + assert len(failures) <= max_failures, f"Too many failures ({len(failures)}/{len(codes)}): {failures[:10]}..." + + +if __name__ == "__main__": + pytest.main(["-v", "-x", __file__]) diff --git a/tests/io/tools/test_inference_processing.py b/tests/io/tools/test_inference_processing.py index f80de814..8675f9e6 100644 --- a/tests/io/tools/test_inference_processing.py +++ b/tests/io/tools/test_inference_processing.py @@ -10,9 +10,11 @@ from atomworks.enums import ChainType from atomworks.io import parse +from atomworks.io.config import ParseConfig from atomworks.io.tools.fasta import split_generalized_fasta_sequence from atomworks.io.tools.inference import ( ChemicalComponent, + CIFOrPDBFileComponent, Protein, SequenceComponent, SmilesComponent, @@ -21,7 +23,8 @@ one_letter_to_ccd_code, read_chai_fasta, ) -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.ccd import register_custom_ccd_entry +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from tests.io.conftest import TEST_DATA_IO, get_pdb_path @@ -129,7 +132,7 @@ def bonds_glycan_glycan(): @pytest.fixture -def custom_residues(): +def custom_ncaa(): return { "C:0": { "path": f"{TEST_DATA_IO}/example_ncaa.cif", @@ -269,6 +272,16 @@ def test_components_to_atom_array_glycan(dict_inputs, bonds_glycan_glycan): assert len(chain_ids) == 3 +def test_generated_glycan_link_removes_leaving_oxygen_in_mixed_input(dict_inputs): + """Finite CCD ideal coordinates must not protect generated leaving atoms.""" + components = dict_inputs["cif_path"] + dict_inputs["glycan_1"] + dict_inputs["glycan_2"] + atom_array = components_to_atom_array(components, bonds=[("G/NAG/1/C1", "H/NAG/1/O4")]) + glycans = np.isin(atom_array.chain_id, ("G", "H")) + + assert glycans.sum() == 29 + assert not np.any((atom_array.chain_id == "G") & (atom_array.atom_name == "O1")) + + def test_components_to_atom_array_cif(dict_inputs): """Test modification of CIF file inputs and conversion of the modified CIF to AtomArray.""" component_dicts = dict_inputs["cif_path"] + dict_inputs["dimer"] @@ -341,10 +354,10 @@ def test_sequence_component_validation(): SmilesComponent(smiles="CCCC", chain_type=ChainType.POLYPEPTIDE_L) with pytest.raises(ValueError): - SequenceComponent(seq="(MET)ACGT", chain_type=ChainType.RNA, is_polymer=True) + SequenceComponent(seq="(MET)ACGT", chain_type=ChainType.RNA) with pytest.raises(ValueError): - SequenceComponent(seq="(DA)MKL", chain_type=ChainType.POLYPEPTIDE_L, is_polymer=True) + SequenceComponent(seq="(DA)MKL", chain_type=ChainType.POLYPEPTIDE_L) def test_full_chai_input(chai_fasta_input): @@ -355,11 +368,13 @@ def test_full_chai_input(chai_fasta_input): assert np.unique(atom_array.chain_id).shape[0] == 4 -def test_full_components_input(dict_inputs, custom_residues): +def test_full_components_input(dict_inputs, custom_ncaa, cleanup_registry): + custom_res_dict = custom_ncaa["C:0"] + custom_component = ChemicalComponent.from_dict(custom_res_dict) + register_custom_ccd_entry("C:0", custom_component.atom_array) + components = sum(dict_inputs.values(), start=[]) - atom_array, components = components_to_atom_array( - components, return_components=True, custom_residues=custom_residues - ) + atom_array, components = components_to_atom_array(components, return_components=True) # Assert that the extracted chain IDs match the values recovered from the components extracted_chain_ids = [entry.get("chain_id", "") for entries in dict_inputs.values() for entry in entries] @@ -394,11 +409,17 @@ def test_sdf_input(dict_inputs): assert not np.any(np.isnan(non_polymer_atom_array.coord)) -def test_custom_residues(dict_inputs, custom_residues): +def test_custom_residues(dict_inputs, custom_ncaa, cleanup_registry): + # Register custom residue using registry + + custom_res_dict = custom_ncaa["C:0"] + custom_component = ChemicalComponent.from_dict(custom_res_dict) + register_custom_ccd_entry("C:0", custom_component.atom_array) + # (Name of the custom residue within the CIF file) custom_residue_name = "C:0" - atom_array = components_to_atom_array(dict_inputs["custom_residues"], custom_residues=custom_residues) + atom_array = components_to_atom_array(dict_inputs["custom_residues"]) # ... all atoms should be part of the same chain (only one chain in the example) assert len(np.unique(atom_array.chain_id)) == 1 @@ -434,7 +455,7 @@ def test_recover_bonds_from_cif(dict_inputs): def test_same_atom_array_from_cif_and_inference(): """Tests if the bonds inferred from the components are the same as the bonds in the CIF file.""" transformation_id = "1" - data = parse(get_pdb_path("7rxs"), hydrogen_policy="remove") + data = parse(get_pdb_path("7rxs"), config=ParseConfig(hydrogen_policy="remove")) atom_array_from_cif = data["assemblies"][transformation_id][0] # ... extract the sequence and build inference input @@ -473,10 +494,12 @@ def test_same_atom_array_from_cif_and_inference(): "b_factor", "is_aromatic", "alt_atom_id", + "label_alt_id", # Sequence/SMILES inputs have no experimental alternate-conformer labels. "molecule_id", # The molecule_id and all entity annotations may differ between the two "molecule_iid", "molecule_entity", "pn_unit_entity", + "nhyd", # nhyd is set from CCD template in CIF path but not in inference path } ) is_ligand = not chain_atom_array_from_cif.is_polymer[0] @@ -494,16 +517,78 @@ def test_same_atom_array_from_cif_and_inference(): chain_atom_array_from_cif, np.arange(1, struc.get_residue_count(chain_atom_array_from_cif) + 1) ) - assert_same_atom_array( + assert_same_atom_array_or_stack( chain_atom_array_from_inference, chain_atom_array_from_cif, compare_coords=False, compare_bonds=True, annotations_to_compare=annotations_to_compare, enforce_order=False, - compare_bond_order=True, + compare_bond_order=not is_ligand, # Can't compare bond order for ligands without atom_name + ) + + +def test_cif_component_with_chain_id_single_molecule(): + """Test chain_id assignment for single-molecule CIF file.""" + # test_unl_ligand_with_bonds.cif is a single ligand + component = CIFOrPDBFileComponent( + path=f"{TEST_DATA_IO}/test_unl_ligand_with_bonds.cif", + chain_id="X", + ) + assert np.all(component.atom_array.chain_id == "X") + assert np.array_equal(component.chain_ids, np.array(["X"])) + + +def test_cif_component_with_chain_id_multi_molecule_raises(): + """Test chain_id assignment fails for multi-molecule CIF file.""" + # test_cif_loading_4q8n.cif.gz has multiple chains that are not covalently bonded + with pytest.raises(ValueError, match="disconnected molecules"): + CIFOrPDBFileComponent( + path=f"{TEST_DATA_IO}/test_cif_loading_4q8n.cif.gz", + chain_id="X", ) +def test_cif_component_without_chain_id_preserves_original(): + """Test that not providing chain_id preserves original chain IDs.""" + component = CIFOrPDBFileComponent( + path=f"{TEST_DATA_IO}/test_cif_loading_4q8n.cif.gz", + ) + # Original chain IDs should be preserved (multiple chains) + assert len(np.unique(component.atom_array.chain_id)) > 1 + + +def test_cif_component_with_chain_id_in_components_to_atom_array(): + """Test that CIF components with chain_id work in components_to_atom_array.""" + components = [ + {"path": f"{TEST_DATA_IO}/test_unl_ligand_with_bonds.cif", "chain_id": "Z"}, + {"seq": "ACDE", "chain_type": "polypeptide(l)", "chain_id": "A"}, + ] + atom_array = components_to_atom_array(components) + + # Check that both chain IDs are present + chain_ids = np.unique(atom_array.chain_id) + assert "Z" in chain_ids + assert "A" in chain_ids + + +@pytest.mark.parametrize( + "seq,expected_chain_type", + [ + # Canonical single-letter DNA format + ("CGCGAATTCGCG", ChainType.DNA), + # Parenthesized CCD format for DNA + ("(DC)(DG)(DC)(DG)(DA)(DA)(DT)(DT)(DC)(DG)(DC)(DG)", ChainType.DNA), + # Single-letter RNA format + ("ACGU", ChainType.RNA), + # Parenthesized CCD format for RNA + ("(A)(C)(G)(U)", ChainType.RNA), + ], +) +def test_dna_rna_chain_type_inference(seq, expected_chain_type): + """Test chain type inference for DNA/RNA sequences in both formats.""" + assert SequenceComponent.infer_chain_type(seq) == expected_chain_type + + if __name__ == "__main__": pytest.main([__file__ + "::test_full_components_input"]) diff --git a/tests/io/tools/test_mmjson.py b/tests/io/tools/test_mmjson.py deleted file mode 100644 index 25e80ff7..00000000 --- a/tests/io/tools/test_mmjson.py +++ /dev/null @@ -1,28 +0,0 @@ -from atomworks.io.parser import parse -from atomworks.io.utils.io_utils import infer_pdb_file_type -from atomworks.io.utils.testing import assert_same_atom_array -from tests.conftest import TEST_DATA_DIR - - -def test_mmjson_inference_and_parsing(): - json_path = TEST_DATA_DIR / "io" / "2hhb.json.gz" - cif_path = TEST_DATA_DIR / "io" / "2hhb.cif.gz" - - assert json_path.exists(), f"mmJSON file not found at {json_path}" - assert cif_path.exists(), f"CIF file not found at {cif_path}" - - # 1. Test File Type Inference - inferred_type = infer_pdb_file_type(json_path) - assert inferred_type == "mmjson", f"Failed to infer 'mmjson'. Got: {inferred_type}" - - # 2. Parse mmJSON - result_json = parse(json_path, file_type="mmjson") - atoms_json = result_json["asym_unit"] - - # 3. Parse CIF for Comparison - result_cif = parse(cif_path, file_type="cif") - atoms_cif = result_cif["asym_unit"] - - # 4. Compare Results - # This utility checks atom count, coordinates, and other annotations - assert_same_atom_array(atoms_json, atoms_cif) diff --git a/tests/io/tools/test_posebusters.py b/tests/io/tools/test_posebusters.py new file mode 100644 index 00000000..bfec842b --- /dev/null +++ b/tests/io/tools/test_posebusters.py @@ -0,0 +1,48 @@ +"""Tests for PoseBusters validation using CCD components with ideal coordinates.""" + +import numpy as np +import pytest + +posebusters = pytest.importorskip("posebusters") + +from atomworks.io.tools.posebusters import validate_molecule +from atomworks.io.utils.ccd import atom_array_from_ccd_code + + +@pytest.fixture(scope="module") +def pb(): + """Shared PoseBusters instance for the test module.""" + from posebusters import PoseBusters + + return PoseBusters(config="mol") + + +@pytest.mark.parametrize( + "ccd_code", + [ + "ALA", # amino acid + "ATP", # nucleotide + "NAG", # sugar (N-acetylglucosamine) + "BEN", # benzene + "HEM", # iron porphyrin (Fe disconnects, organic porphyrin validated) + "BCL", # bacteriochlorophyll (Mg disconnects, organic fragment validated) + "CLA", # chlorophyll a (Mg disconnects, organic fragment validated) + ], +) +def test_validate_molecule_from_ccd(pb, ccd_code): + """CCD components with ideal coordinates should pass, perturbed should fail.""" + # ... first, check that vanilla CCD molecule passes + atom_array = atom_array_from_ccd_code(ccd_code, coords="ideal_pdbx") + failed, checks = validate_molecule(atom_array, pb=pb) + assert not failed, f"{ccd_code} failed PB checks: {[k for k, v in checks.items() if not v]}" + + # ... next, check that a 2A perturbation of a carbon atom causes failure + + # Perturb a carbon atom by 2A — should now fail + perturbed = atom_array.copy() + carbon_indices = np.where(perturbed.element == "C")[0] + assert len(carbon_indices) > 0, f"No carbon atoms in {ccd_code}" + perturbed.coord[carbon_indices[0], 2] += 2.0 + + failed, checks = validate_molecule(perturbed, pb=pb) + assert failed, f"{ccd_code} should fail PB checks after 2A perturbation of carbon atom {carbon_indices[0]}" diff --git a/tests/io/tools/test_rdkit.py b/tests/io/tools/test_rdkit.py index e33736ff..f69e432e 100644 --- a/tests/io/tools/test_rdkit.py +++ b/tests/io/tools/test_rdkit.py @@ -10,9 +10,12 @@ atom_array_from_rdkit, atom_array_to_rdkit, ccd_code_to_rdkit, + fix_charge_based_on_valence, get_morgan_fingerprint_from_rdkit_mol, + sdf_to_rdkit, smiles_to_rdkit, ) +from atomworks.io.utils.ccd import atom_array_from_ccd_code from atomworks.io.utils.io_utils import load_any from tests.io.conftest import TEST_DATA_IO @@ -37,6 +40,116 @@ TEST_ATOM_ARRAYS = [struc.info.residue("ALA"), struc.info.residue("NAD")] +@pytest.mark.parametrize("conformer_id", [None, 17, 41]) +def test_rdkit_conversion_selects_conformer_ids_without_mutating_source(conformer_id): + """Select native conformer IDs, or the first conformer, without sharing coordinates.""" + mol = Chem.MolFromSmiles("CCO") + positions = np.arange(9, dtype=float).reshape(3, 3) + for native_id in (17, 41): + conformer = Chem.Conformer(3) + conformer.SetId(native_id) + conformer.SetPositions(positions + native_id) + mol.AddConformer(conformer, assignId=False) + source_smiles = Chem.MolToSmiles(mol) + + result = atom_array_from_rdkit(mol, conformer_id=conformer_id) + + expected_id = 17 if conformer_id is None else conformer_id + np.testing.assert_array_equal(result.coord, positions + expected_id) + assert result.element.tolist() == ["C", "C", "O"] + np.testing.assert_array_equal(result.bonds.as_array(), [[0, 1, 1], [1, 2, 1]]) + result.coord[0] = 99 + assert Chem.MolToSmiles(mol) == source_smiles + assert [conformer.GetId() for conformer in mol.GetConformers()] == [17, 41] + for conformer in mol.GetConformers(): + np.testing.assert_array_equal(conformer.GetPositions(), positions + conformer.GetId()) + + +def test_rdkit_conversion_looks_up_ids_only_when_coordinates_are_requested(): + """Missing IDs fail native lookup, but disabled or unavailable coordinates remain NaN.""" + mol = Chem.MolFromSmiles("CCO") + conformer = Chem.Conformer(3) + conformer.SetId(17) + mol.AddConformer(conformer, assignId=False) + + with pytest.raises(ValueError, match="Bad Conformer Id"): + atom_array_from_rdkit(mol, conformer_id=0) + without_coords = atom_array_from_rdkit(mol, conformer_id=99, set_coord_if_available=False) + assert np.isnan(without_coords.coord).all() + assert mol.GetConformer(17).GetId() == 17 + no_conformers = atom_array_from_rdkit(Chem.MolFromSmiles("CCO"), conformer_id=99) + assert np.isnan(no_conformers.coord).all() + + +@pytest.mark.parametrize("metadata", ["absent", "unmatched", "partial", "matched"]) +@pytest.mark.parametrize("remove_hydrogens", [False, True]) +@pytest.mark.parametrize("remove_inferred_atoms", [False, True]) +def test_rdkit_output_filters_are_independent_of_annotation_matches(metadata, remove_hydrogens, remove_inferred_atoms): + """Filter authored and inferred atoms independently of carried annotation matches.""" + mol = Chem.AddHs(Chem.MolFromSmiles("CO")) + coordinates = np.arange(18, dtype=float).reshape(6, 3) + conformer = Chem.Conformer(6) + conformer.SetPositions(coordinates) + mol.AddConformer(conformer) + authored_ids = np.array([-1, 20, 30, -1, -1, -1]) + for index in (1, 2): + mol.GetAtomWithIdx(index).SetIntProp("rdkit_atom_id", int(authored_ids[index])) + annotations = {} + if metadata != "absent": + annotations = { + "rdkit_atom_id": np.array({"unmatched": [98, 99], "partial": [20, 99], "matched": [20, 30]}[metadata]), + "custom_tag": np.array([7, 8], dtype=np.uint8), + } + mol._annotations = {name: values.copy() for name, values in annotations.items()} + original = mol.ToBinary(Chem.PropertyPickleOptions.AllProps) + + result = atom_array_from_rdkit(mol, remove_hydrogens=remove_hydrogens, remove_inferred_atoms=remove_inferred_atoms) + + keep = np.ones(6, dtype=bool) + if remove_hydrogens: + keep &= np.array([6, 8, 1, 1, 1, 1]) != 1 + if remove_inferred_atoms: + keep &= authored_ids != -1 + np.testing.assert_array_equal(result.coord, coordinates[keep]) + np.testing.assert_array_equal(result.element, np.array(["C", "O", "H", "H", "H", "H"])[keep]) + np.testing.assert_array_equal(result.rdkit_atom_id, authored_ids[keep]) + bonds = np.array([[0, 1, 1], [0, 2, 1], [0, 3, 1], [0, 4, 1], [1, 5, 1]]) + bonds = bonds[keep[bonds[:, :2]].all(axis=1)] + new_indices = np.cumsum(keep) - 1 + bonds[:, :2] = new_indices[bonds[:, :2]] + np.testing.assert_array_equal(result.bonds.as_array(), bonds) + if remove_hydrogens: + np.testing.assert_array_equal(result.nhyd, np.array([3, 1, 0, 0, 0, 0])[keep]) + else: + assert "nhyd" not in result.get_annotation_categories() + if metadata in ("absent", "unmatched"): + assert "custom_tag" not in result.get_annotation_categories() + else: + tags = np.array([255, 7, 8 if metadata == "matched" else 255, 255, 255, 255], dtype=np.uint8) + np.testing.assert_array_equal(result.custom_tag, tags[keep]) + assert result.custom_tag.dtype == np.uint8 + result.custom_tag[:] = 0 + result.coord[:] = 99 + assert mol.ToBinary(Chem.PropertyPickleOptions.AllProps) == original + assert hasattr(mol, "_annotations") == (metadata != "absent") + if annotations: + assert set(mol._annotations) == set(annotations) + for name, values in annotations.items(): + np.testing.assert_array_equal(mol._annotations[name], values) + + +def test_rdkit_inferred_atom_filter_can_remove_all_unmatched_atoms(): + """Unmatched carried metadata does not retain atoms lacking authored identities.""" + mol = Chem.AddHs(Chem.MolFromSmiles("CO")) + mol._annotations = {"rdkit_atom_id": np.array([99]), "custom_tag": np.array([7])} + + result = atom_array_from_rdkit(mol, remove_inferred_atoms=True) + + assert len(result) == 0 + assert result.bonds.get_bond_count() == 0 + assert "custom_tag" not in result.get_annotation_categories() + + @pytest.mark.parametrize("smiles", TEST_SMILES) def test_smiles_to_rdkit_to_atom_array(smiles): mol = smiles_to_rdkit(smiles) @@ -93,9 +206,40 @@ def test_atom_array_rdkit_interconversion(test_atom_array): for annotation in ["chain_id", "res_id", "res_name", "atom_name"]: assert np.array_equal(new_atom_array.get_annotation(annotation), test_atom_array.get_annotation(annotation)) assert np.allclose(new_atom_array.coord, test_atom_array.coord) + mol._annotations["atom_name"][0] = "" + assert test_atom_array.atom_name[0] != "" + + +@pytest.mark.parametrize("set_coord", [None, False, True]) +@pytest.mark.parametrize("missing", [False, True]) +def test_rdkit_coordinates_respect_explicit_choice(set_coord, missing): + """Infer coordinate inclusion only when the caller leaves it unspecified.""" + atoms = struc.info.residue("ALA") + if missing: + atoms.coord[0] = np.nan + + mol = atom_array_to_rdkit(atoms, set_coord=set_coord) + + expected = not missing if set_coord is None else set_coord + assert mol.GetNumConformers() == int(expected) + if expected: + np.testing.assert_allclose(mol.GetConformer().GetPositions(), atoms.coord, equal_nan=True) def test_fixing_molecules(): + bad = Chem.MolFromSmiles("[O-]=P([O-])([O-])[O-].C[N+](C)(C)C", sanitize=False) + bad.UpdatePropertyCache(strict=False) + expected = Chem.MolFromSmiles("O=P([O-])([O-])[O-].C[N+](C)(C)C") + repaired = fix_charge_based_on_valence(bad) + Chem.SanitizeMol(repaired) + assert Chem.MolToSmiles(repaired) == Chem.MolToSmiles(expected) + assert fix_charge_based_on_valence(repaired) is repaired + + unrepairable = Chem.MolFromSmiles("C(C)(C)(C)(C)C.O=P([O-])([O-])[O-]", sanitize=False) + charges = [atom.GetFormalCharge() for atom in unrepairable.GetAtoms()] + assert fix_charge_based_on_valence(unrepairable) is unrepairable + assert [atom.GetFormalCharge() for atom in unrepairable.GetAtoms()] == charges + smi = "c1cc(c[n](c1)[C@H]2[C@@H]([C@@H]([C@H](O2)CO[P@@](=O)([O-])O[P@](=O)(O)OC[C@@H]3[C@H]([C@H]([C@H](O3)n4cnc5c4ncnc5N)OP(=O)(O)O)O)O)O)C(=O)N" smi_correct = "c1cc(c[n+](c1)[C@H]2[C@@H]([C@@H]([C@H](O2)CO[P@@](=O)([O-])O[P@](=O)(O)OC[C@@H]3[C@H]([C@H]([C@H](O3)n4cnc5c4ncnc5N)OP(=O)(O)O)O)O)O)C(=O)N" @@ -229,5 +373,112 @@ def test_chriality_in_spoofed_rdkit_molecules(): assert Chem.FindMolChiralCenters(mol_ala_inverted) == [(1, "R")] +@pytest.mark.parametrize( + "ccd_code", + [ + "35N", # Simple TMC with copper + "GDP", # Guanosine diphosphate (simpler than ATP) + "ATP", # Adenosine triphosphate (several phosphate groups) + "SAM", # S-adenosyl methionine (sulfur chemistry) + "FMN", # Flavin mononucleotide + ], +) +def test_atom_array_to_rdkit_bond_inference(ccd_code: str): + """Test that inferring bonds from coordinates produces same results as using original bonds.""" + + original = atom_array_from_ccd_code(ccd_code) + + # Path 1: Normal conversion using original CCD bonds + mol_with_bonds = atom_array_to_rdkit( + original, + infer_bonds=False, + hydrogen_policy="keep", # Keep all hydrogens + sanitize=True, + ) + result_with_bonds = atom_array_from_rdkit( + mol_with_bonds, + remove_hydrogens=False, # Keep hydrogens for comparison + ) + + # Path 2: Conversion with bond inference from 3D coordinates + original_no_bonds = original.copy() + original_no_bonds.bonds = None # Remove bonds to force inference + + mol_inferred = atom_array_to_rdkit( + original_no_bonds, + infer_bonds=True, # Infer bonds from coordinates + hydrogen_policy="keep", + sanitize=True, + ) + result_inferred = atom_array_from_rdkit( + mol_inferred, + remove_hydrogens=False, + ) + + # (a) Total charge is the same + charge_with_bonds = int(result_with_bonds.charge.sum()) + charge_inferred = int(result_inferred.charge.sum()) + assert charge_with_bonds == charge_inferred, ( + f"{ccd_code}: Total charge differs - " f"with bonds: {charge_with_bonds}, inferred: {charge_inferred}" + ) + + # (b) Bond connectivity is the same + connectivity_with_bonds = _get_bond_connectivity(result_with_bonds.bonds) + connectivity_inferred = _get_bond_connectivity(result_inferred.bonds) + assert connectivity_with_bonds == connectivity_inferred, ( + f"{ccd_code}: Bond connectivity differs\n" + f" Bonds only in original: {connectivity_with_bonds - connectivity_inferred}\n" + f" Bonds only in inferred: {connectivity_inferred - connectivity_with_bonds}" + ) + + # (c) Hybridization is the same + specified_mask = result_with_bonds.hyb != -1 # Only compare atoms with specified hybridization + assert np.array_equal( + result_with_bonds.hyb[specified_mask], result_inferred.hyb[specified_mask] + ), f"{ccd_code}: Hybridization differs" + + # (d) Aromaticity is the same + assert np.array_equal( + result_with_bonds.is_aromatic, result_inferred.is_aromatic + ), f"{ccd_code}: Aromaticity differs" + + +def _get_bond_connectivity(bonds: struc.BondList | None) -> set[tuple[int, int]]: + """Extract bond connectivity (which atoms are bonded) ignoring bond types.""" + if bonds is None: + return set() + + bond_array = bonds.as_array() # Shape: (n_bonds, 3) - [atom1, atom2, type] + return {(min(int(i), int(j)), max(int(i), int(j))) for i, j, _ in bond_array} + + +def test_nhyd_with_explicit_hydrogens(): + """nhyd must agree whether Hs are implicit or explicit graph neighbors. + + ATP (adenosine triphosphate) has a purine aromatic ring, sp3 sugar carbons, and + phosphate oxygens — a good spread of heavy atoms with differing H counts. + """ + + def nhyd_by_name(hydrogen_policy: str) -> dict[str, int]: + mol = ccd_code_to_rdkit("ATP", hydrogen_policy=hydrogen_policy) + aa = atom_array_from_rdkit(mol, set_coord_if_available=False, remove_hydrogens=True) + return dict(zip(aa.atom_name, aa.nhyd, strict=True)) + + nhyd_implicit = nhyd_by_name("remove") + nhyd_explicit = nhyd_by_name("keep") + + assert nhyd_implicit == nhyd_explicit + + +def test_an_sdf_can_keep_the_hydrogens_it_states(): + """RDKit drops the 32 hydrogens the CCD's HEM ideal SDF writes out, unless asked not to.""" + dropped = sdf_to_rdkit(TEST_DATA_IO / "HEM_ideal.sdf") + kept = sdf_to_rdkit(TEST_DATA_IO / "HEM_ideal.sdf", remove_hydrogens=False) + + assert dropped.GetNumAtoms() == 43 + assert kept.GetNumAtoms() == 75 + assert sum(atom.GetAtomicNum() == 1 for atom in kept.GetAtoms()) == 32 + + if __name__ == "__main__": pytest.main(["-v", "-x", __file__]) diff --git a/tests/io/utils/charge_invariants.py b/tests/io/utils/charge_invariants.py new file mode 100644 index 00000000..dc960bb1 --- /dev/null +++ b/tests/io/utils/charge_invariants.py @@ -0,0 +1,146 @@ +"""Shared charge-validation helpers for IO tests.""" + +import functools + +import biotite.structure as struc +import numpy as np +from biotite.structure import AtomArray + +from atomworks.constants import BIOTITE_BOND_TYPE_TO_BOND_ORDER +from atomworks.io.utils.atom_array import count_bonded_hydrogens +from atomworks.io.utils.ccd import atom_array_from_ccd_code + +_BOND_ORDER_SYMBOL = {1: "-", 2: "=", 3: "≡"} + + +def _format_bad_atoms(atom_array: AtomArray, mask: np.ndarray) -> str: + """Format chain/res_id/atom_name for atoms where mask is True, including bond context.""" + idxs = np.where(mask)[0] + bonds_array = atom_array.bonds.as_array() if atom_array.bonds is not None else None + lines = [] + for i in idxs: + bond_info = "" + if bonds_array is not None: + m0 = bonds_array[:, 0] == i + m1 = bonds_array[:, 1] == i + nb_indices = np.concatenate([bonds_array[m0, 1], bonds_array[m1, 0]]) + bt_values = np.concatenate([bonds_array[m0, 2], bonds_array[m1, 2]]) + bond_strs = [] + for nb_idx, bt in zip(nb_indices, bt_values, strict=False): + order = BIOTITE_BOND_TYPE_TO_BOND_ORDER.get(struc.BondType(bt), 1) + sym = _BOND_ORDER_SYMBOL.get(order, f"~{order}") + same_res = ( + atom_array.chain_id[nb_idx] == atom_array.chain_id[i] + and atom_array.res_id[nb_idx] == atom_array.res_id[i] + and atom_array.res_name[nb_idx] == atom_array.res_name[i] + ) + label = atom_array.atom_name[nb_idx] + if not same_res: + label = ( + f"{atom_array.res_name[nb_idx]}/{label}" + f"@{atom_array.chain_id[nb_idx]}{atom_array.res_id[nb_idx]}" + ) + bond_strs.append(f"{sym}{label}") + if bond_strs: + bond_info = f" bonds=[{', '.join(bond_strs)}]" + lines.append( + f" chain={atom_array.chain_id[i]} res_id={atom_array.res_id[i]} " + f"res_name={atom_array.res_name[i]} atom_name={atom_array.atom_name[i]} " + f"element={atom_array.element[i]} charge={atom_array.charge[i]}{bond_info}" + ) + return "\n".join(lines) + + +@functools.lru_cache(maxsize=512) +def _get_ccd_charge(res_name: str, atom_name: str) -> int | None: + """Look up the charge for an atom in the CCD template. Returns None if not found.""" + try: + ccd = atom_array_from_ccd_code(res_name) + except (ValueError, KeyError): + return None + match = ccd.atom_name == atom_name + if not np.any(match): + return None + return int(ccd.charge[match][0]) + + +def _filter_ccd_justified(atom_array: AtomArray, bad_mask: np.ndarray) -> np.ndarray: + """Remove atoms from bad_mask whose charge matches the CCD template.""" + if not np.any(bad_mask): + return bad_mask + still_bad = bad_mask.copy() + for idx in np.where(bad_mask)[0]: + ccd_charge = _get_ccd_charge(atom_array.res_name[idx], atom_array.atom_name[idx]) + if ccd_charge is not None and atom_array.charge[idx] == ccd_charge: + still_bad[idx] = False + return still_bad + + +def check_charge_invariants(pdb_id: str, atom_array: AtomArray) -> None: + """Run all charge invariant checks on a parsed atom array. + + Collects all failures and reports them in a single assertion at the end. + """ + failures: list[str] = [] + # (a) No extreme organic charges: |charge| < 2 + organic_elements = {"C", "H", "N", "O", "S", "P", "Se"} + organic_mask = np.isin(atom_array.element, list(organic_elements)) + bad = organic_mask & (np.abs(atom_array.charge) >= 2) + if np.any(bad): + failures.append(f"organic element has extreme charge (|charge| >= 2):\n{_format_bad_atoms(atom_array, bad)}") + + # (b) Backbone neutrality (exempting CCD-justified charges) + backbone_mask = atom_array.is_backbone_atom + bad = backbone_mask & (atom_array.charge != 0) + bad = _filter_ccd_justified(atom_array, bad) + if np.any(bad): + failures.append(f"backbone atoms have non-zero charge:\n{_format_bad_atoms(atom_array, bad)}") + + # (c) N+ validation: N with charge +1 must have >= 1 bonded hydrogen + n_plus_mask = (atom_array.element == "N") & (atom_array.charge == 1) + for idx in np.where(n_plus_mask)[0]: + if not count_bonded_hydrogens(atom_array, idx, include_implicit=True) > 0: + failures.append( + f"N+ without bonded hydrogen:\n{_format_bad_atoms(atom_array, n_plus_mask & (np.arange(len(atom_array)) == idx))}" + ) + break # one example is enough + + # (d) Carbon neutrality + bad = (atom_array.element == "C") & (atom_array.charge != 0) + if np.any(bad): + failures.append(f"carbon atoms have non-zero charge:\n{_format_bad_atoms(atom_array, bad)}") + + # (e) Hydrogen neutrality + bad = (atom_array.element == "H") & (atom_array.charge != 0) + if np.any(bad): + failures.append(f"hydrogen atoms have non-zero charge:\n{_format_bad_atoms(atom_array, bad)}") + + # (f) Oxygen charge range: 0 or -1 (exempting CCD-justified charges) + o_mask = atom_array.element == "O" + bad = o_mask & ~np.isin(atom_array.charge, [0, -1]) + bad = _filter_ccd_justified(atom_array, bad) + if np.any(bad): + failures.append( + f"oxygen atoms have unexpected charge (not 0/-1 and differs from CCD):\n{_format_bad_atoms(atom_array, bad)}" + ) + + # (g) Nitrogen charge range: 0 or +1 (exempting CCD-justified charges) + n_mask = atom_array.element == "N" + bad = n_mask & ~np.isin(atom_array.charge, [0, 1]) + bad = _filter_ccd_justified(atom_array, bad) + if np.any(bad): + failures.append( + f"nitrogen atoms have unexpected charge (not 0/+1 and differs from CCD):\n{_format_bad_atoms(atom_array, bad)}" + ) + + # (h) Sulfur charge range: 0 or -1 (exempting CCD-justified charges) + s_mask = atom_array.element == "S" + bad = s_mask & ~np.isin(atom_array.charge, [0, -1]) + bad = _filter_ccd_justified(atom_array, bad) + if np.any(bad): + failures.append( + f"sulfur atoms have unexpected charge (not 0/-1 and differs from CCD):\n{_format_bad_atoms(atom_array, bad)}" + ) + + short = "; ".join(f.split("\n")[0] for f in failures) + assert not failures, f"{pdb_id}: charge invariant violations: {short}\n" + "\n".join(failures) diff --git a/tests/io/utils/schiff_base_double_bond.cif b/tests/io/utils/schiff_base_double_bond.cif new file mode 100644 index 00000000..92a6ee66 --- /dev/null +++ b/tests/io/utils/schiff_base_double_bond.cif @@ -0,0 +1,66 @@ +data_structure +# +_struct_conn.id 1 +_struct_conn.conn_type_id covale +_struct_conn.pdbx_value_order doub +_struct_conn.ptnr1_label_asym_id A +_struct_conn.ptnr2_label_asym_id B +_struct_conn.ptnr1_label_comp_id LYS +_struct_conn.ptnr2_label_comp_id K:X +_struct_conn.ptnr1_label_seq_id 201 +_struct_conn.ptnr2_label_seq_id 4 +_struct_conn.ptnr1_label_atom_id NZ +_struct_conn.ptnr2_label_atom_id CX +_struct_conn.pdbx_ptnr1_PDB_ins_code . +_struct_conn.pdbx_ptnr2_PDB_ins_code . +# +loop_ +_chem_comp_bond.pdbx_ordinal +_chem_comp_bond.comp_id +_chem_comp_bond.atom_id_1 +_chem_comp_bond.atom_id_2 +_chem_comp_bond.value_order +_chem_comp_bond.pdbx_aromatic_flag +_chem_comp_bond.pdbx_stereo_config +1 LYS NZ CE SING N ? +2 LYS NZ HZ1 SING N ? +3 LYS NZ HZ2 SING N ? +4 LYS NZ HZ3 SING N ? +5 LYS CE HE1 SING N ? +6 LYS CE HE2 SING N ? +7 LYS CE HE3 SING N ? +8 K:X CX OQ1 SING N ? +9 K:X CX OQ2 SING N ? +# +loop_ +_atom_site.group_PDB +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_alt_id +_atom_site.label_comp_id +_atom_site.label_asym_id +_atom_site.label_entity_id +_atom_site.label_seq_id +_atom_site.pdbx_PDB_ins_code +_atom_site.auth_seq_id +_atom_site.auth_comp_id +_atom_site.auth_asym_id +_atom_site.auth_atom_id +_atom_site.B_iso_or_equiv +_atom_site.occupancy +_atom_site.Cartn_x +_atom_site.Cartn_y +_atom_site.Cartn_z +_atom_site.pdbx_PDB_model_num +_atom_site.id +_atom_site.pdbx_formal_charge +ATOM N NZ . LYS A 1 201 . 201 LYS A NZ 0.0 1.0 35.916 32.694 -18.274 1 1 1 +ATOM C CE . LYS A 1 201 . 201 LYS A CE 0.0 1.0 36.66 33.189 -19.425 1 2 ? +ATOM H HE1 . LYS A 1 201 . 201 LYS A HE1 0.0 1.0 36.645 32.511 -20.302 1 3 ? +ATOM H HE2 . LYS A 1 201 . 201 LYS A HE2 0.0 1.0 37.723 33.335 -19.138 1 4 ? +ATOM H HE3 . LYS A 1 201 . 201 LYS A HE3 0.0 1.0 36.241 34.168 -19.744 1 5 ? +ATOM H HZ1 . LYS A 1 201 . 201 LYS A HZ1 0.0 1.0 35.955 33.368 -17.558 1 6 ? +ATOM C CX . K:X B 2 4 . 4 K:X B CX 0.0 1.0 35.203 31.555 -18.302 1 7 ? +ATOM O OQ1 . K:X B 2 4 . 4 K:X B OQ1 0.0 1.0 35.168 30.951 -19.48 1 8 -1 +ATOM O OQ2 . K:X B 2 4 . 4 K:X B OQ2 0.0 1.0 34.626 31.103 -17.283 1 9 -1 +# diff --git a/tests/io/utils/test_ase_conversions.py b/tests/io/utils/test_ase_conversions.py new file mode 100644 index 00000000..1a0abb51 --- /dev/null +++ b/tests/io/utils/test_ase_conversions.py @@ -0,0 +1,149 @@ +"""Tests for ASE-Biotite conversion utilities.""" + +from pathlib import Path + +import numpy as np +import pytest + +ase = pytest.importorskip("ase") +from ase import Atoms # noqa: E402 +from biotite.structure import AtomArray, BondList, BondType # noqa: E402 + +from atomworks.io.parser import parse # noqa: E402 +from atomworks.io.utils.ase_conversions import ase_to_atom_array, atom_array_to_ase # noqa: E402 +from atomworks.io.utils.testing import get_pdb_path # noqa: E402 + +DATA = Path(__file__).parents[2] / "data" / "io" + +# structures chosen for what they carry: a ligand whose bonds the file states, a non-canonical +# residue with charged atoms, 1QFE's LYS170 linked to its ligand, and 101M's haem protein +WITH_BONDS = [ + "test_unl_ligand_with_bonds.cif", + "example_ncaa.cif", + "1qfe", + "101m_arginine_nh1nh2_swapped.cif", +] + + +def _structure(name): + """The asymmetric unit of a test file, or of a PDB ID's mmCIF, as a single model.""" + path = DATA / name if name.endswith(".cif") else get_pdb_path(name) + return parse(path, config="minimal")["asym_unit"][0] + + +def _bond_names(array): + """Bonds as the atoms they join, so a reordering is visible as a change.""" + if array.bonds is None: + return set() + return { + ( + *sorted((str(array.atom_name[i]) + str(array.res_id[i]), str(array.atom_name[j]) + str(array.res_id[j]))), + int(order), + ) + for i, j, order in array.bonds.as_array() + } + + +@pytest.mark.parametrize("filename", WITH_BONDS) +def test_a_structure_survives_repeated_round_trips(filename): + """Out to ASE and back, twice, changes nothing about the structure (101M lost its 1307 bonds).""" + original = _structure(filename) + assert original.bonds is not None and original.bonds.get_bond_count() > 0 + + current = original + for _ in range(2): + current = ase_to_atom_array(atom_array_to_ase(current), formal_charges=True) + + np.testing.assert_array_equal(current.element, original.element) + np.testing.assert_array_equal(current.atom_name, original.atom_name) + np.testing.assert_array_equal(current.charge, original.charge) + np.testing.assert_allclose(current.coord, original.coord, atol=1e-5) + assert _bond_names(current) == _bond_names(original) + + +def test_roundtrip_conversion_with_metadata(): + """ASE -> Biotite -> ASE keeps symbols, positions, ``info`` and extra arrays.""" + original_atoms = Atoms("H2O", positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]]) + original_atoms.info["energy"] = -10.5 + original_atoms.info["method"] = "DFT" + original_atoms.arrays["forces"] = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]) + + final_atoms = atom_array_to_ase(ase_to_atom_array(original_atoms)) + + assert original_atoms.get_chemical_symbols() == final_atoms.get_chemical_symbols() + np.testing.assert_array_almost_equal(original_atoms.get_positions(), final_atoms.get_positions(), decimal=5) + assert final_atoms.info["energy"] == -10.5 + assert final_atoms.info["method"] == "DFT" + np.testing.assert_array_almost_equal(original_atoms.arrays["forces"], final_atoms.arrays["forces"]) + + +@pytest.mark.parametrize("filename", WITH_BONDS) +def test_bonds_follow_their_atoms_when_ase_reorders_them(filename): + """ASE keeps a per-atom array in step with the atoms, and the bonds ride on it.""" + original = _structure(filename) + + atoms = atom_array_to_ase(original) + order = np.arange(len(atoms))[::-1] + reversed_back = ase_to_atom_array(atoms[order]) + + assert _bond_names(reversed_back) == _bond_names(original) + + +@pytest.mark.parametrize("bond_type", BondType) +def test_bond_types_survive_ase_reordering_and_deletion(bond_type): + original = AtomArray(3) + original.element[:] = "C" + original.set_annotation("atom_id", np.array([30, 10, 20])) + original.bonds = BondList(3, np.array([[0, 1, bond_type], [1, 2, bond_type]])) + + reordered = atom_array_to_ase(original)[[1, 0]] + restored = ase_to_atom_array(reordered) + + np.testing.assert_array_equal(restored.atom_id, [10, 30]) + np.testing.assert_array_equal(restored.bonds.as_array(), [[0, 1, bond_type]]) + + +@pytest.mark.parametrize("filename", WITH_BONDS) +def test_a_repeated_atom_id_moves_no_bond(filename, caplog): + """ASE repeats ids when structures are joined; no bond may land on the wrong copy.""" + atoms = atom_array_to_ase(_structure(filename)) + joined = ase_to_atom_array(atoms + atoms.copy()) + n = len(atoms) + + bonds = joined.bonds.as_array() + assert not ((bonds[:, 0] < n) != (bonds[:, 1] < n)).any() + assert len(bonds[bonds[:, 0] < n]) == len(bonds[bonds[:, 0] >= n]) + assert f"Dropped {len(atoms.info['atomworks_bonds'])} bonds whose atom_ids are duplicated" in caplog.text + + +def test_a_bond_whose_atom_is_gone_is_dropped_and_the_rest_are_kept(): + """Half the ligand deleted in ASE leaves the bonds among what is left.""" + original = _structure("test_unl_ligand_with_bonds.cif") + + keep = np.arange(0, original.array_length(), 2) + kept = ase_to_atom_array(atom_array_to_ase(original)[keep]) + + assert _bond_names(kept) == _bond_names(original[keep]) + + +def test_a_calculators_partial_charges_are_not_taken_for_formal_charges(): + """``initial_charges`` holds a formal charge or a partial one; both survive.""" + original = _structure("test_unl_ligand_with_bonds.cif") + + atoms = atom_array_to_ase(original) + atoms.set_initial_charges(np.linspace(-0.4, 0.4, len(atoms))) + back = ase_to_atom_array(atoms) + + np.testing.assert_allclose(back.initial_charges, atoms.get_initial_charges()) + assert "charge" not in back.get_annotation_categories() + np.testing.assert_allclose(atom_array_to_ase(back).get_initial_charges(), atoms.get_initial_charges()) + + +def test_a_bond_table_that_cannot_be_read_is_ignored(): + """``info`` is whatever the caller put there; it is not a promise.""" + original = _structure("test_unl_ligand_with_bonds.cif") + + atoms = atom_array_to_ase(original) + atoms.info["atomworks_bonds"] = "not a bond table" + + assert ase_to_atom_array(atoms).bonds is None diff --git a/tests/io/utils/test_atom_array_plus.py b/tests/io/utils/test_atom_array_plus.py index 096f4492..459e9e84 100644 --- a/tests/io/utils/test_atom_array_plus.py +++ b/tests/io/utils/test_atom_array_plus.py @@ -4,15 +4,24 @@ import numpy as np import pytest +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from atomworks.io.utils.annotator import ensure_annotations from atomworks.io.utils.atom_array_plus import ( AnnotationList2D, AtomArrayPlus, AtomArrayPlusStack, concatenate_any, + concatenate_atom_array_plus, insert_atoms, stack_any, stack_atom_array_plus, ) +from atomworks.io.utils.io_utils import CIFWriteConfig, to_cif_buffer +from atomworks.io.utils.standard_annotations import S_ATM, S_SEGMAX, S_SEGMIN +from atomworks.io.utils.standard_annotations.base import Level, StandardAnnotationBase, StandardAnnotationMeta +from atomworks.ml.conditions import C_DIS, C_SEQ +from tests.conftest import TEST_DATA_DIR # --- Fixtures --- @@ -223,6 +232,57 @@ def _make_single_atom(element: str, coord: list[float], annotations_2d: list[str class TestAtomArrayPlus: """Tests for AtomArrayPlus, including 2D annotation logic, slicing, copying, and equality.""" + @pytest.mark.parametrize("as_bcif", [False, True]) + def test_system_annotations_survive_structure_operations(self, monkeypatch, as_bcif): + """System scalars survive structure operations and CIF model selection; conflicting models raise.""" + monkeypatch.setattr(StandardAnnotationMeta, "_registry", StandardAnnotationMeta._registry.copy()) + + class PointGroup(StandardAnnotationBase): + name = "testpointgroup" + n_body, level, dtype = 0, Level.SYSTEM, str + default_value = "" + + @classmethod + def mask_from_annotation(cls, annotation): + return annotation != "" + + config = ParseConfig.from_preset("minimal", load_standard_annotations=True, return_atom_array_plus=True) + atoms = parse(TEST_DATA_DIR / "io" / "6lyz.bcif", config=config)["asym_unit"][0] + value = np.array("C3") + PointGroup.set_annotation(atoms, value) + value[...] = "D2" + assert PointGroup.full_name not in atoms.get_annotation_categories() + for result in ( + atoms.copy(), + atoms[:2], + concatenate_atom_array_plus([atoms, atoms]), + stack_atom_array_plus([atoms, atoms]), + ): + annotation = PointGroup.annotation(result) + assert annotation.shape == () and annotation.item() == "C3" + annotation[...] = "D2" + assert PointGroup.annotation(atoms).item() == "C3" + other = atoms.copy() + PointGroup.set_annotation(other, "D2") + with pytest.raises(ValueError, match="conflicting system annotation"): + concatenate_atom_array_plus([atoms, other]) + + config = config.replace(file_type="bcif" if as_bcif else "cif") + for second in (atoms, other): + buffer = to_cif_buffer( + [atoms, second], model_ids=[7, 3], as_bcif=as_bcif, config=CIFWriteConfig(include_chem_comp=False) + ) + if second is other: + with pytest.raises(ValueError, match="Conflicting system values"): + parse(buffer, config=config) + else: + assert PointGroup.annotation(parse(buffer, config=config)["asym_unit"]).item() == "C3" + for model, expected in ((1, "C3"), (-1, PointGroup.annotation(second).item())): + buffer.seek(0) + loaded = parse(buffer, config=config.replace(model=model))["asym_unit"] + assert PointGroup.annotation(loaded).item() == expected + assert PointGroup.mask(loaded).item() + def test_set_and_get_annotation_2d(self, simple_array): """Test setting and retrieving a 2D annotation on AtomArrayPlus.""" arr = simple_array.copy() @@ -239,6 +299,14 @@ def test_set_and_get_annotation_2d(self, simple_array): assert np.array_equal(ann2.pairs, np.array([[1, 0]])) assert np.array_equal(ann2.values, np.array([24])) + source = AnnotationList2D(len(arr), [(0, 1)], [42]) + arr.set_annotation("foo", source, n_body=2) + source.add_row(1, 2, 24) + assert len(arr.get_annotation("foo", n_body=2)) == 1 + source.pairs[0, 0] = len(arr) + with pytest.raises(AssertionError, match="valid atom indices"): + arr.set_annotation("foo", source, n_body=2) + def test_get_annotation_2d_categories(self, simple_array): """Test retrieving all 2D annotation names from AtomArrayPlus.""" arr = deepcopy(simple_array) @@ -285,6 +353,8 @@ def test_slice_and_filter_2d_annotations(self, complex_array): def test_concatenate_and_slice(self, multi_array_pair): """Test concatenation followed by slicing on AtomArrayPlus.""" arr1, arr2 = deepcopy(multi_array_pair) + arr1.box = np.eye(3) + originals = [array.copy() for array in (arr1, arr2)] # ... concatenate arr_cat = concatenate_any([arr1, arr2]) @@ -300,6 +370,75 @@ def test_concatenate_and_slice(self, multi_array_pair): assert np.all(np.isnan(dense)) assert dense.shape == (3, 3) + arr_cat.coord[:] = -1 + arr_cat.box[:] = 0 + arr_cat.get_annotation_2d("dist").values[:] = -1 + for array, original in zip((arr1, arr2), originals, strict=True): + np.testing.assert_array_equal(array.coord, original.coord) + np.testing.assert_array_equal(array.box, original.box) + np.testing.assert_array_equal( + array.get_annotation_2d("dist").values, original.get_annotation_2d("dist").values + ) + + def test_concatenate_fills_only_missing_standard_annotations(self): + """StandardAnnotations use union-with-defaults; ordinary annotations still use intersection.""" + left = AtomArrayPlus(2) + right = AtomArrayPlus(2) + for chain_id, array in zip(("A", "B"), (left, right), strict=True): + array.chain_id[:] = chain_id + array.res_id[:] = [1, 2] + array.res_name[:] = "ALA" + array.atom_name[:] = "CA" + array.element[:] = "C" + + S_ATM.set_annotation(left, np.array([False, True])) + S_ATM.set_annotation(right, np.array([True, False])) + + S_SEGMIN.set_annotation(left, np.array([-1, 8])) + S_SEGMAX.set_annotation(left, np.array([-1, 12])) + + C_SEQ.set_annotation(right, np.array(["ALA", ""])) + ensure_annotations(left, "is_protein_backbone") + + original_categories = [set(array.get_annotation_categories()) for array in (left, right)] + with pytest.raises(ValueError, match="Annotation mismatch across inputs"): + concatenate_atom_array_plus([left, right]) + with pytest.warns(UserWarning, match="dropping them"): + warned = concatenate_atom_array_plus([left, right], on_annotation_mismatch_policy="warn") + dropped = concatenate_atom_array_plus([left, right], on_annotation_mismatch_policy="drop") + with pytest.raises(ValueError, match="is_protein_backbone"): + concatenate_atom_array_plus([left, right], fill_missing_standard_annotations=True) + with pytest.warns(UserWarning, match="is_protein_backbone"): + warned_and_filled = concatenate_atom_array_plus( + [left, right], + on_annotation_mismatch_policy="warn", + fill_missing_standard_annotations=True, + ) + filled = concatenate_atom_array_plus( + [left, right], + on_annotation_mismatch_policy="drop", + fill_missing_standard_annotations=True, + ) + + shared_standard_fields = {S_ATM.full_name} + one_sided_standard_fields = { + S_SEGMIN.full_name, + S_SEGMAX.full_name, + C_SEQ.full_name, + } + assert shared_standard_fields | one_sided_standard_fields <= set(filled.get_annotation_categories()) + assert shared_standard_fields <= set(dropped.get_annotation_categories()) + assert one_sided_standard_fields.isdisjoint(dropped.get_annotation_categories()) + assert set(warned.get_annotation_categories()) == set(dropped.get_annotation_categories()) + assert set(warned_and_filled.get_annotation_categories()) == set(filled.get_annotation_categories()) + assert "is_protein_backbone" not in filled.get_annotation_categories() + assert "is_protein_backbone" not in dropped.get_annotation_categories() + + assert np.array_equal(S_SEGMIN.annotation(filled), [-1, 8, -1, -1]) + assert np.array_equal(S_SEGMAX.annotation(filled), [-1, 12, -1, -1]) + assert np.array_equal(C_SEQ.mask(filled), [False, False, True, False]) + assert [set(array.get_annotation_categories()) for array in (left, right)] == original_categories + def test_equality_and_nan_handling(self, simple_array): """Test equality and NaN handling for AtomArrayPlus 2D annotations.""" arr1 = simple_array.copy() @@ -418,6 +557,24 @@ def test_insert_atoms_multiple_at_same_position(self, complete_atom_array_plus): assert sorted_bonds.shape == sorted_expected.shape assert np.all([any(np.array_equal(sb, se) for se in sorted_expected) for sb in sorted_bonds]) + def test_insert_atoms_annotation_mismatch_policy(self): + """Annotation mismatch handling is forwarded through insertion.""" + arr = AtomArrayPlus(1) + new_atom = AtomArrayPlus(1) + S_SEGMIN.set_annotation(new_atom, np.array([8])) + + with pytest.raises(ValueError, match="Annotation mismatch across inputs"): + insert_atoms(arr, [new_atom], [1]) + + inserted = insert_atoms( + arr, + [new_atom], + [1], + fill_missing_standard_annotations=True, + ) + assert np.array_equal(S_SEGMIN.mask(inserted), [False, True]) + assert np.array_equal(S_SEGMIN.annotation(inserted), [-1, 8]) + def test_equal_annotations_with_per_stack(self, complete_atom_array_plus): """Test equality checking with per-stack annotations.""" # Create two identical stacks @@ -534,6 +691,15 @@ def test_per_stack_annotation_set(self, complete_atom_array_plus): with pytest.raises(ValueError, match=r"Expected array shape"): stack.set_per_stack_annotation("wrong_shape", wrong_shape) + # Test multidimensional per-stack annotations + annot = np.zeros((2, 4, 3), dtype=float) + stack.set_per_stack_annotation("annot", annot) + assert stack.get_per_stack_annotation("annot").shape == (2, 4, 3) + + wrong_multidim = np.zeros((3, 4, 3), dtype=float) + with pytest.raises(ValueError, match=r"Expected array shape"): + stack.set_per_stack_annotation("wrong_multidim", wrong_multidim) + def test_to_per_stack_annotation(self, complete_atom_array_plus): """Test converting a regular annotation to a per-stack annotation.""" # Create a stack with two identical arrays @@ -797,5 +963,25 @@ def test_stack_roundtrip_to_atom_array_stack_and_back(self, complete_atom_array_ assert stack2.bonds == stack.bonds +@pytest.mark.parametrize("use_alias", [False, True]) +def test_concatenation_fills_missing_scalar_and_pair_conditions(use_alias): + left, right = AtomArrayPlus(2), AtomArrayPlus(1) + sequence_name = C_SEQ.aliases[0] if use_alias else C_SEQ.full_name + distance_name = C_DIS.aliases[0] if use_alias else C_DIS.full_name + left.set_annotation(sequence_name, np.array(["A1AAG", ""])) + left.set_annotation_2d(distance_name, pairs=np.array([[0, 1]]), values=np.array([3.5])) + + combined = concatenate_atom_array_plus([left, right], fill_missing_standard_annotations=True) + + np.testing.assert_array_equal(C_SEQ.annotation(combined), ["A1AAG", "", ""]) + np.testing.assert_array_equal(C_SEQ.mask(combined), [True, False, False]) + distance = C_DIS.annotation(combined) + assert distance.n_atoms == 3 + np.testing.assert_array_equal(distance.pairs, [[0, 1], [1, 0]]) + np.testing.assert_array_equal(distance.values, [3.5, 3.5]) + assert not C_SEQ.has_annotation(right) + assert not C_DIS.has_annotation(right) + + if __name__ == "__main__": pytest.main(["-v", "-x", "--log-cli-level=INFO", __file__]) diff --git a/tests/io/utils/test_bond_distance_filtering.py b/tests/io/utils/test_bond_distance_filtering.py new file mode 100644 index 00000000..b8a4706f --- /dev/null +++ b/tests/io/utils/test_bond_distance_filtering.py @@ -0,0 +1,118 @@ +"""Tests for bond distance-based filtering functionality.""" + +import numpy as np +import pytest + +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from atomworks.io.utils.bonds import filter_bonds_by_distance +from atomworks.io.utils.testing import get_pdb_path_or_buffer + +# (pdb_id, atom_selection, displacement) - displacement must exceed the element's threshold +BOND_FILTER_TEST_CASES = [ + # Metal bonds: threshold 3.6A, displace 10A + pytest.param("1a6l", "element == 'FE'", 10.0, id="metal_FeS_cluster_1a6l"), + pytest.param("1aco", "element == 'FE'", 10.0, id="metal_FeS_cluster_1aco"), + # Organic bonds: threshold 1.8A, displace 5A + pytest.param("1a6l", "(res_name == 'ALA') & (atom_name == 'CA')", 5.0, id="organic_alanine_CA"), + pytest.param("1aco", "(res_name == 'GLY') & (atom_name == 'CA')", 5.0, id="organic_glycine_CA"), +] + + +class TestBondDistanceFiltering: + """Test filtering of bonds based on distance thresholds.""" + + @pytest.mark.parametrize("pdb_id, atom_selection, displacement", BOND_FILTER_TEST_CASES) + def test_bond_filtered_when_moved_beyond_threshold(self, pdb_id: str, atom_selection: str, displacement: float): + """Moving an atom beyond its threshold should cause its bonds to be filtered.""" + path = get_pdb_path_or_buffer(pdb_id) + result = parse(path, config=ParseConfig(build_assembly="all", long_bond_policy="keep")) + atom_array = result["assemblies"]["1"][0] + + # Find the atom to move + mask = eval( + atom_selection, + {"__builtins__": {}}, + { + "element": atom_array.element, + "res_name": atom_array.res_name, + "atom_name": atom_array.atom_name, + }, + ) + atom_idx = np.where(mask)[0][0] + + # Count bonds involving this atom before filtering + bonds_before = atom_array.bonds.as_array() + bonds_with_atom = np.sum((bonds_before[:, 0] == atom_idx) | (bonds_before[:, 1] == atom_idx)) + assert bonds_with_atom > 0, f"Atom {atom_idx} should have bonds" + + # Move the atom beyond threshold + atom_array.coord[atom_idx] += np.array([displacement, 0.0, 0.0]) + + # Filter and verify bonds were removed (policy="filter" is the default for the function) + filtered = filter_bonds_by_distance(atom_array, policy="filter") + bonds_after = filtered.bonds.as_array() + bonds_with_atom_after = np.sum((bonds_after[:, 0] == atom_idx) | (bonds_after[:, 1] == atom_idx)) + + assert ( + bonds_with_atom_after < bonds_with_atom + ), f"Bonds to moved atom should be filtered (before: {bonds_with_atom}, after: {bonds_with_atom_after})" + + def test_warn_policy_keeps_all_bonds(self): + """With long_bond_policy='warn' (default), bond count should remain unchanged.""" + path = get_pdb_path_or_buffer("1a6l") + + result_default = parse(path, config=ParseConfig(build_assembly="all")) + result_keep = parse(path, config=ParseConfig(build_assembly="all", long_bond_policy="keep")) + + assert len(result_default["assemblies"]["1"][0].bonds.as_array()) == len( + result_keep["assemblies"]["1"][0].bonds.as_array() + ) + + def test_filter_policy_removes_long_bonds(self): + """With long_bond_policy='filter', long bonds should be removed.""" + path = get_pdb_path_or_buffer("1a6l") + + result_keep = parse(path, config=ParseConfig(build_assembly="all", long_bond_policy="keep")) + result_filter = parse(path, config=ParseConfig(build_assembly="all", long_bond_policy="filter")) + + # Filter policy should not increase bond count (may decrease or stay same) + assert len(result_filter["assemblies"]["1"][0].bonds.as_array()) <= len( + result_keep["assemblies"]["1"][0].bonds.as_array() + ) + + def test_raise_policy_raises_on_long_bonds(self): + """With long_bond_policy='raise', should raise ValueError if long bonds detected.""" + path = get_pdb_path_or_buffer("1a6l") + result = parse(path, config=ParseConfig(build_assembly="all", long_bond_policy="keep")) + atom_array = result["assemblies"]["1"][0] + + # Move an atom beyond threshold to create a long bond + fe_mask = atom_array.element == "FE" + fe_idx = np.where(fe_mask)[0][0] + atom_array.coord[fe_idx] += np.array([10.0, 0.0, 0.0]) + + with pytest.raises(ValueError, match="bonds exceeding distance thresholds"): + filter_bonds_by_distance(atom_array, policy="raise") + + def test_filter_nonstandard_only_preserves_standard_residues(self): + """filter_nonstandard_only preserves bonds in standard residues.""" + path = get_pdb_path_or_buffer("1a6l") + result = parse(path, config=ParseConfig(build_assembly="all", long_bond_policy="keep")) + atom_array = result["assemblies"]["1"][0] + + # Move a standard AA atom beyond threshold + ala_mask = (atom_array.res_name == "ALA") & (atom_array.atom_name == "CA") + ala_idx = np.where(ala_mask)[0][0] + atom_array.coord[ala_idx] += np.array([5.0, 0.0, 0.0]) + + # Count bonds before and after + bonds_before = len(atom_array.bonds.as_array()) + filtered = filter_bonds_by_distance(atom_array, policy="filter_nonstandard_only") + bonds_after = len(filtered.bonds.as_array()) + + assert bonds_after == bonds_before, "Standard residue bonds should not be filtered" + + +if __name__ == "__main__": + pytest.main(["-v", "-x", __file__]) diff --git a/tests/io/utils/test_bond_utils.py b/tests/io/utils/test_bond_utils.py index b3476158..017d343d 100644 --- a/tests/io/utils/test_bond_utils.py +++ b/tests/io/utils/test_bond_utils.py @@ -1,31 +1,95 @@ -import biotite.structure as struc +import io +from pathlib import Path + import numpy as np import pytest +from biotite.structure import AtomArray, BondList, BondType +from biotite.structure.io.pdb import PDBFile +from biotite.structure.io.pdbx import CIFFile, set_structure +from atomworks.io.config import ParseConfig from atomworks.io.parser import parse -from atomworks.io.template import get_empty_ccd_template +from atomworks.io.template import get_empty_ccd_template, infer_bonds_from_residue_names +from atomworks.io.utils.atom_array import get_bond_degree_per_atom from atomworks.io.utils.bonds import ( - _get_bond_degree_per_atom, correct_formal_charges_for_specified_atoms, - get_inferred_polymer_bonds, hash_atom_array, ) -from atomworks.io.utils.ccd import get_chem_comp_leaving_atom_names -from tests.io.conftest import get_pdb_path +from atomworks.io.utils.io_utils import read_any +from atomworks.io.utils.link_chemistry import get_chem_comp_leaving_atom_groups, infer_link_order, resolve_leaving_atoms +from tests.io.conftest import TEST_DATA_IO, get_pdb_path + +TEST_DATA_DIR = Path(__file__).parent LEAVING_GROUP_TEST_CASES = { - "ALA": {"N": ("H2",), "C": ("OXT", "HXT"), "OXT": ("HXT",)}, - "TYR": {"N": ("H2",), "C": ("OXT", "HXT"), "OXT": ("HXT",)}, + "ALA": {"N": (("H2",),), "C": (("OXT", "HXT"),), "OXT": (("HXT",),)}, + "TYR": {"N": (("H2",),), "C": (("OXT", "HXT"),), "OXT": (("HXT",),)}, } +def _atom_index(atoms, chain_id, res_id, res_name, atom_name): + return np.flatnonzero( + (atoms.chain_id == chain_id) + & (atoms.res_id == res_id) + & (atoms.res_name == res_name) + & (atoms.atom_name == atom_name) + ).item() + + +def test_struct_conn_uses_canonical_labels_not_author_insertions(): + """5A7X's three ASN 88B glycosylation bonds resolve by canonical label identity.""" + atoms = parse( + TEST_DATA_IO / "5a7x.cif.zst", + config=ParseConfig.from_preset("minimal", model=1, build_assembly=None), + )["asym_unit"][0] + bond_pairs = {frozenset(bond[:2]) for bond in atoms.bonds.as_array()} + + for protein_chain, glycan_chain in (("N", "IB"), ("P", "JB"), ("R", "KB")): + nd2 = _atom_index(atoms, protein_chain, 88, "ASN", "ND2") + c1 = _atom_index(atoms, glycan_chain, 1000, "NAG", "C1") + assert frozenset((nd2, c1)) in bond_pairs + + +def test_struct_conn_keeps_explicit_long_glycosylation(caplog): + """2ODP's authored 2.211 A N-glycosylation survives the ordinary 1.7 A filter.""" + atoms = parse( + TEST_DATA_IO / "2odp.cif.zst", + config=ParseConfig.from_preset("minimal", model=1, build_assembly=None), + )["asym_unit"][0] + bond_pairs = {frozenset(bond[:2]) for bond in atoms.bonds.as_array()} + + nd2 = _atom_index(atoms, "A", 408, "ASN", "ND2") + c1 = _atom_index(atoms, "B", 1, "NAG", "C1") + assert frozenset((nd2, c1)) in bond_pairs + assert "pdbx_dist_value 2.211 A exceeds 1.7 A threshold (keeping through 2.4 A)" in caplog.text + + +def test_struct_conn_filters_long_competing_altloc_bond(caplog): + """1RCQ filters the competing altloc bond and infers the kept PLP-DLY double bond.""" + atoms = parse( + TEST_DATA_IO / "1rcq.cif.zst", + config=ParseConfig.from_preset("minimal", model=1, build_assembly=None, altloc="B"), + )["asym_unit"][0] + bond_pairs = {frozenset(bond[:2]) for bond in atoms.bonds.as_array()} + + lys = _atom_index(atoms, "A", 33, "LYS", "NZ") + plp = _atom_index(atoms, "B", 358, "PLP", "C4A") + dly = _atom_index(atoms, "C", 359, "DLY", "NZ") + assert frozenset((plp, dly)) in bond_pairs + assert frozenset((plp, lys)) not in bond_pairs + neighbors, orders = atoms.bonds.get_bonds(plp) + assert orders[neighbors == dly].item() == 2 + assert "pdbx_dist_value 1.784 A exceeds 1.7 A threshold" in caplog.text + + @pytest.mark.parametrize("ccd_code, expected_leaving_groups", LEAVING_GROUP_TEST_CASES.items()) def test_leaving_group_computation(ccd_code, expected_leaving_groups): - assert get_chem_comp_leaving_atom_names(ccd_code) == expected_leaving_groups + assert get_chem_comp_leaving_atom_groups(ccd_code) == expected_leaving_groups -def test_fix_formal_charge_of_deprotonated_alanine(): - ala = get_empty_ccd_template("ALA", res_id=1, remove_hydrogens=False) +@pytest.mark.parametrize("hydrogen_policy", ["keep", "remove"]) +def test_fix_formal_charge_of_deprotonated_alanine(hydrogen_policy): + ala = get_empty_ccd_template("ALA", res_id=1, hydrogen_policy="keep") assert np.array_equal(ala.charge, np.zeros(len(ala))) ala_oxt_deprotonated = ala[ala.atom_name != "HXT"] @@ -36,42 +100,44 @@ def test_fix_formal_charge_of_deprotonated_alanine(): == -1 ) + # Acetate's CCD O(-) can form a neutral ester without displacing H or changing internal bond orders. + atoms = get_empty_ccd_template("ACT", res_id=1, chain_id="A", hydrogen_policy=hydrogen_policy) + atoms += get_empty_ccd_template("ALA", res_id=2, chain_id="B", hydrogen_policy=hydrogen_policy) + atoms.coord[:] = 0 + oxygen = _atom_index(atoms, "A", 1, "ACT", "OXT") + carbon = _atom_index(atoms, "B", 2, "ALA", "CB") + assert atoms.charge[oxygen] == -1 + atoms.bonds.add_bond(oxygen, carbon, infer_link_order(atoms, oxygen, carbon)) + atoms = infer_bonds_from_residue_names(atoms) + oxygen = _atom_index(atoms, "A", 1, "ACT", "OXT") + assert atoms.charge[oxygen] == 0 + assert atoms.bonds.get_bonds(oxygen)[1].tolist() == [BondType.SINGLE, BondType.SINGLE] -def test_infer_polymer_bonds(): - residues = [] - n_intra_bonds = [] - for i, ccd_code in enumerate(["ALA", "TYR", "GLY", "SER"]): - residue = get_empty_ccd_template(ccd_code, res_id=i + 1, chain_id="A", remove_hydrogens=False) - residues.append(residue) - n_intra_bonds.append(len(residue.bonds.as_array())) - atom_array = struc.concatenate(residues) - - assert np.array_equal(atom_array.charge, np.zeros(len(atom_array))) - assert len(atom_array[atom_array.atom_name == "OXT"]) == 4 - assert sum(n_intra_bonds) == len(atom_array.bonds.as_array()) - # ... make polymer bonds - polymer_bonds, leaving_atom_idxs = get_inferred_polymer_bonds(atom_array) - assert len(polymer_bonds) == 3 - assert all(atom_array[leaving_atom_idxs].is_leaving_atom) - - # ... add those bonds to the atom array and remove the leaving atoms - atom_array.bonds = atom_array.bonds.merge(struc.BondList(len(atom_array), polymer_bonds)) - is_leaving = np.zeros(len(atom_array), dtype=bool) - is_leaving[leaving_atom_idxs] = True - atom_array = atom_array[~is_leaving] - - assert len(atom_array[atom_array.atom_name == "OXT"]) == 1 - assert len(atom_array[atom_array.atom_name == "HXT"]) == 1 - assert len(atom_array[atom_array.atom_name == "H2"]) == 1 +@pytest.mark.parametrize( + ("pdb_id", "endpoint1", "endpoint2", "expected_valence"), + [ + ("1e1y", ("A", 14, "SER", "OG"), ("C", 997, "PO3", "P"), 5), + ("8r2z", ("A", 47, "SER", "OG"), ("B", 301, "XR9", "B2"), 4), + ], +) +def test_parse_valid_charged_hypervalent_link(pdb_id, endpoint1, endpoint2, expected_valence): + """Charged P(V) and tetrahedral borate links retain their authored bond, charge, and valence.""" + atoms = parse( + get_pdb_path(pdb_id), + config=ParseConfig.from_preset("rcsb", model=1, build_assembly=None), + )["asym_unit"][0] + atom1 = _atom_index(atoms, *endpoint1) + atom2 = _atom_index(atoms, *endpoint2) + neighbors, bond_types = atoms.bonds.get_bonds(atom1) - # ... fix formal charges - atom_array = correct_formal_charges_for_specified_atoms(atom_array, np.ones(len(atom_array), dtype=bool)) - assert np.array_equal(atom_array.charge, np.zeros(len(atom_array))) + assert bond_types[neighbors == atom2].item() == BondType.SINGLE + assert atoms.charge[atom2] == -1 + assert get_bond_degree_per_atom(atoms)[atom2] == expected_valence def test_hash_atom_array(): - arr1 = get_empty_ccd_template("ALA", res_id=1, chain_id="A", remove_hydrogens=False) + arr1 = get_empty_ccd_template("ALA", res_id=1, chain_id="A", hydrogen_policy="keep") arr2 = arr1.copy() assert hash_atom_array(arr1, annotations=None) == hash_atom_array(arr2, annotations=None) assert hash_atom_array(arr1, annotations=["atom_name"]) == hash_atom_array(arr2, annotations=["atom_name"]) @@ -124,34 +190,225 @@ def test_hash_atom_array(): @pytest.mark.parametrize("pdb_id", ["1TQH"]) -def test_correct_bond_types_for_nucleophilic_additions(pdb_id: str): - # Example with a nucleophilic addition to a carbonyl carbon that the PDB incorrectly shows as a double bond +def test_resolve_leaving_atoms_handles_nucleophilic_additions(pdb_id: str): + """Verify that resolve_leaving_atoms handles nucleophilic additions (bond order decrement for carbons). + + 1TQH chain D has a 4PA-701 residue where SER-94 OG attacks the carbonyl carbon CAI. + The C=O bond to OAD must be decremented to C-O so that the carbon doesn't exceed degree 4. + """ path = get_pdb_path(pdb_id) + # Parse without leaving atom resolution — carbon should be over-saturated result = parse( filename=path, build_assembly="all", hydrogen_policy="remove", - fix_bond_types=False, + add_missing_atoms=False, ) - - atom_array = result["assemblies"]["1"][0] # First bioassembly, first model + atom_array = result["assemblies"]["1"][0] carbon_mask = atom_array.element == "C" - degrees = _get_bond_degree_per_atom(atom_array) + degrees = get_bond_degree_per_atom(atom_array) assert not np.all(degrees[carbon_mask] <= 4), "Example does not show a nucleophilic addition!" - # Try again, with bond type correction + # Parse with defaults — resolve_leaving_atoms should fix the bond order result = parse( filename=path, build_assembly="all", hydrogen_policy="remove", - fix_bond_types=True, ) - atom_array = result["assemblies"]["1"][0] # First bioassembly, first model + atom_array = result["assemblies"]["1"][0] carbon_mask = atom_array.element == "C" - degrees = _get_bond_degree_per_atom(atom_array) - assert np.all(degrees[carbon_mask] <= 4), "Example does not show a nucleophilic addition!" + degrees = get_bond_degree_per_atom(atom_array) + assert np.all(degrees[carbon_mask] <= 4), "Carbon degree > 4 after resolve_leaving_atoms!" + + # Verify that the heteroatom partner (OAD) gets charge=-1 after bond-type + charge correction. + # OAD is an oxygen on 4PA-701 whose C=O bond was downgraded to C-O, making it an alkoxide (charge=-1). + oad_mask = (atom_array.atom_name == "OAD") & (atom_array.res_name == "4PA") + assert oad_mask.any(), "OAD atom not found in 4PA residue" + assert np.all( + atom_array.charge[oad_mask] == -1 + ), f"OAD should have charge=-1 after C=O->C-O correction, got {atom_array.charge[oad_mask]}" + + +def _count_bonded_hydrogens(atom_array: AtomArray, atom_idx: int) -> int: + """Count hydrogens bonded to the atom at ``atom_idx``.""" + bonds = atom_array.bonds.as_array() + return sum( + 1 + for b in bonds + if (b[0] == atom_idx and atom_array.element[b[1]] == "H") + or (b[1] == atom_idx and atom_array.element[b[0]] == "H") + ) + + +def test_resolve_leaving_atoms_respects_double_bond_order(): + """Verify that a double inter-residue bond removes 2 H, not 1. + + Uses a Schiff base theozyme where LYS NZ=CX (double bond via pdbx_value_order). + CCD gives NZ 3 H (HZ1-3); the double bond should displace exactly 2, leaving 1 H. + """ + # The CCD template for a free lysine carries 3 hydrogens on NZ + lys_template = get_empty_ccd_template("LYS", res_id=1, hydrogen_policy="keep") + nz_idx_template = int((lys_template.atom_name == "NZ").nonzero()[0][0]) + h_before = _count_bonded_hydrogens(lys_template, nz_idx_template) + assert h_before == 3, f"CCD template should give NZ 3 H, got {h_before}" + + # Parsing resolves leaving atoms, which should displace exactly 2 of them for the double bond + path = str(TEST_DATA_DIR / "schiff_base_double_bond.cif") + result = parse(filename=path, build_assembly=None, hydrogen_policy="keep") + aa = result["asym_unit"][0] + + nz_idx = int((aa.atom_name == "NZ").nonzero()[0][0]) + h_after = _count_bonded_hydrogens(aa, nz_idx) + assert h_after == 1, f"Double bond should leave 1 H on NZ (removed 2 of 3), got {h_after}" + + +@pytest.mark.parametrize("hydrogen_policy", ["keep", "remove"]) +def test_coordination_preserves_leaving_atoms_and_valence(hydrogen_policy): + """Metal coordination preserves the donor's hydrogens, valence count, and charge.""" + atoms = get_empty_ccd_template("ALA", res_id=1, hydrogen_policy=hydrogen_policy) + atoms += get_empty_ccd_template("MN", res_id=2, hydrogen_policy=hydrogen_policy) + degree = get_bond_degree_per_atom(atoms) + (oxygen,) = np.flatnonzero(atoms.atom_name == "OXT") + atoms.bonds.add_bond(int(oxygen), len(atoms) - 1, BondType.COORDINATION) + before = atoms.copy() + + resolved, impacted = resolve_leaving_atoms(atoms) + resolved = correct_formal_charges_for_specified_atoms(resolved, impacted) + + assert not impacted.any() + np.testing.assert_array_equal(resolved.atom_name, before.atom_name) + np.testing.assert_array_equal(resolved.bonds.as_array(), before.bonds.as_array()) + np.testing.assert_array_equal(resolved.charge, before.charge) + np.testing.assert_array_equal(get_bond_degree_per_atom(resolved), degree) + if hydrogen_policy == "remove": + np.testing.assert_array_equal(resolved.nhyd, before.nhyd) + + +@pytest.mark.parametrize("altloc", ["B", "C"]) +@pytest.mark.parametrize("add_missing_atoms", [False, True]) +def test_rebuilding_preserves_reaction_state_links(altloc, add_missing_atoms): + """6DP5's B state has OP3-Mg; only C has the U O3'-P link. Rebuilding must not mix them.""" + atoms = parse( + get_pdb_path("6dp5"), + config=ParseConfig( + hydrogen_policy="remove", + add_bond_types_from_struct_conn=("covale", "metalc"), + altloc=altloc, + add_missing_atoms=add_missing_atoms, + build_assembly=None, + ), + )["asym_unit"][0] + oxygen = _atom_index(atoms, "B", 4, "U", "O3'") + phosphorus = _atom_index(atoms, "C", 1, "C", "P") + assert (phosphorus in atoms.bonds.get_bonds(oxygen)[0]) == (altloc == "C") + op3 = (atoms.chain_id == "C") & (atoms.res_id == 1) & (atoms.atom_name == "OP3") + assert op3.any() == (altloc == "B") + if altloc == "B": + partners, types = atoms.bonds.get_bonds(int(np.flatnonzero(op3)[0])) + assert np.any((atoms.element[partners] == "MG") & (types == BondType.COORDINATION)) + + +@pytest.mark.parametrize( + "residues,names,elements,expected_order", + [ + (("LYS", "FMT"), ("NZ", "C"), ("N", "C"), BondType.ANY), + (("DLY", "PLP"), ("NZ", "C4A"), ("N", "C"), BondType.ANY), + (("CYS", "ZN"), ("SG", "ZN"), ("S", "ZN"), BondType.COORDINATION), + (("VAL", "VAL"), ("C", "HXT"), ("C", "H"), BondType.ANY), + (("VAL", "VAL"), ("C", "Q1"), ("C", "D"), BondType.ANY), + (("VAL", "VAL", "VAL"), ("C", "HXT", "C"), ("C", "H", "C"), BondType.ANY), + (("UNK", "UNK"), ("H1", "H2"), ("H", "H"), BondType.ANY), + (("UNK", "UNK", "UNK"), ("H1", "H2", "H3"), ("H", "H", "H"), BondType.ANY), + (("UNK", "UNK"), ("X1", "X2"), ("C", "C"), BondType.ANY), + ], +) +def test_minimal_pdb_preserves_conect_connectivity_and_unknown_orders(residues, names, elements, expected_order): + """Preserve CONECT links without chemistry inference; metals retain coordination typing.""" + atoms = AtomArray(len(elements)) + atoms.chain_id = ["A", "B", "C"][: len(atoms)] + atoms.res_id[:] = 1 + atoms.res_name = residues + atoms.atom_name = names + atoms.element = elements + atoms.hetero[:] = True + atoms.coord = np.arange(len(atoms), dtype=np.float32)[:, None] * [1.5, 0, 0] + atoms.bonds = BondList(len(atoms), np.array([[i, i + 1, BondType.SINGLE] for i in range(len(atoms) - 1)])) + file = PDBFile() + file.set_structure(atoms) + buffer = io.StringIO() + file.write(buffer) + assert "CONECT" in buffer.getvalue() + buffer.seek(0) + config = ParseConfig.from_preset("minimal", file_type="pdb", model=1, build_assembly=None) + parsed = parse(buffer, config=config)["asym_unit"][0] + # Even unusual hydrogen connectivity is preserved rather than validated or repaired. + expected_bonds = [[i, i + 1, expected_order] for i in range(len(atoms) - 1)] + np.testing.assert_array_equal(parsed.bonds.as_array(), expected_bonds) + np.testing.assert_array_equal(parsed.coord, atoms.coord) + + +@pytest.mark.parametrize("custom_partner", [0, 1]) +@pytest.mark.parametrize("order", [None, "?", ".", "sing", "doub", "arom"]) +def test_minimal_parse_preserves_links_without_ccd_templates(custom_partner, order): + """Custom components without definitions retain reported connectivity and explicit orders.""" + atoms = AtomArray(2) + atoms.chain_id = ["A", "B"] + atoms.res_id[:] = 1 + atoms.res_name = ["TYR", "TYR"] + atoms.res_name[custom_partner] = "CUSTOM" + atoms.atom_name = ["OH", "OH"] + atoms.element = ["O", "O"] + atoms.hetero[:] = True + atoms.coord = np.array([[0, 0, 0], [1.5, 0, 0]], dtype=np.float32) + atoms.bonds = BondList(2, np.array([[0, 1, BondType.SINGLE]])) + cif = CIFFile() + set_structure(cif, atoms) + for category in ("chem_comp_atom", "chem_comp_bond"): + if category in cif.block: + del cif.block[category] + if order is None: + del cif.block["struct_conn"]["pdbx_value_order"] + else: + cif.block["struct_conn"]["pdbx_value_order"] = [order] + buffer = io.StringIO() + cif.write(buffer) + buffer.seek(0) + config = ParseConfig.from_preset("minimal", model=1, build_assembly=None) + if order == "arom": + with pytest.raises(ValueError, match="Unsupported struct_conn bond order"): + parse(buffer, config=config) + return + parsed = parse(buffer, config=config)["asym_unit"][0] + expected_order = {"sing": BondType.SINGLE, "doub": BondType.DOUBLE}.get(order, BondType.ANY) + np.testing.assert_array_equal(parsed.bonds.as_array(), [[0, 1, expected_order]]) + np.testing.assert_array_equal(parsed.coord, atoms.coord) + with pytest.raises(ValueError, match="missing CCD template"): + infer_link_order(parsed, 0, 1) + + +@pytest.mark.parametrize("missing_order", ["?", ".", None, "arom"]) +def test_struct_conn_infers_missing_orders_and_rejects_unsupported_orders(missing_order): + """1DPN's "?" DA O3'-TAF P link is single: O3' gives up one H, though TAF P loses a P=O.""" + cif = read_any(get_pdb_path("1dpn")) + if missing_order is None: + del cif.block["struct_conn"]["pdbx_value_order"] + else: + cif.block["struct_conn"]["pdbx_value_order"] = [missing_order] * cif.block["struct_conn"].row_count + buffer = io.StringIO() + cif.write(buffer) + buffer.seek(0) + if missing_order == "arom": + # An unsupported explicit order must not silently become a single bond. + with pytest.raises(ValueError, match="Unsupported struct_conn bond order"): + parse(buffer, config=ParseConfig.from_preset("minimal", model=1)) + return + atoms = parse(buffer, config=ParseConfig.from_preset("minimal", model=1))["asym_unit"][0] + + partners, types = atoms.bonds.get_bonds(_atom_index(atoms, "A", 6, "DA", "O3'")) + + assert types[partners == _atom_index(atoms, "A", 7, "TAF", "P")].tolist() == [BondType.SINGLE] if __name__ == "__main__": - test_correct_bond_types_for_nucleophilic_additions("1j8z") + test_resolve_leaving_atoms_handles_nucleophilic_additions("1j8z") diff --git a/tests/io/utils/test_catcif.py b/tests/io/utils/test_catcif.py new file mode 100644 index 00000000..e9edebbe --- /dev/null +++ b/tests/io/utils/test_catcif.py @@ -0,0 +1,50 @@ +from atomworks.io.parser import parse +from atomworks.io.utils.catcif import to_catcif_file +from atomworks.io.utils.io_utils import load_any +from atomworks.io.utils.testing import assert_same_atom_array_or_stack, get_pdb_path +from tests.conftest import skip_if_no_catcif + +# Annotations not reliably preserved in CIF round-trips (same exclusions as test_cif_round_trip): +# - chain_type: derived from entity categories, not written in minimal config +# - atom_id: globally unique counter rewritten on load +# - auth_seq_id: biotite always writes label_seq_id as auth_seq_id, discarding original +_CATCIF_ANNOTATIONS_NOT_PRESERVED = {"chain_type", "atom_id", "auth_seq_id"} + + +@skip_if_no_catcif +def test_catcif_round_trip(tmp_path): + """Write two structures to the same catcif file, reload them via parse() and load_any().""" + pdb_ids = ["6lyz", "1a8o"] + structures = [parse(get_pdb_path(pid), config="minimal")["asym_unit"][0] for pid in pdb_ids] + + catcif_path = str(tmp_path / "test.catcif") + catcif_tag_paths = [ + to_catcif_file(structure, catcif_path, tag) for structure, tag in zip(structures, pdb_ids, strict=False) + ] + + for original, tag_path in zip(structures, catcif_tag_paths, strict=False): + # parse() round-trip + loaded_parse = parse(tag_path, config="minimal")["asym_unit"][0] + annotations_to_compare = [ + f for f in original.get_annotation_categories() if f not in _CATCIF_ANNOTATIONS_NOT_PRESERVED + ] + assert_same_atom_array_or_stack( + original, + loaded_parse, + annotations_to_compare=annotations_to_compare, + compare_bonds=False, + ) + + # load_any() round-trip — compare shared annotations only + loaded_any = load_any(tag_path, model=1) + shared_annotations = [ + f + for f in original.get_annotation_categories() + if f in loaded_any.get_annotation_categories() and f not in _CATCIF_ANNOTATIONS_NOT_PRESERVED + ] + assert_same_atom_array_or_stack( + original, + loaded_any, + annotations_to_compare=shared_annotations, + compare_bonds=False, + ) diff --git a/tests/io/utils/test_ccd.py b/tests/io/utils/test_ccd.py index 882994a1..db5b8b69 100644 --- a/tests/io/utils/test_ccd.py +++ b/tests/io/utils/test_ccd.py @@ -1,16 +1,15 @@ -import os - import numpy as np import pytest -from atomworks.constants import CCD_MIRROR_PATH from atomworks.io.utils.ccd import ( + _get_base_ccd_template, atom_array_from_ccd_code, - get_ccd_component_from_mirror, + get_polymerization_atoms, + register_custom_ccd_entry, + unregister_custom_ccd_entry, ) -@pytest.mark.parametrize("ccd_mirror_path", [CCD_MIRROR_PATH, None]) @pytest.mark.parametrize( "ccd_code,expected_coord_type,coord_preferences", [ @@ -18,13 +17,11 @@ ("HEM", "ideal_pdbx", ("ideal_pdbx", "model")), # HEM should use ideal_pdbx ], ) -def test_coordinate_fallback_behavior( - ccd_mirror_path: os.PathLike | None, ccd_code: str, expected_coord_type: str, coord_preferences: tuple[str, ...] -): +def test_coordinate_fallback_behavior(ccd_code: str, expected_coord_type: str, coord_preferences: tuple[str, ...]): """Test coordinate fallback behavior for different CCD codes.""" # Test with fallback preferences - fallback_result = atom_array_from_ccd_code(ccd_code, ccd_mirror_path=ccd_mirror_path, coords=coord_preferences) - expected_result = atom_array_from_ccd_code(ccd_code, ccd_mirror_path=ccd_mirror_path, coords=(expected_coord_type,)) + fallback_result = atom_array_from_ccd_code(ccd_code, coords=coord_preferences) + expected_result = atom_array_from_ccd_code(ccd_code, coords=(expected_coord_type,)) # Should match the expected coordinate type np.testing.assert_array_equal( @@ -38,10 +35,8 @@ def test_coordinate_fallback_behavior( reversed_prefs = tuple(reversed(coord_preferences)) if reversed_prefs[0] != expected_coord_type: # If the first preference in reversed order is different from expected, result should be different - reversed_result = atom_array_from_ccd_code(ccd_code, ccd_mirror_path=ccd_mirror_path, coords=reversed_prefs) - first_pref_result = atom_array_from_ccd_code( - ccd_code, ccd_mirror_path=ccd_mirror_path, coords=(reversed_prefs[0],) - ) + reversed_result = atom_array_from_ccd_code(ccd_code, coords=reversed_prefs) + first_pref_result = atom_array_from_ccd_code(ccd_code, coords=(reversed_prefs[0],)) np.testing.assert_array_equal( reversed_result.coord, @@ -51,26 +46,26 @@ def test_coordinate_fallback_behavior( def test_coordinate_fallback_with_mirror(): - """Test coordinate fallback with get_ccd_component_from_mirror.""" - # Test H5C with mirror - should fall back to model - h5c_mirror_fallback = get_ccd_component_from_mirror("H5C", coords=("ideal_pdbx", "model")) - h5c_mirror_model = get_ccd_component_from_mirror("H5C", coords=("model",)) + """Test coordinate fallback using atom_array_from_ccd_code (works with Biotite fallback).""" + # Test H5C - should fall back to model + h5c_fallback = atom_array_from_ccd_code("H5C", coords=("ideal_pdbx", "model")) + h5c_model = atom_array_from_ccd_code("H5C", coords=("model",)) np.testing.assert_array_equal( - h5c_mirror_fallback.coord, - h5c_mirror_model.coord, - err_msg="H5C mirror fallback should match model coordinates", + h5c_fallback.coord, + h5c_model.coord, + err_msg="H5C fallback should match model coordinates", ) - # Test HEM with mirror - should use ideal_pdbx - hem_mirror_ideal = get_ccd_component_from_mirror("HEM", coords=("ideal_pdbx", "model")) - hem_mirror_model = get_ccd_component_from_mirror("HEM", coords=("model",)) + # Test HEM - should use ideal_pdbx + hem_ideal = atom_array_from_ccd_code("HEM", coords=("ideal_pdbx", "model")) + hem_model = atom_array_from_ccd_code("HEM", coords=("model",)) with pytest.raises(AssertionError): np.testing.assert_array_equal( - hem_mirror_ideal.coord, - hem_mirror_model.coord, - err_msg="HEM mirror ideal and model coordinates should be different", + hem_ideal.coord, + hem_model.coord, + err_msg="HEM ideal and model coordinates should be different", ) @@ -108,5 +103,46 @@ def test_no_coordinates_fallback(): assert np.all(np.isnan(result.coord)), "Should have NaN coordinates" +def test_custom_ccd_codes_bypass_cache(): + """Non-standard CCD codes must bypass the cache so re-registration is visible.""" + custom_code = "ZZTEST:0" + mol_a = atom_array_from_ccd_code("ALA", coords=None) + mol_b = atom_array_from_ccd_code("GLY", coords=None) + + try: + register_custom_ccd_entry(custom_code, mol_a) + assert len(_get_base_ccd_template(custom_code, "", "keep")) == len(mol_a) + + register_custom_ccd_entry(custom_code, mol_b) + assert len(_get_base_ccd_template(custom_code, "", "keep")) == len(mol_b) + finally: + unregister_custom_ccd_entry(custom_code) + + +@pytest.mark.parametrize( + "ccd_code,expected", + [ + ("ALA", ("C", "N")), # canonical peptide atom names + ("DA", ("O3'", "P")), # canonical nucleotide atom names + ("CRO", ("C3", "N1")), # GFP chromophore: derived from the CCD's leaving-atom flags + ("3DA", ("O2'", "P")), # cordycepin: no O3', so it links 2'->5' via an override + ("4DG", (None, "P")), # acyclic guanine phosphonate: a 3' terminus, nothing exits + ], +) +def test_get_polymerization_atoms(ccd_code: str, expected: tuple[str | None, str | None]): + """Polymerization atoms resolve via canonical names, the CCD's own annotations, or an override.""" + assert get_polymerization_atoms(ccd_code) == expected + + +def test_get_polymerization_atoms_prefers_canonical_names_over_overrides(cleanup_registry): + """A custom component registered under an overridden code resolves by its own atom names.""" + custom_ncaa = atom_array_from_ccd_code("TRP", coords=None) + custom_ncaa.res_name[:] = "3DA" + register_custom_ccd_entry("3DA", custom_ncaa) + + # Without the canonical-names check first, this would return the nucleotide override ("O2'", "P"). + assert get_polymerization_atoms("3DA") == ("C", "N") + + if __name__ == "__main__": pytest.main(["-v", __file__]) diff --git a/tests/io/utils/test_ccd_registry.py b/tests/io/utils/test_ccd_registry.py new file mode 100644 index 00000000..d7237b6e --- /dev/null +++ b/tests/io/utils/test_ccd_registry.py @@ -0,0 +1,202 @@ +"""Tests for custom CCD registry functionality.""" + +import logging + +import biotite.structure.io.pdbx as pdbx +import numpy as np +import pytest + +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from atomworks.io.tools.rdkit import atom_array_to_rdkit +from atomworks.io.utils.ccd import ( + _get_base_ccd_template, + atom_array_from_ccd_code, + build_ccd_entries_from_cif_block, + custom_ccd_residues, + parse_ccd_cif, + register_custom_ccd_entry, +) +from atomworks.io.utils.io_utils import read_any, to_cif_file +from atomworks.io.utils.standardize import standardize_atom_names +from tests.conftest import TEST_DATA_DIR + + +def test_registry_basic_operations(cleanup_registry): + """Registration and lookup, incl. overriding a real code whose template was already cached.""" + custom_ala = atom_array_from_ccd_code("ALA").copy() + custom_ala.charge[:] = 99 # Marker to verify custom entry + + # Poison the memoized template cache with the bundled ALA before registering; the + # registry-aware bypass in _standard_ccd_only_cache must still resolve the override. + assert not np.all(_get_base_ccd_template("ALA", "", hydrogen_policy="keep").charge == 99) + + register_custom_ccd_entry("ALA", custom_ala) + + # Both the direct lookup and the memoized template path (add_annotations_from_ccd, + # get_atom_names_for_residue) now resolve the custom entry. + assert np.all(atom_array_from_ccd_code("ALA").charge == 99) + assert np.all(_get_base_ccd_template("ALA", "", hydrogen_policy="keep").charge == 99) + + +def test_registry_standard_override_difference_logs_at_debug(cleanup_registry, caplog): + """A differing override remains diagnosable without warning during ordinary parsing.""" + custom_ala = atom_array_from_ccd_code("ALA") + custom_ala = custom_ala[custom_ala.atom_name != "OXT"] + message = "Custom CCD entry 'ALA' overrides standard CCD definition" + + with caplog.at_level(logging.DEBUG, logger="atomworks.io.utils.ccd"): + register_custom_ccd_entry("ALA", custom_ala) + + records = [record for record in caplog.records if message in record.getMessage()] + assert len(records) == 1 + assert records[0].levelno == logging.DEBUG + + +def test_registry_rdkit_fallback_with_problematic_cif(cleanup_registry): + """Test registry works with CIF that fails RDKit sanitization (H:A). + + H:A is 3-hydroxy-3-carboxy-adipic acid - RDKit sanitization fails + with AtomValenceException, but ideal coordinates from CIF should + still be usable via the registry. + """ + # Parse H:A.cif from test data + custom_cif_path = TEST_DATA_DIR / "io" / "H_A.cif" + cif = pdbx.CIFFile.read(str(custom_cif_path)) + custom_ligand = parse_ccd_cif(cif, coords="ideal_pdbx") + + # Verify structure is valid (14 atoms: 7 carbons + 7 oxygens) + assert len(custom_ligand) == 14 + assert custom_ligand.res_name[0] == "H:A" + assert not np.any(np.isnan(custom_ligand.coord)) + + # Verify coordinates are valid (not all zeros, reasonable values) + assert not np.all(custom_ligand.coord == 0) + coord_range = np.ptp(custom_ligand.coord, axis=0) # peak-to-peak (max - min) + assert np.all(coord_range > 1.0), "Coordinates should span reasonable distances" + + # Register in global registry + register_custom_ccd_entry("H:A", custom_ligand) + + # Retrieve via standard lookup + result = atom_array_from_ccd_code("H:A") + assert result is not None + assert len(result) == 14 + + # Verify retrieved coordinates are valid (not NaN, not zeros) + assert not np.any(np.isnan(result.coord)) + assert not np.all(result.coord == 0) + + # Verify coordinates match original (preserved through registry) + np.testing.assert_allclose(result.coord, custom_ligand.coord, rtol=1e-5) + + # Test RDKit conversion (will fail sanitization but shouldn't crash) + mol = atom_array_to_rdkit(result, sanitize=True, attempt_fixing_corrupted_molecules=True) + + # Verify molecule was created despite sanitization failure + assert mol is not None + assert mol.GetNumAtoms() > 0 + + # Verify coordinates are preserved in the molecule + conformer = mol.GetConformer() + assert conformer.GetNumAtoms() == 14 + + # Extract RDKit coordinates and verify they're valid + rdkit_coords = np.array([[pos.x, pos.y, pos.z] for pos in [conformer.GetAtomPosition(i) for i in range(14)]]) + assert not np.any(np.isnan(rdkit_coords)) + assert not np.all(rdkit_coords == 0) + np.testing.assert_allclose(rdkit_coords, custom_ligand.coord, rtol=1e-5) + + +def test_build_ccd_entries_collision_policy(cleanup_registry): + """A custom ligand reusing a real CCD code (ATP + foreign atom ZZ9): strict raises, lenient keeps it.""" + block = pdbx.CIFBlock( + { + "chem_comp": pdbx.CIFCategory({"id": ["ATP"], "type": ["NON-POLYMER"]}), + "chem_comp_atom": pdbx.CIFCategory( + {"comp_id": ["ATP"] * 3, "atom_id": ["C1", "C2", "ZZ9"], "type_symbol": ["C", "C", "O"]} + ), + "chem_comp_bond": pdbx.CIFCategory( + { + "comp_id": ["ATP", "ATP"], + "atom_id_1": ["C1", "C1"], + "atom_id_2": ["C2", "ZZ9"], + "value_order": ["SING", "SING"], + "pdbx_aromatic_flag": ["N", "N"], + "pdbx_stereo_config": ["N", "N"], + } + ), + } + ) + + for options in ({}, {"on_mismatch": "error_heavy"}, {"on_mismatch": "error"}): + with pytest.raises(ValueError): + build_ccd_entries_from_cif_block(block, **options) + + entries = build_ccd_entries_from_cif_block(block, on_mismatch="ignore") + assert set(map(str, entries["ATP"].atom_name)) == {"C1", "C2", "ZZ9"} + + # Bond-only overrides do not define custom atoms; normal name validation explains what is missing. + del block["chem_comp_atom"] + assert build_ccd_entries_from_cif_block(block) == {} + with pytest.raises(ValueError, match="custom component.*chem_comp_atom and chem_comp_bond"): + standardize_atom_names(entries["ATP"]) + + +def test_unknown_peptide_without_component_metadata(tmp_path): + """Unknown peptides without component metadata use the existing backbone-type inference.""" + atoms = atom_array_from_ccd_code("ALA") + atoms.set_annotation("res_name", np.full(len(atoms), "UNKNOWN_PEPTIDE")) + atoms.chain_id[:] = "A" + atoms.res_id[:] = 1 + path = tmp_path / "peptide.cif" + to_cif_file(atoms, path, ccd_entries={"UNKNOWN_PEPTIDE": atoms}) + cif = read_any(path) + del cif.block["chem_comp"] + cif.write(path) + parsed = parse(path, config="minimal")["asym_unit"][0] + assert np.all(parsed.chem_comp_type == "L-PEPTIDE LINKING") + assert set(parsed.atom_name) == set(atoms.atom_name) + + +def test_cif_ccd_registry_preserves_ideal_coords(cleanup_registry): + """A CIF-derived registry entry must not shadow the bundled component's ideal coords.""" + src = TEST_DATA_DIR / "io" / "101m_arginine_nh1nh2_swapped.cif" # myoglobin: HEM, no in-file ideal coords + plus = parse(src, config=ParseConfig.from_preset("rcsb", return_atom_array_plus=True))["asym_unit"] + + with custom_ccd_residues(plus._custom_ccd_registry): + assert not np.isnan(atom_array_from_ccd_code("HEM").coord).any() + assert np.isnan(atom_array_from_ccd_code("HEM", coords=None).coord).all() + + +@pytest.mark.parametrize("ccd_code", ["PO4", "PHE"]) +@pytest.mark.parametrize("authored", [True, False]) +def test_authored_annotations_during_atom_completion(tmp_path, ccd_code, authored): + """Rebuilding keeps authored zero charges/false flags and supplements omitted columns.""" + # Capture CCD defaults, then author explicit zero charges and false flags. + atoms = atom_array_from_ccd_code(ccd_code) + ccd_annotations = {name: atoms.get_annotation(name).copy() for name in ("charge", "is_aromatic", "is_leaving_atom")} + atoms.chain_id[:] = "A" + atoms.res_id[:] = 1 + atoms.charge[:] = 0 + atoms.is_aromatic[:] = False + atoms.is_leaving_atom[:] = False + + # Write the component with either authored columns or columns omitted for supplementation. + path = tmp_path / "component.cif" + to_cif_file(atoms, path, ccd_entries={ccd_code: atoms}) + if not authored: + cif = read_any(path) + for column in ("charge", "pdbx_aromatic_flag", "pdbx_leaving_atom_flag"): + del cif.block["chem_comp_atom"][column] + cif.write(path) + + # Rebuild and check both the component template and the retained output annotations. + parsed = parse(path, config=ParseConfig.from_preset("minimal", add_missing_atoms=True))["asym_unit"][0] + template = build_ccd_entries_from_cif_block(read_any(path).block)[ccd_code] + assert len(parsed) == len(atoms) + for name, values in ccd_annotations.items(): + expected_values = np.zeros_like(values) if authored else values + assert np.array_equal(template.get_annotation(name), expected_values) + if name != "is_leaving_atom": # Preparation removes this temporary annotation. + assert np.array_equal(parsed.get_annotation(name), expected_values) diff --git a/tests/io/utils/test_chain_info.py b/tests/io/utils/test_chain_info.py new file mode 100644 index 00000000..915dae2d --- /dev/null +++ b/tests/io/utils/test_chain_info.py @@ -0,0 +1,69 @@ +"""Tests for chain_info utilities.""" + +import biotite.structure as struc +import numpy as np +import pytest + +from atomworks.enums import ChainType +from atomworks.io.parser import parse +from atomworks.io.utils.chain_info import build_chain_info +from tests.io.conftest import CHAIN_TYPE_TEST_CASES, get_pdb_path + + +def _single_atom_residues(res_names: list[str], *, hetero: bool) -> struc.AtomArray: + array = struc.AtomArray(len(res_names)) + array.chain_id[:] = "A" + array.res_id = np.arange(1, len(res_names) + 1) + array.res_name = res_names + array.atom_name[:] = "CA" + array.element[:] = "C" + array.hetero[:] = hetero + array.set_annotation("label_entity_id", np.full(len(array), "1")) + return array + + +@pytest.mark.parametrize("hetero", [False, True]) +def test_generic_entity_polymer_preserves_inferred_polymer_chemistry(hetero): + atom_array = _single_atom_residues(["ALA", "GLY"], hetero=hetero) + + info = build_chain_info(atom_array, entity={"id": np.array(["1"]), "type": np.array(["polymer"])})["A"] + + assert info["chain_type"] == ChainType.POLYPEPTIDE_L + assert info["is_polymer"] is True + assert info["processed_entity_canonical_sequence"] == "AG" + + +def test_generic_entity_polymer_promotes_unknown_chemistry_to_other_polymer(): + atom_array = _single_atom_residues(["UNL"], hetero=True) + + info = build_chain_info(atom_array, entity={"id": np.array(["1"]), "type": np.array([" POLYMER "])})["A"] + + assert info["chain_type"] == ChainType.OTHER_POLYMER + assert info["is_polymer"] is True + + +@pytest.mark.parametrize("test_case", CHAIN_TYPE_TEST_CASES) +def test_infer_chain_info_from_atom_array(test_case: dict): + cif_path = get_pdb_path(test_case["pdb_id"]) + atom_array = parse( + filename=cif_path, + add_missing_atoms=False, + remove_waters=True, + )["asym_unit"][0] + + chain_info = build_chain_info(atom_array) + + for chain_id, info_dict in chain_info.items(): + got = info_dict["chain_type"] + expected = ChainType.as_enum(test_case["chain_types"][chain_id]) + + if got.is_non_polymer(): + # We allow all non-polymers to be interchanged + assert expected.is_non_polymer() + else: + # Enforce strict equality for polymers + assert got == expected + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/io/utils/test_compression.py b/tests/io/utils/test_compression.py new file mode 100644 index 00000000..db83a661 --- /dev/null +++ b/tests/io/utils/test_compression.py @@ -0,0 +1,44 @@ +import pytest +import zstandard as zstd + +from atomworks.io.utils.compression import open_compressed, transfer_with_compression +from tests.io.conftest import TEST_DATA_IO + + +@pytest.mark.parametrize("suffix", ["", ".gz", ".gzip", ".zst"]) +@pytest.mark.parametrize("move", [False, True]) +def test_transfer_codecs(tmp_path, compressed_example, suffix, move): + original = TEST_DATA_IO / "1a8o_modified.cif" + encoded = compressed_example(original) + source = tmp_path / ("source." + encoded.name) + source.write_bytes(encoded.read_bytes()) + destination = tmp_path / ("output.cif" + suffix) + transfer_with_compression(source, destination, move=move) + with open_compressed(destination, "rb") as stream: + assert stream.read() == original.read_bytes() + assert source.exists() != move + + +@pytest.mark.parametrize("damage", [None, "truncated", "corrupt", "empty"]) +def test_zstd_transfer_completion(tmp_path, damage): + data = (TEST_DATA_IO / "1a8o_modified.cif").read_bytes() + compressor = zstd.ZstdCompressor(write_checksum=True, write_content_size=False) + encoded = compressor.compress(data) + compressor.compress(data) + encoded = {"truncated": encoded[:-1], "corrupt": encoded[:-1] + bytes([encoded[-1] ^ 1]), "empty": b""}.get( + damage, encoded + ) + source, destination = tmp_path / "input.cif.zst", tmp_path / "output.cif" + source.write_bytes(encoded) + destination.write_bytes(b"keep") + if damage is None: + with open_compressed(source, "rb") as stream: + assert b"".join(iter(lambda: stream.read(7), b"")) == data * 2 + transfer_with_compression(source, destination, move=True) + assert destination.read_bytes() == data * 2 + assert not source.exists() + else: + with pytest.raises((EOFError, zstd.ZstdError)): + transfer_with_compression(source, destination, move=True) + assert source.read_bytes() == encoded + assert destination.read_bytes() == b"keep" + assert set(tmp_path.iterdir()) == ({destination} if damage is None else {source, destination}) diff --git a/tests/io/utils/test_extra_fields.py b/tests/io/utils/test_extra_fields.py new file mode 100644 index 00000000..99015219 --- /dev/null +++ b/tests/io/utils/test_extra_fields.py @@ -0,0 +1,166 @@ +"""Tests for extra fields utilities.""" + +import numpy as np + +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from atomworks.io.utils.extra_fields import ( + get_default_array, + infer_dtype_from_values, + normalize_extra_fields, + transfer_annotation_if_exists, +) +from atomworks.io.utils.io_utils import load_any +from atomworks.io.utils.testing import get_pdb_path +from atomworks.ml.utils.testing import cached_parse +from tests.io.conftest import TEST_DATA_IO + + +def test_normalize_extra_fields_formats(): + """Test normalization of various input formats.""" + # List format + result = normalize_extra_fields(["field1", "field2"]) + assert list(result.keys()) == ["field1", "field2"] + + # Dict with None + result = normalize_extra_fields({"field1": None}) + assert result["field1"] == {"default": None, "dtype": None} + + # Dict with default (dtype inferred) + result = normalize_extra_fields({"field": {"default": 0}}) + assert result["field"]["dtype"] == np.int64 + + # Dict with dtype only + result = normalize_extra_fields({"field": {"dtype": np.int32}}) + assert result["field"]["dtype"] == np.int32 + assert result["field"]["default"] is None + + # None and "all" return empty + assert normalize_extra_fields(None) == {} + assert normalize_extra_fields("all") == {} + + +def test_normalize_require_defaults(): + """Test require_defaults validation.""" + # Fields without defaults are filtered out (with warning) + result = normalize_extra_fields(["field1"], require_defaults=True) + assert "field1" not in result # Filtered out + + # Passes with default + result = normalize_extra_fields({"f": {"default": 0}}, require_defaults=True) + assert result["f"]["default"] == 0 + + # Passes with dtype only + result = normalize_extra_fields({"f": {"dtype": np.int32}}, require_defaults=True) + assert result["f"]["dtype"] == np.int32 + + # Mixed: keeps fields with defaults, filters out those without + result = normalize_extra_fields( + {"with_default": {"default": 0}, "without_default": None}, + require_defaults=True, + ) + assert "with_default" in result + assert "without_default" not in result + + +def test_infer_dtype_from_values(): + """Test dtype inference from string and numeric values.""" + # String to int + dtype, converted = infer_dtype_from_values(np.array(["1", "2", "3"])) + assert dtype == np.int64 + assert np.array_equal(converted, [1, 2, 3]) + + # String to float + dtype, _ = infer_dtype_from_values(np.array(["1.5", "2.0"])) + assert dtype == np.float64 + + # Non-numeric stays string + dtype, _ = infer_dtype_from_values(np.array(["A", "B"])) + assert dtype.kind == "U" + + # Already numeric unchanged + dtype, _ = infer_dtype_from_values(np.array([1, 2, 3], dtype=np.int32)) + assert dtype == np.int32 + + +def test_get_default_array(): + """Test default array generation.""" + # Explicit default + arr = get_default_array({"default": -99, "dtype": np.int32}, 5) + assert arr.shape == (5,) + assert arr.dtype == np.int32 + assert np.all(arr == -99) + + # Dtype-only uses sensible defaults + assert np.all(get_default_array({"default": None, "dtype": np.int32}, 3) == -1) + assert np.all(np.isnan(get_default_array({"default": None, "dtype": np.float64}, 3))) + + +def test_load_any_backward_compatible(compressed_example): + """Test old extra_fields formats still work.""" + path = compressed_example(TEST_DATA_IO / "1a8o_modified.cif") + # List format + result = load_any(path, extra_fields=["b_factor", "occupancy"]) + assert "b_factor" in result.get_annotation_categories() + + # Dict format + result = load_any(path, extra_fields={"b_factor": None}) + assert "b_factor" in result.get_annotation_categories() + assert np.issubdtype(result.b_factor.dtype, np.floating) + + +def test_parse_extra_fields_with_add_missing_atoms(): + """Test extra fields with add_missing_atoms.""" + # Fields without defaults are skipped (not loaded) with warning + result = parse(get_pdb_path("1a8o"), config=ParseConfig(add_missing_atoms=True, extra_fields=["custom"])) + asym_unit = result["asym_unit"][0] + assert "custom" not in asym_unit.get_annotation_categories() + + # Works with defaults + result = parse( + get_pdb_path("1a8o"), + config=ParseConfig(add_missing_atoms=True, extra_fields={"atom_id": {"default": -1, "dtype": np.int64}}), + ) + asym_unit = result["asym_unit"][0] + assert "atom_id" in asym_unit.get_annotation_categories() + + # Unresolved atoms have the default + unresolved = asym_unit.occupancy == 0 + if np.any(unresolved): + assert np.any(asym_unit.atom_id[unresolved] == -1) + + +def test_transfer_annotation_if_exists(): + """Test transferring annotation value when it exists, or doing nothing when it doesn't.""" + source = cached_parse("1a8o")["atom_array"] + target = source.copy() + + # Test when annotation exists - broadcasts source[0] to all target atoms + transfer_annotation_if_exists(source, target, "chain_id", 0) + assert np.all(target.chain_id == source.chain_id[0]) + + # Test when annotation doesn't exist - does nothing + transfer_annotation_if_exists(source, target, "nonexistent", 0) + assert "nonexistent" not in target.get_annotation_categories() + + +def test_af3_cond_design_cif_example(compressed_example): + path = compressed_example(TEST_DATA_IO / "example_conditional_generation_output.cif") + result = parse( + path, + config=ParseConfig(add_missing_atoms=False, extra_fields="all"), + ) + # Check if processing runs through + assert result is not None + + atom_array = result["assemblies"]["1"][0] + assert atom_array.condition_motif_index is not None + assert atom_array.condition_coords_gt_x is not None + + result = parse( + path, + config=ParseConfig(add_missing_atoms=False, extra_fields=["condition_coords_gt_x"]), + ) + atom_array = result["assemblies"]["1"][0] + assert atom_array.condition_coords_gt_x is not None + assert "condition_coords_gt_y" not in atom_array._annot diff --git a/tests/io/utils/test_io.py b/tests/io/utils/test_io.py index 42e96ea4..e7733709 100644 --- a/tests/io/utils/test_io.py +++ b/tests/io/utils/test_io.py @@ -1,3 +1,5 @@ +import copy +import gzip import io import pickle import tempfile @@ -10,11 +12,29 @@ from biotite.database import rcsb from biotite.structure import AtomArray, AtomArrayStack -from atomworks.io.parser import parse, parse_atom_array -from atomworks.io.tools.inference import build_msa_paths_by_chain_id_from_component_list, components_to_atom_array +from atomworks.constants import ATOMWORKS_COMMON_ANNOTATIONS, CCD_MIRROR_PATH +from atomworks.enums import ChainType +from atomworks.io.config import ParseConfig, PrepareConfig +from atomworks.io.parser import parse, parse_atom_array, prepare_atom_array +from atomworks.io.tools.inference import ( + ChemicalComponent, + build_msa_paths_by_chain_id_from_component_list, + components_to_atom_array, +) from atomworks.io.transforms.atom_array import ensure_atom_array_stack -from atomworks.io.utils.atom_array_plus import AtomArrayPlus, as_atom_array_plus +from atomworks.io.transforms.categories import category_to_dict +from atomworks.io.utils.atom_array_plus import AtomArrayPlus, as_atom_array_plus, concatenate_atom_array_plus +from atomworks.io.utils.ccd import ( + _get_ccd_block, + _get_standard_ccd_codes_cached, + atom_array_from_ccd_code, + get_atom_names_for_residue, + register_custom_ccd_entry, +) from atomworks.io.utils.io_utils import ( + CIF_LIKE_EXTENSIONS, + CIFWriteConfig, + _build_chem_comp_atom, get_structure, infer_pdb_file_type, load_any, @@ -24,7 +44,12 @@ to_cif_string, to_pdb_string, ) -from atomworks.io.utils.testing import assert_same_atom_array, get_pdb_path +from atomworks.io.utils.testing import ( + assert_same_annotation_cardinality, + assert_same_atom_array_or_stack, + get_pdb_path, +) +from atomworks.ml.utils.testing import assert_equal from tests.conftest import skip_if_no_internet from tests.io.conftest import TEST_DATA_IO @@ -74,6 +99,13 @@ def test_infer_filetype(): assert infer_pdb_file_type("6lyz.bcif.gz") == "bcif" assert infer_pdb_file_type("6lyz.bcif.gzip") == "bcif" + for compression in ("", ".gz", ".gzip", ".zst"): + assert infer_pdb_file_type(f"2hhb.mmjson{compression}") == "mmjson" + assert f".mmjson{compression}" in CIF_LIKE_EXTENSIONS + assert f".json{compression}" not in CIF_LIKE_EXTENSIONS + with pytest.raises(ValueError, match="Unsupported file type: json"): + infer_pdb_file_type(f"2hhb.json{compression}") + with open(TEST_DATA_IO / "6lyz.bcif", "rb") as f: buffer = io.BytesIO(f.read()) assert infer_pdb_file_type(buffer) == "bcif" @@ -87,8 +119,26 @@ def test_infer_filetype(): assert infer_pdb_file_type(buffer) == "pdb" -@pytest.mark.requires_internet -@skip_if_no_internet +@pytest.mark.parametrize("source_kind", ["path", "buffer"]) +def test_parse_mmjson_matches_cif(source_kind: str) -> None: + json_path = TEST_DATA_IO / "2hhb.mmjson.gz" + cif_path = TEST_DATA_IO / "2hhb.cif.gz" + + assert infer_pdb_file_type(json_path) == "mmjson" + + if source_kind == "path": + json_source = json_path + else: + with gzip.open(json_path, "rt") as stream: + json_source = io.StringIO(stream.read()) + + file_type = None if source_kind == "path" else "mmjson" + atoms_json = parse(json_source, config=ParseConfig(file_type=file_type))["asym_unit"] + atoms_cif = parse(cif_path, config=ParseConfig(file_type="cif"))["asym_unit"] + + assert_same_atom_array_or_stack(atoms_json, atoms_cif) + + @pytest.mark.parametrize( "extra_fields, include_bonds, model", [ @@ -99,11 +149,8 @@ def test_infer_filetype(): ], ) def test_get_structure_configurations(extra_fields, include_bonds, model): - # Fetch 6lyz.cif as a buffer - cif_buffer = rcsb.fetch("6lyz", "cif") - - # Read the buffer into a CIFFile object - cif_file = read_any(cif_buffer, file_type="cif") + # Read 6lyz CIF into a CIFFile object + cif_file = read_any(get_pdb_path("6lyz"), file_type="cif") # Get the structure with different configurations structure = get_structure( @@ -133,15 +180,15 @@ def test_get_structure_configurations(extra_fields, include_bonds, model): assert isinstance(structure, AtomArrayStack) -@pytest.mark.requires_internet -@skip_if_no_internet def test_parse_atom_array_with_multiple_transformations(): - data_dict = parse(rcsb.fetch("1out", "cif"), file_type="cif", add_missing_atoms=True) + data_dict = parse(get_pdb_path("1out"), config=ParseConfig(add_missing_atoms=True)) parsed_from_file = data_dict["assemblies"]["1"] assert any(chain_iid.endswith("_2") for chain_iid in np.unique(parsed_from_file.chain_iid)) - new_data_dict = parse_atom_array(parsed_from_file, add_missing_atoms=True) + + new_data_dict = parse_atom_array(parsed_from_file, config=PrepareConfig(add_missing_atoms=True)) parsed_from_atom_array = new_data_dict["asym_unit"][0] assert "chain_iid" in parsed_from_atom_array.get_annotation_categories() + chain_iid = parsed_from_atom_array.chain_iid res_id = parsed_from_atom_array.res_id res_name = parsed_from_atom_array.res_name @@ -173,14 +220,9 @@ def test_parse_atom_array_with_multiple_transformations(): assert tfm_1_atom_1_idx not in tfm_2_atom_1_bonds -@pytest.mark.requires_internet -@skip_if_no_internet def test_to_cif_string(): - cif_buffer = rcsb.fetch("6lyz", "cif") - cif_structure = load_any(cif_buffer, file_type="cif", extra_fields=["b_factor", "occupancy", "charge"]) - - with pytest.raises(ValueError, match="Ambiguous bond annotations detected"): - cif_string = to_cif_string(cif_structure) + result = parse(get_pdb_path("6lyz"), config="minimal") + cif_structure = result["asym_unit"] # Make identifiers unique cif_structure.res_id = struc.spread_residue_wise(cif_structure, np.arange(struc.get_residue_count(cif_structure))) @@ -191,11 +233,8 @@ def test_to_cif_string(): assert isinstance(cif_string, str) assert len(cif_string) > 0 - cif_structure2 = load_any( - io.StringIO(cif_string), - file_type="cif", - extra_fields=["b_factor", "occupancy", "charge"], - ) + result2 = parse(io.StringIO(cif_string), config="minimal") + cif_structure2 = result2["asym_unit"][0] assert np.allclose(cif_structure.coord, cif_structure2.coord) assert np.all(cif_structure.atom_name == cif_structure2.atom_name) assert np.all(cif_structure.element == cif_structure2.element) @@ -212,10 +251,10 @@ def test_to_cif_string(): "test_category2": {"test_col1": np.arange(10), "test_col2": np.arange(10)}, "test_category3": {"test_col1": [1, 3, 4], "test_col2": [2, 5, "a"]}, } + cif_string2 = to_cif_string( cif_structure, - id="test_id", - extra_categories=metadata, + config=CIFWriteConfig(id="test_id", extra_categories=metadata), ) metadata_serialized = ( @@ -248,19 +287,17 @@ def test_to_cif_string(): assert metadata_serialized in cif_string2, "Metadata not found in serialized CIF string." -@pytest.mark.requires_internet -@skip_if_no_internet def test_to_pdb_string(): - pdb_buffer = rcsb.fetch("6lyz", "pdb") - pdb_structure = load_any(pdb_buffer, file_type="pdb", extra_fields=["b_factor", "occupancy", "charge"], model=1) + result = parse(get_pdb_path("6lyz"), config="minimal") + pdb_structure = result["asym_unit"][0] n_atoms = pdb_structure.array_length() pdb_string = to_pdb_string(pdb_structure) assert isinstance(pdb_string, str) assert len(pdb_string) > 0 # Test that we can load the pdb string back into an AtomArray - pdb_buffer2 = io.StringIO(pdb_string) - pdb_structure2 = load_any(pdb_buffer2, file_type="pdb", extra_fields=["b_factor", "occupancy", "charge"], model=1) + result2 = parse(io.StringIO(pdb_string), config="minimal") + pdb_structure2 = result2["asym_unit"][0] assert pdb_structure2.array_length() == n_atoms assert np.allclose(pdb_structure.coord, pdb_structure2.coord) assert np.all(pdb_structure.atom_name == pdb_structure2.atom_name) @@ -273,6 +310,362 @@ def test_to_pdb_string(): assert np.all(pdb_structure.occupancy == pdb_structure2.occupancy) +@pytest.mark.parametrize( + "pdb_id,assembly_id,mode", + [ + # General edge cases from the PDB + ("1d9d", "1", "transformation_id"), # DNA_RNA_HYBRID + protein + ("6qhp", "1", "transformation_id"), # protein + non-polymers + ("1fix", "1", "transformation_id"), # DNA + RNA only + ("1ivo", "1", "transformation_id"), # protein + oligosaccharides (branched) + # Assembly tests with transformations - test both modes on 1rxz + ("1rxz", "1", "transformation_id"), # Complex assembly with 3 transformations + ("1rxz", "1", "chain_iid"), # Same structure, chain_iid mode + # Struct_conn preservation tests + ("4js1", "1", "transformation_id"), # Protein-glycosylation with covalent bonds + ("4ndz", "1", "transformation_id"), # Large assembly with ligands covalently bound + ("3ne7", "1", "transformation_id"), # Small assembly with two ligands covalently bound + # Multi-residue ligand tests + ("7zcy", "1", "transformation_id"), # Selenium ligands with 3 transformations + ("1rcq", "1", "transformation_id"), # PLP,DLY ligands with 2 transformations + # Local assembly tests with glycosylations + ("4i7z", "1", "transformation_id"), # 2 transformations + UMQ (ubiquinone) + ("1qh9", "1", "transformation_id"), # 2 transformations + LAC (lactose) + ], +) +def test_cif_assembly_roundtrip(pdb_id: str, assembly_id: str, mode: str): + """CIF roundtrip test for assemblies with various configurations.""" + # Parse original structure + source = get_pdb_path(pdb_id) + + data_dict = parse( + source, + config=ParseConfig( + hydrogen_policy="remove", + build_assembly=[assembly_id], + add_bond_types_from_struct_conn=("covale",), + add_missing_atoms=True, + ), + ) + # Original atom array (for comparison later) + original = data_dict["assemblies"][assembly_id][0] + + # Write to CIF (with specified mode) + with tempfile.TemporaryDirectory() as tmp_dir: + cif_path = Path(tmp_dir) / f"{pdb_id}_assembly_{assembly_id}.cif" + to_cif_file( + original, + cif_path, + include_entity_categories=True, + chain_disambiguation=mode, + ) + + # Read back + reloaded_dict = parse( + cif_path, + config=ParseConfig( + hydrogen_policy="remove", + add_bond_types_from_struct_conn=("covale",), + add_missing_atoms=False, + ), + ) + reloaded = reloaded_dict["assemblies"][assembly_id][0] + + common_annotations = ( + set(original.get_annotation_categories()) + & set(reloaded.get_annotation_categories()) + & set(ATOMWORKS_COMMON_ANNOTATIONS) + ) + # Annotations that are order-dependent and may not roundtrip with the same values (but should have same cardinality) + exclude_annotations = { + "chain_type", + "pn_unit_entity", + "molecule_id", + "molecule_iid", + "molecule_entity", + "label_entity_id", + "atom_id", + "auth_seq_id", + "label_alt_id", + } + + if mode == "chain_iid": + # Annotations derive fro chain_id also won't roundtrip + # (Since we convert chain_id -> chain_iid) + exclude_annotations = exclude_annotations | { + "chain_id", + "transformation_id", + "chain_iid", + "pn_unit_iid", + "pn_unit_id", + } + annotations_to_compare = common_annotations - exclude_annotations + + # Compare structures + assert_same_atom_array_or_stack( + original, + reloaded, + annotations_to_compare=annotations_to_compare, + compare_coords=True, + compare_bonds=True, + compare_bond_order=True, + enforce_order=False, + cast_to_common_dtype=True, # CIF roundtrips may change dtypes (e.g., int8→int64) + ) + + # Validate cardinality of excluded annotations + # (For simplicity, only when disambiguating via transformation_id) + if mode == "transformation_id": + cardinality_annotations = common_annotations - {"chain_type", "label_alt_id"} + assert_same_annotation_cardinality(original, reloaded, cardinality_annotations) + + +@pytest.fixture +def forget_ccd_lookups(): + """Drops the CCD lookups a test cached, keyed by mirror path alone, while it had turned the CCD off.""" + yield + for lookup in (_get_standard_ccd_codes_cached, get_atom_names_for_residue): + lookup.cache_clear() + + +@pytest.mark.parametrize( + "pdb_id", + [ + # General edge cases + "1d9d", # DNA_RNA_HYBRID + protein; bridging oxygen +1 charge from unremoved leaving hydrogen + "6qhp", # protein + non-polymers + "1fix", # DNA + RNA only + "1ivo", # protein + oligosaccharides (branched); covalent modification had +1 charge on amide nitrogen + # Covalent modification charge bugs + "4js1", # covalent modification had +1 charge on amide nitrogen (same as 1IVO) + "1qfe", # erroneous +2 on nitrogen from unremoved hydrogen leaving group in covalent modification + # Multi-residue ligand / bond inference bugs + "4ndz", # multi-residue ligand with spurious bond causing positive charge on oxygen + "1a3g", # positive charge on carbon from missing double bond inference in Schiff base linkage + # Altloc / occupancy handling + "1rcq", # lysine NZ +2 from improper altloc handling in struct conn; multiple occupancy + # Peptide bond inference + "6h9v", # C-gamma linking L-beta peptide bond not correctly inferred across chains + # Hydrogen leaving atom bugs + "1j8z", # ACE carbonyl carbon incorrectly charged due to unremoved hydrogen leaving atom + "1a1e", # positive charge on carbon from unremoved hydrogen leaving atom (not labeled in CCD) + # Custom CCD edge cases + "1twr", # Residue VER provided with custom bonds (aromatic in crystal structure but not in CCD) + "6q9t", # Contains residue `QUK` which uses a mix of `std` and `alt` atom ids; also contains various unusual ligands and NCAA's + ], +) +@pytest.mark.parametrize("biotite_ccd", ["on", "off"]) +@pytest.mark.parametrize( + "reload_config", + [ + pytest.param( + ParseConfig(hydrogen_policy="remove", add_missing_atoms=False), + id="no_rebuild", + ), + pytest.param(ParseConfig(hydrogen_policy="remove", add_missing_atoms=True), id="with_rebuild"), + ], +) +def test_to_cif_file_roundtrip( + pdb_id: str, reload_config: ParseConfig, biotite_ccd: str, monkeypatch, forget_ccd_lookups +): + """CIF roundtrip on diverse asymmetric units, with and without biotite CCD access. + + ``biotite_ccd="off"`` disables ``ALLOW_BIOTITE_CCD`` and clears + ``CCD_MIRROR_PATH`` so the reloader must reconstruct residue chemistry + from the CIF's own ``chem_comp*`` categories alone. + """ + source = get_pdb_path(pdb_id) + data_dict = parse( + source, config=ParseConfig(hydrogen_policy="remove", build_assembly=None, return_atom_array_plus=True) + ) + original = data_dict["asym_unit"][0] + + with tempfile.TemporaryDirectory() as tmp_dir: + cif_path = Path(tmp_dir) / "test.cif" + to_cif_file(original, cif_path, include_entity_categories=True) + + if biotite_ccd == "off": + # Force reload to reconstruct residue chemistry from the CIF's own + # chem_comp* categories + monkeypatch.setattr("atomworks.io.utils.ccd.ALLOW_BIOTITE_CCD", False) + monkeypatch.setattr("atomworks.io.utils.ccd.CCD_MIRROR_PATH", None) + + reloaded = parse(cif_path, config=reload_config)["asym_unit"][0] + + # Compare all standard annotations present in both arrays, plus the + # chem_comp-specific annotations the chem_comp* categories preserve. + annotations_to_compare = ( + set(ATOMWORKS_COMMON_ANNOTATIONS) + & set(original.get_annotation_categories()) + & set(reloaded.get_annotation_categories()) + ) - {"label_alt_id"} + + # Misc. extra annotations to compare if present (but that may not always be present) + for extra in ("charge", "is_aromatic", "is_leaving_atom", "chem_comp_type"): + if extra in original.get_annotation_categories() and extra in reloaded.get_annotation_categories(): + annotations_to_compare.add(extra) + + assert_same_atom_array_or_stack( + original, reloaded, annotations_to_compare=annotations_to_compare, enforce_order=False + ) + + +def test_entity_id_consistent_when_atom_order_disagrees_with_chain_id_order(): + """`_entity` rows and `atom_site.label_entity_id` must reference matching IDs.""" + polymer = as_atom_array_plus(atom_array_from_ccd_code("ALA")) + polymer.chain_id[:] = "A" + polymer.set_annotation("chain_type", np.full(polymer.array_length(), int(ChainType.POLYPEPTIDE_L), dtype=np.int8)) + + ligand = as_atom_array_plus(atom_array_from_ccd_code("HEM")) + ligand.chain_id[:] = "L" + ligand.set_annotation("chain_type", np.full(ligand.array_length(), int(ChainType.NON_POLYMER), dtype=np.int8)) + + # Non-polymer first in physical atom order, but chain_id "L" sorts after "A". + combined = concatenate_atom_array_plus([ligand, polymer]) + + with tempfile.TemporaryDirectory() as tmp_dir: + cif_path = Path(tmp_dir) / "entity_id_swap.cif" + to_cif_file(combined, cif_path) + reloaded = parse( + cif_path, + config=ParseConfig.from_preset("minimal", load_standard_annotations=True, return_atom_array_plus=True), + )["asym_unit"][0] + + chain_types = { + ch: set(reloaded.chain_type[reloaded.chain_id == ch].tolist()) for ch in set(reloaded.chain_id.tolist()) + } + assert chain_types == { + "A": {int(ChainType.POLYPEPTIDE_L)}, + "L": {int(ChainType.NON_POLYMER)}, + }, f"chain_type mismatch on round-trip: {chain_types}" + + +@pytest.mark.parametrize( + "pdb_id", + [ + "1rxz", # 3 transformations, 256 residues, protein + "1qh9", # 2 transformations, 221 residues, protein + lactose + "6lyz", # 1 transformation, 129 residues, monomer (edge case) + ], +) +def test_struct_oper_roundtrip(pdb_id: str): + """Test struct_oper categories enable assembly building from saved CIF. + + Verifies that struct_oper categories can be preserved when saving CIF files, + allowing biological assemblies to be rebuilt from the saved file. + """ + source = get_pdb_path(pdb_id) + assembly_id = "1" + + # Load original file and build assembly directly (for later comparison) + result_original = parse( + source, config=ParseConfig(hydrogen_policy="remove", build_assembly=[assembly_id], add_missing_atoms=True) + ) + assembly_original = result_original["assemblies"][assembly_id][0] + + # Load asym unit only (no assembly building) + result_asym = parse( + source, config=ParseConfig(hydrogen_policy="remove", build_assembly=None, add_missing_atoms=True) + ) + assert ( + "transformation_id" not in result_asym["asym_unit"][0].get_annotation_categories() + ), "transformation_id should not be present without assembly building" + + with tempfile.TemporaryDirectory() as tmp_dir: + cif_path = Path(tmp_dir) / "roundtrip.cif" + + # Save asym unit with struct_oper categories preserved + extra_cats = { + "pdbx_struct_oper_list": category_to_dict(result_asym["extra_info"]["struct_oper_category"]), + "pdbx_struct_assembly_gen": category_to_dict(result_asym["extra_info"]["assembly_gen_category"]), + } + + # Include pdbx_struct_assembly if available + if ( + "cif_block" in result_asym + and result_asym["cif_block"] + and "pdbx_struct_assembly" in result_asym["cif_block"] + ): + extra_cats["pdbx_struct_assembly"] = category_to_dict(result_asym["cif_block"], "pdbx_struct_assembly") + + to_cif_file(result_asym["asym_unit"], cif_path, include_entity_categories=True, extra_categories=extra_cats) + + # Load saved file WITH and WITHOUT missing atoms and build assembly from it + # ... without missing atoms + result_rebuilt_without_missing = parse( + cif_path, + config=ParseConfig(hydrogen_policy="remove", build_assembly=[assembly_id], add_missing_atoms=False), + ) + assembly_rebuilt_without_missing = result_rebuilt_without_missing["assemblies"][assembly_id][0] + # ... with missing atoms (should be identical) + result_rebuilt_with_missing = parse( + cif_path, + config=ParseConfig(hydrogen_policy="remove", build_assembly=[assembly_id], add_missing_atoms=True), + ) + assembly_rebuilt_with_missing = result_rebuilt_with_missing["assemblies"][assembly_id][0] + + # Verify rebuilt assemblies matches original assembly + annotations_to_compare = [ + annot + for annot in set(ATOMWORKS_COMMON_ANNOTATIONS) + if annot in assembly_original.get_annotation_categories() + and annot in assembly_rebuilt_without_missing.get_annotation_categories() + ] + + assert_same_atom_array_or_stack( + assembly_rebuilt_without_missing, + assembly_original, + annotations_to_compare=annotations_to_compare, + compare_bond_order=True, + enforce_order=False, + ) + assert_same_atom_array_or_stack( + assembly_rebuilt_with_missing, + assembly_original, + annotations_to_compare=annotations_to_compare, + compare_bond_order=True, + enforce_order=False, + ) + + +def test_chain_disambiguation_errors(): + """Test error cases for chain disambiguation modes. + + Verifies that appropriate exceptions are raised when: + - No disambiguation method is provided for ambiguous structures + - transformation_id mode is used without transformation_id annotation + - chain_iid mode is used without chain_iid annotation + """ + pdb_id = "1rxz" + assembly_id = "1" + source = get_pdb_path(pdb_id) + + # Load assembly with ambiguous chain annotations + result = parse( + source, config=ParseConfig(hydrogen_policy="remove", build_assembly=[assembly_id], add_missing_atoms=True) + ) + assembly_original = result["assemblies"][assembly_id][0] + + with tempfile.TemporaryDirectory() as tmp_dir: + # Error case 1: No disambiguation for ambiguous structure + with pytest.raises(ValueError, match="Ambiguous chain annotations detected"): + to_cif_file(assembly_original, Path(tmp_dir) / "fail.cif", chain_disambiguation=None) + + # Error case 2: transformation_id mode without transformation_id annotation + assembly_no_tid = assembly_original.copy() + assembly_no_tid.del_annotation("transformation_id") + with pytest.raises( + ValueError, match="chain_disambiguation='transformation_id' specified.*lacks.*'transformation_id'" + ): + to_cif_file(assembly_no_tid, Path(tmp_dir) / "fail.cif", chain_disambiguation="transformation_id") + + # Error case 3: chain_iid mode without chain_iid annotation + assembly_no_ciid = assembly_original.copy() + assembly_no_ciid.del_annotation("chain_iid") + with pytest.raises(ValueError, match="chain_disambiguation='chain_iid' specified.*lacks.*'chain_iid'"): + to_cif_file(assembly_no_ciid, Path(tmp_dir) / "fail.cif", chain_disambiguation="chain_iid") + + def test_parse_with_no_resolved_atoms(tmpdir): # Spoof the input data using the inference pipeline smiles = "C[C@]12CC[C@@H](C[C@H]1CC[C@@H]3[C@@H]2C[C@H]([C@]4([C@@]3(CC[C@@H]4C5=CC(=O)OC5)O)C)O)O" @@ -315,8 +708,7 @@ def test_inject_msa_information_into_chain_info(): cif_buffer_with_metadata = to_cif_buffer( atom_array, - id="test_inject_msa", - extra_categories={"msa_paths_by_chain_id": msa_paths_by_chain_id}, + config=CIFWriteConfig(id="test_inject_msa", extra_categories={"msa_paths_by_chain_id": msa_paths_by_chain_id}), ) # ... parse @@ -333,13 +725,13 @@ def test_inject_msa_information_into_chain_info(): ("7ubd_from_af3.cif", "7ubd"), ], ) -def test_load_from_af3_output(af3_cif_filename, pdb_id): - cif_path_af3 = TEST_DATA_IO / af3_cif_filename +def test_load_from_af3_output(af3_cif_filename, pdb_id, compressed_example): + cif_path_af3 = compressed_example(TEST_DATA_IO / af3_cif_filename) cif_path_rcsb = get_pdb_path(pdb_id) # Parse the structure without CCD mirror path - atom_array_from_af3 = parse(cif_path_af3, hydrogen_policy="remove")["assemblies"]["1"][0] - atom_array_from_rcsb = parse(cif_path_rcsb, hydrogen_policy="remove")["assemblies"]["1"][0] + atom_array_from_af3 = parse(cif_path_af3, config=ParseConfig(hydrogen_policy="remove"))["assemblies"]["1"][0] + atom_array_from_rcsb = parse(cif_path_rcsb, config=ParseConfig(hydrogen_policy="remove"))["assemblies"]["1"][0] assert len(atom_array_from_af3) == len(atom_array_from_rcsb), "Atom arrays are not the same length" @@ -356,15 +748,32 @@ def test_load_from_af3_output(af3_cif_filename, pdb_id): ) +def _drop_empty_keys(d: dict) -> dict: + """Recursively drop keys whose value is ``None`` or an empty dict.""" + out = {} + for k, v in d.items(): + if v is None: + continue + if isinstance(v, dict): + v = _drop_empty_keys(v) + if not v: + continue + out[k] = v + return out + + def assert_data_dicts_equal( obtained_data_dict, expected_data_dict, - ignore_metadata_id=False, compare_assemblies=True, asym_unit_annotations_to_compare=None, assembly_annotations_to_compare=None, - compare_box=True, ): + # Copy both dicts to avoid modifying the originals + # Deep copy is needed because we modify nested dicts (extra_info, metadata) + obtained_data_dict = copy.deepcopy(obtained_data_dict) + expected_data_dict = copy.deepcopy(expected_data_dict) + obtained_asym_unit, expected_asym_unit = obtained_data_dict.pop("asym_unit"), expected_data_dict.pop("asym_unit") obtained_assemblies, expected_assemblies = ( obtained_data_dict.pop("assemblies"), @@ -375,8 +784,11 @@ def assert_data_dicts_equal( ensure_atom_array_stack(expected_asym_unit) assert len(obtained_asym_unit) == len(expected_asym_unit), "Asym unit stack depths do not match" for i in range(len(obtained_asym_unit)): - assert_same_atom_array( - obtained_asym_unit[i], expected_asym_unit[i], annotations_to_compare=asym_unit_annotations_to_compare + assert_same_atom_array_or_stack( + obtained_asym_unit[i], + expected_asym_unit[i], + annotations_to_compare=asym_unit_annotations_to_compare, + enforce_order=False, # Allow bond reordering ) if compare_assemblies: @@ -386,8 +798,11 @@ def assert_data_dicts_equal( ensure_atom_array_stack(expected_assembly) assert len(obtained_assembly) == len(expected_assembly), "Asym unit stack depths do not match" for i in range(len(obtained_assembly)): - assert_same_atom_array( - obtained_assembly[i], expected_assembly[i], annotations_to_compare=assembly_annotations_to_compare + assert_same_atom_array_or_stack( + obtained_assembly[i], + expected_assembly[i], + annotations_to_compare=assembly_annotations_to_compare, + enforce_order=False, # Allow bond reordering ) else: obtained_data_dict["extra_info"].pop("struct_oper_category", None) @@ -395,11 +810,20 @@ def assert_data_dicts_equal( obtained_data_dict["extra_info"].pop("assembly_gen_category", None) expected_data_dict["extra_info"].pop("assembly_gen_category", None) - if ignore_metadata_id: - obtained_data_dict["metadata"].pop("id") - expected_data_dict["metadata"].pop("id") + # Compare only common chain_info keys + for chain_id in obtained_data_dict["chain_info"]: + if chain_id in expected_data_dict["chain_info"]: + common_keys = set(obtained_data_dict["chain_info"][chain_id].keys()) & set( + expected_data_dict["chain_info"][chain_id].keys() + ) + for data_dict in [obtained_data_dict, expected_data_dict]: + data_dict["chain_info"][chain_id] = {k: data_dict["chain_info"][chain_id][k] for k in common_keys} + + # Drop None/empty values so missing-vs-None doesn't cause spurious failures + obtained_data_dict = _drop_empty_keys(obtained_data_dict) + expected_data_dict = _drop_empty_keys(expected_data_dict) - assert obtained_data_dict == expected_data_dict + assert_equal(obtained_data_dict, expected_data_dict, allow_extra_keys=True) CONSISTENCY_TEST_CASES_FULL_DICT = [ @@ -410,16 +834,11 @@ def assert_data_dicts_equal( @pytest.mark.parametrize("filepath, expected_data_dict_path", CONSISTENCY_TEST_CASES_FULL_DICT) def test_parse_consistency_full_dict(filepath, expected_data_dict_path): - """ - Compare the parsed structure to a reference structure (computed pre-refactor). - """ - parse_kwargs = { - "convert_mse_to_met": True, - "hydrogen_policy": "remove", - "build_assembly": ["1"], - } - - obtained_data_dict = parse(filepath, **parse_kwargs) + """Compare the parsed structure to a reference structure (computed pre-refactor).""" + obtained_data_dict = parse( + filepath, + config=ParseConfig(convert_mse_to_met=True, hydrogen_policy="remove", build_assembly=["1"]), + ) # Uncomment to update the regression test data # with open(expected_data_dict_path, "wb") as f: @@ -428,11 +847,20 @@ def test_parse_consistency_full_dict(filepath, expected_data_dict_path): with open(expected_data_dict_path, "rb") as f: expected_data_dict = pickle.load(f) - assert_data_dicts_equal(obtained_data_dict, expected_data_dict) + # Get annotations from expected, excluding ones we don't care about + expected_asym_annots = set(expected_data_dict["asym_unit"][0].get_annotation_categories()) + annots_to_compare = expected_asym_annots - {"alt_atom_id", "uses_alt_atom_id"} + + assert_data_dicts_equal( + obtained_data_dict, + expected_data_dict, + asym_unit_annotations_to_compare=annots_to_compare, + assembly_annotations_to_compare=annots_to_compare, + ) @pytest.fixture -def dict_inputs(): +def dict_inputs(compressed_example): """Fixture providing example chemical components for testing.""" monomer = [ { @@ -501,7 +929,7 @@ def dict_inputs(): sdf = [ { - "path": f"{TEST_DATA_IO}/HEM_ideal.sdf", + "path": str(compressed_example(TEST_DATA_IO / "HEM_ideal.sdf")), } ] @@ -526,40 +954,63 @@ def custom_residues(): } -def test_write_read_vs_parse_atom_array(dict_inputs, custom_residues): +def test_write_read_vs_parse_atom_array(dict_inputs, custom_residues, cleanup_registry): """Compare the write-read to/from CIF vs simply parsing an AtomArray.""" + custom_res_dict = custom_residues["C:0"] + custom_component = ChemicalComponent.from_dict(custom_res_dict) + register_custom_ccd_entry("C:0", custom_component.atom_array) + # Parse input components, as in inference code components = sum(dict_inputs.values(), start=[]) - input_atom_array = components_to_atom_array(components, return_components=False, custom_residues=custom_residues) + input_atom_array = components_to_atom_array(components, return_components=False) # Write-read, as was formerly done in the inference code with tempfile.TemporaryDirectory() as temp_dir: cif_path = Path(temp_dir) / "test.cif" to_cif_file(input_atom_array, cif_path, include_nan_coords=True) - parse_kwargs = { - "convert_mse_to_met": True, - "hydrogen_policy": "remove", - "build_assembly": ["1"], - } + config_kwargs = {"convert_mse_to_met": True, "hydrogen_policy": "remove"} - parsed_from_cif = parse(cif_path, **parse_kwargs) + parsed_from_cif = parse(cif_path, config=ParseConfig(**config_kwargs)) # Directly parse the input AtomArray using new code - parsed_from_atom_array = parse_atom_array(input_atom_array, **parse_kwargs) + parsed_from_atom_array = parse_atom_array(input_atom_array, config=PrepareConfig(**config_kwargs)) - # The asym_unit does not typically have a chain_iid, but it will if parsing from an AtomArray that already had it + # The asym_unit does not typically have iid annotations or transformation_id, but it will if parsing from an AtomArray that already had them + # These annotations don't survive CIF roundtrips so we exclude them from comparison asym_unit_annotations_to_compare = [ - annot for annot in parsed_from_atom_array["asym_unit"].get_annotation_categories() if annot != "chain_iid" + annot + for annot in parsed_from_atom_array["asym_unit"].get_annotation_categories() + if annot not in ("chain_iid", "pn_unit_iid", "molecule_iid", "transformation_id") ] - # Check for equivalence, allowing differences only in the metadata ID and the assemblies (parsing an AtomArray - # directly does not build assemblies) + # Subset chain_info to comparable keys (CIF has extra metadata) + keys_to_match = { + "entity", + "chain_type", + "is_polymer", + "res_id", + "res_name", + "processed_entity_canonical_sequence", + "processed_entity_non_canonical_sequence", + } + for chain_id in parsed_from_cif.get("chain_info", {}): + common_keys = ( + set(parsed_from_cif["chain_info"][chain_id].keys()) + & set(parsed_from_atom_array["chain_info"][chain_id].keys()) + ) & keys_to_match + parsed_from_cif["chain_info"][chain_id] = { + k: parsed_from_cif["chain_info"][chain_id][k] for k in common_keys + } + parsed_from_atom_array["chain_info"][chain_id] = { + k: parsed_from_atom_array["chain_info"][chain_id][k] for k in common_keys + } + + # Compare everything including the subsetted chain_info assert_data_dicts_equal( parsed_from_cif, parsed_from_atom_array, - ignore_metadata_id=True, compare_assemblies=False, asym_unit_annotations_to_compare=asym_unit_annotations_to_compare, ) @@ -567,7 +1018,7 @@ def test_write_read_vs_parse_atom_array(dict_inputs, custom_residues): def test_parse_preserves_atom_array_plus(): """Test that parse_atom_array preserves AtomArrayPlus type and 2D annotations.""" - input_atom_array = parse(rcsb.fetch("1out", "cif"), file_type="cif", add_missing_atoms=True)["assemblies"]["1"][0] + input_atom_array = parse(get_pdb_path("1out"), config=ParseConfig(add_missing_atoms=True))["assemblies"]["1"][0] input_atom_array = as_atom_array_plus(input_atom_array) # Add a test 2D annotation to verify it's preserved @@ -575,7 +1026,9 @@ def test_parse_preserves_atom_array_plus(): test_values = [1.5, 2.3, 3.7, 4.2] input_atom_array.set_annotation_2d("test_distances", test_pairs, test_values) - output_atom_array = parse_atom_array(input_atom_array, add_missing_atoms=False)["asym_unit"][0] + output_atom_array = parse_atom_array(input_atom_array, config=PrepareConfig(add_missing_atoms=False))["asym_unit"][ + 0 + ] assert isinstance(output_atom_array, AtomArrayPlus) # Verify 2D annotations are preserved @@ -585,5 +1038,99 @@ def test_parse_preserves_atom_array_plus(): assert np.allclose(annotation_2d.values, test_values) -if __name__ == "__main__": - pytest.main(["-v", __file__]) +def _ccd_category_arrays(ccd_code: str, category: str) -> dict[str, np.ndarray]: + """Return {column: array} for one CCD category.""" + block = _get_ccd_block(ccd_code, CCD_MIRROR_PATH) + cat = block[category] + return {key: cat[key].as_array() for key in cat} + + +def _assert_result_matches_ccd( + result: dict, + ccd_code: str, + category: str, + match_cols: tuple[str, ...], + *, + sort_by: str, +) -> None: + """Assert result category matches CCD exactly on match_cols. + + Both sides are sorted by sort_by and each stable column is compared as a + numpy array. Row counts must match. + """ + assert category in result, f"Category {category!r} missing from result" + + needed = set(match_cols) | {sort_by} + res = {col: np.asarray(result[category][col]) for col in needed} + ccd = _ccd_category_arrays(ccd_code, category) + + ccd_order = np.argsort(ccd[sort_by]) + res_order = np.argsort(res[sort_by]) + + for col in match_cols: + np.testing.assert_array_equal( + ccd[col][ccd_order].astype(str), + res[col][res_order].astype(str), + err_msg=f"[{category}] column {col!r} mismatch", + ) + + +def test_build_chem_comp_atom(): + """_build_chem_comp_atom output matches CCD template columns.""" + ccd_code = "NAD" + atom_array = atom_array_from_ccd_code(ccd_code) + + match_cols = ("comp_id", "type_symbol", "charge") + + result = _build_chem_comp_atom(atom_array) + _assert_result_matches_ccd(result, ccd_code, "chem_comp_atom", match_cols, sort_by="atom_id") + + +CIF_PATHS = [TEST_DATA_IO / "example_distillation_output.cif"] + + +@pytest.mark.parametrize("path", CIF_PATHS) +def test_load_with_all_resolved(path: str, compressed_example): + result = parse( + filename=compressed_example(path), + config=ParseConfig(add_missing_atoms=True, remove_ccds=(), hydrogen_policy="remove"), + ) + # Check if processing runs through + assert result is not None + + # Check if the extra metadata is present (from the custom `_extra_metadata` CIFCategory) + assert result["metadata"]["extra_metadata"] is not None + + +def test_bcif_example(compressed_example): + result = parse( + filename=compressed_example(TEST_DATA_IO / "6lyz.bcif"), + ) + # Check if processing runs through + assert result is not None + + +def test_af2_predicted_pdb_example(compressed_example): + result = parse( + filename=compressed_example(TEST_DATA_IO / "UniRef50_A0A0S8JQ92_AF2_predicted.pdb"), + config=ParseConfig(remove_waters=True, remove_ccds=()), + ) + # Check if processing runs through + assert result is not None + + +@pytest.mark.parametrize( + "pdb_id,config_name", + [("1ivo", "annotations_only"), ("1a8o", "lightweight")], +) +def test_prepare_atom_array(pdb_id: str, config_name: str): + """Ensure that prepare_atom_array produces expected annotations.""" + data = parse(get_pdb_path(pdb_id)) + atoms = data["asym_unit"][0] + result = prepare_atom_array(atoms, config=config_name) + + assert isinstance(result, struc.AtomArray) + annots = result.get_annotation_categories() + assert "atomic_number" in annots + assert "pn_unit_id" in annots + assert "molecule_id" in annots diff --git a/tests/io/utils/test_link_product_regressions.py b/tests/io/utils/test_link_product_regressions.py new file mode 100644 index 00000000..0498d764 --- /dev/null +++ b/tests/io/utils/test_link_product_regressions.py @@ -0,0 +1,163 @@ +"""Focused regressions for authored-link product chemistry.""" + +import numpy as np +import pytest +from biotite.structure import BondType + +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from atomworks.io.utils.atom_array import get_bond_degree_per_atom +from atomworks.io.utils.io_utils import read_any +from atomworks.io.utils.link_chemistry import _has_valid_rdkit_valence +from tests.io.conftest import get_pdb_path + + +def _parse(pdb_id, *, include_metals=False, hydrogen_policy="remove"): + struct_conn_types = ("covale", "metalc") if include_metals else ("covale",) + return parse( + get_pdb_path(pdb_id), + config=ParseConfig.from_preset( + "rcsb", + model=1, + build_assembly=None, + hydrogen_policy=hydrogen_policy, + add_bond_types_from_struct_conn=struct_conn_types, + ), + )["asym_unit"][0] + + +def _atom(atoms, chain_id, res_id, res_name, atom_name): + return np.flatnonzero( + (atoms.chain_id == chain_id) + & (atoms.res_id == res_id) + & (atoms.res_name == res_name) + & (atoms.atom_name == atom_name) + ).item() + + +def _bond_type(atoms, atom1, atom2): + neighbors, types = atoms.bonds.get_bonds(atom1) + return types[neighbors == atom2].item() + + +def test_phosphoryl_addition_lowers_metal_coordinated_double_bond(): + """1CUL's fifth P substituent changes Mg-coordinated P=O to P-O(-).""" + atoms = _parse("1cul", include_metals=True) + phosphorus = _atom(atoms, "G", 1003, "3PO", "PA") + oxygen = _atom(atoms, "G", 1003, "3PO", "O1A") + + assert _bond_type(atoms, phosphorus, oxygen) == BondType.SINGLE + assert BondType.COORDINATION in atoms.bonds.get_bonds(oxygen)[1] + assert atoms.charge[oxygen] == -1 + assert get_bond_degree_per_atom(atoms)[phosphorus] == 5 + + +@pytest.mark.parametrize("hydrogen_policy", ["keep", "remove"]) +def test_terminal_carboxylate_link_shifts_resonance(hydrogen_policy): + """1PPV links GLU OE1 as a single bond and moves C=O to equivalent OE2.""" + atoms = _parse("1ppv", include_metals=True, hydrogen_policy=hydrogen_policy) + carbon = _atom(atoms, "A", 116, "GLU", "CD") + linked = _atom(atoms, "A", 116, "GLU", "OE1") + sibling = _atom(atoms, "A", 116, "GLU", "OE2") + + assert _bond_type(atoms, carbon, linked) == BondType.SINGLE + assert _bond_type(atoms, carbon, sibling) == BondType.DOUBLE + assert atoms.charge[[linked, sibling]].tolist() == [0, 0] + assert get_bond_degree_per_atom(atoms)[[linked, sibling]].tolist() == [2, 2] + + +@pytest.mark.parametrize( + ("pdb_id", "donor_key", "center_key", "acceptor_key", "acceptor_bond", "acceptor_hydrogens"), + [ + ("8qia", ("A", 62, "CYS", "SG"), ("B", 500, "FMN", "C4A"), ("B", 500, "FMN", "N5"), 1, 1), + ("1u9x", ("A", 27, "CYS", "SG"), ("B", 300, "IHJ", "C12"), ("B", 300, "IHJ", "N13"), 2, 1), + ("2zz6", ("A", 75, "LYS", "NZ"), ("C", 301, "6AZ", "C6"), ("C", 301, "6AZ", "C5"), 1, 2), + ], +) +def test_pi_bond_addition_transfers_donor_hydrogen( + pdb_id, donor_key, center_key, acceptor_key, acceptor_bond, acceptor_hydrogens +): + """C=N, C-triple-N, and C=C additions reduce the pi bond and transfer H.""" + atoms = _parse(pdb_id) + donor, center, acceptor = (_atom(atoms, *key) for key in (donor_key, center_key, acceptor_key)) + + assert _bond_type(atoms, center, donor) == BondType.SINGLE + assert _bond_type(atoms, center, acceptor) == BondType(acceptor_bond) + assert atoms.nhyd[acceptor] == acceptor_hydrogens + assert all(_has_valid_rdkit_valence(atoms, index) for index in (donor, center, acceptor)) + + +def test_dna_link_removes_missing_op3_without_touching_observed_op2(): + """6W13 8OG consumes missing OP3 but preserves the deposited OP2 exactly.""" + path = get_pdb_path("6w13") + atom_site = read_any(path).block["atom_site"] + source = ( + (atom_site["label_asym_id"].as_array(str) == "B") + & (atom_site["label_seq_id"].as_array(str) == "3") + & (atom_site["label_comp_id"].as_array(str) == "8OG") + & (atom_site["label_atom_id"].as_array(str) == "OP2") + & (atom_site["pdbx_PDB_model_num"].as_array(str) == "1") + ) + source_coord = np.array( + [atom_site[axis].as_array(float)[source].item() for axis in ("Cartn_x", "Cartn_y", "Cartn_z")] + ) + atoms = _parse("6w13") + residue = (atoms.chain_id == "B") & (atoms.res_id == 3) & (atoms.res_name == "8OG") + op2 = _atom(atoms, "B", 3, "8OG", "OP2") + + np.testing.assert_array_equal(atoms.coord[op2], source_coord.astype(atoms.coord.dtype)) + assert "OP3" not in atoms.atom_name[residue] + + +def test_thymine_photodimer_consumes_both_pi_bonds(): + """1N4E's two authored links convert adjacent thymines into a cyclobutane.""" + atoms = _parse("1n4e") + degree = get_bond_degree_per_atom(atoms) + + for atom_name in ("C5", "C6"): + first = _atom(atoms, "B", 5, "DT", atom_name) + second = _atom(atoms, "B", 6, "DT", atom_name) + assert _bond_type(atoms, first, second) == BondType.SINGLE + assert degree[[first, second]].tolist() == [4, 4] + for res_id in (5, 6): + assert ( + _bond_type(atoms, _atom(atoms, "B", res_id, "DT", "C5"), _atom(atoms, "B", res_id, "DT", "C6")) + == BondType.SINGLE + ) + + +def test_asn_lys_link_removes_only_unresolved_leaving_nitrogen(): + """6N0A replaces missing ASN ND2 while retaining its observed carbonyl O.""" + atoms = _parse("6n0a") + residue = (atoms.chain_id == "A") & (atoms.res_id == 153) & (atoms.res_name == "ASN") + carbon = _atom(atoms, "A", 153, "ASN", "CG") + nitrogen = _atom(atoms, "A", 15, "LYS", "NZ") + + assert "ND2" not in atoms.atom_name[residue] + assert "OD1" in atoms.atom_name[residue] + assert _bond_type(atoms, carbon, nitrogen) == BondType.SINGLE + assert get_bond_degree_per_atom(atoms)[carbon] == 4 + + +@pytest.mark.parametrize( + ("pdb_id", "atom_key", "charge"), + [ + ("8r2z", ("B", 301, "XR9", "B2"), -1), + ("5lcl", ("C", 8, "8AF", "P"), 1), + ("1q29", ("A", 1, "G", "O5'"), 1), + ("8q2p", ("Y", 2424, "IRY", "SE1"), 1), + ], +) +def test_link_products_assign_only_valid_required_charges(pdb_id, atom_key, charge): + """Representative borate, phosphonium, oxonium, and selenonium products.""" + atoms = _parse(pdb_id) + atom = _atom(atoms, *atom_key) + + assert atoms.charge[atom] == charge + assert _has_valid_rdkit_valence(atoms, atom) + + +def test_impossible_link_product_still_fails_hard(): + """1VQ7's PAE phosphorus cannot be repaired without bond-order sum seven.""" + with pytest.raises(ValueError, match=r"Unresolved link valence at C/5/PAE/P.*bond-order sum=7"): + _parse("1vq7") diff --git a/tests/io/utils/test_multi_model_cif.py b/tests/io/utils/test_multi_model_cif.py new file mode 100644 index 00000000..72a20273 --- /dev/null +++ b/tests/io/utils/test_multi_model_cif.py @@ -0,0 +1,67 @@ +"""Round-trip tests for variable-length multi-model CIF writing and reading.""" + +import pytest + +from atomworks.io.config import ParseConfig +from atomworks.io.parser import parse +from atomworks.io.utils.atom_array_plus import as_atom_array_plus +from atomworks.io.utils.io_utils import read_any, to_cif_file +from atomworks.io.utils.testing import assert_same_atom_array_or_stack +from atomworks.ml.utils.testing import get_pdb_mirror_path + +# Re-use the SA helper from the existing SA test module +from tests.ml.standard_annotations.test_standard_annotations import add_randomized_standard_annotations + +# Annotations excluded from round-trip comparison (consistent with test_io.py::test_cif_round_trip): +# - chain_type: derived from entity categories (_entity, _entity_poly); omitted in +# multi-model CIF as they may vary among models. +# - atom_id: _atom_site.id is a globally unique counter across all models in the file +# (standard mmCIF practice, matching PDB NMR structures). Per-model values are not preserved. +# - auth_seq_id: biotite's set_structure always writes _atom_site.auth_seq_id = res_id +# (label_seq_id), discarding the original author numbering. Same limitation applies to +# single-model round-trips; excluded in test_io.py for the same reason. +_ANNOTATIONS_NOT_PRESERVED = {"chain_type", "atom_id", "auth_seq_id"} + + +@pytest.mark.parametrize("n_models", [2, 3]) +@pytest.mark.parametrize("file_type", ["cif", "cif.gz", "cif.gzip", "cif.zst"]) +def test_multi_model_cif_round_trip(n_models, tmp_path, file_type): + """Save a list of AtomArrays with different lengths to CIF and reload each model. + + Uses non-default model_ids to verify that custom IDs are written to the file + and that models are returned in the correct order on load. + """ + pdb_ids = ["6lyz", "1a8o", "2e2h"][:n_models] + model_ids = [5, 12, 99][:n_models] + arrays = [as_atom_array_plus(parse(get_pdb_mirror_path(pid), config="minimal")["asym_unit"][0]) for pid in pdb_ids] + lengths = [arr.array_length() for arr in arrays] + assert len(set(lengths)) > 1, "Test structures must have different atom counts" + + arrays = [add_randomized_standard_annotations(arr) for arr in arrays] + + path = tmp_path / ("multi_model." + file_type) + to_cif_file(arrays, path, model_ids=model_ids, save_standard_annotations=True) + + # Verify the custom model_ids are written in the correct order + cif_file = read_any(path) + block = cif_file[list(cif_file.keys())[0]] + written_model_nums = list(dict.fromkeys(block["atom_site"]["pdbx_PDB_model_num"].as_array(int).tolist())) + assert written_model_nums == model_ids + + results = parse( + path, config=ParseConfig.from_preset("minimal", load_standard_annotations=True, return_atom_array_plus=True) + ) + assert isinstance(results, list), "parse() must return list[dict] for variable-length multi-model CIF" + assert len(results) == n_models + + for original, result in zip(arrays, results, strict=False): + loaded = result["asym_unit"][0] + annotations_to_compare = [ + f for f in original.get_annotation_categories() if f not in _ANNOTATIONS_NOT_PRESERVED + ] + assert_same_atom_array_or_stack( + original, + loaded, + annotations_to_compare=annotations_to_compare, + compare_bonds=False, + ) diff --git a/tests/io/utils/test_query.py b/tests/io/utils/test_query.py index 11e33058..4521d232 100644 --- a/tests/io/utils/test_query.py +++ b/tests/io/utils/test_query.py @@ -1,23 +1,26 @@ import numpy as np import pytest +from biotite.structure import AtomArray from atomworks.io.transforms.atom_array import is_any_coord_nan from atomworks.io.utils.io_utils import load_any from atomworks.io.utils.query import QueryExpression, idxs, mask, query +from atomworks.io.utils.standard_annotations import S_ATM from atomworks.io.utils.testing import get_pdb_path +from atomworks.ml.conditions import C_IDX, C_NTR @pytest.fixture(scope="module") def atom_array(): """Cache the loaded atom array for all tests in this module.""" - atom_array = load_any(get_pdb_path("6lyz"), model=1) + atom_array = load_any(get_pdb_path("6lyz"), model=1, include_bonds=True) return atom_array @pytest.fixture(scope="module") def atom_array_stack(): """Cache the loaded atom array stack for all tests in this module.""" - atom_array_stack = load_any(get_pdb_path("6lyz")) + atom_array_stack = load_any(get_pdb_path("6lyz"), include_bonds=True) return atom_array_stack @@ -319,6 +322,26 @@ def test_numeric_vs_string_types(self, atom_array): assert isinstance(result2, type(atom_array)) +@pytest.mark.parametrize( + "expr, expected", + [ + # bare path selection via sel() + ("sel('A/*/*/CA')", lambda a: (a.chain_id == "A") & (a.atom_name == "CA")), + # path selection intersected with a query predicate + ("sel('A/*/*/CA') & (res_id < 50)", lambda a: (a.chain_id == "A") & (a.atom_name == "CA") & (a.res_id < 50)), + # bracket list inside sel() + ("sel('A/*/*/[CA,CB]')", lambda a: (a.chain_id == "A") & np.isin(a.atom_name, ["CA", "CB"])), + # top-level comma unions tokens inside sel() + ("sel('A/*/*/CA, A/*/*/CB')", lambda a: (a.chain_id == "A") & np.isin(a.atom_name, ["CA", "CB"])), + # empty sel() is non-raising: contributes an all-False mask and composes with | + ("sel('ZZ/*/*/CA') | (atom_name == 'CA')", lambda a: a.atom_name == "CA"), + ], +) +def test_sel_in_query(atom_array, expr, expected): + """``sel('...')`` bridges path-selection syntax into query expressions.""" + assert np.array_equal(mask(atom_array, expr), expected(atom_array)) + + def test_query_expression_reuse(atom_array): """Test that QueryExpression can be reused across different arrays.""" @@ -340,3 +363,35 @@ def test_query_expression_reuse(atom_array): idxs1 = expr.idxs(atom_array) idxs2 = expr.idxs(atom_array) assert np.array_equal(idxs1, idxs2) + + +def test_standard_annotation_mask_query(atom_array): + array = atom_array.copy() + expected = np.arange(array.array_length()) % 2 == 0 + C_IDX.set_annotation(array, expected) + + assert np.array_equal(mask(array, "sa_mask('index')"), expected) + assert np.array_equal(mask(array, f"sa_mask('{C_IDX.full_name}')"), expected) + + +def test_standard_annotation_mask_query_resolves_shortcuts_and_storage_aliases(atom_array): + array = atom_array.copy() + expected = np.arange(array.array_length()) % 2 == 0 + array.set_annotation("mask_n-terminus_1_residue", expected) + S_ATM.set_annotation(array, expected) + + assert np.array_equal(mask(array, "sa_mask('C_NTR')"), expected) + assert np.array_equal(mask(array, f"sa_mask('{C_NTR.full_name}')"), expected) + assert np.array_equal(mask(array, "sa_mask('mask_n-terminus_1_residue')"), expected) + assert np.array_equal(mask(array, "sa_mask('S_ATM')"), expected) + + +def test_apply_and_spread_query(): + array = AtomArray(4) + array.chain_id = np.array(["A", "A", "A", "A"]) + array.res_id = np.array([1, 1, 2, 2]) + array.atom_name = np.array(["N", "CA", "N", "C"]) + array.set_annotation("b_factor", np.array([1.0, 3.0, 4.0, 6.0])) + + assert mask(array, "apply_and_spread('residue', atom_name == 'CA', 'any')").tolist() == [True, True, False, False] + assert mask(array, "apply_and_spread('residue', b_factor, 'mean') > 4").tolist() == [False, False, True, True] diff --git a/tests/io/utils/test_scatter.py b/tests/io/utils/test_scatter.py index 4db0161b..c3aec51d 100644 --- a/tests/io/utils/test_scatter.py +++ b/tests/io/utils/test_scatter.py @@ -7,6 +7,7 @@ apply_group_wise, get_groups, get_segments, + safe_scatter, spread_group_wise, ) @@ -164,5 +165,15 @@ def test_apply_and_spread_segment_wise_sum_and_mean(): ), f"Failed mean test. Expected {expected_mean}, got {result_mean_groups}" +def test_safe_scatter(): + """Scatter source values onto target by index; ``mapping < 0`` drops, string target widens, input unmutated.""" + target = np.array(["a", "b", "c"], dtype=" None: - """Select a range of residue IDs within a chain using extended syntax.""" - selection_stack = AtomSelectionStack.from_query("A/*/1-2") - mask = selection_stack.get_mask(basic_atom_array) - # Expect residues 1 and 2 in chain A (first four atoms) - expected_mask = np.array([True, True, True, True, False, False], dtype=bool) - assert np.array_equal(mask, expected_mask) +@pytest.fixture +def bracket_array() -> struc.AtomArray: + """chain A spans res_id -1..2 (to exercise 0/negatives/ranges); chain B has res_id 1.""" + return struc.array( + [ + struc.Atom(np.array([0, 0, 0]), chain_id="A", res_id=-1, res_name="ALA", atom_name="N"), + struc.Atom(np.array([0, 0, 0]), chain_id="A", res_id=0, res_name="GLY", atom_name="CA"), + struc.Atom(np.array([0, 0, 0]), chain_id="A", res_id=1, res_name="VAL", atom_name="N"), + struc.Atom(np.array([0, 0, 0]), chain_id="A", res_id=1, res_name="VAL", atom_name="CA"), + struc.Atom(np.array([0, 0, 0]), chain_id="A", res_id=2, res_name="LEU", atom_name="CA"), + struc.Atom(np.array([0, 0, 0]), chain_id="B", res_id=1, res_name="ALA", atom_name="N"), + struc.Atom(np.array([0, 0, 0]), chain_id="B", res_id=1, res_name="ALA", atom_name="CA"), + ] + ) -def test_atom_selection_stack_from_query_multiple_tokens(basic_atom_array: struc.AtomArray) -> None: - """Union of multiple selection tokens.""" - selection_stack = AtomSelectionStack.from_query(["A/ALA", "B/VAL"]) # include ALA in chain A and VAL in chain B - mask = selection_stack.get_mask(basic_atom_array) - # Expect ALA in chain A (first two atoms) and VAL in chain B (last two atoms) - expected_mask = np.array([True, True, False, False, True, True], dtype=bool) - assert np.array_equal(mask, expected_mask) +@pytest.mark.parametrize( + "query, expected_indices", + [ + ("[A,B]/*/*/N", [0, 2, 5]), # chain list + ("A/*/*/[N,CA]", [0, 1, 2, 3, 4]), # atom-name list + ("[A,B]/*/*/[N,CA]", [0, 1, 2, 3, 4, 5, 6]), # cartesian product of two lists + ("A/*/[1-2]", [2, 3, 4]), # bracketed range + ("A/*/[0]", [1]), # res_id 0 filters (not a wildcard) + ("A/*/[-1-0]", [0, 1]), # negative range through 0 + ("A/*/[-1,2]", [0, 4]), # negative id + scalar in a list + ("[A, B]/*/*/N", [0, 2, 5]), # whitespace inside brackets ignored + ("A/*/[1], B/*/[1]", [2, 3, 5, 6]), # top-level comma unions tokens + (["A/GLY", "B/ALA"], [1, 5, 6]), # list input unions tokens + ], +) +def test_from_query_masks(bracket_array: struc.AtomArray, query: str | list[str], expected_indices: list[int]) -> None: + """``from_query`` extended syntax: ``[...]`` lists, bracket-only ranges (incl. 0/negatives), unions.""" + mask = AtomSelectionStack.from_query(query).get_mask(bracket_array) + assert list(np.where(mask)[0]) == expected_indices + + +@pytest.mark.parametrize( + "call", + [ + lambda: AtomSelectionStack.from_query("A/*/5-10"), # bare range must be bracketed + lambda: AtomSelectionStack.from_query("A/[]/1"), # empty bracket list + lambda: AtomSelectionStack.from_query("A/[B,C"), # unbalanced bracket + lambda: AtomSelectionStack.from_query("A/*/[1-2"), # unbalanced bracket (res_id) + lambda: AtomSelection.from_selection_str("[A,B]/ALA"), # single selection rejects lists + lambda: parse_selection_string("A/*/[1-5]"), # single selection rejects lists + ], +) +def test_invalid_selection_raises(call) -> None: + with pytest.raises(ValueError): + call() if __name__ == "__main__": diff --git a/tests/io/utils/test_sequence_maps.py b/tests/io/utils/test_sequence_maps.py index e5c35238..cc6abe84 100644 --- a/tests/io/utils/test_sequence_maps.py +++ b/tests/io/utils/test_sequence_maps.py @@ -5,9 +5,11 @@ from atomworks.enums import ChainType from atomworks.io.parser import parse +from atomworks.io.tools.fasta import infer_chain_type_from_one_letter from atomworks.io.utils.sequence import ( get_1_from_3_letter_code, get_3_from_1_letter_code, + infer_chain_type_from_three_letter, ) from tests.io.conftest import get_pdb_path @@ -15,8 +17,7 @@ def non_canonical_sequence_length(s): - """ - Calculate the length of a non-canonical sequence string. + """Calculate the length of a non-canonical sequence string. Example: >>> custom_length("(ABC)FDS(DCS)") @@ -80,7 +81,7 @@ def test_parser_one_letter_sequence_outputs(pdb_id: str): # If there's no sequence heterogeneity, perform additional checks if ( - not chain_details["has_sequence_heterogeneity"] + not chain_details.get("has_sequence_heterogeneity", False) and (chain_type == "polypeptide(D)" or chain_type == "polypeptide(L)") and (unprocessed_cleaned != processed_cleaned) ): @@ -168,3 +169,60 @@ def test_get_3_from_1_letter_code(letter, chain_type, expected_three_letter): ) def test_get_1_from_3_letter_code(three_letter_code, chain_type, expected_one_letter): assert get_1_from_3_letter_code(three_letter_code, ChainType.as_enum(chain_type)) == expected_one_letter + + +@pytest.mark.parametrize( + "ccd_codes,expected", + [ + (["ALA", "CYS", "ASP"], ChainType.POLYPEPTIDE_L), + (["GLY", "PRO", "VAL", "LEU"], ChainType.POLYPEPTIDE_L), + (["DA", "DT", "DG", "DC"], ChainType.DNA), + (["A", "U", "G", "C"], ChainType.RNA), + ], +) +def test_infer_chain_type_from_three_letter(ccd_codes, expected): + """Test the new function name.""" + result = infer_chain_type_from_three_letter(ccd_codes) + assert result == expected + + +@pytest.mark.parametrize( + "seq,expected", + [ + # Simple one-letter protein + ("ACDEFG", ChainType.POLYPEPTIDE_L), + ( + "MAEGEITTFTALTEKFNLPPGNYKKPKLLYCSNGGHFLRILPDGTVDGTRDRSDQHIQLQLSAESVGEVYIKSTET", + ChainType.POLYPEPTIDE_L, + ), + # Simple one-letter DNA + ("ATGC", ChainType.DNA), + ("CGCGAATTCGCG", ChainType.DNA), + # Simple one-letter RNA + ("ACGU", ChainType.RNA), + ("CGCGAAUUCGCG", ChainType.RNA), + # Parenthesized notation - DNA + ("(DA)(DT)(DG)(DC)", ChainType.DNA), + (["(DA)", "(DT)", "(DG)", "(DC)"], ChainType.DNA), + # Parenthesized notation - RNA + ("(A)(U)(G)(C)", ChainType.RNA), + # Mixed protein with non-standard - unambiguous protein codes dominate + ("ACDE(SEP)FG", ChainType.POLYPEPTIDE_L), + # List format + (["A", "C", "D", "E", "F"], ChainType.POLYPEPTIDE_L), + (["A", "T", "G", "C"], ChainType.DNA), + # Contains valid amino acid Y (tyrosine), so infers as protein + ("XYZ", ChainType.POLYPEPTIDE_L), + ], +) +def test_infer_chain_type_from_one_letter(seq, expected): + """Test chain type inference from sequence notation.""" + result = infer_chain_type_from_one_letter(seq) + assert result == expected + + +def test_infer_chain_type_from_one_letter_raises_on_ambiguous(): + """Test that inference raises ValueError for truly ambiguous sequences.""" + # All letters A, C, G are ambiguous (could be protein or nucleotide) + with pytest.raises(ValueError, match="Could not infer chain type"): + infer_chain_type_from_one_letter(["A", "C", "(SEP)", "G"]) diff --git a/tests/io/utils/test_testing_tools.py b/tests/io/utils/test_testing_tools.py index 81f9118b..b0266b00 100644 --- a/tests/io/utils/test_testing_tools.py +++ b/tests/io/utils/test_testing_tools.py @@ -2,14 +2,17 @@ import pytest from atomworks.io.utils.io_utils import load_any -from atomworks.io.utils.testing import assert_same_atom_array, is_same_in_group, is_same_in_segment +from atomworks.io.utils.testing import assert_same_atom_array_or_stack, is_same_in_group, is_same_in_segment from tests.io.conftest import TEST_DATA_IO @pytest.fixture def atom_array(): return load_any( - TEST_DATA_IO / "6lyz.bcif", model=1, extra_fields=["charge", "b_factor", "occupancy"], include_bonds=True + TEST_DATA_IO / "6lyz.bcif", + model=1, + extra_fields=["charge", "b_factor", "occupancy"], + include_bonds=True, ) @@ -29,52 +32,40 @@ def atom_array(): ) def test_annotations_change(atom_array, annotation, change_value): atom_array2 = atom_array.copy() - assert_same_atom_array(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2) atom_array2.get_annotation(annotation)[0] = change_value with pytest.raises(AssertionError): - assert_same_atom_array(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2) annotations = atom_array.get_annotation_categories() annotations_to_compare = [annot for annot in annotations if annot != annotation] - assert_same_atom_array(atom_array, atom_array2, annotations_to_compare=annotations_to_compare) + assert_same_atom_array_or_stack(atom_array, atom_array2, annotations_to_compare=annotations_to_compare) def test_bonds_change(atom_array): atom_array2 = atom_array.copy() - assert_same_atom_array(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2) atom_array2.bonds.add_bond(0, 1) with pytest.raises(AssertionError): - assert_same_atom_array(atom_array, atom_array2) - assert_same_atom_array(atom_array, atom_array2, compare_bonds=False) + assert_same_atom_array_or_stack(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2, compare_bonds=False) def test_coords_change(atom_array): atom_array2 = atom_array.copy() - assert_same_atom_array(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2) atom_array2.coord[0] = atom_array2.coord[0] + 1 with pytest.raises(AssertionError): - assert_same_atom_array(atom_array, atom_array2) - assert_same_atom_array(atom_array, atom_array2, compare_coords=False) + assert_same_atom_array_or_stack(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2, compare_coords=False) def test_atom_array_length_change(atom_array): atom_array2 = atom_array.copy() - assert_same_atom_array(atom_array, atom_array2) + assert_same_atom_array_or_stack(atom_array, atom_array2) atom_array2 = atom_array2[:-1] with pytest.raises(AssertionError): - assert_same_atom_array(atom_array, atom_array2) - - -def test_scrambled_order(atom_array): - atom_array2 = atom_array.copy() - assert_same_atom_array(atom_array, atom_array2) - # Swap first two atoms - swap_first_two = np.arange(len(atom_array)) - swap_first_two[0], swap_first_two[1] = swap_first_two[1], swap_first_two[0] - atom_array2 = atom_array2[swap_first_two] - with pytest.raises(AssertionError): - assert_same_atom_array(atom_array, atom_array2, enforce_order=True, compare_coords=False) - assert_same_atom_array(atom_array, atom_array2, enforce_order=False, compare_coords=False) + assert_same_atom_array_or_stack(atom_array, atom_array2) def test_is_same_in_segment(): diff --git a/tests/io/utils/test_visualize.py b/tests/io/utils/test_visualize.py index 5b50f85a..6e2c3d53 100644 --- a/tests/io/utils/test_visualize.py +++ b/tests/io/utils/test_visualize.py @@ -2,6 +2,7 @@ import py3Dmol import pytest from biotite.structure import AtomArray +from biotite.structure.bonds import BondList from atomworks.io.transforms.atom_array import is_any_coord_nan from atomworks.io.utils.visualize import view @@ -27,6 +28,7 @@ def _has_pymol_remote() -> bool: def sample_atom_array(): """Create a sample AtomArray for testing.""" atoms = AtomArray(10) + atoms.bonds = BondList(10) atoms.set_annotation("chain_id", ["A"] * 5 + ["B"] * 5) atoms.set_annotation("element", [6] * 5 + [7] * 5) # Carbon and Nitrogen atoms.set_annotation("res_id", list(range(1, 11))) @@ -121,7 +123,7 @@ def test_view_pymol_remote(sample_atom_array): from atomworks.io.utils.visualize import get_pymol_session, view_pymol from tests.io.conftest import get_pdb_path - result = parse(get_pdb_path("5ocm"), ccd_mirror_path=None) + result = parse(get_pdb_path("5ocm")) array = result["assemblies"]["1"][0] obj_name = view_pymol(array) diff --git a/tests/ml/conditions/test_condition_save_load.py b/tests/ml/conditions/test_condition_save_load.py deleted file mode 100644 index a54aaed4..00000000 --- a/tests/ml/conditions/test_condition_save_load.py +++ /dev/null @@ -1,194 +0,0 @@ -from pathlib import Path -from tempfile import TemporaryDirectory - -import numpy as np -import pytest -from biotite.structure import AtomArray - -from atomworks.io.parser import STANDARD_PARSER_ARGS -from atomworks.io.utils.atom_array_plus import ( - AnnotationList2D, - AtomArrayPlus, - as_atom_array_plus, -) -from atomworks.io.utils.selection import get_annotation_categories -from atomworks.ml.conditions import CONDITIONS -from atomworks.ml.utils.condition import ( - load_atom_array_with_conditions_from_cif, - save_atom_array_with_conditions_to_cif, -) -from atomworks.ml.utils.testing import cached_parse - - -@pytest.fixture -def atom_array() -> AtomArrayPlus: - cif_parser_args = { - **STANDARD_PARSER_ARGS, - **{ - "fix_arginines": False, - "add_missing_atoms": False, # this is crucial otherwise the annotations are deleted - }, - } - cif_parser_args.pop("add_bond_types_from_struct_conn") - cif_parser_args.pop("remove_ccds") - data = cached_parse("6lyz", **cif_parser_args) - atom_array = data["atom_array"] - atom_array = as_atom_array_plus(atom_array) - return atom_array - - -def save_and_load_atom_array(atom_array: AtomArray) -> AtomArray: - """ - Saves an atom array to a temporary file and loads it back in using standard functions. - """ - with TemporaryDirectory() as temp_dir: - temp_file = Path(temp_dir) / "test.cif" - save_atom_array_with_conditions_to_cif(atom_array, temp_file) - loaded_atom_array = load_atom_array_with_conditions_from_cif(temp_file, fill_missing_conditions=True) - - return loaded_atom_array - - -def test_save_and_load_atom_array_with_defaults(atom_array: AtomArray): - """ - Test that saving and loading an atom array with all defaults preserves the atom array. - """ - loaded_atom_array = save_and_load_atom_array(atom_array) - - possible = {c.full_name for c in CONDITIONS} | {c.mask_name for c in CONDITIONS} - existing = set( - get_annotation_categories(loaded_atom_array, n_body=1) + get_annotation_categories(loaded_atom_array, n_body=2) - ) - assert possible.issubset( - existing - ), f"Some possible annotations are not found in loaded_atom_array: {possible - existing}" - - for condition_cls in CONDITIONS: - default_mask = condition_cls.default_mask(atom_array) - annotated_mask = condition_cls.mask(loaded_atom_array) - if isinstance(default_mask, AnnotationList2D): - default_mask = default_mask.as_dense_array() - if isinstance(annotated_mask, AnnotationList2D): - annotated_mask = annotated_mask.as_dense_array() - assert np.array_equal(default_mask, annotated_mask, equal_nan=True) - - if not condition_cls.is_mask and condition_cls.name != "sequence": - default_annotation = condition_cls.default_annotation(atom_array) - annotated_annotation = condition_cls.annotation(loaded_atom_array) - if isinstance(default_annotation, AnnotationList2D): - default_annotation = default_annotation.as_dense_array() - if isinstance(annotated_annotation, AnnotationList2D): - annotated_annotation = annotated_annotation.as_dense_array() - assert np.array_equal(default_annotation, annotated_annotation, equal_nan=True) - - -def test_save_and_load_atom_array_with_conditions(atom_array: AtomArray): - """ - Same as test_save_and_load_atom_array_with_defaults but we randomly set some of each condition - and make sure the annotations are preserved. - """ - - # Generate some non-zero values for all conditions - frozen_condition_values = {} - for condition_cls in CONDITIONS: - if condition_cls.name == "coordinate": - random_indices = np.random.choice(len(atom_array), size=5, replace=False) - mask = np.zeros(len(atom_array), dtype=bool) - mask[random_indices] = True - condition_cls.set_mask(atom_array, mask) - values = condition_cls.annotation(atom_array, default="generate") - atom_array.set_annotation(condition_cls.full_name, values) - frozen_condition_values[condition_cls.full_name] = { - "mask": mask, - "condition": values, - } - - elif condition_cls.n_body == 1: - random_indices = np.random.choice(len(atom_array), size=5, replace=False) - random_values = np.random.random(size=5) - if np.issubdtype(condition_cls.dtype, bool): - random_values = random_values > 0.5 - elif np.issubdtype(condition_cls.dtype, np.integer): - random_values = np.random.randint(0, 100, size=5) - mask = np.zeros(len(atom_array), dtype=bool) - mask[random_indices] = True - frozen_condition_values[condition_cls.full_name] = {"mask": mask} - - if not condition_cls.is_mask and condition_cls.name != "sequence": - condition_arr = np.zeros(len(atom_array), dtype=condition_cls.dtype) - condition_arr[random_indices] = random_values - frozen_condition_values[condition_cls.full_name] |= {"condition": condition_arr} - elif condition_cls.n_body == 2: - random_indices = np.random.choice(len(atom_array), size=(5, 2), replace=False) - random_values = np.random.random(size=5) - if np.issubdtype(condition_cls.dtype, bool): - random_values = random_values > 0.5 - elif np.issubdtype(condition_cls.dtype, np.integer): - random_values = random_values.astype(int) - mask = AnnotationList2D( - n_atoms=len(atom_array), - pairs=random_indices, - values=np.ones_like(random_values, dtype=bool), - ) - frozen_condition_values[condition_cls.full_name] = {"mask": mask} - - if not condition_cls.is_mask: - condition_arr = AnnotationList2D( - n_atoms=len(atom_array), - pairs=random_indices, - values=random_values, - ) - frozen_condition_values[condition_cls.full_name] |= {"condition": condition_arr} - else: - raise ValueError( - f"Condition {condition_cls.full_name} has {condition_cls.n_body} bodies, which is not supported." - ) - - # add annotations to atom_array - for annotation_name, annotation_dict in frozen_condition_values.items(): - condition_cls = CONDITIONS.from_full_name(annotation_name) - condition_cls.set_mask(atom_array, annotation_dict["mask"]) - if not condition_cls.is_mask and condition_cls.name != "sequence": - if condition_cls.n_body == 1: - condition_cls.set_annotation(atom_array, array=annotation_dict["condition"]) - elif condition_cls.n_body == 2: - condition_cls.set_annotation( - atom_array, - pairs=annotation_dict["condition"].pairs, - values=annotation_dict["condition"].values, - ) - else: - raise ValueError( - f"Condition {condition_cls.full_name} has {condition_cls.n_body} bodies, which is not supported." - ) - - loaded_atom_array = save_and_load_atom_array(atom_array) - - # Check that all annotations are preserved after save/load - for annotation_name in frozen_condition_values: - condition_cls = CONDITIONS.from_full_name(annotation_name) - target_annotation = condition_cls.annotation(atom_array) - target_mask = condition_cls.mask(atom_array) - - loaded_annotation = condition_cls.annotation(loaded_atom_array) - loaded_mask = condition_cls.mask(loaded_atom_array) - - if condition_cls.n_body > 1: - target_mask = target_mask.as_dense_array() - loaded_mask = loaded_mask.as_dense_array() - target_annotation = target_annotation.as_dense_array() - loaded_annotation = loaded_annotation.as_dense_array() - - assert np.array_equal( - target_mask, loaded_mask, equal_nan=True - ), f"Mismatch for {condition_cls.full_name}: {np.where(target_mask)} != {np.where(loaded_mask)}" - assert np.array_equal( - target_annotation[target_mask], - loaded_annotation[loaded_mask], - equal_nan=np.issubdtype(condition_cls.dtype, np.floating), - ), f"Mismatch for {condition_cls.full_name}: {target_annotation[target_mask]} != {loaded_annotation[loaded_mask]}" - - -if __name__ == "__main__": - # run pytest on this file with verbose output - pytest.main(["-v", __file__]) diff --git a/tests/ml/conditions/test_condition_transfer.py b/tests/ml/conditions/test_condition_transfer.py new file mode 100644 index 00000000..dd7234b1 --- /dev/null +++ b/tests/ml/conditions/test_condition_transfer.py @@ -0,0 +1,50 @@ +"""Unit tests for the atom-level / residue-level / on_mismatch contract of :py:meth:`ConditionBase.transfer`.""" + +import numpy as np +import pytest +from biotite.structure import AtomArray + +from atomworks.io.utils.atom_array_plus import as_atom_array_plus +from atomworks.ml.conditions import C_CRD, C_SEQ +from atomworks.ml.conditions.base import ConditionConflictError + + +def _arr(n: int, res_names: list[str] | None = None) -> AtomArray: + a = AtomArray(n) + a.chain_id = np.array(["A"] * n) + a.res_id = np.arange(1, n + 1) + a.res_name = np.array(res_names if res_names is not None else ["ALA"] * n, dtype=" tgt atoms 1,0) + C_CRD.transfer(src, tgt, np.array([0, 1]), np.array([1, 0])) + ann = C_CRD.annotation(tgt, default="generate") + assert np.allclose(ann[0], [2, 2, 2]) and np.allclose(ann[1], [1, 1, 1]) + + # residue-level values copied per position (aligned pairs) + C_SEQ.transfer(src, tgt, np.array([0, 1]), np.array([0, 1])) + assert C_SEQ.annotation(tgt, default="generate").tolist() == ["PHE", "SER"] + + # unequal-length indices are a misuse -> raise + with pytest.raises(ValueError, match="equal-length"): + C_SEQ.transfer(src, tgt, np.array([0]), np.array([0, 1])) + + # on_mismatch="raise" (default): a differing value on an already-conditioned target clashes, + # and the target is left untouched by the raise. + with pytest.raises(ConditionConflictError): + C_SEQ.transfer(src, tgt, np.array([1]), np.array([0])) # src[1]=SER vs target=PHE + assert C_SEQ.annotation(tgt, default="generate")[0] == "PHE" + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/ml/conftest.py b/tests/ml/conftest.py index c6530031..e997c5af 100644 --- a/tests/ml/conftest.py +++ b/tests/ml/conftest.py @@ -5,17 +5,12 @@ import pandas as pd import pytest -from dotenv import load_dotenv from atomworks.constants import AF3_EXCLUDED_LIGANDS_REGEX, PDB_MIRROR_PATH, _load_env_var from atomworks.io.parser import STANDARD_PARSER_ARGS from atomworks.io.tools.inference import SequenceComponent from atomworks.ml.datasets import ConcatDatasetWithID, PandasDataset -from atomworks.ml.datasets.loaders import ( - create_base_loader, - create_loader_with_interfaces_and_pn_units_to_score, - create_loader_with_query_pn_units, -) +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.pipelines.af3 import build_af3_transform_pipeline from atomworks.ml.pipelines.rf2aa import build_rf2aa_transform_pipeline from atomworks.ml.preprocessing.constants import TRAINING_SUPPORTED_CHAIN_TYPES_INTS @@ -29,15 +24,6 @@ def pytest_configure(config): - # Get the directory where conftest.py is located - current_dir = os.path.dirname(os.path.abspath(__file__)) - - # Construct path to .env file in the parent directory - dotenv_path = os.path.join(current_dir, "../..", ".env") - - # Load the environment variables - load_dotenv(dotenv_path, override=True) - # We require a PDB mirror (of at least a subset of the PDB) for the AtomWorks.ml tests pdb_mirror_path = os.environ.get("PDB_MIRROR_PATH") if not pdb_mirror_path: @@ -176,7 +162,10 @@ def rf2aa_pn_units_dataset(pn_units_df): data=pn_units_df, name="rf2aa_pn_units", id_column="example_id", - loader=create_loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), + loader=create_structure_loader( + base_path=PDB_MIRROR_PATH, + column_mapping={"query_pn_unit_iids": ["q_pn_unit_iid"]}, + ), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -200,8 +189,9 @@ def rf2aa_interfaces_dataset(interfaces_df): data=interfaces_df, name="rf2aa_interfaces", id_column="example_id", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], base_path=PDB_MIRROR_PATH + loader=create_structure_loader( + base_path=PDB_MIRROR_PATH, + column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]}, ), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, @@ -231,11 +221,15 @@ def rf2aa_validation_dataset(af3_validation_df): return PandasDataset( data=af3_validation_df, name="rf2aa_validation", - loader=create_loader_with_interfaces_and_pn_units_to_score( + loader=create_structure_loader( path_colname="pdb_id", base_path=str(PDB_MIRROR_PATH), extension=".cif.gz", sharding_pattern="/1:3/", + column_mapping={ + "interfaces_to_score": "interfaces_to_score", + "pn_units_to_score": "pn_units_to_score", + }, ), transform=build_rf2aa_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, @@ -263,7 +257,10 @@ def af3_pn_units_dataset(pn_units_df): return PandasDataset( data=pn_units_df, name="af3_pn_units", - loader=create_loader_with_query_pn_units(pn_unit_iid_colnames=["q_pn_unit_iid"], base_path=PDB_MIRROR_PATH), + loader=create_structure_loader( + base_path=PDB_MIRROR_PATH, + column_mapping={"query_pn_unit_iids": ["q_pn_unit_iid"]}, + ), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, @@ -286,8 +283,9 @@ def af3_interfaces_dataset(interfaces_df): return PandasDataset( data=interfaces_df, name="af3_interfaces", - loader=create_loader_with_query_pn_units( - pn_unit_iid_colnames=["pn_unit_1_iid", "pn_unit_2_iid"], base_path=PDB_MIRROR_PATH + loader=create_structure_loader( + base_path=PDB_MIRROR_PATH, + column_mapping={"query_pn_unit_iids": ["pn_unit_1_iid", "pn_unit_2_iid"]}, ), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, @@ -316,11 +314,15 @@ def af3_validation_dataset(af3_validation_df): return PandasDataset( data=af3_validation_df, name="af3_validation", - loader=create_loader_with_interfaces_and_pn_units_to_score( + loader=create_structure_loader( path_colname="pdb_id", base_path=PDB_MIRROR_PATH, extension=".cif.gz", sharding_pattern="/1:3/", + column_mapping={ + "interfaces_to_score": "interfaces_to_score", + "pn_units_to_score": "pn_units_to_score", + }, ), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, @@ -342,7 +344,7 @@ def af2_distillation_dataset_no_metadata(af2_distillation_df_no_metadata): return PandasDataset( data=af2_distillation_df_no_metadata, name="af3_af2fb_distillation_no_metadata", - loader=create_base_loader( + loader=create_structure_loader( base_path=str(TEST_DATA_ML / "af2_distillation" / "cif"), extension=".cif", ), @@ -363,7 +365,7 @@ def af2_distillation_dataset_with_metadata(af2_distillation_df_with_metadata): return PandasDataset( data=af2_distillation_df_with_metadata, name="af3_af2fb_distillation_with_metadata", - loader=create_base_loader(), + loader=create_structure_loader(), transform=build_af3_transform_pipeline( protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=[], diff --git a/tests/ml/datasets/test_ase_dataset.py b/tests/ml/datasets/test_ase_dataset.py new file mode 100644 index 00000000..f5b0d60c --- /dev/null +++ b/tests/ml/datasets/test_ase_dataset.py @@ -0,0 +1,59 @@ +"""Tests for AseDBDataset.""" + +import numpy as np +import pandas as pd +import pytest +from biotite.structure import AtomArray + +pytest.importorskip("ase") +pytest.importorskip("ase.db") +pytest.importorskip("ase_db_backends", reason="ase_db_backends required for AseDBDataset tests") + +import ase +import ase.db + +from atomworks.ml.datasets.ase_dataset import AseDBDataset # noqa: E402 + + +def _loader(raw_data: tuple) -> dict: + atoms_row, _, _metadata_row = raw_data + atoms = atoms_row.toatoms() + atom_array = AtomArray(len(atoms)) + atom_array.coord = atoms.get_positions() + atom_array.element = np.array(atoms.get_chemical_symbols()) + return {"atom_array": atom_array} + + +@pytest.fixture +def ase_db(tmp_path): + db_path = tmp_path / "test.aselmdb" + db = ase.db.connect(str(db_path)) + molecules = [ + ase.Atoms("H2O", positions=[[0, 0, 0], [0, 0, 1], [0, 1, 0]]), + ase.Atoms("CH4", positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [-1, 0, 0]]), + ] + for mol in molecules: + db.write(mol) + parquet_path = tmp_path / "meta.parquet" + pd.DataFrame({"example_id": ["water", "methane"], "lmdb_idx": [0, 1]}).to_parquet(parquet_path) + return str(db_path), str(parquet_path), molecules + + +def test_with_metadata(ase_db): + db_path, parquet_path, molecules = ase_db + with AseDBDataset(lmdb_path=db_path, name="test", data=parquet_path, loader=_loader) as ds: + assert len(ds) == 2 + assert ds[0]["example_id"] == "water" + assert len(ds[0]["atom_array"]) == len(molecules[0]) + assert ds.id_to_idx("methane") == 1 + assert "water" in ds + + +def test_without_metadata(ase_db): + db_path, _, molecules = ase_db + with AseDBDataset(lmdb_path=db_path, name="test", loader=_loader) as ds: + assert len(ds) == 2 + assert ds[0]["example_id"] == "0" + assert len(ds[1]["atom_array"]) == len(molecules[1]) + assert "0" in ds + assert ds.id_to_idx("1") == 1 diff --git a/tests/ml/datasets/test_cif_loaders.py b/tests/ml/datasets/test_cif_loaders.py new file mode 100644 index 00000000..999b4226 --- /dev/null +++ b/tests/ml/datasets/test_cif_loaders.py @@ -0,0 +1,127 @@ +from unittest.mock import MagicMock, patch + +import numpy as np +import pandas as pd +import pytest + +from atomworks.ml.datasets.loaders import create_structure_loader + + +def _parsed_structure(assembly_id: str) -> dict: + return { + "assemblies": {assembly_id: ["atoms"]}, + "chain_info": {}, + "ligand_info": {}, + "metadata": {}, + } + + +@patch("atomworks.ml.datasets.loaders.cif.parse") +def test_filesystem_loader_builds_path_and_projects_metadata(mock_parse: MagicMock, tmp_path): + mock_parse.return_value = _parsed_structure("2") + row = pd.Series( + { + "sample_id": "interface-1", + "record": "1abc", + "assembly": "2", + "left": "A_1", + "right": "B_1", + "resolution": 2.0, + } + ) + loader = create_structure_loader( + storage="filesystem", + example_id_colname="sample_id", + path_colname="record", + assembly_id_colname="assembly", + base_path=str(tmp_path), + extension=".cif", + column_mapping={"query_pn_unit_iids": ["left", "right"]}, + ) + + result = loader(row) + + assert result["example_id"] == "interface-1" + assert result["path"] == tmp_path / "1abc.cif" + assert result["query_pn_unit_iids"] == ["A_1", "B_1"] + assert result["extra_info"] == {"resolution": 2.0} + assert mock_parse.call_args.kwargs["config"].build_assembly == ("2",) + + +@pytest.mark.parametrize("seed, expected", [(np.int64(7), 7), (pd.NA, None)]) +@patch("atomworks.ml.datasets.loaders.cif.parse") +def test_filesystem_loader_normalizes_altloc_seed(mock_parse: MagicMock, tmp_path, seed, expected): + mock_parse.return_value = _parsed_structure("1") + row = pd.Series({"example_id": "interface-1", "path": str(tmp_path / "1abc.cif"), "altloc_seed": seed}) + loader = create_structure_loader(storage="filesystem", altloc_seed_colname="altloc_seed") + + result = loader(row) + + assert result["altloc_seed"] == expected + assert mock_parse.call_args.kwargs["config"].altloc_seed == expected + + +@patch("atomworks.ml.datasets.loaders.cif.parse") +def test_bytes_loader_projects_metadata(mock_parse: MagicMock): + mock_parse.return_value = _parsed_structure("2") + row = pd.Series( + {"example_id": "monomer-1", "assembly_id": "2", "altloc_seed": 7, "chain": "A_1", "resolution": 1.5} + ) + loader = create_structure_loader( + storage="bytes", + altloc_seed_colname="altloc_seed", + column_mapping={"query_pn_unit_iids": ["chain"], "optional_selection": None}, + ) + + result = loader((b"data_test\n#\n", 0, row)) + + assert result["query_pn_unit_iids"] == ["A_1"] + assert result["optional_selection"] is None + assert result["extra_info"] == {"altloc_seed": 7, "resolution": 1.5} + assert mock_parse.call_args.kwargs["config"].altloc_seed == 7 + + +@patch("atomworks.ml.datasets.loaders.cif.parse") +def test_blob_loader_separates_record_and_example_ids(mock_parse: MagicMock, tmp_path, monkeypatch): + mock_parse.return_value = _parsed_structure("1") + record = b"data_test\n#\n" + blob_dir = tmp_path / "blob" + data_dir = blob_dir / "data" + data_dir.mkdir(parents=True) + (data_dir / "shard.bin").write_bytes(b"prefix" + record + b"suffix") + pd.DataFrame( + { + "path": ["01/1abc/1abc.cif"], + "shard": ["shard.bin"], + "offset": [len(b"prefix")], + "length": [len(record)], + } + ).to_parquet(blob_dir / "index.parquet") + monkeypatch.setenv("LOCAL_DRIVE_MOUNT", str(tmp_path / "cache")) + loader = create_structure_loader( + storage="blob", + blob_dir=str(blob_dir), + record_id_colname="path", + example_id_colname="sample_id", + column_mapping={"query_pn_unit_iids": ["left", "right"]}, + ) + row = pd.Series( + { + "path": "01/1abc/1abc.cif", + "sample_id": "interface-1", + "left": "A_1", + "right": "B_1", + } + ) + + result = loader(row) + + assert result["example_id"] == "interface-1" + assert result["query_pn_unit_iids"] == ["A_1", "B_1"] + assert result["extra_info"] == {"sample_id": "interface-1"} + assert mock_parse.call_args.args[0]().read() == record.decode() + + +def test_structure_loader_rejects_unknown_storage(): + with pytest.raises(ValueError, match="Unsupported structure storage"): + create_structure_loader(storage="database") diff --git a/tests/ml/datasets/test_datasets_with_filters.py b/tests/ml/datasets/test_datasets_with_filters.py index 92e445a7..050f5f04 100644 --- a/tests/ml/datasets/test_datasets_with_filters.py +++ b/tests/ml/datasets/test_datasets_with_filters.py @@ -1,9 +1,11 @@ import logging import numpy as np +import pandas as pd import pytest from atomworks.ml.datasets import PandasDataset +from tests.ml.conftest import SHARED_TEST_FILTERS, TEST_PN_UNITS_FILTERS # NOTE: See the conftest for the filters applied to pn_units_dataset, which are validated below @@ -13,6 +15,11 @@ def test_filter_impact(pn_units_df, rf2aa_pn_units_dataset): original_data_length = len(pn_units_df) filtered_data_length = len(rf2aa_pn_units_dataset) assert filtered_data_length < original_data_length, "Filter did not reduce the number of rows" + expected = pn_units_df.copy() + for query in SHARED_TEST_FILTERS + TEST_PN_UNITS_FILTERS: + expected = expected.query(query) + expected.set_index("example_id", drop=False, inplace=True) + pd.testing.assert_frame_equal(rf2aa_pn_units_dataset.data, expected) def test_deposition_date_filter(rf2aa_pn_units_dataset): diff --git a/tests/ml/datasets/test_lmdb_dataset.py b/tests/ml/datasets/test_lmdb_dataset.py new file mode 100644 index 00000000..ca9cd14b --- /dev/null +++ b/tests/ml/datasets/test_lmdb_dataset.py @@ -0,0 +1,72 @@ +"""Tests for LmdbDataset using a real OMOL fixture (20 entries from shard_00).""" + +from pathlib import Path + +import biotite.structure as struc +import pandas as pd +import pytest + +pytest.importorskip("lmdb") + +from atomworks.ml.datasets.lmdb_dataset import LmdbDataset + +OMOL_TEST_DIR = Path(__file__).resolve().parents[2] / "data" / "ml" / "omol_test" +OMOL_LMDB = str(OMOL_TEST_DIR / "shard_00.ciflmdb") +OMOL_PARQUET = str(OMOL_TEST_DIR / "metadata.parquet") + + +def _bytes_loader(raw_data: tuple) -> dict: + """Simple loader that returns raw bytes as-is.""" + raw_bytes, global_idx, metadata_row = raw_data + return {"raw_bytes": raw_bytes, "global_idx": global_idx, "metadata_row": metadata_row} + + +def test_load_with_metadata(): + """Load with parquet metadata, verify length and example IDs.""" + expected_ids = pd.read_parquet(OMOL_PARQUET)["example_id"].tolist() + with LmdbDataset(lmdb_path=OMOL_LMDB, name="omol_test", data=OMOL_PARQUET) as ds: + assert len(ds) == 20 + for i in range(3): + example = ds[i] + assert example["example_id"] == expected_ids[i] + assert "assembly_id" in example + assert "extra_info" in example + + +def test_load_without_metadata(): + """Load without parquet — SequentialMetadataIndex with stringified indices.""" + with LmdbDataset(lmdb_path=OMOL_LMDB, name="omol_test", loader=_bytes_loader) as ds: + assert len(ds) == 20 + assert ds[0]["example_id"] == "0" + assert ds[0]["metadata_row"] is None + + +def test_metadata_row_threaded_to_loader(): + """Metadata row is passed through to the loader as the third tuple element.""" + with LmdbDataset(lmdb_path=OMOL_LMDB, name="omol_test", data=OMOL_PARQUET, loader=_bytes_loader) as ds: + example = ds[0] + assert isinstance(example["metadata_row"], pd.Series) + assert "energy" in example["metadata_row"].index + assert "charge" in example["metadata_row"].index + + +def test_cif_parsing(): + """Default CIF loader produces valid AtomArrays with bonds from real OMOL entries.""" + with LmdbDataset(lmdb_path=OMOL_LMDB, name="omol_test", data=OMOL_PARQUET) as ds: + example = ds[0] + atom_array = example["atom_array"] + assert isinstance(atom_array, struc.AtomArray) + assert len(atom_array) > 0 + assert "element" in atom_array.get_annotation_categories() + assert atom_array.bonds is not None + assert len(atom_array.bonds.as_array()) > 0 + + +def test_id_lookups(): + """id_to_idx, idx_to_id, and __contains__ work with real example IDs.""" + expected_ids = pd.read_parquet(OMOL_PARQUET)["example_id"].tolist() + with LmdbDataset(lmdb_path=OMOL_LMDB, name="omol_test", data=OMOL_PARQUET) as ds: + assert expected_ids[0] in ds + assert ds.id_to_idx(expected_ids[0]) == 0 + assert ds.idx_to_id(0) == expected_ids[0] + assert "nonexistent_id_xyz" not in ds diff --git a/tests/ml/datasets/test_materials_loader.py b/tests/ml/datasets/test_materials_loader.py new file mode 100644 index 00000000..3516a964 --- /dev/null +++ b/tests/ml/datasets/test_materials_loader.py @@ -0,0 +1,118 @@ +"""Periodic geometry and worker safety through the shared ASE dataset interface.""" + +import multiprocessing +import pickle + +import numpy as np +import pandas as pd +import pytest + +ase = pytest.importorskip("ase") +pytest.importorskip("ase_db_backends") +from ase.calculators.singlepoint import SinglePointCalculator +from ase.db import connect + +from atomworks.ml.datasets.ase_dataset import AseDBDataset +from atomworks.ml.datasets.loaders.materials import ase_atoms_to_material_dict, create_ase_materials_loader + + +@pytest.fixture +def materials(tmp_path): + """Create two periodic ASE shards with reversed metadata ordering.""" + # Use a tilted cell with only two periodic axes and nonzero partial charges. + atoms = ase.Atoms( + "NaCl", + scaled_positions=[[1.2, -0.1, 1.4], [0.3, 0.6, 0.9]], + cell=[[3.0, 0.0, 0.0], [0.7, 4.0, 0.0], [0.4, 0.5, 5.0]], + pbc=[True, True, False], + ) + atoms.set_initial_charges([0.4, -0.4]) + atoms.calc = SinglePointCalculator(atoms, energy=-3.0, forces=np.array([[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0]])) + + # Store one row per shard; metadata selects the second shard first. + for i in range(2): + with connect(tmp_path / f"shard_{i}.aselmdb", type="aselmdb") as db: + db.write(atoms, sid=f"material-{i}", data={"parent_space_group": 221, "prototype_label": "AB_cP2_221_a_b"}) + metadata = tmp_path / "metadata.parquet" + pd.DataFrame({"example_id": ["material-1", "material-0"], "lmdb_idx": [1, 0]}).to_parquet(metadata) + dataset = AseDBDataset( + lmdb_path=str(tmp_path), + name="materials", + data=str(metadata), + loader=create_ase_materials_loader(include_atom_array=True), + ) + yield atoms, dataset + dataset.close() + + +def test_materials_preserve_periodic_geometry_metadata_and_partial_charges(materials): + """Preserve source geometry and labels without inferring molecular chemistry.""" + atoms, dataset = materials + item = dataset[0] + + # Metadata indices select the intended source row. + assert item["example_id"] == "material-1" and item["global_index"] == 1 + assert item["source_row_id"] == 1 and dataset.id_to_idx("material-1") == 0 + + # Preserve the cell, periodic axes, and partial charges without inventing bonds. + np.testing.assert_allclose(item["fractional_coordinates"] @ item["lattice_vectors"], atoms.positions) + np.testing.assert_allclose(item["atom_array"].box, atoms.cell) + np.testing.assert_array_equal(item["pbc"], [True, True, False]) + np.testing.assert_allclose(item["atom_array"].initial_charges, [0.4, -0.4]) + assert "charge" not in item["atom_array"].get_annotation_categories() + assert item["atom_array"].bonds is None + + # Retain source properties and symmetry labels rather than estimating them. + np.testing.assert_allclose(item["forces"], atoms.get_forces()) + assert item["energy"] == -3.0 and item["cell_volume"] == pytest.approx(atoms.get_volume()) + assert item["parent_space_group"] == 221 and item["space_group"] is None + assert item["extra_info"]["prototype_label"] == "AB_cP2_221_a_b" + assert dataset[1]["example_id"] == "material-0" + + # Wrap only periodic fractional axes, leaving source coordinates unchanged. + wrapped = ase_atoms_to_material_dict(atoms, wrap_fractional_coordinates=True, metadata={"space_group": "1"}) + np.testing.assert_allclose(wrapped["fractional_coordinates"][0], [0.2, 0.9, 1.4]) + np.testing.assert_allclose(wrapped["cartesian_coordinates"], atoms.positions) + assert wrapped["space_group"] == 1 + np.testing.assert_allclose(atoms.get_scaled_positions(wrap=False)[0], [1.2, -0.1, 1.4]) + + # Fractional coordinates require a full-rank cell. + atoms.cell = np.zeros((3, 3)) + with pytest.raises(ValueError, match="full-rank cell"): + ase_atoms_to_material_dict(atoms) + + +def _read_material_in_worker(dataset, queue): + """Return the worker's example order and close its database handles.""" + try: + queue.put([dataset[i]["example_id"] for i in range(len(dataset))]) + finally: + dataset.close() + + +@pytest.mark.parametrize("start_method", ["spawn", "fork"]) +def test_open_materials_dataset_survives_worker_transfer(materials, start_method): + """Keep parent and worker reads usable after pickling or inheriting an open dataset.""" + _, dataset = materials + + # Open a shard before serialization; pickling must not close the parent reader. + assert dataset[0]["example_id"] == "material-1" + pickle.dumps(dataset) + assert dataset[0]["example_id"] == "material-1" + + # Read both shards in a spawned or forked worker, then check the parent again. + context = multiprocessing.get_context(start_method) + queue = context.Queue() + process = context.Process(target=_read_material_in_worker, args=(dataset, queue)) + process.start() + try: + assert queue.get(timeout=30) == ["material-1", "material-0"] + process.join(timeout=30) + assert process.exitcode == 0 + assert dataset[0]["example_id"] == "material-1" + finally: + # Release worker resources even if an assertion fails. + if process.is_alive(): + process.terminate() + process.join() + queue.close() diff --git a/tests/ml/datasets/test_samplers.py b/tests/ml/datasets/test_samplers.py index 83bd12d2..d49e4607 100644 --- a/tests/ml/datasets/test_samplers.py +++ b/tests/ml/datasets/test_samplers.py @@ -1,8 +1,16 @@ import pandas as pd +import pyarrow as pa import pytest +import torch from torch.utils.data import ConcatDataset, Dataset, SequentialSampler -from atomworks.ml.samplers import DistributedMixedSampler, LoadBalancedDistributedSampler, MixedSampler +from atomworks.ml.samplers import ( + DistributedMixedSampler, + LoadBalancedDistributedSampler, + MixedSampler, + calculate_weights_by_inverse_cluster_size, + calculate_weights_for_pdb_dataset_df, +) class DummyDataset(Dataset): @@ -140,5 +148,86 @@ def test_load_balanced_distributed_sampler(dummy_dataset_with_n_tokens): assert set(indices_rank_0[:-1]).isdisjoint(set(indices_rank_1[:-1])) +# --------------------------------------------------------------------------- +# Weight function tests +# --------------------------------------------------------------------------- + +# Minimal table with 6 rows covering three clusters: +# A (size 3): pdb 1abc (altloc seeds 0 and 1) + pdb 2abc (no altlocs) +# B (size 1): pdb 3abc (no altlocs), pure nucleic acid +# C (size 2): pdb 4abc (no altlocs, involves LOI) + pdb 5abc (no altlocs, peptide) +_WEIGHT_DATA = { + "cluster": ["A", "A", "A", "B", "C", "C"], + "pdb_id": ["1abc", "1abc", "2abc", "3abc", "4abc", "5abc"], + "altloc_seed": [0, 1, None, None, None, None], + "n_prot": [1, 1, 2, 0, 1, 0], + "n_nuc": [0, 0, 0, 1, 0, 0], + "n_ligand": [0, 0, 0, 0, 1, 0], + "n_peptide": [0, 0, 0, 0, 0, 2], + "involves_loi": [False, False, False, False, True, False], +} + + +@pytest.fixture(params=["pandas", "pyarrow"]) +def weight_table(request): + df = pd.DataFrame(_WEIGHT_DATA) + if request.param == "pyarrow": + return pa.Table.from_pandas(df) + return df + + +def test_calculate_weights_by_inverse_cluster_size(weight_table): + # Without altloc: weights = 1/cluster_size + # cluster sizes: A=3, B=1, C=2 + expected_no_altloc = torch.tensor([1 / 3, 1 / 3, 1 / 3, 1.0, 0.5, 0.5], dtype=torch.float64) + weights = calculate_weights_by_inverse_cluster_size(weight_table, cluster_column="cluster") + assert torch.allclose(weights.double(), expected_no_altloc) + + # With altloc: effective cluster size = sum of altloc weights per cluster + # 1abc has 2 altloc seeds → contributes 1/2+1/2=1.0 to cluster A; 2abc contributes 1.0 + # effective sizes: A=2, B=1, C=2 + expected_altloc = torch.tensor([1 / 4, 1 / 4, 1 / 2, 1.0, 1 / 2, 1 / 2], dtype=torch.float64) + weights = calculate_weights_by_inverse_cluster_size( + weight_table, cluster_column="cluster", altloc_seed_column="altloc_seed", pdb_id_column="pdb_id" + ) + assert torch.allclose(weights.double(), expected_altloc) + + +def test_calculate_weights_for_pdb_dataset_df(weight_table): + alphas = {"a_prot": 1.0, "a_nuc": 0.5, "a_ligand": 2.0, "a_peptide": 0.25, "a_loi": 3.0} + + # Without altloc: w = (beta/cluster_size) * (n_prot + n_nuc + n_ligand + n_peptide + 2*is_loi) + # row 0 (A): (1/3)*(1) = 1/3 + # row 1 (A): (1/3)*(1) = 1/3 + # row 2 (A): (1/3)*(2) = 2/3 + # row 3 (B): (1/1)*(0.5) = 0.5 + # row 4 (C): (1/2)*(1+2+3) = 3 + # row 5 (C): (1/2)*(2*0.25) = 0.25 + expected_no_altloc = torch.tensor([1 / 3, 1 / 3, 2 / 3, 0.5, 3.0, 0.25], dtype=torch.float64) + weights = calculate_weights_for_pdb_dataset_df(weight_table, alphas=alphas, beta=1.0, cluster_column="cluster") + assert torch.allclose(weights.double(), expected_no_altloc) + + # With altloc: effective cluster size = sum of altloc weights per cluster + # altloc weights: 1abc→1/2, 1abc→1/2, 2abc→1, 3abc→1, 4abc→1, 5abc→1 + # effective sizes: A = 1/2+1/2+1 = 2, B = 1, C = 1+1 = 2 + # w = (1/eff_cluster_size) * altloc_weight * (n_prot + n_nuc + n_ligand + n_peptide + 2*is_loi) + # row 0 (A, 1abc): (1/2) * (1/2) * (1) = 1/4 + # row 1 (A, 1abc): (1/2) * (1/2) * (1) = 1/4 + # row 2 (A, 2abc): (1/2) * 1 * (2) = 1.0 + # row 3 (B, 3abc): (1/1) * 1 * (0.5) = 0.5 + # row 4 (C, 4abc): (1/2) * 1 * (1+2+3) = 3.0 + # row 5 (C, 5abc): (1/2) * 1 * (2*0.25) = 0.25 + expected_altloc = torch.tensor([1 / 4, 1 / 4, 1.0, 0.5, 3.0, 0.25], dtype=torch.float64) + weights = calculate_weights_for_pdb_dataset_df( + weight_table, + alphas=alphas, + beta=1.0, + cluster_column="cluster", + altloc_seed_column="altloc_seed", + pdb_id_column="pdb_id", + ) + assert torch.allclose(weights.double(), expected_altloc) + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/ml/datasets/test_shared_memory_dataset.py b/tests/ml/datasets/test_shared_memory_dataset.py new file mode 100644 index 00000000..daa2a2d0 --- /dev/null +++ b/tests/ml/datasets/test_shared_memory_dataset.py @@ -0,0 +1,40 @@ +"""Tests for memory-mapped metadata via PandasDataset(memory_map=True).""" + +import os + +import pandas as pd +import pytest + +from atomworks.ml.datasets import PandasDataset +from atomworks.ml.datasets.metadata import ArrowMetadataIndex + +N = 20 + + +@pytest.fixture +def dummy_df(): + return pd.DataFrame( + { + "example_id": [f"example_{i}" for i in range(N)], + "col_a": [f"path/to/file_{i}.pdb" for i in range(N)], + "col_b": [f"chain_{i % 4}" for i in range(N)], + } + ) + + +def test_feather_file_reused_on_second_init(tmp_path, monkeypatch, dummy_df): + """A second PandasDataset(memory_map=True) with the same name must reuse the existing feather file.""" + monkeypatch.setenv("SLURM_JOB_ID", "test_job_reuse") + + kwargs = {"filters": ["col_b == 'chain_0'"], "local_drive_mount": str(tmp_path)} + meta1 = ArrowMetadataIndex(data=dummy_df.copy(), name="reuse_dataset", **kwargs) + ds1 = PandasDataset(data=dummy_df.copy(), name="reuse_dataset", metadata=meta1) + mtime_after_first = os.path.getmtime(ds1.metadata.feather_path) + + meta2 = ArrowMetadataIndex(data=dummy_df.copy(), name="reuse_dataset", **kwargs) + ds2 = PandasDataset(data=dummy_df.copy(), name="reuse_dataset", metadata=meta2) + mtime_after_second = os.path.getmtime(ds2.metadata.feather_path) + + assert mtime_after_first == mtime_after_second + assert len(ds1) == len(ds2) == N // 4 + pd.testing.assert_frame_equal(ds1.data.to_pandas(), dummy_df.query("col_b == 'chain_0'")) diff --git a/tests/ml/pipelines/regression_test_data/1wym_inference.pkl b/tests/ml/pipelines/regression_test_data/1wym_inference.pkl index 29786753..8153162b 100644 Binary files a/tests/ml/pipelines/regression_test_data/1wym_inference.pkl and b/tests/ml/pipelines/regression_test_data/1wym_inference.pkl differ diff --git a/tests/ml/pipelines/regression_test_data/1wym_train.pkl b/tests/ml/pipelines/regression_test_data/1wym_train.pkl index f1056c88..b8d768f7 100644 Binary files a/tests/ml/pipelines/regression_test_data/1wym_train.pkl and b/tests/ml/pipelines/regression_test_data/1wym_train.pkl differ diff --git a/tests/ml/pipelines/regression_test_data/6w13_inference.pkl b/tests/ml/pipelines/regression_test_data/6w13_inference.pkl index 2bec2b0d..c1c08fce 100644 Binary files a/tests/ml/pipelines/regression_test_data/6w13_inference.pkl and b/tests/ml/pipelines/regression_test_data/6w13_inference.pkl differ diff --git a/tests/ml/pipelines/regression_test_data/6w13_train.pkl b/tests/ml/pipelines/regression_test_data/6w13_train.pkl index c42beea0..a7c2b76e 100644 Binary files a/tests/ml/pipelines/regression_test_data/6w13_train.pkl and b/tests/ml/pipelines/regression_test_data/6w13_train.pkl differ diff --git a/tests/ml/pipelines/test_inference_pipelines.py b/tests/ml/pipelines/test_inference_pipelines.py index a47746e6..19648066 100644 --- a/tests/ml/pipelines/test_inference_pipelines.py +++ b/tests/ml/pipelines/test_inference_pipelines.py @@ -4,14 +4,15 @@ import torch from atomworks.io import parse +from atomworks.io.config import ParseConfig from atomworks.io.tools.inference import ( build_msa_paths_by_chain_id_from_component_list, components_to_atom_array, read_chai_fasta, ) -from atomworks.io.utils.io_utils import to_cif_buffer -from atomworks.io.utils.non_rcsb import initialize_chain_info_from_atom_array -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.chain_info import build_chain_info +from atomworks.io.utils.io_utils import CIFWriteConfig, to_cif_buffer +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from atomworks.ml.pipelines.af3 import build_af3_transform_pipeline from atomworks.ml.utils.testing import cached_parse from tests.conftest import skip_if_on_github_runner @@ -28,7 +29,7 @@ def test_af3_confidence_pipeline_from_chai_fasta(): fasta_path = TEST_DATA_ML / "inference_like_chai_fasta.fasta" inference_input_components = read_chai_fasta(fasta_path) atom_array = components_to_atom_array(inference_input_components) - chain_info = initialize_chain_info_from_atom_array(atom_array) + chain_info = build_chain_info(atom_array) assert atom_array is not None, "Failed to load atom array from FASTA file" @@ -68,7 +69,7 @@ def test_af3_pipeline_from_chai_fasta(): fasta_path = TEST_DATA_ML / "inference_like_chai_fasta.fasta" inference_input_components = read_chai_fasta(fasta_path) atom_array = components_to_atom_array(inference_input_components) - chain_info = initialize_chain_info_from_atom_array(atom_array) + chain_info = build_chain_info(atom_array) assert atom_array is not None, "Failed to load atom array from FASTA file" @@ -124,7 +125,7 @@ def test_af3_pipeline_from_chai_fasta(): @pytest.mark.parametrize("inference_components", AF3_PIPELINE_FROM_COMPONENTS_TEST_CASES) def test_af3_pipeline_from_sequence_and_smiles(inference_components): atom_array, initialized_components = components_to_atom_array(inference_components, return_components=True) - chain_info = initialize_chain_info_from_atom_array(atom_array) + chain_info = build_chain_info(atom_array) # Spoof MSA paths msa_paths_by_chain_id = build_msa_paths_by_chain_id_from_component_list(initialized_components) @@ -193,8 +194,10 @@ def test_same_pipeline_outputs_from_cif_and_inference(): } ] ligand = [{"smiles": "Cc1cc(cc(c1)Oc2nccc(n2)c3c(ncn3[C@H]4CCN(C4)CCN)c5ccc(cc5)I)C", "chain_id": "C"}] - buffer = to_cif_buffer(components_to_atom_array(monomer + ligand), include_entity_poly=True) - pipeline_inputs_from_inference = parse(buffer, hydrogen_policy="remove") + buffer = to_cif_buffer( + components_to_atom_array(monomer + ligand), config=CIFWriteConfig(include_entity_categories=True) + ) + pipeline_inputs_from_inference = parse(buffer, config=ParseConfig(hydrogen_policy="remove")) atom_array_from_inference = pipeline_inputs_from_inference["assemblies"][transformation_id][0] annotations_to_compare = set(atom_array_from_cif.get_annotation_categories()) - { @@ -204,14 +207,17 @@ def test_same_pipeline_outputs_from_cif_and_inference(): "stereo", "b_factor", "alt_atom_id", + "label_alt_id", # Sequence/SMILES inputs have no experimental alternate-conformer labels. "is_aromatic", "occupancy", + "nhyd", # nhyd may differ between CIF and inference paths due to H atom handling differences } - assert_same_atom_array( + assert_same_atom_array_or_stack( atom_array_from_cif, atom_array_from_inference, compare_coords=False, compare_bonds=True, + compare_bond_order=False, annotations_to_compare=annotations_to_compare, enforce_order=False, ) diff --git a/tests/ml/pipelines/test_pipeline_regression.py b/tests/ml/pipelines/test_pipeline_regression.py index 7dcb3f69..e75530f8 100644 --- a/tests/ml/pipelines/test_pipeline_regression.py +++ b/tests/ml/pipelines/test_pipeline_regression.py @@ -3,7 +3,6 @@ import pickle from pathlib import Path -import numpy as np import pytest import torch @@ -13,10 +12,11 @@ ) from atomworks.enums import ChainType from atomworks.io import parse -from atomworks.io.parser import STANDARD_PARSER_ARGS -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.config import ParseConfig +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from atomworks.ml.pipelines.af3 import build_af3_transform_pipeline from atomworks.ml.utils.rng import create_rng_state_from_seeds, rng_state +from atomworks.ml.utils.testing import assert_tensor_or_array_equal logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -74,9 +74,8 @@ def instantiate_example(example_name: str): test_data_dir = Path(os.path.join(os.path.dirname(os.path.abspath(__file__))), "test_data") file = test_data_dir / example_name / f"{example_name}.cif.gz" result_dict = parse( - filename=file, - build_assembly=("1",), - **STANDARD_PARSER_ARGS, + file, + config=ParseConfig.from_preset("rcsb", build_assembly=("1",)), ) # Only set msa_path if the MSA file exists @@ -118,14 +117,17 @@ def _assert_pipeline_results_equal(result: dict, expected: dict, example_name: s # Check atom array if present if is_inference: - assert "atom_array" in result, "Atom array not found in result" - assert_same_atom_array( + annotations_to_compare = set(expected["atom_array"].get_annotation_categories()) - { + "alt_atom_id", + "uses_alt_atom_id", + } + assert_same_atom_array_or_stack( result["atom_array"], expected["atom_array"], compare_coords=True, compare_bonds=True, - # (All annotation categories present in the expected atom array are compared) - annotations_to_compare=expected["atom_array"].get_annotation_categories(), + annotations_to_compare=annotations_to_compare, + enforce_order=False, # Allow bond reordering - content matters, not order ) # Check features @@ -158,35 +160,13 @@ def _assert_features_equal(feats: dict, expected_feats: dict, example_name: str, ), f"Feature {key} shape mismatch for {example_name} in {mode} mode: {feat.shape} vs {expected_feat.shape}" # Check values with tolerance - _assert_tensor_or_array_equal( + assert_tensor_or_array_equal( feat, expected_feat, f"Feature {key} values don't match for {example_name} in {mode} mode", ) -def _assert_tensor_or_array_equal(actual, expected, error_msg: str): - """Assert that two tensors or arrays are equal, with appropriate tolerance for different dtypes.""" - if torch.is_tensor(actual): - if actual.dtype == torch.bool or actual.dtype in [torch.int32, torch.int64]: - torch.testing.assert_close( - actual, expected, atol=0, rtol=0, equal_nan=True, msg=lambda x: error_msg + ": " + x - ) - else: - torch.testing.assert_close( - actual, expected, atol=1e-4, rtol=1e-4, equal_nan=True, msg=lambda x: error_msg + ": " + x - ) - elif isinstance(actual, np.ndarray): - if ( - actual.dtype.kind in ["U", "S"] or actual.dtype == bool or np.issubdtype(actual.dtype, np.integer) - ): # String dtypes - assert np.testing.assert_array_equal(actual, expected, err_msg=error_msg) - else: - assert np.testing.assert_allclose(actual, expected, atol=1e-4, rtol=1e-4, equal_nan=True, err_msg=error_msg) - else: - assert actual == expected, error_msg - - def _make_test_identifier(example_name: str, is_inference: bool) -> str: """Generate a test identifier for parametrized tests and file paths.""" mode = "inference" if is_inference else "train" diff --git a/tests/ml/pipelines/test_prior_pipeline_bugs.py b/tests/ml/pipelines/test_prior_pipeline_bugs.py index 39adab7e..8cc26bbd 100644 --- a/tests/ml/pipelines/test_prior_pipeline_bugs.py +++ b/tests/ml/pipelines/test_prior_pipeline_bugs.py @@ -20,7 +20,8 @@ # ------------------- AF3 Pipeline -----------------------# ########################################################## -PRIOR_PIPELINE_BUGS_AF3 = ["6raz", "7qbs", "5epq", "2g37", "4v4s"] +# 4v4s's conflicting links are checked by tests/io/components/test_parse_invariants.py::test_parse_expected_errors. +PRIOR_PIPELINE_BUGS_AF3 = ["7qbs", "5epq", "2g37"] @pytest.mark.parametrize("pdb_id", PRIOR_PIPELINE_BUGS_AF3) diff --git a/tests/ml/preprocessing/conftest.py b/tests/ml/preprocessing/conftest.py index c2c20c12..0ab3a500 100644 --- a/tests/ml/preprocessing/conftest.py +++ b/tests/ml/preprocessing/conftest.py @@ -1,5 +1,6 @@ -from atomworks.ml.preprocessing.get_pn_unit_data_from_structure import DataPreprocessor +from atomworks.ml.preprocessing.preprocess import PreprocessConfig -DATA_PREPROCESSOR = DataPreprocessor( - polymer_pn_unit_limit=50, # Set to 50 for processing speed during testing +# Test configuration with lower polymer limit for speed +TEST_CONFIG = PreprocessConfig( + polymer_pn_unit_limit=50, ) diff --git a/tests/ml/preprocessing/msa/__init__.py b/tests/ml/preprocessing/msa/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ml/preprocessing/msa/test_colabfold_server.py b/tests/ml/preprocessing/msa/test_colabfold_server.py new file mode 100644 index 00000000..b03402ed --- /dev/null +++ b/tests/ml/preprocessing/msa/test_colabfold_server.py @@ -0,0 +1,270 @@ +"""Tests for atomworks.ml.preprocessing.msa.colabfold_server. + +Does not hit the real ColabFold MSA server -- query_colabfold_msa_server is +monkeypatched. See test_organizing.py for coverage of the underlying +organize_msas / get_msa_path contract this module relies on. +""" + +import io +import tarfile +from pathlib import Path + +import pytest + +from atomworks.ml.preprocessing.msa import colabfold_server +from atomworks.ml.preprocessing.msa.colabfold_server import make_msas_colabfold_server_batch +from atomworks.ml.preprocessing.msa.finding import find_paired_msas, find_template_alignments, get_paired_msa_path +from atomworks.ml.transforms.msa._msa_loading_utils import get_msa_path +from atomworks.ml.utils.misc import get_complex_id, hash_sequence + +QUERY_SEQUENCE = "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKR" +SECOND_SEQUENCE = "MSEQVENCETWOFORPAIRINGTESTSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + +def _fake_a3m(sequence: str) -> str: + """A minimal, well-formed single-hit a3m block for `sequence`.""" + return f">query\n{sequence}\n>hit_1\n{sequence}\n" + + +def _fake_query_colabfold_msa_server(x, prefix, user_agent, use_pairing=False, use_templates=False, **kwargs): + # Mirrors the real function's contract: one a3m string per input sequence, in + # input order. Does not touch the network or `prefix`. When use_templates is set, + # also returns one raw template-alignment hit block per sequence (None for the + # second sequence, mirroring a sequence with no PDB70 hits). + a3m_lines = [_fake_a3m(seq) for seq in x] + if use_templates: + template_alignments = [_fake_m8_hit(seq) if i == 0 else None for i, seq in enumerate(x)] + return a3m_lines, template_alignments + return a3m_lines + + +def _fake_m8_hit(sequence: str) -> str: + """A minimal, well-formed single-row pdb70.m8 hit block for `sequence`.""" + return f"101\t1abc_A\t0.99\t{len(sequence)}\t0\t0\t1\t{len(sequence)}\t1\t{len(sequence)}\t1e-20\t100\n" + + +def test_make_msas_colabfold_server_batch_writes_unpaired_and_paired_under_one_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """One output_dir covers both unpaired and paired output -- the caller never + constructs unpaired/paired paths itself, and never computes complex_id itself + either (derived automatically from the a3m results' content). complex_id is + sharded the same way sequence hashes are (many complexes in a large run); chain + files within one complex's own directory stay flat (few chains per complex). + """ + sequences = [QUERY_SEQUENCE, SECOND_SEQUENCE] + complex_id = get_complex_id(sequences) + + monkeypatch.setattr(colabfold_server, "query_colabfold_msa_server", _fake_query_colabfold_msa_server) + + output_dir = tmp_path / "msas" + make_msas_colabfold_server_batch(sequences, output_dir, complexes=[sequences], user_agent="test-agent") + + unpaired_dir = {"dir": str(output_dir / "unpaired"), "extension": ".a3m.gz", "directory_depth": 1} + for seq in sequences: + found = get_msa_path(seq, [unpaired_dir]) + assert found is not None, f"unpaired MSA not found for sequence hash {hash_sequence(seq)}" + + paired_dir = {"dir": str(output_dir / "paired"), "extension": ".a3m.gz"} + for seq in sequences: + found = get_paired_msa_path(complex_id, seq, [paired_dir]) + assert found is not None, f"paired MSA not found for sequence hash {hash_sequence(seq)}" + # complex_id is sharded (not a flat //... directory) + assert found.parent.parent.name == complex_id[:2] + + +def test_make_msas_colabfold_server_batch_skips_empty_inputs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """No sequences and no complexes -- no query_colabfold_msa_server call at all.""" + calls = [] + monkeypatch.setattr( + colabfold_server, + "query_colabfold_msa_server", + lambda *a, **kw: calls.append(1) or _fake_query_colabfold_msa_server(*a, **kw), + ) + + make_msas_colabfold_server_batch([], tmp_path / "msas", complexes=None, user_agent="test-agent") + + assert calls == [] + + +def test_make_msas_colabfold_server_batch_dedupes_complexes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Complexes with the same complex_id (reordered, or with repeated chains) are + submitted as one paired query, rather than re-querying and then failing on the + existing complex directory. + """ + paired_calls = [] + + def fake(x, *args, use_pairing=False, **kwargs): + if use_pairing: + paired_calls.append(x) + return _fake_query_colabfold_msa_server(x, *args, use_pairing=use_pairing, **kwargs) + + monkeypatch.setattr(colabfold_server, "query_colabfold_msa_server", fake) + + complexes = [ + [QUERY_SEQUENCE, SECOND_SEQUENCE], + [SECOND_SEQUENCE, QUERY_SEQUENCE], + [QUERY_SEQUENCE, QUERY_SEQUENCE, SECOND_SEQUENCE, SECOND_SEQUENCE], + ] + output_dir = tmp_path / "msas" + make_msas_colabfold_server_batch([], output_dir, complexes=complexes, user_agent="test-agent") + + assert len(paired_calls) == 1 + missing, _ = find_paired_msas([QUERY_SEQUENCE, SECOND_SEQUENCE], [output_dir / "paired"]) + assert missing == [] + + +def test_make_msas_colabfold_server_batch_organizes_template_alignments( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """use_templates=True organizes raw template-alignment hits for the unpaired + batch, discoverable via find_template_alignments, keyed by the same sequence + hash as the unpaired MSAs. A sequence with no hits (SECOND_SEQUENCE here) yields + no file, and is reported missing. + """ + sequences = [QUERY_SEQUENCE, SECOND_SEQUENCE] + monkeypatch.setattr(colabfold_server, "query_colabfold_msa_server", _fake_query_colabfold_msa_server) + + output_dir = tmp_path / "msas" + make_msas_colabfold_server_batch(sequences, output_dir, use_templates=True, user_agent="test-agent") + + template_dir = {"dir": str(output_dir / "templates"), "extension": ".m8", "directory_depth": 1} + missing, found = find_template_alignments(sequences, [template_dir]) + + assert missing == [SECOND_SEQUENCE] + assert found[QUERY_SEQUENCE].read_text() == _fake_m8_hit(QUERY_SEQUENCE) + + +def test_make_msas_colabfold_server_batch_auto_includes_complex_members_in_unpaired_batch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Complex members not explicitly passed in `sequences` are still submitted as an + unpaired query -- every chain needs its own unpaired MSA (and, if requested, + template alignment) regardless of whether it also participates in pairing. + """ + monkeypatch.setattr(colabfold_server, "query_colabfold_msa_server", _fake_query_colabfold_msa_server) + + output_dir = tmp_path / "msas" + make_msas_colabfold_server_batch( + [], output_dir, complexes=[[QUERY_SEQUENCE, SECOND_SEQUENCE]], use_templates=True, user_agent="test-agent" + ) + + unpaired_dir = {"dir": str(output_dir / "unpaired"), "extension": ".a3m.gz", "directory_depth": 1} + for seq in (QUERY_SEQUENCE, SECOND_SEQUENCE): + assert get_msa_path(seq, [unpaired_dir]) is not None, f"unpaired MSA not found for {seq[:10]}..." + + template_dir = {"dir": str(output_dir / "templates"), "extension": ".m8", "directory_depth": 1} + missing, found = find_template_alignments([QUERY_SEQUENCE, SECOND_SEQUENCE], [template_dir]) + assert missing == [SECOND_SEQUENCE] + assert found[QUERY_SEQUENCE].read_text() == _fake_m8_hit(QUERY_SEQUENCE) + + +def test_make_msas_colabfold_server_batch_paired_submission_never_requests_templates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The paired/complex query itself never requests templates (not supported by + the ColabFold server) -- only the (now auto-included) unpaired call does. + """ + calls = [] + + def _tracking_fake(x, prefix, user_agent, use_pairing=False, use_templates=False, **kwargs): + calls.append({"use_pairing": use_pairing, "use_templates": use_templates}) + return _fake_query_colabfold_msa_server( + x, prefix, user_agent, use_pairing=use_pairing, use_templates=use_templates, **kwargs + ) + + monkeypatch.setattr(colabfold_server, "query_colabfold_msa_server", _tracking_fake) + + output_dir = tmp_path / "msas" + make_msas_colabfold_server_batch( + [], output_dir, complexes=[[QUERY_SEQUENCE, SECOND_SEQUENCE]], use_templates=True, user_agent="test-agent" + ) + + paired_calls = [c for c in calls if c["use_pairing"]] + unpaired_calls = [c for c in calls if not c["use_pairing"]] + assert len(paired_calls) == 1 and paired_calls[0]["use_templates"] is False + assert len(unpaired_calls) == 1 and unpaired_calls[0]["use_templates"] is True + + +class _FakeResponse: + """Minimal stand-in for requests.Response -- only what query_colabfold_msa_server + actually reads (.json() for submit/status, .content for download).""" + + def __init__(self, json_data: dict | None = None, content: bytes = b"") -> None: + self._json_data = json_data + self.content = content + + def json(self) -> dict: + return self._json_data + + +def _build_tar_gz(files: dict[str, str]) -> bytes: + """Build an in-memory tar.gz matching the layout of ColabFold's out.tar.gz.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def test_query_colabfold_msa_server_parses_raw_template_hits_without_fetching_structures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """use_templates=True on the real query_colabfold_msa_server (only the HTTP layer + is mocked, not the raw output directory -- see + test_query_colabfold_msa_server_raises_if_raw_dir_exists for why) returns the raw + pdb70.m8 hit rows per sequence, without any request to fetch the underlying PDB70 + structure database -- that retrieval step isn't implemented. + """ + tar_gz_bytes = _build_tar_gz( + { + "uniref.a3m": f">101\n{QUERY_SEQUENCE}\n>hit_1\n{QUERY_SEQUENCE}\n", + "bfd.mgnify30.metaeuk30.smag30.a3m": f">101\n{QUERY_SEQUENCE}\n", + # M=102 belongs to a different query in the same batch job -- shouldn't + # leak into this query's returned template alignment. + "pdb70.m8": _fake_m8_hit(QUERY_SEQUENCE) + "102\t2xyz_B\t0.5\t1\t0\t0\t1\t1\t1\t1\t1e-5\t50\n", + } + ) + + def _fake_post(url: str, **kwargs) -> _FakeResponse: + assert url.endswith("/ticket/msa") + return _FakeResponse(json_data={"status": "COMPLETE", "id": "job123"}) + + def _fake_get(url: str, **kwargs) -> _FakeResponse: + assert url.endswith("/result/download/job123") + return _FakeResponse(content=tar_gz_bytes) + + monkeypatch.setattr(colabfold_server.requests, "post", _fake_post) + monkeypatch.setattr(colabfold_server.requests, "get", _fake_get) + + prefix = tmp_path / "raw" # must not already exist -- see the guard test below + a3m_lines, template_alignments = colabfold_server.query_colabfold_msa_server( + [QUERY_SEQUENCE], prefix=prefix, user_agent="test-agent", use_templates=True + ) + + # Gathered from both uniref.a3m and the env (bfd) file, concatenated under the + # shared M=101 key -- mirrors the real server output, where both are read in. + assert a3m_lines == [f">101\n{QUERY_SEQUENCE}\n>hit_1\n{QUERY_SEQUENCE}\n>101\n{QUERY_SEQUENCE}\n"] + assert template_alignments == [_fake_m8_hit(QUERY_SEQUENCE)] + # No structure database was downloaded -- no templates_* directory created. + assert not list(prefix.glob("templates_*")) + + +def test_query_colabfold_msa_server_raises_if_raw_dir_exists(tmp_path: Path) -> None: + """A pre-existing raw output directory is always treated as a stale leftover from + an earlier (likely failed) run at this exact path -- reusing it would risk + silently parsing a *different* query's out.tar.gz, corrupting results in a way + that's very hard to debug downstream. + + See https://github.com/aqlaboratory/openfold-3/issues/39, where this exact + footgun (a stale out.tar.gz silently reused via `if not os.path.isfile(...)`) + was hit in production and fixed by erroring instead of reusing. + """ + prefix = tmp_path / "raw" + prefix.mkdir() + + with pytest.raises(FileExistsError, match="raw output directory"): + colabfold_server.query_colabfold_msa_server([QUERY_SEQUENCE], prefix=prefix, user_agent="test-agent") diff --git a/tests/ml/preprocessing/msa/test_organizing.py b/tests/ml/preprocessing/msa/test_organizing.py new file mode 100644 index 00000000..f588e1ee --- /dev/null +++ b/tests/ml/preprocessing/msa/test_organizing.py @@ -0,0 +1,176 @@ +"""Tests for atomworks.ml.preprocessing.msa.organizing. + +Covers organize_msas's contract with the lookup side (get_msa_path / +finding.find_msas): anything organize_msas produces must be discoverable by +re-hashing the sequence, using the *matching* directory_depth. +""" + +from pathlib import Path + +import pytest + +from atomworks.io.utils.io_utils import build_sharding_pattern +from atomworks.ml.preprocessing.msa.finding import find_paired_msas, find_template_alignments +from atomworks.ml.preprocessing.msa.organizing import ( + MSAOrganizationConfig, + organize_msas, + organize_paired_msas, + organize_template_alignments, +) +from atomworks.ml.transforms.msa._msa_loading_utils import get_msa_path +from atomworks.ml.utils.misc import get_complex_id, hash_sequence + +QUERY_SEQUENCE = "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKR" +SECOND_SEQUENCE = "MSEQVENCETWOFORPAIRINGTESTSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + +def _fake_a3m(sequence: str) -> str: + """A minimal, well-formed single-hit a3m block for `sequence`.""" + return f">query\n{sequence}\n>hit_1\n{sequence}\n" + + +def test_organize_then_find_msa_path(tmp_path: Path) -> None: + """A raw a3m file, once organized, is discoverable via get_msa_path.""" + raw_dir = tmp_path / "raw" + raw_dir.mkdir() + (raw_dir / "0.a3m").write_text(_fake_a3m(QUERY_SEQUENCE)) + directory_depth = 1 + + # Derive the sharding_pattern from the same helper get_msa_path uses internally + # (build_sharding_pattern(depth, chars_per_dir=2)), rather than hand-formatting it -- + # "/0:{depth}/" looks plausible but is wrong: depth is a *level* count with 2 + # chars/level baked in, not a character count, and get_msa_path silently returns + # None (not an error) on a mismatch. + organized_dir = tmp_path / "organized" + organize_msas( + raw_dir, + organized_dir, + MSAOrganizationConfig( + input_extension=".a3m", + output_extension=".a3m", + sharding_pattern=build_sharding_pattern(depth=directory_depth), + ), + ) + + msa_dir = {"dir": str(organized_dir), "extension": ".a3m", "directory_depth": directory_depth} + found = get_msa_path(QUERY_SEQUENCE, [msa_dir]) + + expected_hash = hash_sequence(QUERY_SEQUENCE) + expected_path = organized_dir / expected_hash[:2] / f"{expected_hash}.a3m" + assert found == expected_path + assert found.read_text() == _fake_a3m(QUERY_SEQUENCE) + + +@pytest.mark.parametrize("dir_format", ["dict", "plain_path"]) +def test_organize_paired_then_find_paired_msas(tmp_path: Path, dir_format: str) -> None: + """Raw per-chain a3m files from one complex, once organized, are discoverable via + find_paired_msas -- complex_id is derived from the files' own content (their + first sequences, hashed and joined via get_complex_id), not passed in. + + Both directory formats must default to the writer's layout (complex_id sharded one + level deep). A plain path must not auto-detect depth: the per-complex directory + would be miscounted as a second shard level. + """ + raw_dir = tmp_path / "raw" + raw_dir.mkdir() + (raw_dir / "0.a3m").write_text(_fake_a3m(QUERY_SEQUENCE)) + (raw_dir / "1.a3m").write_text(_fake_a3m(SECOND_SEQUENCE)) + + organized_dir = tmp_path / "organized" + returned_complex_id = organize_paired_msas( + raw_dir, organized_dir, MSAOrganizationConfig(input_extension=".a3m", output_extension=".a3m") + ) + + expected_complex_id = get_complex_id([QUERY_SEQUENCE, SECOND_SEQUENCE]) + assert returned_complex_id == expected_complex_id + + msa_dir = {"dir": str(organized_dir), "extension": ".a3m"} if dir_format == "dict" else organized_dir + missing, found = find_paired_msas([QUERY_SEQUENCE, SECOND_SEQUENCE], [msa_dir]) + + assert missing == [] + for seq in (QUERY_SEQUENCE, SECOND_SEQUENCE): + expected_path = organized_dir / expected_complex_id[:2] / expected_complex_id / f"{hash_sequence(seq)}.a3m" + assert found[seq] == expected_path + assert expected_path.read_text() == _fake_a3m(seq) + + +def test_organize_paired_with_repeated_chains_found_by_unique_set(tmp_path: Path) -> None: + """A complex submitted with repeated chains (e.g. H2L2 -> one raw file per submitted + chain) is organized under the same complex_id as its unique set, so it's found by + looking up the unique sequences. + """ + raw_dir = tmp_path / "raw" + raw_dir.mkdir() + for i, seq in enumerate([QUERY_SEQUENCE, QUERY_SEQUENCE, SECOND_SEQUENCE, SECOND_SEQUENCE]): + (raw_dir / f"{i}.a3m").write_text(_fake_a3m(seq)) + + organized_dir = tmp_path / "organized" + complex_id = organize_paired_msas( + raw_dir, organized_dir, MSAOrganizationConfig(input_extension=".a3m", output_extension=".a3m") + ) + + assert complex_id == get_complex_id([QUERY_SEQUENCE, SECOND_SEQUENCE]) + missing, found = find_paired_msas([QUERY_SEQUENCE, SECOND_SEQUENCE], [organized_dir]) + assert missing == [] + assert set(found) == {QUERY_SEQUENCE, SECOND_SEQUENCE} + + +def test_organize_paired_refuses_existing_complex_dir(tmp_path: Path) -> None: + """Re-organizing a complex whose directory already exists (e.g. partial, from an + interrupted run) raises rather than mixing chains from two separate searches. + """ + config = MSAOrganizationConfig(input_extension=".a3m", output_extension=".a3m") + organized_dir = tmp_path / "organized" + + first_run = tmp_path / "first_run" + first_run.mkdir() + (first_run / "0.a3m").write_text(_fake_a3m(QUERY_SEQUENCE)) + (first_run / "1.a3m").write_text(_fake_a3m(SECOND_SEQUENCE)) + complex_id = organize_paired_msas(first_run, organized_dir, config) + + retry = tmp_path / "retry" + retry.mkdir() + (retry / "0.a3m").write_text(_fake_a3m(QUERY_SEQUENCE) + ">hit_from_retry\nXXXX\n") + (retry / "1.a3m").write_text(_fake_a3m(SECOND_SEQUENCE) + ">hit_from_retry\nXXXX\n") + with pytest.raises(FileExistsError): + organize_paired_msas(retry, organized_dir, config) + + complex_dir = organized_dir / complex_id[:2] / complex_id + for seq in (QUERY_SEQUENCE, SECOND_SEQUENCE): + assert (complex_dir / f"{hash_sequence(seq)}.a3m").read_text() == _fake_a3m(seq) + + +def _fake_m8_hit(sequence: str) -> str: + """A minimal, well-formed single-row pdb70.m8 hit block for `sequence`.""" + return f"101\t1abc_A\t0.99\t{len(sequence)}\t0\t0\t1\t{len(sequence)}\t1\t{len(sequence)}\t1e-20\t100\n" + + +def test_organize_then_find_template_alignments(tmp_path: Path) -> None: + """Raw pdb70.m8 hit blocks, once organized, are discoverable via + find_template_alignments. + """ + organized_dir = tmp_path / "templates" + organize_template_alignments( + {QUERY_SEQUENCE: _fake_m8_hit(QUERY_SEQUENCE), SECOND_SEQUENCE: ""}, + organized_dir, + ) + + template_dir = {"dir": str(organized_dir), "extension": ".m8", "directory_depth": 1} + missing, found = find_template_alignments([QUERY_SEQUENCE, SECOND_SEQUENCE], [template_dir]) + + assert missing == [SECOND_SEQUENCE] # empty content -- no file written + assert list(found) == [QUERY_SEQUENCE] + + expected_hash = hash_sequence(QUERY_SEQUENCE) + expected_path = organized_dir / expected_hash[:2] / f"{expected_hash}.m8" + assert found[QUERY_SEQUENCE] == expected_path + assert expected_path.read_text() == _fake_m8_hit(QUERY_SEQUENCE) + + +def test_find_template_alignments_with_no_dirs_reports_all_missing() -> None: + """No directories (the default) -- every sequence is reported missing, matching + find_msas's behavior for an empty/unset search path. + """ + missing, found = find_template_alignments([QUERY_SEQUENCE]) + assert missing == [QUERY_SEQUENCE] + assert found == {} diff --git a/tests/ml/preprocessing/msa/test_template_structures.py b/tests/ml/preprocessing/msa/test_template_structures.py new file mode 100644 index 00000000..e47a429f --- /dev/null +++ b/tests/ml/preprocessing/msa/test_template_structures.py @@ -0,0 +1,116 @@ +"""Tests for atomworks.ml.preprocessing.msa.template_structures. + +Does not hit the real RCSB API -- biotite.database.rcsb.fetch is monkeypatched. +""" + +from pathlib import Path + +import pytest +from biotite.database import RequestError + +from atomworks.ml.preprocessing.msa import template_structures + +# Mirrors the real pdb70.m8 column layout: M, pdb_chain, seq_id, aln_len, mismatches, +# gaps, q_start, q_end, t_start, t_end, e_value, bit_score, cigar. +M8_CONTENT = ( + "101\t1qfe_A\t1.000\t252\t0\t0\t1\t252\t1\t252\t1.748E-77\t260\t252M\n" + "101\t1qfe_B\t1.000\t252\t0\t0\t1\t252\t1\t252\t1.748E-77\t260\t252M\n" # same entry, different chain + "101\t3oex_B\t0.769\t252\t58\t0\t1\t252\t2\t253\t6.149E-77\t258\t252M\n" +) + +# "0000" and its error text match what biotite.database.rcsb.fetch raised against the +# real RCSB API when tested on 28-Aug-2026. +INVALID_PDB_ID = "0000" + + +@pytest.fixture +def fake_fetch(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Replace RCSB fetching with a local fake; returns the list of fetched IDs.""" + calls = [] + + def _fake_fetch(pdb_ids: str, format: str, target_path: str) -> str: + calls.append(pdb_ids) + if pdb_ids == INVALID_PDB_ID: + raise RequestError(f"PDB ID {pdb_ids} is invalid") + dest = Path(target_path) / f"{pdb_ids}.{format}" + dest.write_text(f"fake mmCIF for {pdb_ids}") + return str(dest) + + monkeypatch.setattr(template_structures, "fetch", _fake_fetch) + return calls + + +@pytest.mark.parametrize("m8_content", [M8_CONTENT, "\n\n" + M8_CONTENT + "\n"], ids=["plain", "blank_lines"]) +def test_parse_template_pdb_ids(m8_content: str) -> None: + """One lowercased entry ID per hit row, in the server's best-first order.""" + assert template_structures.parse_template_pdb_ids(m8_content) == ["1qfe", "1qfe", "3oex"] + + +@pytest.mark.parametrize("empty_content", ["", "\n\n\n"]) +def test_parse_template_pdb_ids_raises_on_empty_content(empty_content: str) -> None: + """A sequence with no PDB70 hits never gets a .m8 file written (see + organize_template_alignments), so empty content means a caller bug. + """ + with pytest.raises(ValueError, match="No template hits found"): + template_structures.parse_template_pdb_ids(empty_content) + + +@pytest.mark.parametrize( + "max_hits, mirror_ids, expected_ids, expected_downloads", + [ + (20, [], ["1qfe", "3oex"], ["1qfe", "3oex"]), + (1, [], ["1qfe"], ["1qfe"]), + (20, ["1qfe"], ["1qfe", "3oex"], ["3oex"]), + ], + ids=["dedupes_by_entry", "respects_max_hits", "uses_pdb_mirror"], +) +def test_fetch_template_structures( + tmp_path: Path, + fake_fetch: list[str], + max_hits: int, + mirror_ids: list[str], + expected_ids: list[str], + expected_downloads: list[str], +) -> None: + """One structure per unique entry (1qfe_A and 1qfe_B share one mmCIF), best-first, + capped at `max_hits` unique entries; entries already in the PDB mirror are used in + place and not downloaded. + """ + mirror_dir = tmp_path / "mirror" + for pdb_id in mirror_ids: + mirror_file = mirror_dir / pdb_id[1:3] / f"{pdb_id}.cif.gz" + mirror_file.parent.mkdir(parents=True) + mirror_file.write_text("mirror mmCIF") + + output_dir = tmp_path / "downloads" + pdb_ids = template_structures.parse_template_pdb_ids(M8_CONTENT) + fetched = template_structures.fetch_template_structures( + pdb_ids, output_dir, max_hits=max_hits, pdb_mirror_path=mirror_dir + ) + + assert fake_fetch == expected_downloads + assert list(fetched) == expected_ids + for pdb_id in expected_ids: + if pdb_id in mirror_ids: + assert fetched[pdb_id] == mirror_dir / pdb_id[1:3] / f"{pdb_id}.cif.gz" + else: + assert fetched[pdb_id].read_text() == f"fake mmCIF for {pdb_id}" + + +def test_fetch_template_structures_from_m8_file_skips_failed_fetch( + tmp_path: Path, fake_fetch: list[str], caplog: pytest.LogCaptureFixture +) -> None: + """End to end from a .m8 file: a hit that fails to fetch (e.g. an obsolete/invalid + PDB ID) is omitted with a warning, without failing the other hits. + """ + m8_path = tmp_path / "template.m8" + m8_path.write_text(M8_CONTENT + f"101\t{INVALID_PDB_ID}_A\t0.50\t10\t0\t0\t1\t10\t1\t10\t1e-5\t20\t10M\n") + + with caplog.at_level("WARNING", logger=template_structures.logger.name): + fetched = template_structures.fetch_template_structures_from_m8_file( + m8_path, tmp_path / "structures", pdb_mirror_path=None + ) + + assert set(fetched) == {"1qfe", "3oex"} + assert len(caplog.records) == 1 + assert INVALID_PDB_ID in caplog.records[0].message diff --git a/tests/ml/preprocessing/test_filtering_criteria.py b/tests/ml/preprocessing/test_filtering_criteria.py index 0e7de234..6fdf2e2a 100644 --- a/tests/ml/preprocessing/test_filtering_criteria.py +++ b/tests/ml/preprocessing/test_filtering_criteria.py @@ -1,19 +1,17 @@ -""" -PyTest function to test filtering criteria. +"""Test preprocessing filtering criteria. + Tested criteria includes: - The detection and resolutions of clashes within a structure - The exclusion of non-polymers bonded to a polymer via a non-biological bond """ -from __future__ import annotations - from typing import Any -import pandas as pd import pytest +from atomworks.ml.preprocessing.preprocess import preprocess from atomworks.ml.utils.testing import get_pdb_mirror_path -from tests.ml.preprocessing.conftest import DATA_PREPROCESSOR +from tests.ml.preprocessing.conftest import TEST_CONFIG FILTERING_CRITERIA_TEST_CASES = [ # Clashing test cases @@ -30,7 +28,6 @@ }, # Non-biological bond test cases {"pdb_id": "1pak", "pn_units_to_keep": ["A_1"], "pn_units_to_remove": ["B_1"]}, - # 2pnf - non-biological bonds ] @@ -39,16 +36,8 @@ def test_filtering_criteria(test_case: dict[str, Any]): pdb_id = test_case["pdb_id"] path = get_pdb_mirror_path(pdb_id) - rows = DATA_PREPROCESSOR.get_rows(path) - df = pd.DataFrame(rows) - pn_unit_iids = eval(df.iloc[0]["all_pn_unit_iids_after_processing"]) - - assert set(pn_unit_iids) >= set(df["q_pn_unit_iid"].unique().tolist()) - - # ...assert that all of the rows have the same PN units - assert ( - df["all_pn_unit_iids_after_processing"] == df["all_pn_unit_iids_after_processing"].iloc[0] - ).all(), "Not all rows have the same pn_units" + _, pn_units, _ = preprocess(path, TEST_CONFIG) + pn_unit_iids = {p.pn_unit_iid for p in pn_units} pn_units_to_keep = set(test_case["pn_units_to_keep"]) pn_units_to_remove = set(test_case["pn_units_to_remove"]) diff --git a/tests/ml/preprocessing/test_find_contacts.py b/tests/ml/preprocessing/test_find_contacts.py index 9d04558e..11b97db6 100644 --- a/tests/ml/preprocessing/test_find_contacts.py +++ b/tests/ml/preprocessing/test_find_contacts.py @@ -1,20 +1,15 @@ """Pytest function to test the detection and assignment of contacting PN units.""" -from __future__ import annotations - -import json from typing import Any -import pandas as pd import pytest +from atomworks.ml.preprocessing.preprocess import preprocess from atomworks.ml.utils.testing import get_pdb_mirror_path -from tests.ml.preprocessing.conftest import DATA_PREPROCESSOR +from tests.ml.preprocessing.conftest import TEST_CONFIG FIND_CONTACTS_TEST_CASES = [ - # Defined with: - # - contact_distance = 5 - # - close_distance = 30 + # Defined with contact_distance = 5 { # Simple protein complex "pdb_id": "1fu2", @@ -24,7 +19,6 @@ "pn_unit_iid": "A_1", "num_contacting_pn_units": 2, "num_contacts": 461, # 458 for (A,B) and 3 for (A, D) - "num_close_pn_units": 11, # NOTE: 8 if excluding AF-3 exclusion ligands (Na, Cl) } ], }, @@ -37,7 +31,6 @@ "pn_unit_iid": "C_1", "num_contacting_pn_units": 1, "num_contacts": 94, # 94 for (C, A) - "num_close_pn_units": 10, } ], }, @@ -49,31 +42,27 @@ def test_find_contacts(test_case: dict[str, Any]): pdb_id = test_case["pdb_id"] path = get_pdb_mirror_path(pdb_id) - rows = DATA_PREPROCESSOR.get_rows(path) - df = pd.DataFrame(rows) + _, pn_units, _ = preprocess(path, TEST_CONFIG) for example in test_case["contact_information"]: assembly_id = example["assembly_id"] - pn_unit_iid = example["pn_unit_iid"] - - # Filter the DataFrame to only the PN unit of interest - pn_unit_row = df[(df["assembly_id"] == assembly_id) & (df["q_pn_unit_iid"] == pn_unit_iid)] - - # Assert that there is only one row - assert len(pn_unit_row) == 1 - - contacting_pn_unit_iids = json.loads(pn_unit_row["q_pn_unit_contacting_pn_unit_iids"].iloc[0]) - assert len(contacting_pn_unit_iids) == example["num_contacting_pn_units"] - - # Count contacting atoms - contacting_atoms = 0 - for partner in contacting_pn_unit_iids: - contacting_atoms += partner["num_contacts"] - assert example["num_contacts"] == contacting_atoms - - # Count close PN units - num_close_pn_units = len(json.loads(pn_unit_row["q_pn_unit_close_pn_unit_iids"].iloc[0])) - assert example["num_close_pn_units"] == num_close_pn_units + target_iid = example["pn_unit_iid"] + + # Find the PN unit of interest + pn_unit = next( + (p for p in pn_units if p.assembly_id == assembly_id and p.pn_unit_iid == target_iid), + None, + ) + assert pn_unit is not None, f"PN unit {target_iid} not found" + + # Check contacting PN units count + assert len(pn_unit.contacting_pn_unit_iids) == example["num_contacting_pn_units"] + + # Check total contact count + total_contacts = sum(c["num_contacts"] for c in pn_unit.contacting_pn_unit_iids) + assert ( + total_contacts == example["num_contacts"] + ), f"Expected {example['num_contacts']} contacts, got {total_contacts}" if __name__ == "__main__": diff --git a/tests/ml/preprocessing/test_get_atom_mask.py b/tests/ml/preprocessing/test_get_atom_mask.py deleted file mode 100644 index eda173e3..00000000 --- a/tests/ml/preprocessing/test_get_atom_mask.py +++ /dev/null @@ -1,70 +0,0 @@ -import biotite.structure as struc -import numpy as np -import pytest - -from atomworks.ml.preprocessing.utils.structure_utils import get_atom_mask_from_cell_list - - -@pytest.fixture -def mock_data(): - query_coords = np.random.rand(200, 3) * 3 - atom_array = np.random.rand(3000, 3) * 3 - cell_list_size = atom_array.shape[0] - clash_distance = 1.5 - return query_coords, atom_array, cell_list_size, clash_distance - - -def test_get_atom_mask_from_cell_list(mock_data): - """ - Test get_atom_mask_from_cell_list function with various edge cases. - """ - - query_coords, atom_array, cell_list_size, clash_distance = mock_data - cell_list = struc.CellList(atom_array, cell_size=1.0) - - # No chunking needed - chunk_size = query_coords.shape[0] * atom_array.shape[0] + 1 # Ensure no chunking is applied - result = get_atom_mask_from_cell_list(query_coords, cell_list, cell_list_size, clash_distance, chunk_size) - expected = cell_list.get_atoms(query_coords, clash_distance, as_mask=True) - assert np.array_equal(result, expected), "Result with no chunking does not match expected output." - - # Chunking applied - chunk_size = (query_coords.shape[0] * atom_array.shape[0]) // 10 # Ensure chunking is applied - result = get_atom_mask_from_cell_list(query_coords, cell_list, cell_list_size, clash_distance, chunk_size) - expected = cell_list.get_atoms(query_coords, clash_distance, as_mask=True) - assert np.array_equal(result, expected), "Result with chunking does not match expected output." - - # max_rows_per_chunk = 1 - chunk_size = cell_list_size - 1 # Ensure max_rows_per_chunk is 1 - result = get_atom_mask_from_cell_list(query_coords, cell_list, cell_list_size, clash_distance, chunk_size=1) - expected = cell_list.get_atoms(query_coords, clash_distance, as_mask=True) - assert np.array_equal(result, expected), "Result with max_rows_per_chunk set to 1 does not match expected output." - - # Empty coord array - empty_query_coords = np.empty((0, 3)) - result = get_atom_mask_from_cell_list( - empty_query_coords, cell_list, cell_list_size, clash_distance, chunk_size=1000 - ) - expected = np.zeros((0, cell_list_size), dtype=bool) - assert len(result) == len(expected), "Result with empty coord does not match expected output." - - # Very small chunk_size - chunk_size = 1 # Set chunk_size to a very small value - result = get_atom_mask_from_cell_list(query_coords, cell_list, cell_list_size, clash_distance, chunk_size) - expected = cell_list.get_atoms(query_coords, clash_distance, as_mask=True) - assert np.array_equal(result, expected), "Result with very small chunk_size does not match expected output." - - # Large coord array with small chunk_size - large_query_coords = np.random.rand(10000, 3) * 3 # Large coord array - chunk_size = 1000 # Small chunk_size - result = get_atom_mask_from_cell_list(large_query_coords, cell_list, cell_list_size, clash_distance, chunk_size) - expected = cell_list.get_atoms(large_query_coords, clash_distance, as_mask=True) - assert np.array_equal( - result, expected - ), "Result with large coord array and small chunk_size does not match expected output." - - # Non-divisible chunk_size - chunk_size = 1507 # Non-divisible chunk_size - result = get_atom_mask_from_cell_list(query_coords, cell_list, cell_list_size, clash_distance, chunk_size) - expected = cell_list.get_atoms(query_coords, clash_distance, as_mask=True) - assert np.array_equal(result, expected), "Result with non-divisible chunk_size does not match expected output." diff --git a/tests/ml/preprocessing/test_loi_extraction.py b/tests/ml/preprocessing/test_loi_extraction.py index 9d6bcbdf..8958f812 100644 --- a/tests/ml/preprocessing/test_loi_extraction.py +++ b/tests/ml/preprocessing/test_loi_extraction.py @@ -1,16 +1,14 @@ """Pytest for LOI - SOI (subject of investigation) extraction""" -from __future__ import annotations - from typing import Any import numpy as np -import pandas as pd import pytest +from atomworks.ml.preprocessing.preprocess import _load_structure, preprocess from atomworks.ml.utils.testing import get_pdb_mirror_path from tests.conftest import skip_if_no_internet -from tests.ml.preprocessing.conftest import DATA_PREPROCESSOR +from tests.ml.preprocessing.conftest import TEST_CONFIG LOI_EXTRACTION_TEST_CASES = [ { @@ -51,19 +49,18 @@ def test_loi_extraction(test_case: dict[str, Any]): path = get_pdb_mirror_path(pdb_id) # Check that the LOI is extracted correctly from the CIF file - parsed = DATA_PREPROCESSOR._load_structure_with_atomworks(path) + parsed = _load_structure(path, TEST_CONFIG) loi_set = set(parsed["ligand_info"]["ligand_of_interest"]) assert loi_set == test_case["loi"] # Check that the LOI examples give the correct molecule - rows = DATA_PREPROCESSOR.get_rows(path) - df = pd.DataFrame(rows) + _, pn_units, _ = preprocess(path, TEST_CONFIG) loi_seen = {k: 0 for k in loi_set} - for _, row in df.iterrows(): - if row.q_pn_unit_is_loi: - assembly_id = row.assembly_id - chain_ids = row.q_pn_unit_id.split(",") + for pn_unit in pn_units: + if pn_unit.is_loi: + assembly_id = pn_unit.assembly_id + chain_ids = pn_unit.pn_unit_id.split(",") structure = parsed["assemblies"][assembly_id][0] res_names = np.unique( structure[(np.isin(structure.chain_id, chain_ids)) & (structure.occupancy > 0)].res_name @@ -71,12 +68,12 @@ def test_loi_extraction(test_case: dict[str, Any]): if test_case.get("has_covalently_bonded_loi", False): assert any( res in loi_set for res in res_names - ), f"No LOI molecule found for {row.q_pn_unit_iid} in {res_names}. LOIs: {loi_set}" + ), f"No LOI molecule found for {pn_unit.pn_unit_iid} in {res_names}. LOIs: {loi_set}" for res in res_names: if res in loi_set: loi_seen[res] += 1 else: - assert len(res_names) == 1, f"Multiple LOI molecules found for {row.q_pn_unit_iid}: {res_names}" + assert len(res_names) == 1, f"Multiple LOI molecules found for {pn_unit.pn_unit_iid}: {res_names}" assert res_names[0] in loi_set, f"LOI molecule {res_names[0]} not found in {loi_set}" loi_seen[res_names[0]] += 1 diff --git a/tests/ml/preprocessing/test_pdb_preprocessing_pipeline.py b/tests/ml/preprocessing/test_pdb_preprocessing_pipeline.py new file mode 100644 index 00000000..f1c7d00d --- /dev/null +++ b/tests/ml/preprocessing/test_pdb_preprocessing_pipeline.py @@ -0,0 +1,116 @@ +"""End-to-end test for PDB preprocessing pipeline. + +Tests the full workflow: structure parsing -> PN unit records -> interfaces. +""" + +from typing import Any + +import pytest + +from atomworks.ml.preprocessing.preprocess import preprocess +from atomworks.ml.preprocessing.records import Assembly, Interface, PNUnit +from atomworks.ml.utils.testing import get_pdb_mirror_path + +PDB_PROCESSING_TEST_CASES = [ + { + # Protein-ligand with LOI + "pdb_id": "6wjc", + "require_matching_interfaces": True, # Whether we are enumerating all interfaces in `expected_interfaces` or only relevant ones + "num_pn_units": 8, + "expected_interfaces": [ + # Protein-protein + {"pn_unit_1": "A_1", "pn_unit_2": "B_1"}, + # Protein-ligand + {"pn_unit_1": "A_1", "pn_unit_2": "C_1", "involves_covalent_modification": True}, + {"pn_unit_1": "A_1", "pn_unit_2": "D_1", "involves_loi": True}, + {"pn_unit_1": "A_1", "pn_unit_2": "E_1", "involves_loi": True}, + {"pn_unit_1": "A_1", "pn_unit_2": "F_1", "involves_loi": True}, + {"pn_unit_1": "A_1", "pn_unit_2": "G_1", "involves_loi": True}, + {"pn_unit_1": "A_1", "pn_unit_2": "H_1", "involves_loi": True}, + # Ligand-ligand + {"pn_unit_1": "F_1", "pn_unit_2": "G_1", "involves_loi": True}, + ], + }, + { + # Covalent modifications + "pdb_id": "1ivo", + "require_matching_interfaces": False, + "num_pn_units": 13, + "expected_interfaces": [ + {"pn_unit_1": "A_1", "pn_unit_2": "B_1"}, + {"pn_unit_1": "B_1", "pn_unit_2": "K_1", "involves_covalent_modification": True}, + {"pn_unit_1": "A_1", "pn_unit_2": "J_1", "involves_covalent_modification": True}, + ], + }, + { + # Homomeric symmetry + "pdb_id": "1a8o", + "require_matching_interfaces": True, + "num_pn_units": 2, + "expected_interfaces": [ + {"pn_unit_1": "A_1", "pn_unit_2": "A_2"}, + ], + }, + # TODO: Add additional test cases, including clashes, DNA, RNA, multi-chain ligands, etc. +] + +# Cache for preprocessing results to avoid re-running for each test +_preprocessing_cache: dict[str, tuple[list[Assembly], list[PNUnit], list[Interface]]] = {} + + +def _get_cached_results(pdb_id: str) -> tuple[list[Assembly], list[PNUnit], list[Interface]]: + """Get preprocessing results, cached to avoid re-running.""" + if pdb_id not in _preprocessing_cache: + path = get_pdb_mirror_path(pdb_id) + _preprocessing_cache[pdb_id] = preprocess(path) + return _preprocessing_cache[pdb_id] + + +@pytest.mark.parametrize("test_case", PDB_PROCESSING_TEST_CASES) +def test_pn_units_counts(test_case: dict[str, Any]): + """Test that each PDB has correct number of PN units.""" + pdb_id = test_case["pdb_id"] + expected_num = test_case["num_pn_units"] + + _, pn_units, _ = _get_cached_results(pdb_id) + + assert len(pn_units) == expected_num, f"Number of PN units for {pdb_id} is incorrect" + + +@pytest.mark.parametrize("test_case", PDB_PROCESSING_TEST_CASES) +def test_interfaces(test_case: dict[str, Any]): + """Test interface generation and properties.""" + pdb_id = test_case["pdb_id"] + expected_interfaces = test_case["expected_interfaces"] + + _, _, interfaces = _get_cached_results(pdb_id) + + if test_case["require_matching_interfaces"]: + assert len(interfaces) == len(expected_interfaces), f"{pdb_id}: Number of interfaces is incorrect" + + for expected in expected_interfaces: + pn_unit_1 = expected["pn_unit_1"] + pn_unit_2 = expected["pn_unit_2"] + involves_loi = expected.get("involves_loi", False) + involves_covalent_modification = expected.get("involves_covalent_modification", False) + involves_metal = expected.get("involves_metal", False) + + # Find matching interface by direct property access + matching = [i for i in interfaces if i.pn_unit_1_iid == pn_unit_1 and i.pn_unit_2_iid == pn_unit_2] + + assert len(matching) == 1, f"{pdb_id}: Interface {pn_unit_1}-{pn_unit_2} not found or multiple found" + + interface = matching[0] + assert ( + interface.involves_loi == involves_loi + ), f"{pdb_id}: LOI involvement for interface {pn_unit_1}-{pn_unit_2} is incorrect" + assert ( + interface.involves_covalent_modification == involves_covalent_modification + ), f"{pdb_id}: Covalent modification for interface {pn_unit_1}-{pn_unit_2} is incorrect" + assert ( + interface.involves_metal == involves_metal + ), f"{pdb_id}: Metal involvement for interface {pn_unit_1}-{pn_unit_2} is incorrect" + + +if __name__ == "__main__": + pytest.main(["-v", "-x", "--log-cli-level=WARNING", __file__]) diff --git a/tests/ml/preprocessing/test_prior_bugs_and_edge_cases.py b/tests/ml/preprocessing/test_prior_bugs_and_edge_cases.py index 7c1806e5..82e9f869 100644 --- a/tests/ml/preprocessing/test_prior_bugs_and_edge_cases.py +++ b/tests/ml/preprocessing/test_prior_bugs_and_edge_cases.py @@ -2,8 +2,9 @@ import pytest +from atomworks.ml.preprocessing.preprocess import preprocess from atomworks.ml.utils.testing import get_pdb_mirror_path -from tests.ml.preprocessing.conftest import DATA_PREPROCESSOR +from tests.ml.preprocessing.conftest import TEST_CONFIG """ PDB IDs with unusual characteristics that we will ensure run through the data preprocessing pipeline @@ -26,7 +27,7 @@ # "1ZY8", # Incorrect in the legacy parser. An FAD ligand, (P, 4750, FAD), has two alternative locations; in the label-assigned ID's (and in PyMol) those are correctly noted, but they have different author residue ID's and thus are both present in the legacy parser. "6DMH", # Incorrect in the legacy parser. Waters with multiple occupancies not resolved correctly. # "1FU2", # Simple, small example with multiple chains - "6DMG", # Multiconformer ligand + # "6DMG", # Multiconformer ligand (covered by regression tests) "1Y1W", # Protein-nucleic-acid complex # "5XNL", # Another ribosomal monstrous molecule to limit-test loading speeds (slow to load; commenting out) # "2E2H", # Complex with protein, DNA, and RNA (slow to load; commenting out) @@ -41,16 +42,16 @@ "7SBV", # Oligosaccharide defined as separate chains "3EPC", # Invalid index to scalar "6O7K", # Empty coordinates after filtering - "104D", # DNA/RNA Hybrid + # "104D", # DNA/RNA Hybrid (covered by regression tests) "5X3O", # polypeptide(D) "5GAM", # Complex with proteins and RNA; used in MSA tests "6A5J", # Small peptide, used in MSA tests (ensure no MSA) - "3NE2", # Manageable-size example with two simple proteins + # "3NE2", # Manageable-size example with two simple proteins (covered by regression tests) "1MNA", # Simple homomer (for MSA testing) "1HGE", # Simple heteromer (for MSA testing) "3EJJ", # Simple heteromer (for MSA testing) "112M", # Protein-ligand, no LOI, heme ligand - "1A3G", # Involves covalent modification, protein-ligand + # "1A3G", # Involves covalent modification, protein-ligand (covered by regression tests) "1A2N", # Protein-protein homeric interface "1A2Y", # Protein-protein heteromeric interface "1BDV", # Protein-nucleic acid interface @@ -66,11 +67,11 @@ @pytest.mark.parametrize("test_case", EDGE_CASE_LIST) def test_prior_bugs_and_edge_cases(test_case): """Runs data loading for a list of prior problematic entries to ensure they run through without error.""" - rows = DATA_PREPROCESSOR.get_rows(get_pdb_mirror_path(test_case)) - assert rows is not None # Check if the processing runs through + assemblies, pn_units, interfaces = preprocess(get_pdb_mirror_path(test_case), TEST_CONFIG) + assert assemblies is not None def examine_specific_case(pdb_id): """Used for debugging""" - rows = DATA_PREPROCESSOR.get_rows(get_pdb_mirror_path(pdb_id)) - assert rows is not None # Check if the processing runs through + assemblies, pn_units, interfaces = preprocess(get_pdb_mirror_path(pdb_id), TEST_CONFIG) + assert assemblies is not None diff --git a/tests/ml/profiling/profile_data_loading.py b/tests/ml/profiling/profile_data_loading.py index 9f1e1d1c..a43d1485 100644 --- a/tests/ml/profiling/profile_data_loading.py +++ b/tests/ml/profiling/profile_data_loading.py @@ -11,8 +11,6 @@ +-----------------------------------------------------------------------------------------+ """ -from __future__ import annotations - import logging from tqdm import tqdm diff --git a/tests/ml/standard_annotations/__init__.py b/tests/ml/standard_annotations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ml/conditions/test_annotator.py b/tests/ml/standard_annotations/test_annotator.py similarity index 78% rename from tests/ml/conditions/test_annotator.py rename to tests/ml/standard_annotations/test_annotator.py index c89bae7e..efe55a55 100644 --- a/tests/ml/conditions/test_annotator.py +++ b/tests/ml/standard_annotations/test_annotator.py @@ -8,12 +8,14 @@ import numpy as np import pytest -from atomworks.ml.conditions.annotator import ( +from atomworks.io.utils.annotator import ( ANNOTATOR_REGISTRY, clear_generated_annotations, ensure_annotations, remove_annotations, ) +from atomworks.io.utils.standard_annotations.definitions import Atomize +from atomworks.ml.conditions.definitions import Sequence from atomworks.ml.utils.testing import cached_parse @@ -121,5 +123,36 @@ def test_remove_specific_annotations(atom_array): assert annot in final_categories, f"Annotation '{annot}' should still be present" +@pytest.mark.parametrize( + "annotation_cls", + [ + Atomize, + Sequence, + ], +) +def test_ensure_annotations_standard_annotation(atom_array, annotation_cls): + """Test that ensure_annotations generates StandardAnnotation defaults by registry and full name.""" + annotation_name = annotation_cls.full_name + # Ensure the annotation is absent to start + assert ( + annotation_name not in atom_array.get_annotation_categories() + ), f"Annotation '{annotation_name}' should not be present before test" + + ensure_annotations(atom_array, annotation_name) + + assert ( + annotation_name in atom_array.get_annotation_categories() + ), f"Annotation '{annotation_name}' should be present after ensure_annotations" + annotation_data = atom_array.get_annotation(annotation_name) + assert len(annotation_data) == atom_array.array_length() + assert isinstance(annotation_data, np.ndarray) + + +def test_ensure_annotations_standard_annotation_registry_name(atom_array): + ensure_annotations(atom_array, Sequence.name) + + assert Sequence.full_name in atom_array.get_annotation_categories() + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/ml/standard_annotations/test_standard_annotations.py b/tests/ml/standard_annotations/test_standard_annotations.py new file mode 100644 index 00000000..4bd19690 --- /dev/null +++ b/tests/ml/standard_annotations/test_standard_annotations.py @@ -0,0 +1,192 @@ +"""Tests for the StandardAnnotation framework""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import biotite.structure as struc +import numpy as np +import pytest +from biotite.structure import AtomArray +from biotite.structure.io import pdbx + +from atomworks.constants import MASKED +from atomworks.io.config import ParseConfig +from atomworks.io.parser import STANDARD_PARSER_ARGS, parse +from atomworks.io.utils.atom_array_plus import ( + as_atom_array_plus, +) +from atomworks.io.utils.io_utils import ( + load_any, + to_cif_file, +) +from atomworks.io.utils.selection import get_annotation_categories +from atomworks.io.utils.standard_annotations import STANDARD_ANNOTATIONS +from atomworks.io.utils.standard_annotations.serialization import _deserialize_standard_annotations +from atomworks.io.utils.testing import assert_same_atom_array_or_stack +from atomworks.ml.conditions import C_CRD, C_IDX, C_NTR, C_SEQ +from atomworks.ml.utils.testing import ( + add_randomized_standard_annotations, + cached_parse, + get_pdb_mirror_path, + save_and_load_atom_array, +) + +# --------------------------------------------------------------------------- +# Save/load round-trips +# --------------------------------------------------------------------------- + + +@pytest.fixture +def minimal_atom_array(): + atom_array = AtomArray(3) + atom_array.res_name = np.array(["ALA", "GLY", "SER"]) + atom_array.coord = np.arange(9, dtype=float).reshape(3, 3) + return as_atom_array_plus(atom_array) + + +def test_masks_are_derived_from_annotations(minimal_atom_array): + sequence = np.array(["ALA", MASKED, "SER"]) + coordinate = minimal_atom_array.coord.copy() + coordinate[1] = np.nan + index = np.array([True, False, True]) + + C_SEQ.set_annotation(minimal_atom_array, sequence) + C_CRD.set_annotation(minimal_atom_array, coordinate) + C_IDX.set_annotation(minimal_atom_array, index) + + assert C_SEQ.mask(minimal_atom_array).tolist() == [True, False, True] + assert C_CRD.mask(minimal_atom_array).tolist() == [True, False, True] + assert np.array_equal(C_IDX.mask(minimal_atom_array), index) + assert not any(name.startswith("mask_") for name in minimal_atom_array.get_annotation_categories()) + + +def test_setting_condition_replaces_string_alias_dtype(minimal_atom_array): + alias = C_NTR.aliases[0] + minimal_atom_array.set_annotation(alias, np.array(["?", "0", "1"])) + C_NTR.set_annotation(minimal_atom_array, np.array([False, True, False])) + assert minimal_atom_array.get_annotation(alias).dtype == np.dtype(bool) + np.testing.assert_array_equal(C_NTR.annotation(minimal_atom_array), [False, True, False]) + + +def test_direct_aliases_are_non_destructive_and_conflicts_raise(minimal_atom_array): + alias = C_NTR.aliases[0] + minimal_atom_array.set_annotation(alias, np.array([True, False, True])) + + assert C_NTR.annotation(minimal_atom_array).tolist() == [True, False, True] + assert C_NTR.full_name not in minimal_atom_array.get_annotation_categories() + + C_NTR.set_annotation(minimal_atom_array, np.array([False, True, False])) + assert minimal_atom_array.get_annotation(alias).tolist() == [False, True, False] + + minimal_atom_array.set_annotation(alias, np.ones(3, dtype=bool)) + with pytest.raises(ValueError, match="Conflicting fields"): + C_NTR.annotation(minimal_atom_array) + + +def test_legacy_mask_only_fields_are_migrated(minimal_atom_array): + sequence_mask = np.array([True, False, True]) + coordinate_mask = np.array([False, True, True]) + minimal_atom_array.set_annotation("mask_sequence_1_residue", sequence_mask) + minimal_atom_array.set_annotation("mask_coordinate_1_atom", coordinate_mask) + + loaded = _deserialize_standard_annotations(minimal_atom_array, pdbx.CIFBlock()) + + assert C_SEQ.annotation(loaded).tolist() == ["ALA", MASKED, "SER"] + assert np.array_equal(C_CRD.annotation(loaded)[coordinate_mask], minimal_atom_array.coord[coordinate_mask]) + assert np.isnan(C_CRD.annotation(loaded)[~coordinate_mask]).all() + assert "mask_sequence_1_residue" not in loaded.get_annotation_categories() + assert "mask_coordinate_1_atom" not in loaded.get_annotation_categories() + + +def test_legacy_coordinate_mask_is_restored_on_stack(minimal_atom_array): + coordinate_mask = np.array([False, True, True]) + stack = struc.stack([minimal_atom_array, minimal_atom_array]) + stack.coord[1] += 10 + stack.set_annotation("mask_coordinate_1_atom", coordinate_mask) + + loaded = _deserialize_standard_annotations(stack, pdbx.CIFBlock()) + + for model in range(2): + np.testing.assert_array_equal(C_CRD.mask(loaded[model]), coordinate_mask) + np.testing.assert_allclose( + C_CRD.annotation(loaded[model])[coordinate_mask], stack.coord[model, coordinate_mask] + ) + assert "mask_coordinate_1_atom" not in loaded.get_annotation_categories() + + +def test_legacy_index_prefers_boolean_alias(minimal_atom_array): + minimal_atom_array.set_annotation(C_IDX.full_name, np.array([4, 5, 6])) + minimal_atom_array.set_annotation("mask_index_1_residue", np.array([True, False, True])) + + loaded = _deserialize_standard_annotations(minimal_atom_array, pdbx.CIFBlock()) + + assert C_IDX.annotation(loaded).dtype == bool + assert C_IDX.annotation(loaded).tolist() == [True, False, True] + assert "mask_index_1_residue" not in loaded.get_annotation_categories() + + +def test_save_and_load_with_standard_annotations(): + """Round-trip with non-default values for all StandardAnnotations, extra annotations included.""" + + atom_array = load_any(get_pdb_mirror_path("6lyz"))[0] + atom_array = as_atom_array_plus(atom_array) + + test_atom_array = add_randomized_standard_annotations(atom_array) + + # Extra non-SA scalar annotations + extra_mask_original = np.zeros(len(test_atom_array), dtype=bool) + extra_mask_original[np.random.choice(len(test_atom_array), 5, replace=False)] = True + test_atom_array.set_annotation("extra_mask", extra_mask_original) + + extra_annotation_original = np.random.randint(0, 100, size=test_atom_array.array_length()) + test_atom_array.set_annotation("extra_annotation", extra_annotation_original) + + loaded = save_and_load_atom_array( + test_atom_array, + extra_fields_to_load=["extra_mask", "extra_annotation"], + extra_annotations_to_save=["extra_mask", "extra_annotation"], + ) + + assert set(get_annotation_categories(loaded, n_body="all")).issuperset( + set(STANDARD_ANNOTATIONS.get_field_names()) + ), "Loaded atom array must have all StandardAnnotation categories for this test to be valid." + + assert_same_atom_array_or_stack(test_atom_array, loaded, annotations_to_compare="arr1", enforce_order=False) + + +def test_loading_standard_annotations_requires_plus(): + with pytest.raises(ValueError, match="load_standard_annotations=True requires return_atom_array_plus=True"): + ParseConfig.from_preset("minimal", load_standard_annotations=True, return_atom_array_plus=False) + + +def test_round_trip_parse(): + """Save with to_cif_file, load with parse + load_standard_annotations=True.""" + + parser_args = STANDARD_PARSER_ARGS.copy() + parser_args["fix_arginines"] = False + parser_args["add_missing_atoms"] = False + parser_args["convert_mse_to_met"] = False + data = cached_parse( + "6lyz", + **parser_args, + ) + atom_array = as_atom_array_plus(data["atom_array"]) + + test_atom_array = add_randomized_standard_annotations(atom_array) + + with TemporaryDirectory() as tmp: + path = Path(tmp) / "test.cif" + to_cif_file(test_atom_array, path, save_standard_annotations=True) + + result = parse( + path, + config=ParseConfig(**{**parser_args, "load_standard_annotations": True, "return_atom_array_plus": True}), + ) + assembly_ids = list(result["assemblies"].keys()) + loaded = result["assemblies"][assembly_ids[0]][0] + + assert_same_atom_array_or_stack(test_atom_array, loaded) + + +if __name__ == "__main__": + pytest.main(["-v", __file__]) diff --git a/tests/ml/transforms/msa/test_load_msas.py b/tests/ml/transforms/msa/test_load_msas.py index 6e3c6121..9d773667 100644 --- a/tests/ml/transforms/msa/test_load_msas.py +++ b/tests/ml/transforms/msa/test_load_msas.py @@ -1,6 +1,4 @@ -import copy import logging -import time from typing import Any import numpy as np @@ -88,9 +86,7 @@ def _validate_msa_results(test_case: dict[str, Any], result: dict[str, Any], cha @pytest.fixture def load_polymer_msas_transform(): """Fixture to create a LoadPolymerMSAs transformation pipeline.""" - return LoadPolymerMSAs( - protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, max_msa_sequences=2_000, msa_cache_dir=None - ) + return LoadPolymerMSAs(protein_msa_dirs=PROTEIN_MSA_DIRS, rna_msa_dirs=RNA_MSA_DIRS, max_msa_sequences=2_000) @pytest.mark.parametrize("test_case", MSA_TEST_CASES) @@ -108,49 +104,6 @@ def test_load_msas(test_case: dict[str, Any], load_polymer_msas_transform: LoadP _validate_msa_results(test_case, result, chain_type) -@pytest.mark.slow -@pytest.mark.parametrize("test_case", MSA_TEST_CASES) -def test_cache_msas(test_case: dict[str, Any], tmp_path: str, load_polymer_msas_transform): - """Tests the MSA caching functionality by loading the same MSA with and without caching and comparing the results.""" - data = cached_parse(test_case["pdb_id"], convert_mse_to_met=True) - - # Load with caching turned off - start_time = time.time() - out_without_cache = load_polymer_msas_transform(copy.deepcopy(data)) - first_run_time = time.time() - start_time - - # Load with caching turned on - cache_pipeline = LoadPolymerMSAs( - protein_msa_dirs=PROTEIN_MSA_DIRS, - rna_msa_dirs=RNA_MSA_DIRS, - max_msa_sequences=2_000, - msa_cache_dir=tmp_path / "msa_cache", - ) - - # (Warmup, which caches the MSA) - out_with_cache_1 = cache_pipeline(copy.deepcopy(data)) - - # ... and again, loading from cache - start_time = time.time() - out_with_cache_2 = cache_pipeline(copy.deepcopy(data)) - last_run_time = time.time() - start_time - - # The results should be the same - chain_id = test_case["chain_id"] - for key in out_without_cache["polymer_msas_by_chain_id"][chain_id]: - assert np.array_equal( - out_without_cache["polymer_msas_by_chain_id"][chain_id][key], - out_with_cache_1["polymer_msas_by_chain_id"][chain_id][key], - ) - assert np.array_equal( - out_with_cache_1["polymer_msas_by_chain_id"][chain_id][key], - out_with_cache_2["polymer_msas_by_chain_id"][chain_id][key], - ) - - # The second run should be faster than non-cached MSA loading - assert last_run_time < first_run_time * 0.7, "Cached MSA loading should be >2x faster than non-cached" - - def _check_coverage_for_pdb_id( pdb_id: str, protein_msa_dirs: list[str] | None = None, rna_msa_dirs: list[str] | None = None ): @@ -172,7 +125,7 @@ def _check_coverage_for_pdb_id( # Load MSAs load_polymer_msas_transform = LoadPolymerMSAs( - protein_msa_dirs=protein_msa_dirs, rna_msa_dirs=rna_msa_dirs, max_msa_sequences=2_000, msa_cache_dir=None + protein_msa_dirs=protein_msa_dirs, rna_msa_dirs=rna_msa_dirs, max_msa_sequences=2_000 ) output = load_polymer_msas_transform(data) @@ -207,14 +160,19 @@ def test_inference_msa_transform(test_case): chain_id = test_case["chain_id"] chain_type = data["chain_info"][chain_id]["chain_type"] - # ... spoof the MSA path in the chain info + # ... spoof the MSA path in the chain info and remove keys users won't have access to. + keys_to_remove = ["processed_entity_non_canonical_sequence", "processed_entity_canonical_sequence"] if chain_type.is_protein(): sequence = data["chain_info"][chain_id]["processed_entity_non_canonical_sequence"] - data["chain_info"][chain_id]["msa_path"] = get_msa_path(sequence, PROTEIN_MSA_DIRS) + msa_path = get_msa_path(sequence, PROTEIN_MSA_DIRS) + data["chain_info"][chain_id]["msa_path"] = msa_path elif chain_type == ChainType.RNA: # HACK: Replace U with T to match the RNA MSA file names (legacy issue) sequence = data["chain_info"][chain_id]["processed_entity_non_canonical_sequence"].replace("U", "T") - data["chain_info"][chain_id]["msa_path"] = get_msa_path(sequence, RNA_MSA_DIRS) + msa_path = get_msa_path(sequence, RNA_MSA_DIRS) + data["chain_info"][chain_id]["msa_path"] = msa_path + for key in keys_to_remove: + data["chain_info"][chain_id].pop(key, None) # Inference MSA pipeline inference_pipeline = LoadPolymerMSAs( diff --git a/tests/ml/transforms/symmetry/test_automorphisms_networkx.py b/tests/ml/transforms/symmetry/test_automorphisms_networkx.py index 1579ff1d..07abd09a 100644 --- a/tests/ml/transforms/symmetry/test_automorphisms_networkx.py +++ b/tests/ml/transforms/symmetry/test_automorphisms_networkx.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import biotite.structure as struc import numpy as np import pytest @@ -7,6 +9,7 @@ from atomworks.ml.transforms.atomize import AtomizeByCCDName from atomworks.ml.transforms.symmetry import ( FindAutomorphismsWithNetworkX, + count_heavy_neighbors, find_automorphisms_with_networkx, generate_automorphisms_from_atom_array_with_networkx, ) @@ -40,6 +43,16 @@ def test_find_automorphisms_from_atom_array_with_networkx(test_case: dict): automorphisms = generate_automorphisms_from_atom_array_with_networkx(residue_atom_array) assert len(automorphisms) == test_case["expected_automorphisms"], f"Failed for CCD code: {test_case['ccd_code']}" + factory = Mock(return_value=residue_atom_array) + options = {"hash_key": object(), "normalize_bond_orders": True, "max_automorphs": 1000} + for _ in range(2): + np.testing.assert_array_equal( + generate_automorphisms_from_atom_array_with_networkx(factory, **options), automorphisms, strict=True + ) + assert factory.call_count == 1 + limited = generate_automorphisms_from_atom_array_with_networkx(factory, **(options | {"max_automorphs": 1})) + assert factory.call_count == 2 + np.testing.assert_array_equal(limited, generate_automorphisms_from_atom_array_with_networkx(residue_atom_array, 1)) def test_manual_generate_automorphs_with_networkx(): @@ -79,7 +92,7 @@ def array_in_list(array: np.ndarray, list_of_arrays: list[np.ndarray] | np.ndarr return any(np.array_equal(array, item) for item in list_of_arrays) -TEST_PDB_IDS = ["4js1", "6gej", "6wtf"] +TEST_PDB_IDS = ["4js1", "6gej", "6wtf", "2jof"] @pytest.mark.parametrize("pdb_id", TEST_PDB_IDS) @@ -100,6 +113,11 @@ def test_find_automorphisms_within_entire_structure(pdb_id: str): automorphisms = find_automorphisms_transform(output)["automorphisms"] atom_array = output["atom_array"] + repeated = find_automorphisms_with_networkx(atom_array) + assert len(repeated) == len(automorphisms) + for actual, expected in zip(repeated, automorphisms, strict=True): + np.testing.assert_array_equal(actual, expected, strict=True) + n_heavy_neighbors = count_heavy_neighbors(atom_array) for automorphism in automorphisms: # ...get the identity residue = atom_array[automorphism[0]] @@ -109,6 +127,8 @@ def test_find_automorphisms_within_entire_structure(pdb_id: str): if "OXT" in residue.atom_name: continue + residue.set_annotation("n_heavy_neighbors", n_heavy_neighbors[automorphism[0]]) + # ...if it's a glycine, there should be no automorphisms if residue_name == "GLY": assert len(automorphism) == 1, "Glycine should have no automorphisms, unless it's terminal." @@ -132,8 +152,16 @@ def test_find_automorphisms_within_entire_structure(pdb_id: str): if residue_name == "ILE": assert len(automorphism) == 1, "Isoleucine should have no automorphisms." - # ...calculate automorphisms - local_automorphisms = generate_automorphisms_from_atom_array_with_networkx(residue) + # ... backbone atoms only look swappable once the peptide bonds are severed + moved_atom_names = residue.atom_name[get_indices_of_non_constant_columns(automorphism)] + assert not ( + set(moved_atom_names) & {"N", "CA", "C", "O"} + ), f"{residue_name} automorphism permutes backbone atoms {moved_atom_names}." + + # ... calculate automorphisms (colored as in `find_automorphisms_with_networkx`) + local_automorphisms = generate_automorphisms_from_atom_array_with_networkx( + residue, node_features=["element", "n_heavy_neighbors"] + ) assert len(local_automorphisms) == len( automorphism diff --git a/tests/ml/transforms/symmetry/test_resonance_aware_automorphisms.py b/tests/ml/transforms/symmetry/test_resonance_aware_automorphisms.py new file mode 100644 index 00000000..22ac8816 --- /dev/null +++ b/tests/ml/transforms/symmetry/test_resonance_aware_automorphisms.py @@ -0,0 +1,86 @@ +"""Regression tests for resonance-aware, bond-order-aware automorphism search.""" + +import json +from pathlib import Path + +import numpy as np +import pytest +from biotite import structure as struc +from biotite.structure import AtomArray + +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications +from atomworks.ml.transforms.symmetry import build_automorphism_units, find_automorphisms_with_networkx +from atomworks.ml.utils.testing import cached_parse + +REFERENCE = json.loads((Path(__file__).resolve().parents[3] / "data" / "automorphism_reference.json").read_text()) + + +def as_ligand(ccd_code: str) -> AtomArray: + """Return a heavy-atom CCD entry annotated as a single non-polymer unit.""" + atom_array = struc.info.residue(ccd_code) + atom_array = atom_array[atom_array.element != "H"] + atom_array.res_name[:] = "LIG" + atom_array.hetero[:] = True + atom_array.chain_id[:] = "A" + atom_array.res_id[:] = 1 + atom_array.set_annotation("pn_unit_iid", np.full(atom_array.array_length(), "L0")) + atom_array.set_annotation("is_polymer", np.zeros(atom_array.array_length(), dtype=bool)) + return atom_array + + +def swapped_atom_names(atom_array: AtomArray) -> set[str]: + """Atom names that move under some automorphism of ``atom_array``.""" + return { + str(atom_array.atom_name[source]) + for group in find_automorphisms_with_networkx(atom_array) + for row in group[1:] + for source, target in zip(group[0], row, strict=True) + if source != target + } + + +@pytest.mark.parametrize("ccd_code", sorted(REFERENCE), ids=sorted(REFERENCE)) +def test_automorphisms_match_reference(ccd_code: str) -> None: + """Each hard molecule keeps the automorphism group recorded on disk.""" + expected = REFERENCE[ccd_code] + groups = find_automorphisms_with_networkx(as_ligand(ccd_code)) + n_automorphisms = sum(len(group) for group in groups if len(group) > 1) or 1 + assert n_automorphisms == expected["n_automorphisms"] + assert sorted(swapped_atom_names(as_ligand(ccd_code))) == expected["swapped_atoms"] + + +def test_covalent_modifications_on_1ivo() -> None: + """The search respects covalent modifications on a real N-glycosylated protein (1ivo).""" + # ``raw`` is a pristine snapshot (the transform reassigns pn_units in place, so reassign a + # separate copy and keep ``raw`` for the original pn_unit layout). + raw = cached_parse("1ivo", hydrogen_policy="remove")["atom_array"].copy() + atom_array = AnnotateCovalentModifications()( + {"atom_array": cached_parse("1ivo", hydrogen_policy="remove")["atom_array"]} + )["atom_array"] + units = build_automorphism_units(atom_array) + + # Units partition every atom and none spans a pn_unit. + assert np.array_equal(np.sort(np.concatenate(units)), np.arange(atom_array.array_length())) + for unit in units: + assert len(set(atom_array.pn_unit_iid[unit])) == 1, "a unit spans several pn_units" + + # Every covalent modification (a bond across pn_units in the raw parse) shares a unit. + unit_of = np.empty(atom_array.array_length(), dtype=int) + for index, unit in enumerate(units): + unit_of[unit] = index + covalent_bonds = [(int(i), int(j)) for i, j, _ in raw.bonds.as_array() if raw.pn_unit_iid[i] != raw.pn_unit_iid[j]] + assert covalent_bonds, "expected 1ivo to carry covalent modifications" + for i, j in covalent_bonds: + assert unit_of[i] == unit_of[j], "a glycosylated residue and its glycan should share a unit" + + # No backbone atom is swappable (a terminal carboxylate genuinely swaps O/OXT and is exempt). + for group in find_automorphisms_with_networkx(atom_array): + if "OXT" in atom_array.atom_name[group[0]]: + continue + moved = { + str(atom_array.atom_name[source]) + for row in group[1:] + for source, target in zip(group[0], row, strict=True) + if source != target + } + assert not moved & {"N", "CA", "C", "O"}, f"backbone atoms {sorted(moved)} permuted" diff --git a/tests/ml/transforms/test_af3_reference_molecule.py b/tests/ml/transforms/test_af3_reference_molecule.py index 96ba0002..8cb59909 100644 --- a/tests/ml/transforms/test_af3_reference_molecule.py +++ b/tests/ml/transforms/test_af3_reference_molecule.py @@ -1,5 +1,3 @@ -import time - import biotite.structure as struc import numpy as np import pytest @@ -8,7 +6,6 @@ from atomworks.constants import STANDARD_AA, STANDARD_DNA, STANDARD_RNA from atomworks.enums import ChainType, GroundTruthConformerPolicy from atomworks.io.tools.inference import components_to_atom_array -from atomworks.io.tools.rdkit import atom_array_from_rdkit from atomworks.io.utils.selection import get_residue_starts from atomworks.ml.transforms.af3_reference_molecule import ( GetAF3ReferenceMoleculeFeatures, @@ -100,7 +97,13 @@ def test_get_af3_reference_molecule_features_res(res_name): def test_get_af3_reference_molecule_features_chain(): - atom_array = struc.info.residue("ALA") + struc.info.residue("R2R") + struc.info.residue("TYR") + ala = struc.info.residue("ALA") + ala.res_id[:] = 1 + r2r = struc.info.residue("R2R") + r2r.res_id[:] = 2 + tyr = struc.info.residue("TYR") + tyr.res_id[:] = 3 + atom_array = ala + r2r + tyr # Add the necessary annotations from `parse` atom_array = atom_array[atom_array.atom_name != "OXT"] atom_array = atom_array[atom_array.element != "H"] @@ -394,45 +397,6 @@ def test_max_conformers_per_residue_functionality(): assert val_conformers_with_limit <= 3 -def test_af3_reference_molecule_features_with_cached_conformers(cache_dir): - """Test AF3 reference molecule features using cached conformers.""" - data = cached_parse("1crn", hydrogen_policy="remove") - pipe = Compose( - [ - AddGlobalResIdAnnotation(), - LoadCachedResidueLevelData(dir=cache_dir, sharding_depth=1), - ] - ) - cached_residue_data = pipe(data) - - # Create transform with cached conformers enabled and max conformers limit - transform_with_cache = GetAF3ReferenceMoleculeFeatures( - max_conformers_per_residue=3, use_cached_conformers=True, save_rdkit_mols=True - ) - transform_no_cache = GetAF3ReferenceMoleculeFeatures( - max_conformers_per_residue=3, use_cached_conformers=False, conformer_generation_timeout=5.0 - ) - data_no_cache = {"atom_array": cached_residue_data["atom_array"]} - - # ... time the cached version - start_time = time.time() - result_data_cached = transform_with_cache(cached_residue_data) - cached_time = time.time() - start_time - - # ... time the non-cached version - start_time = time.time() - _ = transform_no_cache(data_no_cache) - no_cache_time = time.time() - start_time - - assert ( - cached_time < 0.8 * no_cache_time - ), f"Cached version should be faster than no cache version, but got {cached_time} vs {no_cache_time}" - - feats = result_data_cached["feats"] - assert not np.any(np.isnan(feats["ref_pos"])) - assert not np.any(np.all(feats["ref_pos"] == 0, axis=1)) - - @pytest.fixture def data_with_subsampled_conformers(cache_dir): """Fixture providing AF3 reference molecule features with subsampled conformers.""" @@ -450,56 +414,6 @@ def data_with_subsampled_conformers(cache_dir): return pipeline(data) -def test_af3_reference_molecule_features_with_subsampled_conformers(data_with_subsampled_conformers): - """Ensure that the actual reference molecules at each res_idx match""" - result_data = data_with_subsampled_conformers - - atom_array = result_data["atom_array"] - feats = result_data["feats"] - cached_residue_level_data = result_data["cached_residue_level_data"]["residues"] - conformer_indices = result_data["residue_conformer_indices"] - - # Get residue start/end positions - _res_start_ends = get_residue_starts(atom_array, add_exclusive_stop=True) - _res_starts, _res_ends = _res_start_ends[:-1], _res_start_ends[1:] - - # Loop through each residue and check that the reference conformer coordinates match - # the coordinates using the appropriate index from the subsampled conformer indices - for res_start, res_end in zip(_res_starts, _res_ends, strict=False): - res_name = atom_array.res_name[res_start] - res_global_id = int(atom_array.res_id_global[res_start]) - - # Get the cached RDKit molecule and the conformer index that was selected - cached_mol = cached_residue_level_data[res_name]["mol"] - assert cached_mol is not None and cached_mol.GetNumConformers() > 0, f"No conformers found for {res_name}" - selected_conformer_idx = int( - conformer_indices[res_global_id][0] - ) # Take first conformer index and convert to Python int - - # Get the expected coordinates from RDKit using the selected conformer index - expected_conformer = atom_array_from_rdkit( - cached_mol, - conformer_id=selected_conformer_idx, - remove_hydrogens=True, - ) - - # Map the expected conformer coordinates to the residue atom order - expected_ref_pos, expected_ref_mask = _map_reference_conformer_to_residue( - res_name=res_name, - atom_names=atom_array.atom_name[res_start:res_end], - conformer=expected_conformer, - ) - - # Get the actual reference positions from the AF3 features - actual_ref_pos = feats["ref_pos"][res_start:res_end] - actual_ref_mask = feats["ref_mask"][res_start:res_end] - - assert np.array_equal(actual_ref_mask, expected_ref_mask), "Reference masks don't match" - assert np.allclose( - expected_ref_pos[expected_ref_mask], actual_ref_pos[actual_ref_mask], atol=1e-6 - ), "Reference coordinates don't match" - - def test_chiral_centers_with_cached_conformers(cache_dir, data_with_subsampled_conformers): chiral_pipe = Compose( [ diff --git a/tests/ml/transforms/test_bonds.py b/tests/ml/transforms/test_bonds.py index b4614214..af2eddc3 100644 --- a/tests/ml/transforms/test_bonds.py +++ b/tests/ml/transforms/test_bonds.py @@ -18,7 +18,7 @@ _create_rf2aa_bond_features_matrix, get_token_bond_adjacency, ) -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.encoding import EncodeAF3TokenLevelFeatures from atomworks.ml.transforms.filters import RemoveHydrogens from atomworks.ml.utils.testing import cached_parse @@ -296,7 +296,7 @@ def test_af3_token_bond_features(test_case: dict): pipe = Compose( [ RemoveHydrogens(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName( atomize_by_default=True, res_names_to_ignore=STANDARD_AA, diff --git a/tests/ml/transforms/test_chirals.py b/tests/ml/transforms/test_chirals.py index 831094be..fc1ee11a 100644 --- a/tests/ml/transforms/test_chirals.py +++ b/tests/ml/transforms/test_chirals.py @@ -17,7 +17,7 @@ get_dih, get_rf2aa_chiral_features, ) -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.crop import CropSpatialLikeAF3 from atomworks.ml.transforms.filters import RemoveHydrogens from atomworks.ml.transforms.openbabel_utils import ( @@ -217,7 +217,7 @@ def test_chiral_featurization_with_covalent_modification(test_case: dict): [ AddGlobalAtomIdAnnotation(), RemoveHydrogens(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), AddOpenBabelMoleculesForAtomizedMolecules(), GetChiralCentersFromOpenBabel(), @@ -248,7 +248,7 @@ def test_chiral_featurize_after_cropping(): pipe = Compose( [ AddGlobalAtomIdAnnotation(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), AddOpenBabelMoleculesForAtomizedMolecules(), CropSpatialLikeAF3(crop_size=128), diff --git a/tests/ml/transforms/test_condition_selection.py b/tests/ml/transforms/test_condition_selection.py index 2defed4a..6bc69964 100755 --- a/tests/ml/transforms/test_condition_selection.py +++ b/tests/ml/transforms/test_condition_selection.py @@ -8,7 +8,7 @@ remove_hydrogens, remove_waters, ) -from atomworks.ml.conditions.annotator import ensure_annotations +from atomworks.io.utils.annotator import ensure_annotations from atomworks.ml.transforms import mask_generator as mg from atomworks.ml.utils.rng import create_rng_state_from_seeds, rng_state from atomworks.ml.utils.testing import cached_parse diff --git a/tests/ml/transforms/test_confidence_transforms.py b/tests/ml/transforms/test_confidence_transforms.py index e4d0434f..0816c348 100644 --- a/tests/ml/transforms/test_confidence_transforms.py +++ b/tests/ml/transforms/test_confidence_transforms.py @@ -21,7 +21,7 @@ Compose, ConvertToTorch, ) -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.encoding import EncodeAF3TokenLevelFeatures, EncodeAtomArray from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveNucleicAcidTerminalOxygen, RemoveTerminalOxygen from atomworks.ml.utils.testing import cached_parse @@ -69,7 +69,7 @@ def test_add_is_real_atom(test_case: dict[str, Any]): [ # Base pipeline RemoveHydrogens(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AddGlobalAtomIdAnnotation(), AtomizeByCCDName( atomize_by_default=True, @@ -124,7 +124,7 @@ def test_add_frame_indices(test_case: dict[str, Any]): [ # Base pipeline RemoveHydrogens(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AddGlobalAtomIdAnnotation(), AtomizeByCCDName( atomize_by_default=True, diff --git a/tests/ml/transforms/test_covalent_modifications.py b/tests/ml/transforms/test_covalent_modifications.py index 377bce29..59cc4476 100644 --- a/tests/ml/transforms/test_covalent_modifications.py +++ b/tests/ml/transforms/test_covalent_modifications.py @@ -4,7 +4,7 @@ import pytest from atomworks.ml.transforms.base import Compose -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.utils.testing import cached_parse COVALENT_MODIFICATION_TEST_CASES = [ @@ -31,7 +31,7 @@ def test_covalent_modifications(test_case: dict[str, Any]): unprocessed_atom_array = data["atom_array"] covalent_modification_pipeline = Compose( [ - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), ] ) result_with_covalent_modifications = covalent_modification_pipeline(data) diff --git a/tests/ml/transforms/test_crop.py b/tests/ml/transforms/test_crop.py index dc9ea87f..6380b218 100644 --- a/tests/ml/transforms/test_crop.py +++ b/tests/ml/transforms/test_crop.py @@ -1,4 +1,5 @@ import copy +import itertools from copy import deepcopy from functools import cache from typing import Any @@ -6,10 +7,12 @@ import biotite.structure as struc import numpy as np import pytest +from scipy.spatial import KDTree -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.constants import PDB_MIRROR_PATH +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from atomworks.ml.datasets import get_row_and_index_by_example_id -from atomworks.ml.datasets.parsers import InterfacesDFParser, PNUnitsDFParser, load_example_from_metadata_row +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.encoding_definitions import RF2AA_ATOM36_ENCODING from atomworks.ml.transforms.atom_array import ( AddGlobalAtomIdAnnotation, @@ -17,11 +20,12 @@ ) from atomworks.ml.transforms.atomize import AtomizeByCCDName from atomworks.ml.transforms.base import Compose -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.crop import ( CropContiguousLikeAF3, CropSpatialLikeAF3, compute_local_hash, + get_spatial_crop_center, ) from atomworks.ml.transforms.filters import RemoveHydrogens, RemoveTerminalOxygen from atomworks.ml.utils.rng import create_rng_state_from_seeds, rng_state @@ -125,8 +129,14 @@ @cache def _get_example(example_id: str, dataset: Any) -> dict: row = get_row_and_index_by_example_id(dataset, example_id)["row"] - dataset_parser = PNUnitsDFParser() if "pn_units" in example_id else InterfacesDFParser() - return load_example_from_metadata_row(metadata_row=row, metadata_row_parser=dataset_parser) + query_columns = ["q_pn_unit_iid"] if "pn_units" in example_id else ["pn_unit_1_iid", "pn_unit_2_iid"] + return create_structure_loader( + path_colname="pdb_id", + base_path=PDB_MIRROR_PATH, + extension=".cif.gz", + sharding_pattern="/1:3/", + column_mapping={"query_pn_unit_iids": query_columns}, + )(row) def get_example(example_id: str, dataset: Any) -> dict: @@ -143,7 +153,7 @@ def test_af3_like_spatial_crop_transform( AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), ], track_rng_state=False, @@ -154,6 +164,23 @@ def test_af3_like_spatial_crop_transform( data = get_example(example_id, rf2aa_pdb_dataset) data = prep_pipe(data) pre_crop_atom_array = data["atom_array"] + query = data["query_pn_unit_iids"] + occupied_query = np.isin(pre_crop_atom_array.pn_unit_iid, query) & (pre_crop_atom_array.occupancy > 0) + expected_centers = occupied_query.copy() if len(query) == 1 else np.zeros(len(pre_crop_atom_array), dtype=bool) + for left, right in itertools.combinations(query, 2): + indices = [ + np.flatnonzero((pre_crop_atom_array.pn_unit_iid == unit) & occupied_query) for unit in (left, right) + ] + for a, b in (indices, indices[::-1]): + counts = KDTree(pre_crop_atom_array.coord[b]).query_ball_point( + pre_crop_atom_array.coord[a], r=15.0, return_length=True + ) + expected_centers[a[counts > 0]] = True + np.testing.assert_array_equal( + get_spatial_crop_center(pre_crop_atom_array, query, raise_if_missing_crop_center=False), + expected_centers if expected_centers.any() else occupied_query, + strict=True, + ) data = crop_pipe(data) post_crop_atom_array = data["atom_array"] @@ -181,7 +208,7 @@ def test_af3_like_spatial_crop_transform( np.testing.assert_array_equal( crop_token_idxs, np.arange(n_tokens), err_msg="Not all atoms in crop despite not cropping." ) - assert_same_atom_array(pre_crop_atom_array, post_crop_atom_array) + assert_same_atom_array_or_stack(pre_crop_atom_array, post_crop_atom_array) return # Ensure correct, expected token count @@ -240,7 +267,7 @@ def test_af3_like_contiguous_crop_transform( AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), ], track_rng_state=False, @@ -268,7 +295,7 @@ def test_af3_like_contiguous_crop_transform( if not data["crop_info"]["requires_crop"]: # ... no crop was performed - assert_same_atom_array(pre_crop_atom_array, post_crop_atom_array) + assert_same_atom_array_or_stack(pre_crop_atom_array, post_crop_atom_array) return # Ensure crop is contiguous within each polymer instance @@ -300,7 +327,7 @@ def regression_test_af3_like_spatial_crop_transform( AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), ], track_rng_state=False, @@ -347,7 +374,7 @@ def test_resize_crops_with_too_many_atoms(pdb_id, np_seed=1): AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), ], track_rng_state=False, @@ -376,6 +403,45 @@ def test_resize_crops_with_too_many_atoms(pdb_id, np_seed=1): ), f"Expected contiguous crop to be resized to less than 3000 atoms, got {len(post_crop_atom_array)} atoms." +@pytest.mark.parametrize("crop_transform_cls", [CropContiguousLikeAF3, CropSpatialLikeAF3]) +@pytest.mark.parametrize( + "crop_size_range,max_crop_size", + [ + ((20, 40), None), # absolute token-count range + ((0.1, 0.9), 60), # fraction-of-length range, capped by max_crop_size + ], +) +def test_dynamic_crop_size_range(crop_transform_cls, crop_size_range, max_crop_size): + """Test that `crop_size_range` samples a variety of crop sizes, respecting `max_crop_size`.""" + prep_pipe = Compose( + [ + AddGlobalAtomIdAnnotation(), + RemoveHydrogens(), + RemoveTerminalOxygen(), + AnnotateCovalentModifications(), + AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), + ], + track_rng_state=False, + ) + data = prep_pipe(cached_parse("6lyz")) + + sampled_sizes = set() + for seed in range(20): + crop_pipe = crop_transform_cls(crop_size_range=crop_size_range, max_crop_size=max_crop_size) + with rng_state(create_rng_state_from_seeds(np_seed=seed)): + data_cropped = crop_pipe(copy.deepcopy(data)) + crop_size = data_cropped["crop_info"]["crop_size"] + if max_crop_size is not None: + assert crop_size <= max_crop_size, f"Sampled crop size {crop_size} exceeds max_crop_size {max_crop_size}." + else: + assert ( + crop_size_range[0] <= crop_size <= crop_size_range[1] + ), f"Sampled crop size {crop_size} outside of range {crop_size_range}." + sampled_sizes.add(crop_size) + + assert len(sampled_sizes) > 1, f"Expected variety in sampled crop sizes across seeds, got {sampled_sizes}" + + def test_compute_local_hash(): data = cached_parse("6lyz") @@ -384,7 +450,7 @@ def test_compute_local_hash(): AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), ], track_rng_state=False, @@ -395,11 +461,13 @@ def test_compute_local_hash(): # Annotate the pre-crop hash pre_crop = data["atom_array"] hash_pre = compute_local_hash(pre_crop) + tree = KDTree(pre_crop.coord) + np.testing.assert_array_equal(hash_pre, np.array(list(map(len, tree.query_ball_tree(tree, 6.0)))), strict=True) pre_crop.set_annotation("hash_pre", hash_pre) data["atom_array"] = pre_crop # Test 1: Effectively no cropping - crop_pipe = CropSpatialLikeAF3(crop_size=10_000) + crop_pipe = CropSpatialLikeAF3(crop_size=10_000, annotate_crop_boundary=True) with rng_state(create_rng_state_from_seeds(np_seed=12)): data_cropped = crop_pipe(copy.deepcopy(data)) @@ -411,9 +479,10 @@ def test_compute_local_hash(): assert np.all( post_crop.hash_pre == post_crop.hash_post ), "Hash mismatch between pre- and post-crop despite not cropping." + assert not post_crop.at_crop_boundary.any() # Test 2: Spatial cropping - crop_pipe = CropSpatialLikeAF3(crop_size=32) + crop_pipe = CropSpatialLikeAF3(crop_size=32, annotate_crop_boundary=True) with rng_state(create_rng_state_from_seeds(np_seed=12)): data_cropped = crop_pipe(copy.deepcopy(data)) post_crop = data_cropped["atom_array"] @@ -421,6 +490,9 @@ def test_compute_local_hash(): post_crop.set_annotation("hash_post", hash_post) affected_idxs = np.where(post_crop.hash_pre != post_crop.hash_post)[0] + np.testing.assert_array_equal( + post_crop.at_crop_boundary, post_crop.hash_pre != post_crop.hash_post, strict=True + ) # fmt: off # NOTE: These were manually checked in pymol for a 6 A cut-off radius @@ -444,12 +516,15 @@ def test_compute_local_hash(): assert np.all(affected_idxs == _affected_idxs), f"Affected idxs mismatch: {affected_idxs} != {_affected_idxs}" # Test 3: Contiguous cropping - crop_pipe = CropContiguousLikeAF3(crop_size=32) + crop_pipe = CropContiguousLikeAF3(crop_size=32, annotate_crop_boundary=True) with rng_state(create_rng_state_from_seeds(np_seed=12)): data_cropped = crop_pipe(copy.deepcopy(data)) post_crop = data_cropped["atom_array"] hash_post = compute_local_hash(post_crop, radius=6.0) post_crop.set_annotation("hash_post", hash_post) + np.testing.assert_array_equal( + post_crop.at_crop_boundary, post_crop.hash_pre != post_crop.hash_post, strict=True + ) # fmt: off # NOTE: These were manually checked in pymol for a 6 A cut-off radius diff --git a/tests/ml/transforms/test_dssp.py b/tests/ml/transforms/test_dssp.py new file mode 100644 index 00000000..d99ee716 --- /dev/null +++ b/tests/ml/transforms/test_dssp.py @@ -0,0 +1,151 @@ +"""Tests for DSSP secondary structure annotation.""" + +import os + +import numpy as np +import pytest + +from atomworks.constants import STANDARD_AA +from atomworks.enums import ChainType +from atomworks.ml.executables import _EXECUTABLES +from atomworks.ml.executables.dssp import DSSPExecutable +from atomworks.ml.transforms.atom_array import AddGlobalAtomIdAnnotation +from atomworks.ml.transforms.atomize import AtomizeByCCDName +from atomworks.ml.transforms.base import Compose +from atomworks.ml.transforms.crop import CropSpatialLikeAF3 +from atomworks.ml.transforms.dssp import AnnotateSecondaryStructure, SSEnum +from atomworks.ml.utils.testing import cached_parse +from atomworks.ml.utils.token import get_token_starts + +DSSP_PATH = os.environ.get("DSSP_PATH", "/projects/ml/dssp/install/bin/mkdssp") + +DSSP_TEST_CASES = [ + "1a1e", # Protein structure + "1fu2", # Multi-chain protein +] + + +def _has_dssp() -> bool: + """Check if DSSP is installed.""" + if os.path.isfile(DSSP_PATH) and os.access(DSSP_PATH, os.X_OK): + try: + DSSPExecutable.get_or_initialize(DSSP_PATH) + return True + except Exception: + return False + return False + + +has_dssp = _has_dssp() + + +skip_if_no_dssp = pytest.mark.skipif( + not has_dssp, + reason="DSSP is not installed", +) + + +@pytest.fixture +def cleanup_dssp(): + """Reinitialize DSSPExecutable for test isolation. + + Ensures the DSSP executable singleton is reset between tests. + """ + if has_dssp: + DSSPExecutable.reinitialize(DSSP_PATH) + yield + _EXECUTABLES.pop(DSSPExecutable.name, None) + DSSPExecutable._is_initialized = False + + +def check_dssp_annotations(atom_array): + """Verify DSSP annotations are present and valid.""" + # Check annotation exists + assert "dssp_sse" in atom_array.get_annotation_categories() + assert "dssp_sse_is_valid" in atom_array.get_annotation_categories() + + # Check annotation length and type + sse = atom_array.get_annotation("dssp_sse") + assert len(sse) == atom_array.array_length() + assert sse.dtype in [np.int8, np.int16, np.int32, np.int64] + + # Check dssp_sse_is_valid length and type + sse_is_valid = atom_array.get_annotation("dssp_sse_is_valid") + assert len(sse_is_valid) == atom_array.array_length() + assert sse_is_valid.dtype == bool or sse_is_valid.dtype == np.bool_ + + # Token-level checks + token_starts = get_token_starts(atom_array) + token_chain_types = atom_array.chain_type[token_starts] + protein_token_mask = np.isin(token_chain_types, ChainType.get_proteins()) + + # Get the DSSP group for each token (first atom of each token) + token_dssp_groups = sse[token_starts] + token_sse_is_valid = sse_is_valid[token_starts] + + # Protein tokens with valid DSSP should have non-NON_PROTEIN groups + protein_valid_mask = protein_token_mask & token_sse_is_valid + if np.any(protein_valid_mask): + assert np.all( + token_dssp_groups[protein_valid_mask] != SSEnum.NON_PROTEIN + ), "Protein tokens with valid DSSP must have non-NON_PROTEIN groups" + + # All non-protein tokens should have NON_PROTEIN group + assert np.all( + token_dssp_groups[~protein_token_mask] == SSEnum.NON_PROTEIN + ), "All non-protein tokens must have NON_PROTEIN DSSP group" + + # All non-protein tokens should have is_valid False + assert np.all(~token_sse_is_valid[~protein_token_mask]), "All non-protein tokens must have dssp_sse_is_valid False" + + # Check all values are valid SSEnum indices + valid_values = {0, 1, 2, 3, 4} # 0 for NONE, 1-4 for HELIX, SHEET, LOOP, NON_PROTEIN + assert set(np.unique(sse)).issubset(valid_values), f"Invalid DSSP values: {np.unique(sse)}" + + +@pytest.mark.requires_dssp +@skip_if_no_dssp +@pytest.mark.parametrize("pdb_id", DSSP_TEST_CASES) +def test_annotate_secondary_structure(pdb_id, cleanup_dssp): + """Test DSSP annotation on various structures.""" + data = cached_parse(pdb_id) + pipe = Compose( + [ + AtomizeByCCDName( + atomize_by_default=True, + res_names_to_ignore=STANDARD_AA, + ), + AnnotateSecondaryStructure(), # Uses environment variable or default + ] + ) + data = pipe(data) + atom_array = data["atom_array"] + check_dssp_annotations(atom_array) + + +@pytest.mark.requires_dssp +@skip_if_no_dssp +@pytest.mark.parametrize("pdb_id", ["1fu2"]) +def test_annotate_secondary_structure_after_spatial_crop(pdb_id, cleanup_dssp): + """Test DSSP annotation works correctly after spatial cropping.""" + + data = cached_parse(pdb_id) + pipe = Compose( + [ + AddGlobalAtomIdAnnotation(), + AtomizeByCCDName( + atomize_by_default=True, + res_names_to_ignore=STANDARD_AA, + ), + CropSpatialLikeAF3(crop_size=100, seed=42), + AnnotateSecondaryStructure(), # Uses environment variable or default + ] + ) + + data = pipe(data) + atom_array = data["atom_array"] + check_dssp_annotations(atom_array) + + +if __name__ == "__main__": + pytest.main(["-v", "-x", "--log-cli-level=INFO", __file__]) diff --git a/tests/ml/transforms/test_encodings.py b/tests/ml/transforms/test_encodings.py index c29afae7..cf3ae7ba 100644 --- a/tests/ml/transforms/test_encodings.py +++ b/tests/ml/transforms/test_encodings.py @@ -2,6 +2,8 @@ import numpy as np import pytest +from atomworks.constants import ELEMENT_NAME_TO_ATOMIC_NUMBER +from atomworks.io.utils.io_utils import load_any from atomworks.ml.encoding_definitions import ( AF2_ATOM14_ENCODING, AF2_ATOM37_ENCODING, @@ -9,17 +11,26 @@ RF2_ATOM23_ENCODING, RF2_ATOM36_ENCODING, RF2AA_ATOM36_ENCODING, + UNIFIED_ATOM37_ENCODING, TokenEncoding, ) from atomworks.ml.transforms.atomize import AtomizeByCCDName from atomworks.ml.transforms.base import Compose, Identity -from atomworks.ml.transforms.encoding import AddTokenAnnotation, EncodeAtomArray, get_token_count +from atomworks.ml.transforms.encoding import ( + AddTokenAnnotation, + EncodeAtomArray, + atom_array_to_encoded_resnames, + atom_array_to_encoding, + get_token_count, +) from atomworks.ml.transforms.filters import ( FilterToProteins, RemoveHydrogens, RemoveTerminalOxygen, ) from atomworks.ml.utils.testing import cached_parse +from atomworks.ml.utils.token import token_iter +from tests.conftest import TEST_DATA_DIR @pytest.mark.parametrize("pdb_id", ["5ocm", "5ocn"]) @@ -204,5 +215,84 @@ def test_extra_annotations(test_case: dict): # - Test encoding-decoding roundtrip (known tokens + atomized unknowns) # - Test encoding-decoding roundtrip (known tokens + atomized unknowns + unknowns) + +@pytest.mark.parametrize("override", [False, True]) +@pytest.mark.parametrize("atom_mode", ["element", "atomic_number", "literal"]) +def test_resname_encoding_at_token_starts(override: bool, atom_mode: str): + atoms = struc.AtomArray(6) + atoms.res_name = np.array(["ALA", "ALA", "DA", "U", "UNL", "UNL"]) + atoms.element = np.array(["C", "N", "C", "C", "c", "n"]) + atoms.set_annotation("token_id", np.array([0, 0, 1, 2, 3, 4])) + atoms.set_annotation("atomize", np.array([False] * 4 + [True] * 2)) + if atom_mode == "atomic_number": + atoms.set_annotation("atomic_number", np.array([6, 7, 6, 6, 8, 26])) + encoding = TokenEncoding( + token_atoms={name: [str(name)] for name in ("ALA", "DA", "U", "", "UNK", "", 6, 7, 8, 26)}, + chemcomp_type_to_unknown={"L-PEPTIDE LINKING": "UNK"}, + ) + names = np.array(["MSE", "ALA", "", "DA", "", ""]) if override else None + atom_tokens = {"element": [6, 7], "atomic_number": [8, 26], "literal": ["", ""]}[atom_mode] + expected_names = (["UNK", "UNK", "", "DA"] if override else ["ALA", "ALA", "DA", "U"]) + atom_tokens + expected = np.array([encoding.token_to_idx[name] for name in expected_names], dtype=int) + actual = atom_array_to_encoded_resnames( + atoms, encoding, atomize_token="" if atom_mode == "literal" else None, res_names=names + ) + assert actual.dtype == expected.dtype and actual.shape == expected.shape + assert actual.tobytes() == expected.tobytes() + + +def test_atomize_token_encodes_atomized_tokens(): + atom_array = load_any(str(TEST_DATA_DIR / "io" / "1qfe.pdb"), model=1) + + assert hasattr(atom_array, "hetero") and atom_array.hetero is not None + assert np.any(atom_array.hetero), "Expected ligand atoms in 1qfe.pdb" + atom_array.set_annotation("atomize", atom_array.hetero.copy()) + + encoding = UNIFIED_ATOM37_ENCODING + encoded = atom_array_to_encoding( + atom_array, + encoding, + atomize_token="", + atomize_atom_name="X", + ) + + atomize_idx = encoding.token_to_idx[""] + saw_atomized = False + saw_non_atomized = False + for i, token in enumerate(token_iter(atom_array)): + if token.atomize[0]: + saw_atomized = True + assert encoded["seq"][i] == atomize_idx + else: + saw_non_atomized = True + assert encoded["seq"][i] != atomize_idx + + assert saw_atomized and saw_non_atomized + + +def test_atomize_token_none_uses_atomic_number_tokens(): + atom_array = load_any(str(TEST_DATA_DIR / "io" / "test_unl_ligand_with_bonds.cif"), model=1) + atom_array.set_annotation("atomize", np.ones(atom_array.array_length(), dtype=bool)) + + atomic_numbers = np.unique( + atom_array.atomic_number + if "atomic_number" in atom_array.get_annotation_categories() + else np.array([ELEMENT_NAME_TO_ATOMIC_NUMBER[e.upper()] for e in atom_array.element]) + ) + token_atoms = {int(n): [str(int(n))] for n in atomic_numbers} + token_atoms.setdefault(0, ["0"]) + encoding = TokenEncoding(token_atoms=token_atoms) + + encoded = atom_array_to_encoding(atom_array, encoding, atomize_token=None) + + for i, token in enumerate(token_iter(atom_array)): + token_name = ( + token.atomic_number[0] + if "atomic_number" in token.get_annotation_categories() + else ELEMENT_NAME_TO_ATOMIC_NUMBER[token.element[0].upper()] + ) + assert encoded["seq"][i] == encoding.token_to_idx[token_name] + + if __name__ == "__main__": pytest.main(["-v", "-x", __file__]) diff --git a/tests/ml/transforms/test_featurize_unresolved_residues.py b/tests/ml/transforms/test_featurize_unresolved_residues.py index 10191ff9..8febdea1 100644 --- a/tests/ml/transforms/test_featurize_unresolved_residues.py +++ b/tests/ml/transforms/test_featurize_unresolved_residues.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from atomworks.ml.encoding_definitions import ( RF2AA_ATOM36_ENCODING, ) @@ -52,6 +52,7 @@ def test_mask_residues_with_unresolved_backbone_atoms(pdb_id): assert np.all(updated_atom_array.occupancy[unchanged_residue_mask] == atom_array.occupancy[unchanged_residue_mask]) +# 7RCU includes a sequence-defined ACE-SER cap link with both attachment atoms unresolved. FEATURIZE_UNRESOLVED_RESIDUES_TEST_CASES = ["6wtf", "7rcu", "8e83", "7okl", "7z24"] @@ -127,7 +128,7 @@ def test_place_unresolved_token_atoms_on_representative_atom(pdb_id): for chain_iid in np.unique(unresolved_non_polymer_atoms.chain_iid): output_chain_atom_array = output_atom_array[output_atom_array.chain_iid == chain_iid] input_chain_atom_array = atom_array[atom_array.chain_iid == chain_iid] - assert_same_atom_array(output_chain_atom_array, input_chain_atom_array) + assert_same_atom_array_or_stack(output_chain_atom_array, input_chain_atom_array) @pytest.mark.parametrize("pdb_id", FEATURIZE_UNRESOLVED_RESIDUES_TEST_CASES) diff --git a/tests/ml/transforms/test_filters.py b/tests/ml/transforms/test_filters.py index 68b4573b..93783174 100644 --- a/tests/ml/transforms/test_filters.py +++ b/tests/ml/transforms/test_filters.py @@ -4,8 +4,8 @@ import pytest from biotite.structure import AtomArray -from atomworks.constants import STANDARD_AA, STANDARD_DNA, STANDARD_RNA -from atomworks.ml.datasets.parsers import PNUnitsDFParser, load_example_from_metadata_row +from atomworks.constants import PDB_MIRROR_PATH, STANDARD_AA, STANDARD_DNA, STANDARD_RNA +from atomworks.ml.datasets.loaders import create_structure_loader from atomworks.ml.preprocessing.constants import TRAINING_SUPPORTED_CHAIN_TYPES, ChainType from atomworks.ml.transforms.atomize import AtomizeByCCDName from atomworks.ml.transforms.base import Compose @@ -26,6 +26,14 @@ from atomworks.ml.utils.testing import cached_parse from atomworks.ml.utils.token import get_token_count, get_token_starts +PN_UNIT_LOADER = create_structure_loader( + path_colname="pdb_id", + base_path=PDB_MIRROR_PATH, + extension=".cif.gz", + sharding_pattern="/1:3/", + column_mapping={"query_pn_unit_iids": ["q_pn_unit_iid"]}, +) + @pytest.mark.parametrize("test_case", [{"pdb_id": "1s2k"}]) def test_remove_polymers_with_too_few_resolved_residues(test_case): @@ -180,7 +188,7 @@ def test_remove_unsupported_chain_types(pdb_id: str, pn_units_df: pd.DataFrame): assert not rows.empty for _, row in rows.iterrows(): - data = load_example_from_metadata_row(row, PNUnitsDFParser()) + data = PN_UNIT_LOADER(row) is_unsupported_type = row["q_pn_unit_type"] not in TRAINING_SUPPORTED_CHAIN_TYPES original_atom_array = data["atom_array"].copy() @@ -299,7 +307,7 @@ def test_filter_to_specified_pn_units(test_case: str, pn_units_df: pd.DataFrame) rows = rows.iloc[:1] for _, row in rows.iterrows(): - data = load_example_from_metadata_row(row, PNUnitsDFParser()) + data = PN_UNIT_LOADER(row) # Apply transforms # fmt: off @@ -411,15 +419,15 @@ def test_randomly_remove_ligands(): categories_100_percent["all"] == expected_remaining_100_percent ), f"With 100% probability, only free-floating ligands should be removed. Expected {len(expected_remaining_100_percent)}, got {categories_100_percent['counts']['all']}" - # Test with 50% probability multiple times to verify randomness works + # Test with 50% probability across 20 explicit seeded rngs. results_50_percent = [] - for seed in range(10): # Run 10 times with different seeds - with rng_state(create_rng_state_from_seeds(np_seed=seed)): - result_50_percent = random_remove_pn_units_by_annotation_query( - atom_array_with_covalent_mods.copy(), - query="~is_polymer & ~is_covalent_modification", - delete_probability=0.5, - ) + for seed in range(20): + result_50_percent = random_remove_pn_units_by_annotation_query( + atom_array_with_covalent_mods.copy(), + query="~is_polymer & ~is_covalent_modification", + delete_probability=0.5, + rng=np.random.default_rng(seed), + ) categories_50_percent = _categorize_pn_units(result_50_percent) results_50_percent.append(categories_50_percent["all"]) diff --git a/tests/ml/transforms/test_interactions.py b/tests/ml/transforms/test_interactions.py new file mode 100644 index 00000000..aec6355e --- /dev/null +++ b/tests/ml/transforms/test_interactions.py @@ -0,0 +1,100 @@ +"""Interactions AnnotateInteractions finds on real structures, run as callers run it.""" + +from pathlib import Path + +import numpy as np +import pytest + +from atomworks.io import parse +from atomworks.io.config import ParseConfig +from atomworks.ml.transforms.interactions import AnnotateInteractions, MetalRole, PiCationRole + +DATA = Path(__file__).parents[2] / "data" / "io" / "interactions" + + +def _annotate(entry, inter_chain_only=False, remove_waters=True): + atoms = parse(str(DATA / entry), config="rcsb")["asym_unit"][0] + if remove_waters: + atoms = atoms[atoms.res_name != "HOH"] + if "chain_iid" not in atoms.get_annotation_categories(): + atoms.set_annotation("chain_iid", np.char.add(atoms.chain_id.astype(str), "_1")) + return AnnotateInteractions(inter_chain_only=inter_chain_only)({"atom_array": atoms})["atom_array"] + + +def _residue_pairs(atoms, interaction): + pairs = atoms.get_annotation_2d(f"interaction_{interaction}").as_array()[:, :2].astype(int) + keys = list(zip(atoms.chain_id, atoms.res_id.tolist(), strict=True)) + return {frozenset((keys[a], keys[b])) for a, b in pairs} + + +def _atom(atoms, chain, res_id, atom_name): + (index,) = np.flatnonzero((atoms.chain_id == chain) & (atoms.res_id == res_id) & (atoms.atom_name == atom_name)) + return index + + +# (entry, interaction, residue, residue): a pair of residues the entry is known for +PAIRS = [ + pytest.param("iron_site_9oer.cif.zst", "metal", ("K", 303), ("A", 142), id="9OER Fe-HIS142"), + pytest.param("iron_site_9oer.cif.zst", "metal", ("K", 303), ("A", 209), id="9OER Fe-HIS209"), + pytest.param("pication_1gai.cif.zst", "pication", ("A", 107), ("A", 115), id="1GAI LYS107-TYR115"), + pytest.param("pication_1gai.cif.zst", "pication", ("A", 107), ("A", 119), id="1GAI LYS107-TRP119"), + pytest.param("7MKV_UstD_2_dimer.cif", "pistacking", ("B", 148), ("B", 258), id="7MKV HIS148-LLP258 B"), + pytest.param("7MKV_UstD_2_dimer.cif", "pistacking", ("C", 148), ("C", 258), id="7MKV HIS148-LLP258 C"), + pytest.param("PTE_enhanced_KCX.cif", "pistacking", ("A", 132), ("A", 131), id="PTE PHE132-TRP131"), + pytest.param("PTE_enhanced_KCX.cif", "pistacking", ("A", 132), ("A", 201), id="PTE PHE132-HIS201"), +] + + +@pytest.mark.parametrize(("entry", "interaction", "first", "second"), PAIRS) +def test_an_entry_shows_the_interaction_it_is_known_for(entry, interaction, first, second): + assert frozenset((first, second)) in _residue_pairs(_annotate(entry), interaction) + + +# (entry, keep waters, chain, residue, atom): a donor the entry's metal is known to hold +COORDINATING = [ + pytest.param("1IZC_macrophomate_synthase_dimer.cif", False, "A", 185, "OE1", id="1IZC GLU185 OE1"), + pytest.param("1IZC_macrophomate_synthase_dimer.cif", False, "A", 211, "OD2", id="1IZC ASP211 OD2"), + pytest.param("PTE_group1_theozyme.cif", True, "A", 1, "NE2", id="PTE HIS1 NE2"), + pytest.param("PTE_group1_theozyme.cif", True, "F", 6, "OE1", id="PTE GLU6 OE1"), + pytest.param("PTE_enhanced_KCX.cif", True, "A", 201, "ND1", id="PTE HIS201 ND1"), + pytest.param("PTE_enhanced_KCX.cif", True, "A", 230, "NE2", id="PTE HIS230 NE2"), +] + + +@pytest.mark.parametrize(("entry", "remove_waters", "chain", "res_id", "atom_name"), COORDINATING) +def test_a_metal_holds_the_donors_the_entry_is_known_for(entry, remove_waters, chain, res_id, atom_name): + atoms = _annotate(entry, remove_waters=remove_waters) + + assert atoms.interaction_metal_role[_atom(atoms, chain, res_id, atom_name)] == MetalRole.COORDINATING + + +def test_a_lysine_over_two_rings_is_the_cation_of_both(): + """1GAI LYS 107 NZ is the cation of its pi-cation pairs.""" + atoms = _annotate("pication_1gai.cif.zst") + + assert atoms.interaction_pication_role[_atom(atoms, "A", 107, "NZ")] & PiCationRole.CATION + + +@pytest.mark.parametrize("model", ["plip", "rosetta"]) +@pytest.mark.parametrize("inter_chain_only", [False, True]) +@pytest.mark.parametrize( + "entry, expected", + [ + ( + "1bna", + {(1, "N4", 12, "O6"), (12, "N1", 1, "N3"), (12, "N2", 1, "O2"), (5, "N6", 8, "O4"), (8, "N3", 5, "N1")}, + ), + ("1ehz", {(1, "N1", 72, "N3"), (1, "N2", 72, "O2"), (72, "N4", 1, "O6")}), + ], +) +def test_canonical_base_pair_hydrogen_bonds(entry, expected, inter_chain_only, model): + atoms = parse( + DATA / "nucleic_hbonds" / f"{entry}.cif.zst", + config=ParseConfig.from_preset("minimal", add_id_and_entity_annotations=True), + )["asym_unit"][0] + annotated = AnnotateInteractions( + interaction_types=("hbond",), hbond_model=model, inter_chain_only=inter_chain_only + )({"atom_array": atoms})["atom_array"] + pairs = annotated.get_annotation("interaction_hbond", n_body=2).pairs + observed = {(atoms.res_id[i], atoms.atom_name[i], atoms.res_id[j], atoms.atom_name[j]) for i, j in pairs} + assert observed == (set() if inter_chain_only and entry == "1ehz" else expected) diff --git a/tests/ml/transforms/test_masks.py b/tests/ml/transforms/test_masks.py index 22bc1615..f93a3f60 100644 --- a/tests/ml/transforms/test_masks.py +++ b/tests/ml/transforms/test_masks.py @@ -10,7 +10,7 @@ ) from atomworks.ml.transforms.atomize import AtomizeByCCDName from atomworks.ml.transforms.base import Compose -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.crop import ( CropSpatialLikeAF3, ) @@ -76,7 +76,7 @@ def test_spatial_knn_mask(test_case: dict[str, Any]): AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), ] ) @@ -99,7 +99,7 @@ def test_spatial_knn_mask(test_case: dict[str, Any]): AddGlobalAtomIdAnnotation(), RemoveHydrogens(), RemoveTerminalOxygen(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), AtomizeByCCDName(atomize_by_default=True, res_names_to_ignore=RF2AA_ATOM36_ENCODING.tokens), CropSpatialLikeAF3(crop_size=128), ] diff --git a/tests/ml/transforms/test_openbabel_utils.py b/tests/ml/transforms/test_openbabel_utils.py index 08d18d95..d1896860 100644 --- a/tests/ml/transforms/test_openbabel_utils.py +++ b/tests/ml/transforms/test_openbabel_utils.py @@ -1,7 +1,7 @@ import pytest from biotite.structure import AtomArray -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from atomworks.ml.transforms.openbabel_utils import atom_array_from_openbabel, atom_array_to_openbabel from atomworks.ml.utils.testing import cached_parse @@ -26,7 +26,7 @@ def _get_test_case(pdb_id: str, selector: callable) -> AtomArray: def test_with_explicit_hydrogens(atom_array): obmol = atom_array_to_openbabel(atom_array, infer_hydrogens=False, infer_aromaticity=False) array_reconstructed = atom_array_from_openbabel(obmol) - assert_same_atom_array( + assert_same_atom_array_or_stack( atom_array, array_reconstructed, compare_coords=True, @@ -40,7 +40,7 @@ def test_with_implicit_hydrogens_explicit_removed(atom_array): atom_array_no_hydrogens = atom_array[atom_array.atomic_number != 1] obmol = atom_array_to_openbabel(atom_array, infer_hydrogens=True, infer_aromaticity=False) array_reconstructed = atom_array_from_openbabel(obmol) - assert_same_atom_array( + assert_same_atom_array_or_stack( atom_array_no_hydrogens, array_reconstructed, compare_coords=True, @@ -54,7 +54,7 @@ def test_with_implicit_hydrogens_no_explicit(atom_array): atom_array_no_hydrogens = atom_array[atom_array.atomic_number != 1] obmol = atom_array_to_openbabel(atom_array, infer_hydrogens=True, infer_aromaticity=False) array_reconstructed = atom_array_from_openbabel(obmol) - assert_same_atom_array( + assert_same_atom_array_or_stack( atom_array_no_hydrogens, array_reconstructed, compare_coords=True, @@ -68,7 +68,7 @@ def test_ignoring_hydrogens(atom_array): atom_array_no_hydrogens = atom_array[atom_array.atomic_number != 1] obmol = atom_array_to_openbabel(atom_array_no_hydrogens, infer_hydrogens=False, infer_aromaticity=False) array_reconstructed = atom_array_from_openbabel(obmol) - assert_same_atom_array( + assert_same_atom_array_or_stack( atom_array_no_hydrogens, array_reconstructed, compare_coords=True, diff --git a/tests/ml/transforms/test_rdkit_transforms.py b/tests/ml/transforms/test_rdkit_transforms.py index cb5313dd..dd1b0fb5 100644 --- a/tests/ml/transforms/test_rdkit_transforms.py +++ b/tests/ml/transforms/test_rdkit_transforms.py @@ -6,7 +6,7 @@ from atomworks.ml.transforms.atom_array import AddGlobalAtomIdAnnotation from atomworks.ml.transforms.atomize import AtomizeByCCDName from atomworks.ml.transforms.base import Compose -from atomworks.ml.transforms.covalent_modifications import FlagAndReassignCovalentModifications +from atomworks.ml.transforms.covalent_modifications import AnnotateCovalentModifications from atomworks.ml.transforms.filters import HandleUndesiredResTokens, RemoveHydrogens from atomworks.ml.transforms.rdkit_utils import ( AddRDKitMoleculesForAtomizedMolecules, @@ -61,7 +61,7 @@ def test_add_rdkit_molecules_for_atomized_molecules(test_case): [ AddGlobalAtomIdAnnotation(), RemoveHydrogens(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), HandleUndesiredResTokens(["UNL"]), AtomizeByCCDName( atomize_by_default=True, @@ -118,7 +118,7 @@ def test_generate_rdkit_conformers(test_case): [ AddGlobalAtomIdAnnotation(), RemoveHydrogens(), - FlagAndReassignCovalentModifications(), + AnnotateCovalentModifications(), HandleUndesiredResTokens(["UNL"]), AtomizeByCCDName( atomize_by_default=True, diff --git a/tests/ml/transforms/test_rdkit_utils.py b/tests/ml/transforms/test_rdkit_utils.py index 7fb244ee..da558fce 100644 --- a/tests/ml/transforms/test_rdkit_utils.py +++ b/tests/ml/transforms/test_rdkit_utils.py @@ -190,15 +190,12 @@ def test_tuple_timeout_policy(caplog): "ALA", n_conformers=100, timeout=(0.1, 0.0), timeout_strategy="subprocess" ) end = time.time() + # Should complete within ~0.2s (allowing slight overhead) assert end - start < 0.5, f"Timeout didn't trigger properly, took {end - start:.2f}s" + # Should still return the requested number of conformers (via fallback) assert mol.GetNumConformers() == 100 - # Should have logged the specific warning about falling back to CCD conformer - assert ( - "Failed to generate 100 conformers for ccd_code='ALA'. Falling back to idealized conformer from the CCD" - in caplog.text - ) # Should succeed: with scaling timeout (0.1 + 0.1 * (n-1)) for 100 conformers caplog.clear() @@ -206,8 +203,7 @@ def test_tuple_timeout_policy(caplog): mol = ccd_code_to_rdkit_with_conformers("ALA", n_conformers=100, timeout=(0.1, 0.1), timeout_strategy="subprocess") end = time.time() assert mol.GetNumConformers() == 100 - # Should have taken at least the minimum timeout but not too long - assert end - start > 0.1 + # A timeout is an upper bound, not a minimum generation time. assert end - start < 15.0 # Generous upper bound # Should NOT have logged any warnings about fallback (since timeout was sufficient) assert "Falling back to idealized conformer" not in caplog.text diff --git a/tests/ml/transforms/test_sasa.py b/tests/ml/transforms/test_sasa.py index 82d9c847..9bb16ff8 100644 --- a/tests/ml/transforms/test_sasa.py +++ b/tests/ml/transforms/test_sasa.py @@ -1,90 +1,59 @@ from typing import Any +import biotite.structure as struc import numpy as np import pytest from biotite.structure import AtomArray - -from atomworks.ml.transforms.sasa import CalculateSASA, calculate_atomwise_rasa +from biotite.structure.info import vdw_radius_single + +from atomworks.ml.transforms.sasa import ( + CalculateSASA, + _get_element_radii, + _get_protor_element_fallback_radii, + calculate_atomwise_rasa, +) from atomworks.ml.utils.testing import cached_parse -# Define test cases -# (all values for radii and SASA are from "WhatIF") SASA_TEST_CASES = [ { - "pdb_id": "1fu2", # (multi-chain protein) - "probe_radius": 1.4, # default radius for water as a solvent + "pdb_id": "1fu2", # multi-chain protein with ZN + "probe_radius": 1.4, "atom_radii": "ProtOr", "point_number": 100, "spot_checks": [ - {"atom_name": "H"}, # should be nan - {"atom_name": "ZN"}, # should be nan - {"atom_name": "N"}, # should be not nan + {"atom_name": "H", "expect_nan": True}, + {"atom_name": "ZN", "expect_nan": True}, # ProtOr has no metal radii + {"atom_name": "N", "expect_nan": False}, ], }, { - "pdb_id": "3p42", # testing protein with certain NaN coordinates - "probe_radius": 1.4, # default radius for water as a solvent + "pdb_id": "3p42", # protein with NaN coordinates + "probe_radius": 1.4, "atom_radii": "ProtOr", "point_number": 100, "spot_checks": [ - {"atom_name": "H"}, # should be nan - {"atom_name": "N"}, # should be not nan + {"atom_name": "H", "expect_nan": True}, + {"atom_name": "N", "expect_nan": False}, + ], + }, + { + "pdb_id": "1fu2", # element mode: covers metals and ligand atoms + "probe_radius": 1.4, + "atom_radii": "element", + "point_number": 100, + "spot_checks": [ + {"atom_name": "H", "expect_nan": True}, + {"atom_name": "ZN", "expect_nan": False}, # element mode has metal radii + {"atom_name": "N", "expect_nan": False}, ], }, ] -def _define_residue_keys( - atom_array: AtomArray, -): - """ - Defines a key for each residue in the atom array. - """ - res_ids = atom_array.res_id - res_names = atom_array.res_name - chain_ids = atom_array.chain_id - transformation_ids = atom_array.transformation_id - - # Create unique keys for each residue using chain_id, res_id, and res_name - res_keys = [ - f"{chain}_{transform}:{name}_{res_id}" - for chain, transform, name, res_id in zip(chain_ids, transformation_ids, res_names, res_ids, strict=False) - ] - return res_keys - - -def _count_atoms_in_each_residue(atom_array: AtomArray) -> dict[int, int]: - """Count the number of atoms in each residue in the atom array.""" - # Get the residue keys - res_keys = _define_residue_keys(atom_array) - # Count atoms per unique residue - unique_keys, counts = np.unique(res_keys, return_counts=True) - return dict(zip(unique_keys, counts, strict=False)) - - -def _get_indices_of_singleton_residues(atom_array: AtomArray) -> np.ndarray: - """Get the indices of residues with only one atom in the atom array.""" - res_count_mapping = _count_atoms_in_each_residue(atom_array) - - res_keys = _define_residue_keys(atom_array) - singleton_residue_keys = [key for key, count in res_count_mapping.items() if count == 1] - singleton_residue_indices = np.isin(res_keys, singleton_residue_keys) - return singleton_residue_indices - - @pytest.mark.parametrize("test_case", SASA_TEST_CASES) def test_calculate_sasa(test_case: dict[str, Any]): - """ - Test the CalculateSASA transform using a multi-chain protein. - Checks: - - The SASA of atoms that should not have SASA calculated are NaN - - The SASA of heavy atoms that should have SASA are >=0 - """ - - # Load the atom array + """Test CalculateSASA with ProtOr radii (protein-only).""" data = cached_parse(test_case["pdb_id"]) - - # Apply the transform transform = CalculateSASA( probe_radius=test_case["probe_radius"], atom_radii=test_case["atom_radii"], @@ -92,30 +61,23 @@ def test_calculate_sasa(test_case: dict[str, Any]): ) data = transform(data) - # Check SASA values of specific atoms - for spot_check in test_case["spot_checks"]: atom_mask = data["atom_array"].atom_name == spot_check["atom_name"] - if data["atom_array"][atom_mask].atom_name[0] in (["ZN", "NA", "H"]): - assert np.isnan(data["atom_array"][atom_mask].sasa).all() + if spot_check["expect_nan"]: + assert np.isnan( + data["atom_array"][atom_mask].sasa + ).all(), f"{spot_check['atom_name']} should have NaN SASA with {test_case['atom_radii']} radii" else: - valid_mask = ~np.isnan(data["atom_array"][atom_mask].coord).all(axis=1) - assert np.all(data["atom_array"][atom_mask][valid_mask].sasa >= 0) + valid_mask = ~np.isnan(data["atom_array"][atom_mask].coord).any(axis=-1) + assert np.all( + data["atom_array"][atom_mask][valid_mask].sasa >= 0 + ), f"{spot_check['atom_name']} should have valid SASA with {test_case['atom_radii']} radii" @pytest.mark.parametrize("test_case", SASA_TEST_CASES) def test_calculate_rasa(test_case: dict[str, Any]): - """ - Test the CalculateRASA transform using a multi-chain protein. - Checks: - - The RASA of atoms that should not have RASA calculated are NaN - - The RASA of heavy atoms that should have RASA are >=0 - """ - - # Load the atom array + """Test RASA with ProtOr radii (protein-only).""" data = cached_parse(test_case["pdb_id"]) - - # Apply the transform atom_array = data["atom_array"] rasa = calculate_atomwise_rasa( atom_array, @@ -123,27 +85,104 @@ def test_calculate_rasa(test_case: dict[str, Any]): atom_radii=test_case["atom_radii"], point_number=test_case["point_number"], ) - data["atom_array"].set_annotation("rasa", rasa) - singleton_residue_indices = _get_indices_of_singleton_residues(atom_array) - # convert indices to boolean mask - is_singleton_residues = np.zeros(len(atom_array), dtype=bool) - if len(singleton_residue_indices) > 0: - is_singleton_residues[singleton_residue_indices] = True + has_coords = ~np.isnan(atom_array.coord).any(axis=-1) + is_heavy = atom_array.element != "H" + has_sasa = ~np.isnan(rasa) + valid_mask = has_coords & is_heavy & has_sasa + + assert np.all(rasa[valid_mask] >= 0), "RASA should be >= 0 for valid heavy atoms" + assert np.all(rasa[valid_mask] <= 1), "RASA should be <= 1 for valid heavy atoms" + + no_coords_mask = ~has_coords + assert np.isnan(rasa[no_coords_mask]).all(), "Atoms without coords should have NaN RASA" + - # Check RASA values of specific atoms - atom_mask = data["atom_array"].element == "H" - atom_mask |= data["atom_array"].occupancy == 0 - atom_mask |= is_singleton_residues - assert np.isnan(data["atom_array"][atom_mask].rasa).all() - assert np.all(data["atom_array"][~atom_mask].rasa >= 0), "RASA should be >= 0 for heavy atoms" - assert np.all(data["atom_array"][~atom_mask].rasa <= 1), "RASA should be <= 1 for heavy atoms" +def _assert_valid_rasa(rasa: np.ndarray, atom_array: AtomArray, mode: str) -> None: + """Shared assertions for RASA across modes.""" + has_coords = ~np.isnan(atom_array.coord).any(axis=-1) + is_heavy = atom_array.element != "H" + has_sasa = ~np.isnan(rasa) + valid_mask = has_coords & is_heavy & has_sasa + valid_rasa = rasa[valid_mask] + assert np.all(valid_rasa >= 0), f"[{mode}] RASA should be >= 0" + assert np.all(valid_rasa <= 1), f"[{mode}] RASA should be <= 1" -def test_calculate_rasa_failure(): - data = cached_parse("7eeu") + +def test_rasa_auto_protein_metal(): + """Test auto mode on 1fu2 (protein + ZN): ProtOr for residues, element fallback for ZN.""" + data = cached_parse("1fu2") atom_array = data["atom_array"] - rasa = calculate_atomwise_rasa( - atom_array, + + rasa = calculate_atomwise_rasa(atom_array, atom_radii="auto") + _assert_valid_rasa(rasa, atom_array, "auto") + + has_coords = ~np.isnan(atom_array.coord).any(axis=-1) + zn_mask = (atom_array.element == "ZN") & has_coords + if zn_mask.any(): + assert not np.isnan(rasa[zn_mask]).any(), "ZN should have valid RASA with auto mode" + + radii = _get_protor_element_fallback_radii(atom_array) + ala_ca_mask = (atom_array.res_name == "ALA") & (atom_array.atom_name == "CA") + if ala_ca_mask.any(): + protor_ca = struc.info.radii.vdw_radius_protor("ALA", "CA") + assert radii[ala_ca_mask][0] == pytest.approx(protor_ca), "Standard residues should use ProtOr" + zn_only = atom_array.element == "ZN" + if zn_only.any(): + assert radii[zn_only][0] == pytest.approx(vdw_radius_single("ZN")), "ZN should use element fallback" + + +def test_rasa_auto_protein_dna_metal(): + """Test auto mode on 6w13 (protein + DNA + MG + ligand): all atom types should work.""" + data = cached_parse("6w13") + atom_array = data["atom_array"] + + rasa = calculate_atomwise_rasa(atom_array, atom_radii="auto") + _assert_valid_rasa(rasa, atom_array, "auto") + + has_coords = ~np.isnan(atom_array.coord).any(axis=-1) + mg_mask = (atom_array.element == "MG") & has_coords + if mg_mask.any(): + assert not np.isnan(rasa[mg_mask]).any(), "MG should have valid RASA" + + dna_residues = {"DA", "DT", "DC", "DG"} + dna_mask = np.isin(atom_array.res_name, dna_residues) & has_coords & (atom_array.element != "H") + if dna_mask.any(): + assert not np.isnan(rasa[dna_mask]).any(), "DNA heavy atoms should have valid RASA" + + +def test_element_radii_unknown_raises(): + """Unknown elements should raise ValueError, not silently default.""" + atom_array = AtomArray(1) + atom_array.element = np.array(["XX"]) + atom_array.atom_name = np.array(["XX"]) + atom_array.res_name = np.array(["UNK"]) + + with pytest.raises(ValueError, match="No VdW radius found for element 'XX'"): + _get_element_radii(atom_array) + + +def test_element_radii_ligand_atoms(): + """Element-based SASA/RASA should work for synthetic ligand-like atom arrays.""" + atom_array = AtomArray(6) + atom_array.chain_id = np.array(["A", "A", "A", "L", "L", "L"]) + atom_array.res_id = np.array([1, 1, 1, 0, 0, 0]) + atom_array.res_name = np.array(["ALA", "ALA", "ALA", "L", "L", "L"]) + atom_array.atom_name = np.array(["CA", "C", "N", "C0", "O0", "N0"]) + atom_array.element = np.array(["C", "C", "N", "C", "O", "N"]) + atom_array.coord = np.array( + [ + [0.0, 0.0, 0.0], + [1.5, 0.0, 0.0], + [0.0, 1.5, 0.0], + [5.0, 5.0, 5.0], + [6.5, 5.0, 5.0], + [5.0, 6.5, 5.0], + ] ) - assert np.isnan(rasa).all(), "RASA should be NaN for all atoms in this case" + + rasa = calculate_atomwise_rasa(atom_array, atom_radii="element") + assert not np.isnan(rasa).any(), "All atoms should have valid RASA" + assert np.all(rasa >= 0) + assert np.all(rasa <= 1) diff --git a/tests/ml/transforms/test_stereochemistry.py b/tests/ml/transforms/test_stereochemistry.py new file mode 100644 index 00000000..d118676a --- /dev/null +++ b/tests/ml/transforms/test_stereochemistry.py @@ -0,0 +1,94 @@ +"""Atom and bond readouts from one RDKit 3D assignment.""" + +import numpy as np +import pytest +from rdkit import Chem +from rdkit.Chem import AllChem + +from atomworks.io.tools.rdkit import atom_array_from_rdkit +from atomworks.io.utils.ccd import custom_ccd_residues +from atomworks.ml.transforms.rdkit_utils import get_chiral_centers, get_stereochemistry +from atomworks.ml.utils.annotator import ensure_annotations + + +def molecule(smiles): + mol = Chem.AddHs(Chem.MolFromSmiles(smiles)) + assert AllChem.EmbedMolecule(mol, randomSeed=9) == 0 + for atom in mol.GetAtoms(): + atom.SetProp("atom_name", f"A{atom.GetIdx()}") + return mol + + +@pytest.mark.parametrize( + "smiles,count", + [ + ("C/C=C/C", 1), + (r"C/C=C\C", 1), + ("CC=CC", 1), + ("CC=NC", 1), + ("CC=C(C)C", 0), + ("CC/C(CC)=C(/C)CC", 0), + ("CC=O", 0), + ("C1=CCCCC1", 0), + ("FC=C=CCl", 0), + ("C/C=C/C=C/C", 2), + ("[2H]C([H])=C(F)Cl", 1), + ("C/C=C([13CH3])C", 1), + ], +) +@pytest.mark.parametrize("explicit_hydrogens", [True, False]) +def test_assigned_double_bonds(smiles, count, explicit_hydrogens): + mol = molecule(smiles) + if not explicit_hydrogens: + mol = Chem.RemoveHs(mol) + result = get_stereochemistry(mol) + assert len(result["double_bonds"]) == count + for record in result["double_bonds"]: + left, right, a, b = record["atom_idxs"] + bond = mol.GetBondBetweenAtoms(left, right) + assert list(bond.GetStereoAtoms()) == [a, b] + assert record["stereo"] == bond.GetStereo() + assert record["atom_names"] == tuple(mol.GetAtomWithIdx(i).GetProp("atom_name") for i in (left, right, a, b)) + + +@pytest.mark.parametrize("right_y,expected", [(-1, Chem.BondStereo.STEREOE), (1, Chem.BondStereo.STEREOZ)]) +def test_stereo_comes_from_coordinates_and_follows_renumbering(right_y, expected): + mol = Chem.MolFromSmiles("CC=CC") + conf = Chem.Conformer(4) + for i, coord in enumerate([[-1, 1, 0], [0, 0, 0], [1, 0, 0], [2, right_y, 0]]): + conf.SetAtomPosition(i, coord) + mol.GetAtomWithIdx(i).SetProp("atom_name", f"A{i}") + mol.AddConformer(conf) + mol = Chem.RenumberAtoms(mol, [3, 1, 0, 2]) + [record] = get_stereochemistry(mol)["double_bonds"] + assert record == {"atom_idxs": (1, 3, 2, 0), "atom_names": ("A1", "A2", "A0", "A3"), "stereo": expected} + + +def test_center_contract_and_tetrahedral_filter(): + mol = Chem.RemoveHs(molecule("N[C@@](C)(O)/C=C/F")) + result = get_stereochemistry(mol) + assert get_chiral_centers(mol) == result["chiral_centers"] + [center] = result["chiral_centers"] + assert center["chiral_center_idx"] == 1 + assert center["bonded_explicit_atom_idxs"] == [0, 2, 3, 4] + assert center["chiral_type"].is_tetrahedral() + assert center["chiral_center_atom_name"] == "A1" + for i, coord in enumerate(mol.GetConformer().GetPositions() * [-1, 1, 1]): + mol.GetConformer().SetAtomPosition(i, coord) + reflected = get_stereochemistry(mol, tetrahedral_only=True) + assert reflected["chiral_centers"][0]["chiral_type"] != center["chiral_type"] + assert reflected["double_bonds"] == result["double_bonds"] + + +@pytest.mark.parametrize("smiles,expected", [("C/C=C/C", [False, True, True, False]), ("CC=C(C)C", [False] * 5)]) +def test_stereogenic_bond_annotation_marks_only_endpoints(smiles, expected): + atoms = atom_array_from_rdkit(molecule(smiles), remove_hydrogens=True) + atoms.res_name[:] = "EZ1" + with custom_ccd_residues({"EZ1": atoms}): + order = np.roll(np.arange(len(atoms)), 1) + reordered = atoms[order] + ensure_annotations(reordered, "stereogenic_bond_atoms") + np.testing.assert_array_equal(reordered.stereogenic_bond_atoms, np.array(expected)[order]) + endpoints = atoms[1:3] + ensure_annotations(endpoints, "stereogenic_bond_atoms") + np.testing.assert_array_equal(endpoints.stereogenic_bond_atoms, expected[1:3]) diff --git a/tests/ml/transforms/test_token_utils.py b/tests/ml/transforms/test_token_utils.py index 2db6e355..4cdd3bf5 100644 --- a/tests/ml/transforms/test_token_utils.py +++ b/tests/ml/transforms/test_token_utils.py @@ -4,7 +4,7 @@ from atomworks.constants import STANDARD_AA, STANDARD_DNA, STANDARD_RNA from atomworks.io.utils.sequence import STANDARD_PURINE_RESIDUES, STANDARD_PYRIMIDINE_RESIDUES -from atomworks.io.utils.testing import assert_same_atom_array +from atomworks.io.utils.testing import assert_same_atom_array_or_stack from atomworks.ml.encoding_definitions import RF2AA_ATOM36_ENCODING from atomworks.ml.transforms.atom_array import ( AddGlobalAtomIdAnnotation, @@ -16,6 +16,7 @@ from atomworks.ml.utils.testing import cached_parse from atomworks.ml.utils.token import ( apply_segment_wise_2d, + apply_token_wise, get_af3_token_center_masks, get_af3_token_representative_masks, get_token_count, @@ -25,7 +26,9 @@ @pytest.mark.parametrize("pdb_id", ["6lyz", "5ocm"]) -def test_tokens_are_residues_without_atomization(pdb_id: str): +@pytest.mark.parametrize("function", [np.any, np.all, np.min, np.max, np.sum]) +@pytest.mark.parametrize("dtype", [bool, np.int32, np.float32]) +def test_tokens_are_residues_without_atomization(pdb_id: str, function, dtype): data = cached_parse(pdb_id) atom_array = data["atom_array"] @@ -36,7 +39,11 @@ def test_tokens_are_residues_without_atomization(pdb_id: str): == struc.get_residue_starts(atom_array, add_exclusive_stop=True) ) for res_1, res_2 in zip(struc.residue_iter(atom_array), token_iter(atom_array), strict=False): - assert_same_atom_array(res_1, res_2) + assert_same_atom_array_or_stack(res_1, res_2) + values = (atom_array.element == "C").astype(dtype) + for axis in (None, 0, -1): + expected = struc.apply_residue_wise(atom_array, values, function, axis=axis) + np.testing.assert_array_equal(apply_token_wise(atom_array, values, function, axis=axis), expected, strict=True) @pytest.mark.parametrize("pdb_id", ["6lyz", "5ocm"]) @@ -47,6 +54,9 @@ def test_tokens_are_atoms_with_full_atomization(pdb_id: str): assert get_token_count(atom_array) == len(atom_array) assert np.all(get_token_starts(atom_array) == np.arange(len(atom_array))) assert np.all(get_token_starts(atom_array, add_exclusive_stop=True) == np.arange(len(atom_array) + 1)) + for function in (np.any, np.all, np.min, np.max): + values = atom_array.element == "C" + np.testing.assert_array_equal(apply_token_wise(atom_array, values, function), values, strict=True) def test_apply_segment_wise_2d(): @@ -55,6 +65,11 @@ def test_apply_segment_wise_2d(): assert np.all( apply_segment_wise_2d(array, segment_start_end_idxs, reduce_func=np.sum) == np.array([[12, 9], [15, 9]]) ) + for starts in (segment_start_end_idxs, np.array([0, 0, 3]), np.array([1, 3]), np.array([0])): + for values in (array > 4, array[:, 0] > 4): + expected = struc.segments.apply_segment_wise(starts, values, np.any, axis=0) + actual = apply_token_wise(None, values, np.any, axis=0, token_starts=starts) + np.testing.assert_array_equal(actual, expected, strict=True) @pytest.mark.parametrize("pdb_id", ["6lyz", "5ocm"]) diff --git a/tests/ml/transforms/test_undesired_res_token_leaving_atoms.py b/tests/ml/transforms/test_undesired_res_token_leaving_atoms.py new file mode 100644 index 00000000..2c2574b6 --- /dev/null +++ b/tests/ml/transforms/test_undesired_res_token_leaving_atoms.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import biotite.structure as struc +import numpy as np +import pytest +from biotite.structure import AtomArray + +from atomworks.enums import ChainType +from atomworks.io.utils.link_chemistry import get_chem_comp_leaving_atom_groups +from atomworks.ml.transforms.filters import HandleUndesiredResTokens + +MAPPABLE_RESIDUES = {"ABA": "ALA", "SAR": "GLY", "PTR": "TYR", "SEP": "SER"} + + +def _make_polymer_residue(res_name: str, *, keep_leaving_group: bool) -> AtomArray: + """Build a heavy-atom polymer residue, retaining leaving atoms only for a terminus.""" + residue = struc.info.residue(res_name) + residue = residue[residue.element != "H"] + + if not keep_leaving_group: + leaving = { + str(name) + for groups in get_chem_comp_leaving_atom_groups(res_name).values() + for group in groups + for name in group + } + residue = residue[~np.isin(residue.atom_name, list(leaving))] + + n_atoms = residue.array_length() + residue.res_id = np.full(n_atoms, 5) + residue.set_annotation("is_polymer", np.ones(n_atoms, dtype=bool)) + residue.set_annotation("pn_unit_iid", np.full(n_atoms, -1, dtype=int)) + residue.set_annotation("chain_type", np.full(n_atoms, int(ChainType.POLYPEPTIDE_L))) + residue.set_annotation("atomize", np.zeros(n_atoms, dtype=bool)) + return residue + + +@pytest.mark.parametrize(("res_name", "expected_canonical"), MAPPABLE_RESIDUES.items()) +@pytest.mark.parametrize("keep_leaving_group", [False, True], ids=["internal", "terminal"]) +def test_canonical_substitution_preserves_retained_atoms( + res_name: str, expected_canonical: str, keep_leaving_group: bool +): + """Map internal and terminal residues while preserving retained atoms and any terminal OXT.""" + residue = _make_polymer_residue(res_name, keep_leaving_group=keep_leaving_group) + residue.set_annotation("source_atom_index", np.arange(len(residue))) + canonical_names = struc.info.residue(expected_canonical).atom_name + expected = residue[np.isin(residue.atom_name, canonical_names)] + expected.res_name[:] = expected_canonical + + transform = HandleUndesiredResTokens(undesired_res_tokens=[res_name]) + result = transform.forward({"atom_array": residue})["atom_array"] + + assert np.all(result.res_name == expected_canonical) + assert not result.atomize.any() + assert ("OXT" in result.atom_name) == keep_leaving_group + np.testing.assert_array_equal(result.coord, expected.coord) + assert set(result.get_annotation_categories()) == set(expected.get_annotation_categories()) + for annotation in expected.get_annotation_categories(): + np.testing.assert_array_equal(result.get_annotation(annotation), expected.get_annotation(annotation)) + np.testing.assert_array_equal(result.bonds.as_array(), expected.bonds.as_array()) + + +@pytest.mark.parametrize("res_name", MAPPABLE_RESIDUES) +def test_missing_required_backbone_atom_prevents_substitution(res_name: str): + """A missing backbone N prevents both canonical and unknown-residue substitution.""" + residue = _make_polymer_residue(res_name, keep_leaving_group=False) + residue = residue[residue.atom_name != "N"] + original = residue.copy() + + transform = HandleUndesiredResTokens(undesired_res_tokens=[res_name]) + result = transform.forward({"atom_array": residue})["atom_array"] + + assert np.all(result.res_name == res_name) + assert result.atomize.all() + np.testing.assert_array_equal(result.atom_name, original.atom_name) + np.testing.assert_array_equal(result.coord, original.coord) + np.testing.assert_array_equal(result.bonds.as_array(), original.bonds.as_array()) diff --git a/tests/ml/utils/test_example_id.py b/tests/ml/utils/test_example_id.py new file mode 100644 index 00000000..c1f79632 --- /dev/null +++ b/tests/ml/utils/test_example_id.py @@ -0,0 +1,37 @@ +"""Tests for example_id parsing.""" + +import pytest + +from atomworks.ml.example_id import parse_example_id + + +@pytest.mark.parametrize( + "example_id,keys,permissive,expected", + [ + # Short format + ("{mgnify}{123}", ("datasets", "pdb_id"), False, {"datasets": "mgnify", "pdb_id": "123"}), + # Full PDB format with lists + ( + "{['pdb']}{6vyb}{1}{['A_1']}", + ("datasets", "pdb_id", "assembly_id", "query_pn_unit_iids"), + False, + {"datasets": ["pdb"], "pdb_id": "6vyb", "assembly_id": "1", "query_pn_unit_iids": ["A_1"]}, + ), + # Permissive with missing keys + ( + "{mgnify}{123}", + ("datasets", "pdb_id", "assembly_id"), + True, + {"datasets": "mgnify", "pdb_id": "123", "assembly_id": None}, + ), + ], +) +def test_parse_example_id(example_id, keys, permissive, expected): + """Test parsing various example ID formats.""" + assert parse_example_id(example_id, keys=keys, permissive=permissive) == expected + + +def test_parse_example_id_strict_raises(): + """Test that strict mode raises on mismatch.""" + with pytest.raises(ValueError, match="Expected 4 values"): + parse_example_id("{mgnify}{123}") diff --git a/tests/ml/utils/test_expandable_segments.py b/tests/ml/utils/test_expandable_segments.py new file mode 100644 index 00000000..8c756d13 --- /dev/null +++ b/tests/ml/utils/test_expandable_segments.py @@ -0,0 +1,406 @@ +"""Tests for expandable segment sentinel functionality. + +Covers: insert_expandable_segment, realize_expandable_segments, collapse_expandable_segments. +""" + +import numpy as np +import pytest + +from atomworks.constants import BIOTITE_COMMON_OPTIONAL_ANNOTATIONS, BIOTITE_DEFAULT_ANNOTATIONS, MASKED +from atomworks.enums import ChainType +from atomworks.io.config import ParseConfig +from atomworks.io.utils.annotator import ensure_annotations +from atomworks.io.utils.atom_array_plus import AtomArrayPlus, as_atom_array_plus +from atomworks.io.utils.selection import get_residue_starts +from atomworks.io.utils.standard_annotations import S_SEGMAX, S_SEGMIN +from atomworks.io.utils.testing import assert_same_atom_array_or_stack +from atomworks.ml.utils.expandable_segment import ( + _SEG_UID_ANNOT, + collapse_expandable_segments, + insert_expandable_segment, + realize_expandable_segments, +) +from atomworks.ml.utils.testing import add_randomized_standard_annotations, cached_parse, save_and_load_atom_array + +BIOTITE_RESERVED_ANNOTATIONS = [ + "ins_code", + *BIOTITE_DEFAULT_ANNOTATIONS, + *BIOTITE_COMMON_OPTIONAL_ANNOTATIONS, +] + + +def _compare_atom_arrays( + arr1: AtomArrayPlus, + arr2: AtomArrayPlus, + annotations_to_compare: list[str], + compare_bonds: bool = True, +) -> None: + """Compares without bonds but enforcing order, then with bonds without enforcing order.""" + assert_same_atom_array_or_stack( + arr1, + arr2, + annotations_to_compare=annotations_to_compare, + enforce_order=True, + compare_bonds=False, + cast_to_common_dtype=True, + ) + if compare_bonds: + assert_same_atom_array_or_stack( + arr1, + arr2, + annotations_to_compare=annotations_to_compare, + enforce_order=False, + compare_bonds=True, + cast_to_common_dtype=True, + ) + + +def test_expandable_segment_sentinel_roundtrip(): + """Insert segment, realize, collapse, save/load: non-segment atoms are preserved throughout.""" + rng = np.random.default_rng(42) + + # Load a realistic structure with randomized standard annotations (including 2D) + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + ensure_annotations(atom_array, "is_res_start") + atom_array = add_randomized_standard_annotations(atom_array) + + # Reset expandable segment annotations after randomizing + S_SEGMIN.set_annotation(atom_array, S_SEGMIN.default_annotation(atom_array)) + S_SEGMAX.set_annotation(atom_array, S_SEGMAX.default_annotation(atom_array)) + + # insert_expandable_segment changes some annotations expectedly, so we don't want to compare them + annotations_to_compare = [ + annot + for annot in atom_array.get_annotation_categories() + if ( + annot + not in [ + "res_id", + "atom_id", + "token_id", + "within_poly_res_idx", + "auth_seq_id", + "condition_coordinate_1_atom", + "transformation_id", + ] + ) + and (not (annot.startswith("label_") or annot.startswith("auth_"))) + ] + + # --- Insert an expandable segment sentinel in the middle but at a res_start --- + res_start_inds = np.where(atom_array.is_res_start)[0] + mid = res_start_inds[res_start_inds.size // 2] + arr_with_segment = insert_expandable_segment(atom_array.copy(), seg_min=3, seg_max=8, atom_idx=mid) + + # Slicing out segment atoms must recover the original (bond graph legitimately + # differs at the insertion point, so skip bond comparison for sliced arrays). + non_segment = arr_with_segment[~S_SEGMIN.mask(arr_with_segment)] + _compare_atom_arrays(atom_array, non_segment, annotations_to_compare, compare_bonds=False) + + # --- Realize the segment into concrete MASKED stubs --- + realized = realize_expandable_segments(arr_with_segment.copy(), rng=rng) + assert np.any(realized.res_name == MASKED), "Realized array should contain MASKED stubs" + non_segment_realized = realized[~S_SEGMIN.mask(realized)] + _compare_atom_arrays(atom_array, non_segment_realized, annotations_to_compare, compare_bonds=False) + + # --- Collapse stubs back to a single sentinel --- + collapsed = collapse_expandable_segments(realized.copy()) + + _compare_atom_arrays(arr_with_segment, collapsed, annotations_to_compare) + + # --- Round-trip the collapsed (sentinel) form through CIF --- + extra_annotations_to_save = [ + annot + for annot in annotations_to_compare + if (annot not in BIOTITE_RESERVED_ANNOTATIONS) + and (not (annot.startswith("label_") or annot.startswith("auth_"))) + ] + loaded_collapsed = save_and_load_atom_array( + collapsed, extra_annotations_to_save=extra_annotations_to_save, extra_fields_to_load=extra_annotations_to_save + ) + _compare_atom_arrays(collapsed, loaded_collapsed, annotations_to_compare=annotations_to_compare) + + # --- Round-trip the realized (expanded) form, then collapse and verify --- + loaded_realized = save_and_load_atom_array( + realized, extra_annotations_to_save=extra_annotations_to_save, extra_fields_to_load=extra_annotations_to_save + ) + _compare_atom_arrays(realized, loaded_realized, annotations_to_compare=annotations_to_compare) + collapsed_again = collapse_expandable_segments(loaded_realized.copy()) + _compare_atom_arrays(arr_with_segment, collapsed_again, annotations_to_compare=annotations_to_compare) + + +def test_insert_expandable_segment_as_separate_chain_updates_chain_dependent_annotations(): + """Insert expandable segment as a new chain and verify chain-dependent annotations update consistently.""" + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + atom_array = add_randomized_standard_annotations(atom_array) + + # Reset expandable segment annotations after randomizing + S_SEGMIN.set_annotation(atom_array, S_SEGMIN.default_annotation(atom_array)) + S_SEGMAX.set_annotation(atom_array, S_SEGMAX.default_annotation(atom_array)) + + annots_before = set(atom_array.get_annotation_categories()) + new_chain_id = "ZZ" + result = insert_expandable_segment( + atom_array.copy(), + seg_min=3, + seg_max=8, + new_chain=True, + new_chain_id=new_chain_id, + new_chain_type=ChainType.NON_POLYMER, + ) + + segment_mask = S_SEGMIN.mask(result) + assert np.sum(segment_mask) == 1, "Expected exactly one inserted sentinel segment atom." + segment_idx = int(np.where(segment_mask)[0][0]) + + # New chain identity for sentinel + assert result.chain_id[segment_idx] == new_chain_id + assert result.res_name[segment_idx] == "3-8" + assert int(result.res_id[segment_idx]) == 1 + + # Chain-dependent annotations that should track new chain insertion + if "chain_type" in annots_before: + assert int(result.chain_type[segment_idx]) == int(ChainType.NON_POLYMER) + if "is_polymer" in annots_before: + assert bool(result.is_polymer[segment_idx]) is False + if "pn_unit_id" in annots_before: + assert result.pn_unit_id[segment_idx] == new_chain_id + if "chain_iid" in annots_before: + assert str(result.chain_iid[segment_idx]).startswith(f"{new_chain_id}_") + if "pn_unit_iid" in annots_before: + assert str(result.pn_unit_iid[segment_idx]).startswith(f"{new_chain_id}_") + if "molecule_id" in annots_before: + assert result.molecule_id[segment_idx] not in set(atom_array.molecule_id.tolist()) + if "molecule_iid" in annots_before: + assert result.molecule_iid[segment_idx] not in set(atom_array.molecule_iid.tolist()) + if "chain_entity" in annots_before: + assert result.chain_entity[segment_idx] not in set(atom_array.chain_entity.tolist()) + if "pn_unit_entity" in annots_before: + assert result.pn_unit_entity[segment_idx] not in set(atom_array.pn_unit_entity.tolist()) + if "molecule_entity" in annots_before: + assert result.molecule_entity[segment_idx] not in set(atom_array.molecule_entity.tolist()) + if "label_entity_id" in annots_before and "chain_entity" in annots_before: + # New entity → fresh label_entity_id that doesn't clash with original ones. + assert int(result.label_entity_id[segment_idx]) not in set(atom_array.label_entity_id.tolist()) + + # All non-segment atoms should match original except expected globally rederived IDs/entities. + non_segment = result[~segment_mask] + annotations_to_compare = [ + annot + for annot in atom_array.get_annotation_categories() + if ( + annot + not in [ + "res_id", + "atom_id", + "token_id", + "within_chain_res_idx", + "within_poly_res_idx", + "chain_entity", + "pn_unit_entity", + "molecule_entity", + "molecule_id", + "molecule_iid", + "pn_unit_id", + "pn_unit_iid", + "chain_iid", + "label_entity_id", + "auth_seq_id", + ] + ) + and (not (annot.startswith("label_") or annot.startswith("auth_"))) + ] + _compare_atom_arrays(atom_array, non_segment, annotations_to_compare) + + +def test_insert_expandable_segment_new_chain_entity_id_avoids_all_collisions(): + """New-chain entity id must avoid every existing entity id, not just the last atom's.""" + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"].copy()) + + # Force a non-monotonic layout + half = atom_array.array_length() // 2 + chain_id = atom_array.chain_id.copy() + chain_id[:half] = "AAA" + chain_id[half:] = "BBB" + atom_array.set_annotation("chain_id", chain_id) + + entity_id = atom_array.label_entity_id.copy() + entity_id[:half] = 3 # AAA (first in array order) + entity_id[half:] = 2 # BBB (last in array order) -- old algo: 2 + 1 = 3, collides! + atom_array.set_annotation("label_entity_id", entity_id.astype(np.int8)) + + result = insert_expandable_segment( + atom_array.copy(), + seg_min=1, + seg_max=1, + new_chain=True, + new_chain_id="ZZZ", + new_chain_type=ChainType.NON_POLYMER, + ) + segment_mask = S_SEGMIN.mask(result) + new_entity_id = int(result.label_entity_id[segment_mask][0]) + assert new_entity_id not in {2, 3}, f"new sentinel entity id {new_entity_id} collides with an existing entity id" + assert new_entity_id == 4, "expected max(existing entity ids) + 1" + + +def test_insert_expandable_segment_rejects_mid_residue_atom_idx(): + """``atom_idx`` must land on a residue boundary -- mid-residue must raise, not silently splice in.""" + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + ensure_annotations(atom_array, "is_res_start") + res_start_inds = np.where(atom_array.is_res_start)[0] + + mid_residue_start = None + for s in map(int, res_start_inds): + if s + 1 < atom_array.array_length() and not atom_array.is_res_start[s + 1]: + mid_residue_start = s + break + assert mid_residue_start is not None, "test setup: no multi-atom residue found" + mid_atom_idx = mid_residue_start + 1 # strictly inside the residue, not its first/last atom + + with pytest.raises(ValueError, match="not the first atom of its residue"): + insert_expandable_segment(atom_array.copy(), seg_min=1, seg_max=1, atom_idx=mid_atom_idx, insert_before=True) + + with pytest.raises(ValueError, match="not the last atom of its residue"): + insert_expandable_segment(atom_array.copy(), seg_min=1, seg_max=1, atom_idx=mid_atom_idx, insert_before=False) + + +def test_collapse_expandable_segments_keeps_independent_identical_range_segments_separate(): + """Two independently-inserted, identical-range segments that end up chain-adjacent + after realization must collapse back into TWO sentinels, not merge into one. + """ + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + ensure_annotations(atom_array, "is_res_start") + res_start_inds = np.where(atom_array.is_res_start)[0] + mid = int(res_start_inds[res_start_inds.size // 2]) + + # Insert two independent segments with an identical (seg_min, seg_max) at + # the same position, so the second lands immediately adjacent to the first + # -- both single-atom (seg_min=seg_max=1) so realization is deterministic. + step1 = insert_expandable_segment(atom_array.copy(), seg_min=1, seg_max=1, atom_idx=mid, insert_before=True) + step2 = insert_expandable_segment(step1.copy(), seg_min=1, seg_max=1, atom_idx=mid, insert_before=True) + assert np.sum(S_SEGMIN.mask(step2)) == 2, "test setup: expected exactly two sentinels" + + realized = realize_expandable_segments(step2.copy(), rng=np.random.default_rng(0)) + collapsed = collapse_expandable_segments(realized.copy()) + + assert np.sum(S_SEGMIN.mask(collapsed)) == 2, ( + "two independently-inserted segments sharing an identical range were merged " + "into one sentinel instead of collapsing back into two" + ) + + +def test_realize_expandable_segments_backfills_missing_seg_uid(): + """Sentinels lacking ``_expseg_uid`` (e.g. loaded from CIF, which drops internal + annotations, or created before this safeguard existed) still get disambiguated + correctly -- ``realize_expandable_segments`` backfills a fresh id for each one + before ``collapse_expandable_segments`` ever needs it. + """ + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + ensure_annotations(atom_array, "is_res_start") + res_start_inds = np.where(atom_array.is_res_start)[0] + mid = int(res_start_inds[res_start_inds.size // 2]) + + step1 = insert_expandable_segment(atom_array.copy(), seg_min=1, seg_max=1, atom_idx=mid, insert_before=True) + step2 = insert_expandable_segment(step1.copy(), seg_min=1, seg_max=1, atom_idx=mid, insert_before=True) + assert np.sum(S_SEGMIN.mask(step2)) == 2, "test setup: expected exactly two sentinels" + + # Simulate the annotation never having existed at all. + step2.del_annotation(_SEG_UID_ANNOT) + assert _SEG_UID_ANNOT not in step2.get_annotation_categories() + + realized = realize_expandable_segments(step2.copy(), rng=np.random.default_rng(0)) + assert _SEG_UID_ANNOT in realized.get_annotation_categories(), "realize should backfill the annotation" + + collapsed = collapse_expandable_segments(realized.copy()) + assert np.sum(S_SEGMIN.mask(collapsed)) == 2, ( + "realize_expandable_segments failed to backfill distinct seg_uids for " + "sentinels that were missing the annotation entirely" + ) + + +def _bond_partners(array: AtomArrayPlus, atom_idx: int) -> set[int]: + """Return global indices of all atoms bonded to atom_idx.""" + bonds = array.bonds.as_array() + partners: set[int] = set() + for b in bonds: + if int(b[0]) == atom_idx: + partners.add(int(b[1])) + elif int(b[1]) == atom_idx: + partners.add(int(b[0])) + return partners + + +def test_expandable_segment_bond_connectivity(): + """Each sentinel/stub has exactly 2 bond partners across insert, realize, and collapse.""" + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + ensure_annotations(atom_array, "chain_type") + + ensure_annotations(atom_array, "is_res_start") + res_start_inds = np.where(atom_array.is_res_start)[0] + mid = res_start_inds[res_start_inds.size // 2] + arr_with_segment = insert_expandable_segment(atom_array.copy(), seg_min=3, seg_max=3, atom_idx=mid) + + def check_segment_bond_counts(array, label): + for idx in np.where(S_SEGMIN.mask(array))[0]: + n = len(_bond_partners(array, int(idx))) + assert n == 2, f"{label}: segment atom {idx} has {n} bond partners, expected 2" + + check_segment_bond_counts(arr_with_segment, "after insert") + + realized = realize_expandable_segments(arr_with_segment.copy(), rng=np.random.default_rng(0)) + check_segment_bond_counts(realized, "after realize") + + collapsed = collapse_expandable_segments(realized.copy()) + check_segment_bond_counts(collapsed, "after collapse") + + +def test_adjacent_expandable_segments_form_one_polymer_bond_graph(): + """Separately named adjacent segments remain connected through realization and collapse.""" + data = cached_parse("6lyz", **ParseConfig.from_preset("annotations_only").to_dict()) + atom_array = as_atom_array_plus(data["atom_array"]) + ensure_annotations(atom_array, "chain_type") + + new_chain_id = "Z" + with_segments = insert_expandable_segment( + atom_array, + seg_min=2, + seg_max=2, + new_chain=True, + new_chain_id=new_chain_id, + new_chain_type=ChainType.POLYPEPTIDE_L, + ) + for length in (1, 3): + chain_tail = int(np.where(with_segments.chain_id == new_chain_id)[0][-1]) + with_segments = insert_expandable_segment( + with_segments, + seg_min=length, + seg_max=length, + atom_idx=chain_tail, + insert_before=False, + ) + + realized = realize_expandable_segments(with_segments.copy(), rng=np.random.default_rng(0)) + collapsed = collapse_expandable_segments(realized.copy()) + + for label, array, expected_residues in ( + ("sentinels", with_segments, 3), + ("realized", realized, 6), + ("collapsed", collapsed, 3), + ): + chain = array[array.chain_id == new_chain_id] + starts = get_residue_starts(chain) + atom_to_residue = np.searchsorted(starts, np.arange(chain.array_length()), side="right") - 1 + bonded_residue_pairs = { + tuple(sorted((int(atom_to_residue[a]), int(atom_to_residue[b])))) + for a, b, _ in chain.bonds.as_array() + if atom_to_residue[a] != atom_to_residue[b] + } + assert len(starts) == expected_residues + assert bonded_residue_pairs == {(i, i + 1) for i in range(expected_residues - 1)}, label diff --git a/tests/ml/utils/test_geometry.py b/tests/ml/utils/test_geometry.py index 49f6ef5d..d2b56cc0 100644 --- a/tests/ml/utils/test_geometry.py +++ b/tests/ml/utils/test_geometry.py @@ -1,3 +1,4 @@ +import numpy as np import pytest import torch @@ -126,3 +127,25 @@ def test_get_random_rots_and_rigids(device): assert r_single.shape == (3, 3) assert t_single.shape == (3,) assert torch.allclose(torch.det(r_single), torch.tensor(1.0, device=device), atol=1e-6) + + +@pytest.mark.parametrize("mode", ["numpy", "torch"]) +def test_get_random_rots_is_uniform(mode, device): + batch_size = 8192 + numpy_state = np.random.get_state() + try: + np.random.seed(42) + with torch.random.fork_rng(devices=[device] if device.type == "cuda" else []): + torch.manual_seed(42) + rotations = get_random_rots(batch_size, mode=mode, device=device) + finally: + np.random.set_state(numpy_state) + + if isinstance(rotations, torch.Tensor): + rotations = rotations.cpu().numpy() + + identity = np.broadcast_to(np.eye(3), rotations.shape) + np.testing.assert_allclose(rotations @ rotations.transpose(0, 2, 1), identity, atol=1e-6) + np.testing.assert_allclose(np.linalg.det(rotations), 1.0, atol=1e-6) + np.testing.assert_allclose(rotations.mean(axis=0), 0.0, atol=0.03) + np.testing.assert_allclose((rotations**2).mean(axis=0), 1.0 / 3.0, atol=0.03) diff --git a/uv.lock b/uv.lock deleted file mode 100644 index b285e636..00000000 --- a/uv.lock +++ /dev/null @@ -1,2896 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", -] - -[[package]] -name = "accessible-pygments" -version = "0.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, -] - -[[package]] -name = "alabaster" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, -] - -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - -[[package]] -name = "ase" -version = "3.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "matplotlib" }, - { name = "numpy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/33/2ffa44950267450f6a5cdb711c68e12d0d72d626e246e5897e3bada7bac6/ase-3.26.0.tar.gz", hash = "sha256:a071a355775b0a8062d23e9266e9d811b19d9f6d9ec5215e8032f7d93dc65075", size = 2405567, upload-time = "2025-08-12T13:06:55.589Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/f0/6e52d797bee63530f4b778cb4cbb3a01970f104197c364a8ff51bc9f5a21/ase-3.26.0-py3-none-any.whl", hash = "sha256:77fd0e609bd3868006d4bb3bb95cdc4081d9e292ac84f6c9fb564b5751d2689e", size = 2946787, upload-time = "2025-08-12T13:06:53.316Z" }, -] - -[[package]] -name = "ase-db-backends" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ase" }, - { name = "cryptography" }, - { name = "lmdb" }, - { name = "numpy" }, - { name = "psycopg2-binary" }, - { name = "pymysql" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d5/c448e9c4470e4b8612154e13cd5369c8b8792a1bdf901a32a35e79408d20/ase_db_backends-0.11.0.tar.gz", hash = "sha256:8575938eaf7708a52eb951c6f955f39082fa79c8332f4568715dacb8e733717a", size = 35605, upload-time = "2025-11-12T08:02:50.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/24/35e57bdbd6d1c37ae889b669c4bf06b159fa275afe189a81460d392d187e/ase_db_backends-0.11.0-py3-none-any.whl", hash = "sha256:dd5959b9a3cbe3deb5726724afa0045d7d009e04e08be5f4b7ffae703a563a99", size = 44738, upload-time = "2025-11-12T08:02:49.215Z" }, -] - -[[package]] -name = "asttokens" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, -] - -[[package]] -name = "atomworks" -version = "2.1.2" -source = { editable = "." } -dependencies = [ - { name = "biotite" }, - { name = "cython" }, - { name = "cytoolz" }, - { name = "hydride" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "py3dmol" }, - { name = "pyarrow" }, - { name = "pymol-remote" }, - { name = "rdkit" }, - { name = "scipy" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "zstandard" }, -] - -[package.optional-dependencies] -ase = [ - { name = "ase" }, - { name = "ase-db-backends" }, - { name = "lmdb" }, -] -dev = [ - { name = "ipykernel" }, - { name = "pytest" }, - { name = "pytest-benchmark" }, - { name = "pytest-cov" }, - { name = "pytest-dotenv" }, - { name = "pytest-testmon" }, - { name = "pytest-xdist" }, - { name = "ruff" }, -] -docs = [ - { name = "matplotlib" }, - { name = "pydata-sphinx-theme" }, - { name = "sphinx" }, - { name = "sphinx-gallery" }, -] -ml = [ - { name = "beartype" }, - { name = "einops" }, - { name = "jaxtyping" }, - { name = "torch" }, -] -openbabel = [ - { name = "openbabel-wheel" }, -] - -[package.dev-dependencies] -dev = [ - { name = "atomworks", extra = ["dev"] }, -] -docs = [ - { name = "atomworks", extra = ["docs"] }, -] -ml = [ - { name = "atomworks", extra = ["ml"] }, -] -openbabel = [ - { name = "atomworks", extra = ["openbabel"] }, -] - -[package.metadata] -requires-dist = [ - { name = "ase", marker = "extra == 'ase'", specifier = ">=3.22.0,<4" }, - { name = "ase-db-backends", marker = "extra == 'ase'", specifier = ">=0.10.0,<1" }, - { name = "beartype", marker = "extra == 'ml'", specifier = ">=0.18.0,<1" }, - { name = "biotite", specifier = "==1.4.0" }, - { name = "cython", specifier = ">=3.0.0,<4" }, - { name = "cytoolz", specifier = ">=0.12.3,<1" }, - { name = "einops", marker = "extra == 'ml'", specifier = ">=0.7.0,<1" }, - { name = "hydride", specifier = ">=1.2.3,<2" }, - { name = "ipykernel", marker = "extra == 'dev'", specifier = ">=6.28.0" }, - { name = "jaxtyping", marker = "extra == 'ml'", specifier = ">=0.2.17,<1" }, - { name = "lmdb", marker = "extra == 'ase'", specifier = ">=1.5.0,<2" }, - { name = "matplotlib", marker = "extra == 'docs'", specifier = ">=3.10.0,<4" }, - { name = "numpy", specifier = ">=1.25.0,<3" }, - { name = "openbabel-wheel", marker = "extra == 'openbabel'", specifier = "==3.1.1.22" }, - { name = "pandas", specifier = ">=2.2,<2.4" }, - { name = "py3dmol", specifier = ">=2.2.1,<3" }, - { name = "pyarrow", specifier = "==17.0.0" }, - { name = "pydata-sphinx-theme", marker = "extra == 'docs'", specifier = ">=0.16.1,<1" }, - { name = "pymol-remote", specifier = ">=0.0.5" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0,<9" }, - { name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=5.0.0,<6" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0,<5" }, - { name = "pytest-dotenv", marker = "extra == 'dev'", specifier = ">=0.5.2,<1" }, - { name = "pytest-testmon", marker = "extra == 'dev'", specifier = ">=2.1.1,<3" }, - { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.1,<4" }, - { name = "rdkit", specifier = ">=2024.3.5,<2025.9" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.8.3" }, - { name = "scipy", specifier = ">=1.13.1,<2" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=8.0.0,<9" }, - { name = "sphinx-gallery", marker = "extra == 'docs'", specifier = ">=0.19.0,<1" }, - { name = "torch", marker = "extra == 'ml'", specifier = ">=2.2.0,<2.8" }, - { name = "tqdm", specifier = ">=4.65.0,<5" }, - { name = "typer", specifier = ">=0.12.5,<1" }, - { name = "zstandard", specifier = ">=0.21.0,<1" }, -] -provides-extras = ["ml", "ase", "openbabel", "dev", "docs"] - -[package.metadata.requires-dev] -dev = [{ name = "atomworks", extras = ["dev"] }] -docs = [{ name = "atomworks", extras = ["docs"] }] -ml = [{ name = "atomworks", extras = ["ml"] }] -openbabel = [{ name = "atomworks", extras = ["openbabel"] }] - -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - -[[package]] -name = "beartype" -version = "0.22.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/1d/794ae2acaa67c8b216d91d5919da2606c2bb14086849ffde7f5555f3a3a5/beartype-0.22.8.tar.gz", hash = "sha256:b19b21c9359722ee3f7cc433f063b3e13997b27ae8226551ea5062e621f61165", size = 1602262, upload-time = "2025-12-03T05:11:10.766Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2a/fbcbf5a025d3e71ddafad7efd43e34ec4362f4d523c3c471b457148fb211/beartype-0.22.8-py3-none-any.whl", hash = "sha256:b832882d04e41a4097bab9f63e6992bc6de58c414ee84cba9b45b67314f5ab2e", size = 1331895, upload-time = "2025-12-03T05:11:08.373Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, -] - -[[package]] -name = "biotite" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "biotraj" }, - { name = "msgpack" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/64/3526f99fe09add35decea977bd3049672fc0be689d7e0557b0564a55600e/biotite-1.4.0.tar.gz", hash = "sha256:0428427fff47e046a36ecdda1cbb38fc61e652e8df4339bf0a0b7a248a051a8b", size = 37035933, upload-time = "2025-07-07T12:08:53.956Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/9b/b1015d8a6c4fe2c13dd586f7b692264a902cbe53b61cb53a44802e94c851/biotite-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d937447eec6247b439d887a79e0dd94e5156a037fa5b399520ac8bee8b078103", size = 40205001, upload-time = "2025-07-07T12:08:15.177Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a8/8395bbe28f3286df2386ce0830bb168c9f7b0c4f6c6b3f994fd117485819/biotite-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a97562fd0341591f6eba58c001cec86cc6de2a45aa1e8d4349b4ff7d7b2cbe3a", size = 39991959, upload-time = "2025-07-07T12:08:18.461Z" }, - { url = "https://files.pythonhosted.org/packages/07/91/5ac386b325dc638eec21a68ef76a7ed6bb3a1fde0dbf8c7783a9545b7f21/biotite-1.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0813b424796dcc11bd2093a5e976c034c7190f3b2bedc503fb556119375977b1", size = 54434605, upload-time = "2025-07-07T12:08:22.278Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5d/34df5d5acb35f8d4b6bb8df4f73f50ec2a9aa2e419cdb5ab3e5c24012cd7/biotite-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cae4560fefd5583976d6434f647b73fe302864a1a7891e35cad4477309f58cce", size = 39884893, upload-time = "2025-07-07T12:08:25.461Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a5/67c8808fe0935cfdf0f8bd993735595ed5110a6dbee98f84581b81066258/biotite-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:145f229f4262ab352eb5869f2ea56a5823911267abe7edca50ec6e5c90837097", size = 40144461, upload-time = "2025-07-07T12:08:28.358Z" }, - { url = "https://files.pythonhosted.org/packages/21/e0/64716fb2ce9d7749bb41cc932c667b0f6d478e09367b36d273b1f77b06f3/biotite-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:19b4cfbdbe1ad94319dfea61d3dcdb07d476ba5bf55fb507021d6a5a5d4762d8", size = 39924549, upload-time = "2025-07-07T12:08:31.139Z" }, - { url = "https://files.pythonhosted.org/packages/77/e4/eb18efca3bf159164144238ebfde8c064cfb122194183507b5dfcd690969/biotite-1.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d17dbcabf8dfce25fff50f2c31e03392f8bd66dce064379e5f4c03d28ad66fc6", size = 56284190, upload-time = "2025-07-07T12:08:34.406Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/c1f1ea1c4c407b24c1dd9a92b02c6babaa1800152693ae662c471daf6f95/biotite-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3dcb2c88f5dcfce6fee94800be1a42aca82c468baea13f04701ebefd711d9438", size = 39803852, upload-time = "2025-07-07T12:08:37.68Z" }, - { url = "https://files.pythonhosted.org/packages/a1/38/7b6f69b3b5e6e3dbc0aabdd76c6f071a9e763d606f7b9a489cb95b92a000/biotite-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2b85d704c7f24bd03a2023d122776443588c97cfcc2875eed9acb4e29ce36a5", size = 40130802, upload-time = "2025-07-07T12:08:40.942Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5d/087d2bd4db67bebdc6d7500be671cf8dd7471d6b887456ecd5c47561986f/biotite-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e681619cc9712cee436950a66965791fd40548d0369ddc8dba2c52a4f1d26a95", size = 39912406, upload-time = "2025-07-07T12:08:43.839Z" }, - { url = "https://files.pythonhosted.org/packages/ca/2e/aea1d77a3197d8f7caac0ffb19a6abdb3138ba69d90c8f74ef5a996d13a8/biotite-1.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96644d9bcf8b14f3ce7758c855f3773ddd5d95c06cba178847911d871b81b7d2", size = 56171242, upload-time = "2025-07-07T12:08:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b6/f81ab2e7b0ef3bb3dccd147cff8458e16ec36bf7b08901772075e872ce66/biotite-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c9c214f56a4868f840dcbbac9ad75f507b9a87c978c68c321dda745cffecba4c", size = 39807120, upload-time = "2025-07-07T12:08:50.633Z" }, -] - -[[package]] -name = "biotraj" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/21/2287edfd0d2569639eea706e25c39e63b46a384cf1712db8ea05768317b0/biotraj-1.2.2.tar.gz", hash = "sha256:4bcba92101ed50f369cc1487fb5dfcfe1d8402ad47adaa9232b080553271663a", size = 3909030, upload-time = "2024-11-02T11:30:54.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/dd/4e257a394e4a686a5973d6238bc5fb6fa321423c9401e278a34c51a01425/biotraj-1.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8dc790c95f90760d08986c896783f3531cfd5feb3bd8496f07949ccccd8eafb3", size = 863170, upload-time = "2024-11-02T11:30:34.007Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3e/90d2c50912df949c5731c564f5c7bf853378d7f2d31f276dd31eb42fb663/biotraj-1.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:39d1ce8d7c7e55c87bf35248c20f4d7ed09349b99ee090e61068c9c08d32b179", size = 835504, upload-time = "2024-11-02T11:30:35.878Z" }, - { url = "https://files.pythonhosted.org/packages/b5/86/d24589c5f8f6ef4685e6a8c8a670a9be17657677f793b48e19520ed08209/biotraj-1.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0accd626b3cb6b29fb4491d593c41daa694edddde8e8dc113228b1969a30c9f6", size = 2246778, upload-time = "2024-11-02T11:30:37.642Z" }, - { url = "https://files.pythonhosted.org/packages/3a/78/3cd3ca90490ff5730fffed57464c104a146a0c35280fc4bea2bfafb72b48/biotraj-1.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8fb09bf6f7cd44c9356880166e45fe1606b05113b4642b99f8b85c25f51699ba", size = 366212, upload-time = "2024-11-02T11:30:39.427Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/bf56c6cc27212ed2b5ad3beb1c399b74ac147236b22900a9434760ebfa27/biotraj-1.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b584c2da6d353c09f839e9b72fa243b6f45050b311cdcd5a0f3a44ced80d714e", size = 859982, upload-time = "2024-11-02T11:30:40.948Z" }, - { url = "https://files.pythonhosted.org/packages/c4/d2/369ed44ad23f8c0464f7b80d41143d717896097b39fe3dd3e07e6d162fef/biotraj-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7bab65f5e3975a844c1018ea5873acd7dd21a64e8ebf0c145635c1d2fb9ef9bd", size = 833765, upload-time = "2024-11-02T11:30:42.628Z" }, - { url = "https://files.pythonhosted.org/packages/80/cd/c607b6337ddcf55cc0249527819b727ef28481d7c56ace54cca69400c2b9/biotraj-1.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b449930fa70666db0f2e7b1e5f5dad65917086adc66a70f26db38d79b0171815", size = 2239973, upload-time = "2024-11-02T11:30:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/13/56/bed88eb34acb31855e7c82a1d68e58775c037ec66ccb2bb02ff310615240/biotraj-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:3427567cc0bd0e981bf7de1a6b65baf82984171852994d304b2f316a8926db83", size = 358790, upload-time = "2024-11-02T11:30:46.19Z" }, - { url = "https://files.pythonhosted.org/packages/1f/12/8f6a2b21a8b43a2bf85352c993afecb21d40eb3d7373b6242163a93f57e0/biotraj-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31ed2a9e8a6436f5432f22883e68a36c223b98de0ab80225efbaf67da339a2b2", size = 856347, upload-time = "2024-11-02T11:30:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4d/b1e792c01e61bbfe03b290a0805f0c7bc3949bf9cd4031bcdd183d2f2524/biotraj-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6a8c84ac95bf65b73928774ec46b72d62b0f30fa12eb8c56c78ed25235f0acf8", size = 832499, upload-time = "2024-11-02T11:30:49.363Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6f/ab71525583a7824c70f71de387a1c5ceb27ddcb3fda2dacb734e5b875f14/biotraj-1.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dcfa4a4c755ddc206f81999fd47664747cd2e546e16a51d885332cd4c955f41", size = 2246545, upload-time = "2024-11-02T11:30:50.829Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9a/46d2d67b5b672d5d2ffbfb3551fc4b499b45d5edce2558e259f69b72a0d9/biotraj-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:e0ed1b586e23ad53e53fb42be8541fb964ec6c4183e6d500fef0bcc1bdcd7487", size = 359596, upload-time = "2024-11-02T11:30:52.876Z" }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - -[[package]] -name = "coverage" -version = "7.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, - { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, - { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, - { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, - { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, - { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, - { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, - { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, - { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, - { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, - { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, - { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, - { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, - { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, - { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, - { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, - { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, - { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, - { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, - { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, - { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, - { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, - { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, - { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, - { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, - { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, - { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, - { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, - { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, - { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, - { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, - { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, - { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, - { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "cython" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/17/55fc687ba986f2210298fa2f60fec265fa3004c3f9a1e958ea1fe2d4e061/cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634", size = 3275797, upload-time = "2025-11-30T12:48:20.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/ba/d785f60564a43bddbb7316134252a55d67ff6f164f0be90c4bf31482da82/cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd", size = 2951181, upload-time = "2025-11-30T12:48:39.723Z" }, - { url = "https://files.pythonhosted.org/packages/8c/36/277ea908bd7a326adcce5f67581926783ec11a4402ead0719e72555da2e7/cython-3.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50bbaabee733fd2780985e459fc20f655e02def83e8eff10220ad88455a34622", size = 3243194, upload-time = "2025-11-30T12:48:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/74/87/7776495bbff80bdd7754f4849bfde126b1d97741df5bc58a3a00af641747/cython-3.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9509f1e9c41c86b790cff745bb31927bbc861662a3b462596d71d3d2a578abb", size = 3378096, upload-time = "2025-11-30T12:48:43.584Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/fabb9f4767f7101f1c0d6b23116d6d250148f656dac152afd792756661a8/cython-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:034ab96cb8bc8e7432bc27491f8d66f51e435b1eb21ddc03aa844be8f21ad847", size = 2768932, upload-time = "2025-11-30T12:48:45.193Z" }, - { url = "https://files.pythonhosted.org/packages/57/0f/6fd78dc581373722bb9dedfc90c35b59ba88af988756315af227a877c7a2/cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f", size = 2968037, upload-time = "2025-11-30T12:48:47.279Z" }, - { url = "https://files.pythonhosted.org/packages/b0/52/50b6263c2fbad73aae8911ce54641ee1739d430a0592d3b3510591d7842b/cython-3.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:098590c1dc309f8a0406ade031963a95a87714296b425539f9920aebf924560d", size = 3223137, upload-time = "2025-11-30T12:48:48.951Z" }, - { url = "https://files.pythonhosted.org/packages/d6/44/4e34d161674c9162c6eb9ddef0cd69d41d92ae7e6dee3945fed3a6871ebe/cython-3.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3898c076e9c458bcb3e4936187919fda5f5365fe4c567d35d2b003444b6f3fe", size = 3390943, upload-time = "2025-11-30T12:48:51.125Z" }, - { url = "https://files.pythonhosted.org/packages/62/8a/ffc2df024c1341737008fbaf0fbea51ef983a7146b43b84a239f197cf005/cython-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:2b910b89a2a71004064c5e890b9512a251eda63fae252caa0feb9835057035f9", size = 2756403, upload-time = "2025-11-30T12:48:52.929Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/b5355918962ec28b376eb8e357c718d58baf32d6df7017be8d147dd4ba29/cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7", size = 2958578, upload-time = "2025-11-30T12:48:55.354Z" }, - { url = "https://files.pythonhosted.org/packages/46/21/a8038c8253e7a5241ed1db6d031bac586f7a502d92f487124abbc3f3e94f/cython-3.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60f4aa425e1ff98abf8d965ae7020f06dd2cbc01dbd945137d2f9cca4ff0524a", size = 3212479, upload-time = "2025-11-30T12:48:57.567Z" }, - { url = "https://files.pythonhosted.org/packages/57/c1/76928c07176a4402c74d5b304936ad8ee167dd04a07cf7dca545e8c25f9b/cython-3.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a473df474ba89e9fee81ee82b31062a267f9e598096b222783477e56d02ad12c", size = 3374773, upload-time = "2025-11-30T12:48:59.318Z" }, - { url = "https://files.pythonhosted.org/packages/fa/cb/ce641e07ba9c0cde8468e83e0214fb87020b74ba34dbb9dfe8d250a327f5/cython-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4df52101209817fde7284cf779156f79142fb639b1d7840f11680ff4bb30604", size = 2754492, upload-time = "2025-11-30T12:49:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f2/cd60f639f0fde38b71319d7b6808e1ff17a6fd7f3feaff475b866a5c0aef/cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3", size = 2969023, upload-time = "2025-11-30T12:49:02.734Z" }, - { url = "https://files.pythonhosted.org/packages/5d/45/6f155a9ad125536d8f30716c4d7571caae73ec811039d3ae33f9b535090d/cython-3.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8db28aef793c81dc69383b619ca508668998aaf099cd839d3cbae85184cce744", size = 3258270, upload-time = "2025-11-30T12:49:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/af/7e/022c25886fdc3ff6a005b6ae4a1c3d8522006bb738367aa5bd6c2590130b/cython-3.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3de43a5786033a27fae1c882feb5ff0d023c38b83356e6800c1be0bcd6cf9f11", size = 3384504, upload-time = "2025-11-30T12:49:07.078Z" }, - { url = "https://files.pythonhosted.org/packages/b6/07/1e3e4faf6f785d5ba053e9d6320b3f338162dc122c27a7c540b49615fc39/cython-3.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fed44d0ab2d36f1b0301c770b0dafec23bcb9700d58e7769cd6d9136b3304c11", size = 2791504, upload-time = "2025-11-30T12:49:08.729Z" }, - { url = "https://files.pythonhosted.org/packages/f4/69/5430879d35235ec3d5ffd778862173b6419390509ae4e37a72bdd45d9e86/cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56", size = 2874031, upload-time = "2025-11-30T12:49:18.34Z" }, - { url = "https://files.pythonhosted.org/packages/51/fa/584f4b56b35b3e7a43dc16603dd722cb5528484da67c27136534b782827b/cython-3.2.2-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:436eb562d0affbc0b959f62f3f9c1ed251b9499e4f29c1d19514ae859894b6bf", size = 3210813, upload-time = "2025-11-30T12:49:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/d1/d4/063c34a34d9ef54836a5dafb100b8f4fdbdaa63942913fe93f9eb93a11a2/cython-3.2.2-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f560ff3aea5b5df93853ec7bf1a1e9623d6d511f4192f197559aca18fca43392", size = 2855611, upload-time = "2025-11-30T12:49:22.303Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/c0b8854e0bf6d444c88cc2050f550d964596daea20eaf1bc592fcfde2782/cython-3.2.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d8c93fe128b58942832b1fcac96e48f93c2c69b569eff0d38d30fb5995fecfa0", size = 2992824, upload-time = "2025-11-30T12:49:24.02Z" }, - { url = "https://files.pythonhosted.org/packages/90/6f/741186935c52de99acf4d7fad5c3dcf28d980b4c95d171d9618f9c399316/cython-3.2.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b4fe499eed7cd70b2aa4e096b9ce2588f5e6fdf049b46d40a5e55efcde6e4904", size = 2890389, upload-time = "2025-11-30T12:49:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/5c/79/3e487876addd0d69c148a529f3973c1942498ad39cede1e63565676064ed/cython-3.2.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:14432d7f207245a3c35556155873f494784169297b28978a6204f1c60d31553e", size = 3224881, upload-time = "2025-11-30T12:49:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/15/b9/d9a103feb74d04579c6bde7b0cad6d5f45c002d843ca70788a5758707b68/cython-3.2.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:820c4a99dbf6b3e6c0300be42b4040b501eff0e1feeb80cfa52c48a346fb0df2", size = 3114308, upload-time = "2025-11-30T12:49:29.292Z" }, - { url = "https://files.pythonhosted.org/packages/18/56/90445707cff62ab72136857a0134c5e50f9c73920c1a3af5218dfdae1c19/cython-3.2.2-cp39-abi3-win32.whl", hash = "sha256:826cad0ad43ab05a26e873b5d625f64d458dc739ec6fdeecab848b60a91c4252", size = 2435212, upload-time = "2025-11-30T12:49:32.179Z" }, - { url = "https://files.pythonhosted.org/packages/44/54/25a98c2731521ac9fc18e17d79a0e7d58164d4db398f09e1bd24cdd27ed1/cython-3.2.2-cp39-abi3-win_arm64.whl", hash = "sha256:5f818d40bbcf17e2089e2de7840f0de1c0ca527acf9b044aba79d5f5d8a5bdba", size = 2440536, upload-time = "2025-11-30T12:49:34.109Z" }, - { url = "https://files.pythonhosted.org/packages/76/f2/98fd8d0b514622a789fd2824b59bd6041b799aaeeba14a8d92d52f6654dd/cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb", size = 1255106, upload-time = "2025-11-30T12:48:18.454Z" }, -] - -[[package]] -name = "cytoolz" -version = "0.12.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "toolz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/d8/8df71050b214686591241a1826d2e6934b5c295c5bc57f643e4ed697f1eb/cytoolz-0.12.3.tar.gz", hash = "sha256:4503dc59f4ced53a54643272c61dc305d1dbbfbd7d6bdf296948de9f34c3a282", size = 625899, upload-time = "2024-01-25T01:39:13.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/8a/618a70326d4a52998a6bbb11ca019979891a51b85cbbfce8f9762eec5d2c/cytoolz-0.12.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3ac4f2fb38bbc67ff1875b7d2f0f162a247f43bd28eb7c9d15e6175a982e558d", size = 416356, upload-time = "2024-01-25T01:36:30.344Z" }, - { url = "https://files.pythonhosted.org/packages/bb/17/542f708b9116aa8d8c9c5551500bfa6ab1bddd3edc11457070599a97c197/cytoolz-0.12.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cf1e1e96dd86829a0539baf514a9c8473a58fbb415f92401a68e8e52a34ecd5", size = 398412, upload-time = "2024-01-25T01:36:32.628Z" }, - { url = "https://files.pythonhosted.org/packages/d6/9c/52a0460f2b59009da63794cfe11ad002d55fc88fced6ccc85f7ae10a3259/cytoolz-0.12.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08a438701c6141dd34eaf92e9e9a1f66e23a22f7840ef8a371eba274477de85d", size = 2090389, upload-time = "2024-01-25T01:36:34.398Z" }, - { url = "https://files.pythonhosted.org/packages/c7/6d/284d4a1f88b1e63b8a1c80162244ca5d4e0b31647c4f56186f3385fd0d0e/cytoolz-0.12.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6b6f11b0d7ed91be53166aeef2a23a799e636625675bb30818f47f41ad31821", size = 2187333, upload-time = "2024-01-25T01:36:36.099Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/44efcf2acf824aefab5f3fdb97235d360c216c2425db391a8dee687f1774/cytoolz-0.12.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7fde09384d23048a7b4ac889063761e44b89a0b64015393e2d1d21d5c1f534a", size = 2173106, upload-time = "2024-01-25T01:36:37.827Z" }, - { url = "https://files.pythonhosted.org/packages/6f/74/7c2d5af25d51f481e9be771bda2cf5496631cd0fba876d2cc0086caa732d/cytoolz-0.12.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d3bfe45173cc8e6c76206be3a916d8bfd2214fb2965563e288088012f1dabfc", size = 2098805, upload-time = "2024-01-25T01:36:40.09Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/9995bf66caee139459ab70d8b5820c37718d5f7e7380481adb85ef522e4d/cytoolz-0.12.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27513a5d5b6624372d63313574381d3217a66e7a2626b056c695179623a5cb1a", size = 1995956, upload-time = "2024-01-25T01:36:41.741Z" }, - { url = "https://files.pythonhosted.org/packages/93/73/eee7a7f455d4691f1915b35d1713ba69c6851f940c6f8fffdd2efcbe4411/cytoolz-0.12.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d294e5e81ff094fe920fd545052ff30838ea49f9e91227a55ecd9f3ca19774a0", size = 2074891, upload-time = "2024-01-25T01:36:43.887Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c7/6f0c60bbb911ee3f33b243a695e2fc944285a1865bbb1dfefee1b7c35d62/cytoolz-0.12.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:727b01a2004ddb513496507a695e19b5c0cfebcdfcc68349d3efd92a1c297bf4", size = 1980228, upload-time = "2024-01-25T01:36:45.42Z" }, - { url = "https://files.pythonhosted.org/packages/58/7c/d7524626b415d410208fd2774ff2824fab4dcbe4a496d53bdba85a1407df/cytoolz-0.12.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:fe1e1779a39dbe83f13886d2b4b02f8c4b10755e3c8d9a89b630395f49f4f406", size = 2147616, upload-time = "2024-01-25T01:36:47.06Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7b/9bdf8615e61958386f5c7108d857312214837ae3eacf3589873499d2f0aa/cytoolz-0.12.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:de74ef266e2679c3bf8b5fc20cee4fc0271ba13ae0d9097b1491c7a9bcadb389", size = 2148775, upload-time = "2024-01-25T01:36:48.813Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/b92ab0e29bf7cfaacfedc85391a0d0365a6ac74363f2532ba307a760db14/cytoolz-0.12.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e04d22049233394e0b08193aca9737200b4a2afa28659d957327aa780ddddf2", size = 2079810, upload-time = "2024-01-25T01:36:50.116Z" }, - { url = "https://files.pythonhosted.org/packages/2f/33/8204d65871fa639d1d3ae788e3afc187f610126f53f2324d4aa37caf0630/cytoolz-0.12.3-cp311-cp311-win32.whl", hash = "sha256:20d36430d8ac809186736fda735ee7d595b6242bdb35f69b598ef809ebfa5605", size = 320212, upload-time = "2024-01-25T01:36:51.73Z" }, - { url = "https://files.pythonhosted.org/packages/ab/e3/7ce2efaf243c9ee75fc730cd018981788b56e7ad3767f24e7a9cd14831ac/cytoolz-0.12.3-cp311-cp311-win_amd64.whl", hash = "sha256:780c06110f383344d537f48d9010d79fa4f75070d214fc47f389357dd4f010b6", size = 363570, upload-time = "2024-01-25T01:36:53.371Z" }, - { url = "https://files.pythonhosted.org/packages/7f/57/b0feabefdff4707d0b96bf75e2298ecb209ba22c2e3fd15e5230dd68441a/cytoolz-0.12.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:86923d823bd19ce35805953b018d436f6b862edd6a7c8b747a13d52b39ed5716", size = 419825, upload-time = "2024-01-25T01:36:54.522Z" }, - { url = "https://files.pythonhosted.org/packages/54/82/795aa9f91ee81818732b435755559857a76e16b7e435e1de434b47ce72b9/cytoolz-0.12.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3e61acfd029bfb81c2c596249b508dfd2b4f72e31b7b53b62e5fb0507dd7293", size = 401673, upload-time = "2024-01-25T01:36:56.435Z" }, - { url = "https://files.pythonhosted.org/packages/23/f3/298f32b2e374f4f716ca74a9e46b64ee1df9f160de262391f7f9bc53ecca/cytoolz-0.12.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd728f4e6051af6af234651df49319da1d813f47894d4c3c8ab7455e01703a37", size = 2096352, upload-time = "2024-01-25T01:36:58.428Z" }, - { url = "https://files.pythonhosted.org/packages/39/44/49929b33bd0cdccc98b281e4ca11f0836b2963c6fdec8ebb7891807ed908/cytoolz-0.12.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe8c6267caa7ec67bcc37e360f0d8a26bc3bdce510b15b97f2f2e0143bdd3673", size = 2161581, upload-time = "2024-01-25T01:36:59.818Z" }, - { url = "https://files.pythonhosted.org/packages/e9/77/980a8bc123490dd7112e1e068da989932b7612504355ddd25593880c3156/cytoolz-0.12.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99462abd8323c52204a2a0ce62454ce8fa0f4e94b9af397945c12830de73f27e", size = 2178057, upload-time = "2024-01-25T01:37:02.23Z" }, - { url = "https://files.pythonhosted.org/packages/23/d9/2f7a4db7f92eab4b567e56cc2fc147fed1ef35f7968e44cefcbbfb366980/cytoolz-0.12.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da125221b1fa25c690fcd030a54344cecec80074df018d906fc6a99f46c1e3a6", size = 2130881, upload-time = "2024-01-25T01:37:03.703Z" }, - { url = "https://files.pythonhosted.org/packages/85/bf/1147229198a8fc3f2090d01263234d47b5b7f8c52c20090b1cceda0929cd/cytoolz-0.12.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c18e351956f70db9e2d04ff02f28e9a41839250d3f936a4c8a1eabd1c3094d2", size = 1979975, upload-time = "2024-01-25T01:37:06.277Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/b730e9f428e74971f934bf73f0868099d325ffbf0c080a201bb16b6ab508/cytoolz-0.12.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:921e6d2440ac758c4945c587b1d1d9b781b72737ac0c0ca5d5e02ca1db8bded2", size = 2086861, upload-time = "2024-01-25T01:37:07.869Z" }, - { url = "https://files.pythonhosted.org/packages/24/24/df510850c1c57ad69a23a2700083cfd285faf27d16e61f0e503eec122473/cytoolz-0.12.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1651a9bd591a8326329ce1d6336f3129161a36d7061a4d5ea9e5377e033364cf", size = 1973211, upload-time = "2024-01-25T01:37:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ad/a6e27924a6c8048b4f1ffbd8b9b0b871642bd94d3e457aa42a629c1f667b/cytoolz-0.12.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8893223b87c2782bd59f9c4bd5c7bf733edd8728b523c93efb91d7468b486528", size = 2133017, upload-time = "2024-01-25T01:37:11.684Z" }, - { url = "https://files.pythonhosted.org/packages/26/a3/f9dfb406232d0a100994a86c123259cd2ab023aa8ae325b0d9926f80091f/cytoolz-0.12.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:e4d2961644153c5ae186db964aa9f6109da81b12df0f1d3494b4e5cf2c332ee2", size = 2156771, upload-time = "2024-01-25T01:37:13.62Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bf/ce2c6f6175a29c830904b23faee557de8c167255dbb2bb84e04d7cf5605a/cytoolz-0.12.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:71b6eb97f6695f7ba8ce69c49b707a351c5f46fd97f5aeb5f6f2fb0d6e72b887", size = 2121104, upload-time = "2024-01-25T01:37:16.053Z" }, - { url = "https://files.pythonhosted.org/packages/bf/97/95a5fb2b5db9b1ea8856741a2a94741a71cb5640112403029beb7b3ef1df/cytoolz-0.12.3-cp312-cp312-win32.whl", hash = "sha256:cee3de65584e915053412cd178729ff510ad5f8f585c21c5890e91028283518f", size = 320537, upload-time = "2024-01-25T01:37:18.026Z" }, - { url = "https://files.pythonhosted.org/packages/0a/11/3e69585342ce4e6b7f2f6139c8aa235dab5a4fe5c76a051fe01696618bd0/cytoolz-0.12.3-cp312-cp312-win_amd64.whl", hash = "sha256:9eef0d23035fa4dcfa21e570961e86c375153a7ee605cdd11a8b088c24f707f6", size = 363238, upload-time = "2024-01-25T01:37:19.343Z" }, -] - -[[package]] -name = "debugpy" -version = "1.8.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129, upload-time = "2025-09-17T16:33:20.633Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154, upload-time = "2025-09-17T16:33:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322, upload-time = "2025-09-17T16:33:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078, upload-time = "2025-09-17T16:33:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c5/c012c60a2922cc91caa9675d0ddfbb14ba59e1e36228355f41cab6483469/debugpy-1.8.17-cp311-cp311-win_amd64.whl", hash = "sha256:b532282ad4eca958b1b2d7dbcb2b7218e02cb934165859b918e3b6ba7772d3f4", size = 5179011, upload-time = "2025-09-17T16:33:35.711Z" }, - { url = "https://files.pythonhosted.org/packages/08/2b/9d8e65beb2751876c82e1aceb32f328c43ec872711fa80257c7674f45650/debugpy-1.8.17-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:f14467edef672195c6f6b8e27ce5005313cb5d03c9239059bc7182b60c176e2d", size = 2549522, upload-time = "2025-09-17T16:33:38.466Z" }, - { url = "https://files.pythonhosted.org/packages/b4/78/eb0d77f02971c05fca0eb7465b18058ba84bd957062f5eec82f941ac792a/debugpy-1.8.17-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:24693179ef9dfa20dca8605905a42b392be56d410c333af82f1c5dff807a64cc", size = 4309417, upload-time = "2025-09-17T16:33:41.299Z" }, - { url = "https://files.pythonhosted.org/packages/37/42/c40f1d8cc1fed1e75ea54298a382395b8b937d923fcf41ab0797a554f555/debugpy-1.8.17-cp312-cp312-win32.whl", hash = "sha256:6a4e9dacf2cbb60d2514ff7b04b4534b0139facbf2abdffe0639ddb6088e59cf", size = 5277130, upload-time = "2025-09-17T16:33:43.554Z" }, - { url = "https://files.pythonhosted.org/packages/72/22/84263b205baad32b81b36eac076de0cdbe09fe2d0637f5b32243dc7c925b/debugpy-1.8.17-cp312-cp312-win_amd64.whl", hash = "sha256:e8f8f61c518952fb15f74a302e068b48d9c4691768ade433e4adeea961993464", size = 5319053, upload-time = "2025-09-17T16:33:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386, upload-time = "2025-09-17T16:33:54.594Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100, upload-time = "2025-09-17T16:33:56.353Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002, upload-time = "2025-09-17T16:33:58.231Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047, upload-time = "2025-09-17T16:34:00.586Z" }, - { url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899, upload-time = "2025-09-17T16:34:02.657Z" }, - { url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254, upload-time = "2025-09-17T16:34:04.486Z" }, - { url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203, upload-time = "2025-09-17T16:34:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493, upload-time = "2025-09-17T16:34:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210, upload-time = "2025-09-17T16:34:25.835Z" }, -] - -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, -] - -[[package]] -name = "docutils" -version = "0.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, -] - -[[package]] -name = "einops" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/81/df4fbe24dff8ba3934af99044188e20a98ed441ad17a274539b74e82e126/einops-0.8.1.tar.gz", hash = "sha256:de5d960a7a761225532e0f1959e5315ebeafc0cd43394732f103ca44b9837e84", size = 54805, upload-time = "2025-02-09T03:17:00.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" }, -] - -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - -[[package]] -name = "filelock" -version = "3.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, -] - -[[package]] -name = "fonttools" -version = "4.61.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/f9/0e84d593c0e12244150280a630999835a64f2852276161b62a0f98318de0/fonttools-4.61.0.tar.gz", hash = "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7", size = 3561884, upload-time = "2025-11-28T17:05:49.491Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/be/5aa89cdddf2863d8afbdc19eb8ec5d8d35d40eeeb8e6cf52c5ff1c2dbd33/fonttools-4.61.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a32a16951cbf113d38f1dd8551b277b6e06e0f6f776fece0f99f746d739e1be3", size = 2847553, upload-time = "2025-11-28T17:04:30.539Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3e/6ff643b07cead1236a534f51291ae2981721cf419135af5b740c002a66dd/fonttools-4.61.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:328a9c227984bebaf69f3ac9062265f8f6acc7ddf2e4e344c63358579af0aa3d", size = 2388298, upload-time = "2025-11-28T17:04:32.161Z" }, - { url = "https://files.pythonhosted.org/packages/c3/15/fca8dfbe7b482e6f240b1aad0ed7c6e2e75e7a28efa3d3a03b570617b5e5/fonttools-4.61.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f0bafc8a3b3749c69cc610e5aa3da832d39c2a37a68f03d18ec9a02ecaac04a", size = 5054133, upload-time = "2025-11-28T17:04:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a2/821c61c691b21fd09e07528a9a499cc2b075ac83ddb644aa16c9875a64bc/fonttools-4.61.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5ca59b7417d149cf24e4c1933c9f44b2957424fc03536f132346d5242e0ebe5", size = 5031410, upload-time = "2025-11-28T17:04:36.141Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f6/8b16339e93d03c732c8a23edefe3061b17a5f9107ddc47a3215ecd054cac/fonttools-4.61.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:df8cbce85cf482eb01f4551edca978c719f099c623277bda8332e5dbe7dba09d", size = 5030005, upload-time = "2025-11-28T17:04:38.314Z" }, - { url = "https://files.pythonhosted.org/packages/ac/eb/d4e150427bdaa147755239c931bbce829a88149ade5bfd8a327afe565567/fonttools-4.61.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7fb5b84f48a6a733ca3d7f41aa9551908ccabe8669ffe79586560abcc00a9cfd", size = 5154026, upload-time = "2025-11-28T17:04:40.34Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5f/3dd00ce0dba6759943c707b1830af8c0bcf6f8f1a9fe46cb82e7ac2aaa74/fonttools-4.61.0-cp311-cp311-win32.whl", hash = "sha256:787ef9dfd1ea9fe49573c272412ae5f479d78e671981819538143bec65863865", size = 2276035, upload-time = "2025-11-28T17:04:42.59Z" }, - { url = "https://files.pythonhosted.org/packages/4e/44/798c472f096ddf12955eddb98f4f7c906e7497695d04ce073ddf7161d134/fonttools-4.61.0-cp311-cp311-win_amd64.whl", hash = "sha256:14fafda386377b6131d9e448af42d0926bad47e038de0e5ba1d58c25d621f028", size = 2327290, upload-time = "2025-11-28T17:04:44.57Z" }, - { url = "https://files.pythonhosted.org/packages/00/5d/19e5939f773c7cb05480fe2e881d63870b63ee2b4bdb9a77d55b1d36c7b9/fonttools-4.61.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e24a1565c4e57111ec7f4915f8981ecbb61adf66a55f378fdc00e206059fcfef", size = 2846930, upload-time = "2025-11-28T17:04:46.639Z" }, - { url = "https://files.pythonhosted.org/packages/25/b2/0658faf66f705293bd7e739a4f038302d188d424926be9c59bdad945664b/fonttools-4.61.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2bfacb5351303cae9f072ccf3fc6ecb437a6f359c0606bae4b1ab6715201d87", size = 2383016, upload-time = "2025-11-28T17:04:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/29/a3/1fa90b95b690f0d7541f48850adc40e9019374d896c1b8148d15012b2458/fonttools-4.61.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0bdcf2e29d65c26299cc3d502f4612365e8b90a939f46cd92d037b6cb7bb544a", size = 4949425, upload-time = "2025-11-28T17:04:50.482Z" }, - { url = "https://files.pythonhosted.org/packages/af/00/acf18c00f6c501bd6e05ee930f926186f8a8e268265407065688820f1c94/fonttools-4.61.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6cd0d9051b8ddaf7385f99dd82ec2a058e2b46cf1f1961e68e1ff20fcbb61af", size = 4999632, upload-time = "2025-11-28T17:04:52.508Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e0/19a2b86e54109b1d2ee8743c96a1d297238ae03243897bc5345c0365f34d/fonttools-4.61.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e074bc07c31406f45c418e17c1722e83560f181d122c412fa9e815df0ff74810", size = 4939438, upload-time = "2025-11-28T17:04:54.437Z" }, - { url = "https://files.pythonhosted.org/packages/04/35/7b57a5f57d46286360355eff8d6b88c64ab6331107f37a273a71c803798d/fonttools-4.61.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a9b78da5d5faa17e63b2404b77feeae105c1b7e75f26020ab7a27b76e02039f", size = 5088960, upload-time = "2025-11-28T17:04:56.348Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0e/6c5023eb2e0fe5d1ababc7e221e44acd3ff668781489cc1937a6f83d620a/fonttools-4.61.0-cp312-cp312-win32.whl", hash = "sha256:9821ed77bb676736b88fa87a737c97b6af06e8109667e625a4f00158540ce044", size = 2264404, upload-time = "2025-11-28T17:04:58.149Z" }, - { url = "https://files.pythonhosted.org/packages/36/0b/63273128c7c5df19b1e4cd92e0a1e6ea5bb74a400c4905054c96ad60a675/fonttools-4.61.0-cp312-cp312-win_amd64.whl", hash = "sha256:0011d640afa61053bc6590f9a3394bd222de7cfde19346588beabac374e9d8ac", size = 2314427, upload-time = "2025-11-28T17:04:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/17/45/334f0d7f181e5473cfb757e1b60f4e60e7fc64f28d406e5d364a952718c0/fonttools-4.61.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba774b8cbd8754f54b8eb58124e8bd45f736b2743325ab1a5229698942b9b433", size = 2841801, upload-time = "2025-11-28T17:05:01.621Z" }, - { url = "https://files.pythonhosted.org/packages/cc/63/97b9c78e1f79bc741d4efe6e51f13872d8edb2b36e1b9fb2bab0d4491bb7/fonttools-4.61.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c84b430616ed73ce46e9cafd0bf0800e366a3e02fb7e1ad7c1e214dbe3862b1f", size = 2379024, upload-time = "2025-11-28T17:05:03.668Z" }, - { url = "https://files.pythonhosted.org/packages/4e/80/c87bc524a90dbeb2a390eea23eae448286983da59b7e02c67fa0ca96a8c5/fonttools-4.61.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b2b734d8391afe3c682320840c8191de9bd24e7eb85768dd4dc06ed1b63dbb1b", size = 4923706, upload-time = "2025-11-28T17:05:05.494Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f6/a3b0374811a1de8c3f9207ec88f61ad1bb96f938ed89babae26c065c2e46/fonttools-4.61.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5c5fff72bf31b0e558ed085e4fd7ed96eb85881404ecc39ed2a779e7cf724eb", size = 4979751, upload-time = "2025-11-28T17:05:07.665Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3b/30f63b4308b449091573285f9d27619563a84f399946bca3eadc9554afbe/fonttools-4.61.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:14a290c5c93fcab76b7f451e6a4b7721b712d90b3b5ed6908f1abcf794e90d6d", size = 4921113, upload-time = "2025-11-28T17:05:09.551Z" }, - { url = "https://files.pythonhosted.org/packages/41/6c/58e6e9b7d9d8bf2d7010bd7bb493060b39b02a12d1cda64a8bfb116ce760/fonttools-4.61.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:13e3e20a5463bfeb77b3557d04b30bd6a96a6bb5c15c7b2e7908903e69d437a0", size = 5063183, upload-time = "2025-11-28T17:05:11.677Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e3/52c790ab2b07492df059947a1fd7778e105aac5848c0473029a4d20481a2/fonttools-4.61.0-cp313-cp313-win32.whl", hash = "sha256:6781e7a4bb010be1cd69a29927b0305c86b843395f2613bdabe115f7d6ea7f34", size = 2263159, upload-time = "2025-11-28T17:05:13.292Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1f/116013b200fbeba871046554d5d2a45fefa69a05c40e9cdfd0d4fff53edc/fonttools-4.61.0-cp313-cp313-win_amd64.whl", hash = "sha256:c53b47834ae41e8e4829171cc44fec0fdf125545a15f6da41776b926b9645a9a", size = 2313530, upload-time = "2025-11-28T17:05:14.848Z" }, - { url = "https://files.pythonhosted.org/packages/d3/99/59b1e25987787cb714aa9457cee4c9301b7c2153f0b673e2b8679d37669d/fonttools-4.61.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:96dfc9bc1f2302224e48e6ee37e656eddbab810b724b52e9d9c13a57a6abad01", size = 2841429, upload-time = "2025-11-28T17:05:16.671Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/4c1911d4332c8a144bb3b44416e274ccca0e297157c971ea1b3fbb855590/fonttools-4.61.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b2065d94e5d63aafc2591c8b6ccbdb511001d9619f1bca8ad39b745ebeb5efa", size = 2378987, upload-time = "2025-11-28T17:05:18.69Z" }, - { url = "https://files.pythonhosted.org/packages/24/b0/f442e90fde5d2af2ae0cb54008ab6411edc557ee33b824e13e1d04925ac9/fonttools-4.61.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e0d87e81e4d869549585ba0beb3f033718501c1095004f5e6aef598d13ebc216", size = 4873270, upload-time = "2025-11-28T17:05:20.625Z" }, - { url = "https://files.pythonhosted.org/packages/bb/04/f5d5990e33053c8a59b90b1d7e10ad9b97a73f42c745304da0e709635fab/fonttools-4.61.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cfa2eb9bae650e58f0e8ad53c49d19a844d6034d6b259f30f197238abc1ccee", size = 4968270, upload-time = "2025-11-28T17:05:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/94/9f/2091402e0d27c9c8c4bab5de0e5cd146d9609a2d7d1c666bbb75c0011c1a/fonttools-4.61.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4238120002e68296d55e091411c09eab94e111c8ce64716d17df53fd0eb3bb3d", size = 4919799, upload-time = "2025-11-28T17:05:24.437Z" }, - { url = "https://files.pythonhosted.org/packages/a8/72/86adab22fde710b829f8ffbc8f264df01928e5b7a8f6177fa29979ebf256/fonttools-4.61.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ceac262cc62bec01b3bb59abccf41b24ef6580869e306a4e88b7e56bb4bdda", size = 5030966, upload-time = "2025-11-28T17:05:26.115Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a7/7c8e31b003349e845b853f5e0a67b95ff6b052fa4f5224f8b72624f5ac69/fonttools-4.61.0-cp314-cp314-win32.whl", hash = "sha256:adbb4ecee1a779469a77377bbe490565effe8fce6fb2e6f95f064de58f8bac85", size = 2267243, upload-time = "2025-11-28T17:05:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/20/ee/f434fe7749360497c52b7dcbcfdbccdaab0a71c59f19d572576066717122/fonttools-4.61.0-cp314-cp314-win_amd64.whl", hash = "sha256:02bdf8e04d1a70476564b8640380f04bb4ac74edc1fc71f1bacb840b3e398ee9", size = 2318822, upload-time = "2025-11-28T17:05:29.882Z" }, - { url = "https://files.pythonhosted.org/packages/33/b3/c16255320255e5c1863ca2b2599bb61a46e2f566db0bbb9948615a8fe692/fonttools-4.61.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:627216062d90ab0d98215176d8b9562c4dd5b61271d35f130bcd30f6a8aaa33a", size = 2924917, upload-time = "2025-11-28T17:05:31.46Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b8/08067ae21de705a817777c02ef36ab0b953cbe91d8adf134f9c2da75ed6d/fonttools-4.61.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b446623c9cd5f14a59493818eaa80255eec2468c27d2c01b56e05357c263195", size = 2413576, upload-time = "2025-11-28T17:05:33.343Z" }, - { url = "https://files.pythonhosted.org/packages/42/f1/96ff43f92addce2356780fdc203f2966206f3d22ea20e242c27826fd7442/fonttools-4.61.0-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:70e2a0c0182ee75e493ef33061bfebf140ea57e035481d2f95aa03b66c7a0e05", size = 4877447, upload-time = "2025-11-28T17:05:35.278Z" }, - { url = "https://files.pythonhosted.org/packages/d0/1e/a3d8e51ed9ccfd7385e239ae374b78d258a0fb82d82cab99160a014a45d1/fonttools-4.61.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9064b0f55b947e929ac669af5311ab1f26f750214db6dd9a0c97e091e918f486", size = 5095681, upload-time = "2025-11-28T17:05:37.142Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f6/d256bd6c1065c146a0bdddf1c62f542e08ae5b3405dbf3fcc52be272f674/fonttools-4.61.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cb5e45a824ce14b90510024d0d39dae51bd4fbb54c42a9334ea8c8cf4d95cbe", size = 4974140, upload-time = "2025-11-28T17:05:39.5Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0c/96633eb4b26f138cc48561c6e0c44b4ea48acea56b20b507d6b14f8e80ce/fonttools-4.61.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e5ca8c62efdec7972dfdfd454415c4db49b89aeaefaaacada432f3b7eea9866", size = 5001741, upload-time = "2025-11-28T17:05:41.424Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/3b536bad3be4f26186f296e749ff17bad3e6d57232c104d752d24b2e265b/fonttools-4.61.0-cp314-cp314t-win32.whl", hash = "sha256:63c7125d31abe3e61d7bb917329b5543c5b3448db95f24081a13aaf064360fc8", size = 2330707, upload-time = "2025-11-28T17:05:43.548Z" }, - { url = "https://files.pythonhosted.org/packages/18/ea/e6b9ac610451ee9f04477c311ad126de971f6112cb579fa391d2a8edb00b/fonttools-4.61.0-cp314-cp314t-win_amd64.whl", hash = "sha256:67d841aa272be5500de7f447c40d1d8452783af33b4c3599899319f6ef9ad3c1", size = 2395950, upload-time = "2025-11-28T17:05:45.638Z" }, - { url = "https://files.pythonhosted.org/packages/0c/14/634f7daea5ffe6a5f7a0322ba8e1a0e23c9257b80aa91458107896d1dfc7/fonttools-4.61.0-py3-none-any.whl", hash = "sha256:276f14c560e6f98d24ef7f5f44438e55ff5a67f78fa85236b218462c9f5d0635", size = 1144485, upload-time = "2025-11-28T17:05:47.573Z" }, -] - -[[package]] -name = "fsspec" -version = "2025.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/27/954057b0d1f53f086f681755207dda6de6c660ce133c829158e8e8fe7895/fsspec-2025.12.0.tar.gz", hash = "sha256:c505de011584597b1060ff778bb664c1bc022e87921b0e4f10cc9c44f9635973", size = 309748, upload-time = "2025-12-03T15:23:42.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" }, -] - -[[package]] -name = "hydride" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "biotite" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/a2/d600d62ea1f551002503066576cd45a7bc478da1acdc44c8796d5b05dc19/hydride-1.2.3.tar.gz", hash = "sha256:d1d06dcfb4c4957f109bd2445a115021cf69ff7c1639cb3f96961c58b38c6453", size = 293435, upload-time = "2024-11-04T11:37:06.648Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/5c/ad922192c64eada9bba98bd3a9255faf4faae78adfd4537d11eb2ed1b8e3/hydride-1.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b249a18109d60ce990c0a47d07620863dfe3f23495aa1e04ca15e9e086ffc9e", size = 452841, upload-time = "2024-11-04T11:36:52.47Z" }, - { url = "https://files.pythonhosted.org/packages/93/ec/0894ec1e38266e183db3e5ac148d7bddab4fda2f25447ec13716ec05d8fe/hydride-1.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f6ef6875d6f720de694fe53254648e886c83681d05fe4988a823a2dbd06d38fb", size = 441244, upload-time = "2024-11-04T11:36:53.951Z" }, - { url = "https://files.pythonhosted.org/packages/ed/28/c52153387c6efa7ac7d9c3c5df068aa686d8468c797f9a045d0993c77913/hydride-1.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c196b287c7dd6ceb6e98d52aec1f8755f9bd641146b55a3236bae58c48ddf6be", size = 1196593, upload-time = "2024-11-04T11:36:55.093Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5a/2c72655c2554840081d61b5680a067f4b8c1bc746032ab8f355846aeb750/hydride-1.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:6220f9159bcb1a5e985382dff5d25fcf0c848896190265d4d43a0252a039691e", size = 425532, upload-time = "2024-11-04T11:36:56.907Z" }, - { url = "https://files.pythonhosted.org/packages/0d/fb/6163b0f4f6b89daa9045bf15fd2546ce43e717614116a477c7a40b7d73b7/hydride-1.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3884f4969f2df7237e5bc8c82e47026b6c4ba91de3bbc546b5ac938b4365bac5", size = 451179, upload-time = "2024-11-04T11:36:58.665Z" }, - { url = "https://files.pythonhosted.org/packages/87/23/409e5c2b1cfaf918c4d9c84d7a3a9f930e293e05affc6b5d020d08b0a9de/hydride-1.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b7221eceb732539e42419c6612e5f9c6a33074ad0547e5048d0d5c38a23734c", size = 440277, upload-time = "2024-11-04T11:37:00.204Z" }, - { url = "https://files.pythonhosted.org/packages/73/d4/7ca768b20e6d62ee0c967c3e770ca9bf720b396f03111ccaf230e94a023c/hydride-1.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76ecdf1cebe6042549fa4580ee829670c472563a8b66b25cb74c48d4e0f1e2d2", size = 1186352, upload-time = "2024-11-04T11:37:04.144Z" }, - { url = "https://files.pythonhosted.org/packages/a4/2e/9095f2ba3da50db53e21db7d93a85e8f96c0ebb7eb43f375e81596f46e32/hydride-1.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:051de12202d675e551b741e9f1734f375b504bd63486c5aa8b565f89d20c444d", size = 422029, upload-time = "2024-11-04T11:37:05.247Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "imagesize" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "ipykernel" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, -] - -[[package]] -name = "ipython" -version = "9.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/12/51/a703c030f4928646d390b4971af4938a1b10c9dfce694f0d99a0bb073cb2/ipython-9.8.0.tar.gz", hash = "sha256:8e4ce129a627eb9dd221c41b1d2cdaed4ef7c9da8c17c63f6f578fe231141f83", size = 4424940, upload-time = "2025-12-03T10:18:24.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/df/8ee1c5dd1e3308b5d5b2f2dfea323bb2f3827da8d654abb6642051199049/ipython-9.8.0-py3-none-any.whl", hash = "sha256:ebe6d1d58d7d988fbf23ff8ff6d8e1622cfdb194daf4b7b73b792c4ec3b85385", size = 621374, upload-time = "2025-12-03T10:18:22.335Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - -[[package]] -name = "jaxtyping" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wadler-lindig" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/1e/827f9e17b26e21c7d4d934fd1a214284ad05663afedd37c21ed105db366b/jaxtyping-0.3.3.tar.gz", hash = "sha256:8003cfd16ba2ad9b47fdda1d982a575299a81ddfc7997ad0e917c87a0897ea86", size = 45484, upload-time = "2025-10-01T13:46:51.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/97/88264b1af140f66ba7ca6eb2f3a108be233ee278bb3f1d5c750243e7458a/jaxtyping-0.3.3-py3-none-any.whl", hash = "sha256:a1c2f0f4351a8deda84b0e3b5c5a50894a1cdae2b82d841279fce4393aff4a7c", size = 55926, upload-time = "2025-10-01T13:46:50.621Z" }, -] - -[[package]] -name = "jedi" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "jupyter-client" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/27/d10de45e8ad4ce872372c4a3a37b7b35b6b064f6f023a5c14ffcced4d59d/jupyter_client-8.7.0.tar.gz", hash = "sha256:3357212d9cbe01209e59190f67a3a7e1f387a4f4e88d1e0433ad84d7b262531d", size = 344691, upload-time = "2025-12-09T18:37:01.953Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f5/fddaec430367be9d62a7ed125530e133bfd4a1c0350fe221149ee0f2b526/jupyter_client-8.7.0-py3-none-any.whl", hash = "sha256:3671a94fd25e62f5f2f554f5e95389c2294d89822378a5f2dd24353e1494a9e0", size = 106215, upload-time = "2025-12-09T18:37:00.024Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - -[[package]] -name = "kiwisolver" -version = "1.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, -] - -[[package]] -name = "lmdb" -version = "1.7.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/a3/3756f2c6adba4a1413dba55e6c81a20b38a868656517308533e33cb59e1c/lmdb-1.7.5.tar.gz", hash = "sha256:f0604751762cb097059d5412444c4057b95f386c7ed958363cf63f453e5108da", size = 883490, upload-time = "2025-10-15T03:39:44.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/65/7a5776ae6b32e7a752c6df8112c8f9ae7f97d22d381c62a240fc464bc79c/lmdb-1.7.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1a1be54f8c89f979c22c30b98ccb5d9478acdc9a0acb37f9a374a5adfd82e4b", size = 100773, upload-time = "2025-10-15T03:38:54.117Z" }, - { url = "https://files.pythonhosted.org/packages/60/93/ac197909df7bb7842d2d8020accea674e75ad70ddfabfbab2c9703241c58/lmdb-1.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:01907a5e9346d58fe29651f44119fe3aa33e1b248bb2ac27b48289cdfb552e68", size = 99294, upload-time = "2025-10-15T03:38:55.109Z" }, - { url = "https://files.pythonhosted.org/packages/95/4b/e0230ed03233732e5e87d6ebc0bd4e3a6ba463e33f3e1bc9d953e19ec765/lmdb-1.7.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb5d820d528dc1ec73e5e4b6c8d9d54103779256d504c1a84261ca944e749160", size = 294350, upload-time = "2025-10-15T03:38:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/b8/96/53cf72032516fd77b94ccbc00c92e58a220f5d9f7295672bc13e62e3adb6/lmdb-1.7.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:716c93d170be22630e2e560c67081f80095b951b32de032a876b9c20dff8cc07", size = 295126, upload-time = "2025-10-15T03:38:57.306Z" }, - { url = "https://files.pythonhosted.org/packages/02/4d/af393c476a630331d6b5fb27ed5fac20f1597c8dcef877f5ddcccc58b4e5/lmdb-1.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:2e0d17e917011fb303ece772e64adcbad409aea8ff4c53806511a6283290ae18", size = 99277, upload-time = "2025-10-15T03:38:58.428Z" }, - { url = "https://files.pythonhosted.org/packages/54/a9/82e506906f3fd7c12de97ac64f3b33f45fc8091ec6c915f58eb7660491bb/lmdb-1.7.5-cp311-cp311-win_arm64.whl", hash = "sha256:e6adff25d298df9bc1c9b9e9625a126d3b9e0c6b86c1d633d1a82152bcaf0afe", size = 94127, upload-time = "2025-10-15T03:38:59.789Z" }, - { url = "https://files.pythonhosted.org/packages/34/b4/8b862c4d7fd6f68cb33e2a919169fda8924121dc5ff61e3cc105304a6dd4/lmdb-1.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b48c2359eea876d7b634b49f84019ecc8c1626da97c795fc7b39a793676815df", size = 100910, upload-time = "2025-10-15T03:39:00.727Z" }, - { url = "https://files.pythonhosted.org/packages/27/64/8ab5da48180d5f13a293ea00a9f8758b1bee080e76ea0ab0d6be0d51b55f/lmdb-1.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f84793baeb430ba984eb6c1b4e08c0a508b1c03e79ce79fcda0f29ecc06a95a", size = 99376, upload-time = "2025-10-15T03:39:01.791Z" }, - { url = "https://files.pythonhosted.org/packages/43/e0/51bc942fe5ed3fce69c631b54f52d97785de3d94487376139be6de1e199a/lmdb-1.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68cc21314a33faac1b749645a976b7655e7fa7cc104a72365d2429d2db7f6342", size = 298556, upload-time = "2025-10-15T03:39:02.787Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/19ea75c88b91d12da5c6f4bbe2aca633047b6b270fd613d557583d32cc5c/lmdb-1.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2d9b7e102fcfe5e0cfb3acdebd403eb55ccbe5f7202d8f49d60bdafb1546d1e", size = 299449, upload-time = "2025-10-15T03:39:03.903Z" }, - { url = "https://files.pythonhosted.org/packages/1b/74/365194203dbff47d3a1621366d6a1133cdcce261f4ac0e1d0496f01e6ace/lmdb-1.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:69de89cc79e03e191fc6f95797f1bef91b45c415d1ea9d38872b00b2d989a50f", size = 99328, upload-time = "2025-10-15T03:39:04.949Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3a/a441afebff5bd761f7f58d194fed7ac265279964957479a5c8a51c42f9ad/lmdb-1.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:0c880ee4b309e900f2d58a710701f5e6316a351878588c6a95a9c0bcb640680b", size = 94191, upload-time = "2025-10-15T03:39:05.975Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/03275084218eacdbdf7e185d693e1db4cb79c35d18fac47fa0d388522a0d/lmdb-1.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66ae02fa6179e46bb69fe446b7e956afe8706ae17ec1d4cd9f7056e161019156", size = 101508, upload-time = "2025-10-15T03:39:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/20/b9/bc33ae2e4940359ba2fc412e6a755a2f126bc5062b4aaf35edd3a791f9a5/lmdb-1.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf65c573311ac8330c7908257f76b28ae3576020123400a81a6b650990dc028c", size = 100105, upload-time = "2025-10-15T03:39:08.491Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f6/22f84b776a64d3992f052ecb637c35f1764a39df4f2190ecc5a3a1295bd7/lmdb-1.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97bcb3fc12841a8828db918e494fe0fd016a73d2680ad830d75719bb3bf4e76a", size = 301500, upload-time = "2025-10-15T03:39:09.463Z" }, - { url = "https://files.pythonhosted.org/packages/2a/4d/8e6be8d7d5a30d47fa0ce4b55e3a8050ad689556e6e979d206b4ac67b733/lmdb-1.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:865f374f6206ab4aacb92ffb1dc612ee1a31a421db7c89733abe06b81ac87cb0", size = 302285, upload-time = "2025-10-15T03:39:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/dc/7e04fb31a8f88951db81ac677e3ccb3e09248eda40e6ad52f74fd9370c32/lmdb-1.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:82a04d5ca2a6a799c8db7f209354c48aebb49ff338530f5813721fc4c68e4450", size = 99447, upload-time = "2025-10-15T03:39:12.151Z" }, - { url = "https://files.pythonhosted.org/packages/5b/50/e3f97efab17b3fad4afde99b3c957ecac4ffbefada6874a57ad0c695660a/lmdb-1.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:0ad85a15acbfe8a42fdef92ee5e869610286d38507e976755f211be0fc905ca7", size = 94145, upload-time = "2025-10-15T03:39:13.461Z" }, - { url = "https://files.pythonhosted.org/packages/b9/03/4db578e0031fc4991b6e26ba023123d47a7f85614927cddc60c9c0e68249/lmdb-1.7.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f7407ad1c02fba22d0f9b68e40fef0099992fe0ec4ab0ab0ccbe69f4ffea2f61", size = 101626, upload-time = "2025-10-15T03:39:14.386Z" }, - { url = "https://files.pythonhosted.org/packages/d1/79/e3572dd9f04eb9c68066ba158ea4f32754728882a6f2e7891cdb5b41691e/lmdb-1.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7553ef5fa6ffa5c7476b5d9a2415137b6b05a7456d1c94b51630d89e89e1c21", size = 100221, upload-time = "2025-10-15T03:39:15.653Z" }, - { url = "https://files.pythonhosted.org/packages/c3/4b/af08cf9930afa504011b73c4470788f63a2d1500f413c4d88e12d9f07194/lmdb-1.7.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cef75c5a21cb6fa53e766396ab71b0d10efbedd61e80018666cd26ee099f1c13", size = 301179, upload-time = "2025-10-15T03:39:16.995Z" }, - { url = "https://files.pythonhosted.org/packages/28/5a/ff0cb35519e991dd1737e45d50e16e356b49c4c6d5de3f8915644f9f667d/lmdb-1.7.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f201190ce528bf6e6ce564dae56bc235358df83099a55a9ec3f5f9c2e97d50d6", size = 303089, upload-time = "2025-10-15T03:39:18.369Z" }, - { url = "https://files.pythonhosted.org/packages/c6/49/a9bd905b4aaf71e251175f66d84235a777b349ef6e7c74d0d9f1eb8cd8ba/lmdb-1.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:4121908b2a635aac71c9ca80a45233829223d07d0265801f629c3bd275d1614c", size = 101159, upload-time = "2025-10-15T03:39:19.516Z" }, - { url = "https://files.pythonhosted.org/packages/06/a9/1d26d67c78f154d954b8af49045b5cae587b5209b82c0fe49ce1a6f3f0db/lmdb-1.7.5-cp314-cp314-win_arm64.whl", hash = "sha256:8ee77e98ae968d29d254b0b609708aa03b1277ceb4d95711495faf9993e755a9", size = 96439, upload-time = "2025-10-15T03:39:20.502Z" }, - { url = "https://files.pythonhosted.org/packages/5b/41/0ab869d5fcfbc52a6eef3728a787d84a207c6b931cfa954c07495e7928e3/lmdb-1.7.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:db041344512fa8b7c78dcb97fd5d01ffd280bf408a400db6934688ff4216aed5", size = 102816, upload-time = "2025-10-15T03:39:21.788Z" }, - { url = "https://files.pythonhosted.org/packages/9b/0f/11ab447366d55f2d5ae7f70e8cbb1b84501fdea455a5dd1c382abaa03852/lmdb-1.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:484b3ff676a78f7a33aaeca35e273634a093da282e0eb0a9e9c4f69cfe28d702", size = 101136, upload-time = "2025-10-15T03:39:23.078Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8d/b02f5d7b6ea08dfa847bb27c839d98e248ed5bb7f6211731dece78526ee9/lmdb-1.7.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a73e8b7a748c0bbfeecf7d9be19da1d207736f7d613102c364512674390e6189", size = 321282, upload-time = "2025-10-15T03:39:24.085Z" }, - { url = "https://files.pythonhosted.org/packages/ab/55/31f2b31ab67f5af46121f2fbb123f97bdf01677af30320e3740a751d0961/lmdb-1.7.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0beb884f9af290efade5043899fc60a0b2b7c64fd1c2fde663cf584f7953fd7", size = 320360, upload-time = "2025-10-15T03:39:25.231Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b5/8e762c972817669967146425fcd34bedaa169badda8ae7a1fbf630ef9ec5/lmdb-1.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8770d57233853eaa6ccc16b0ff885f7b7af0c2618a5f0cc3e90370b78a4399a", size = 100947, upload-time = "2025-10-15T03:39:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c0/dc66cd1981de260ce14815a6821e33caf9003c206f43d50ae09052edb311/lmdb-1.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:742ed8fba936a10d13c72e5b168736c3d51656bd7b054d931daea17bcfcd7b41", size = 96895, upload-time = "2025-10-15T03:39:27.685Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2c/982cb5afed533d0cb8038232b40c19b5b85a2d887dec74dfd39e8351ef4b/lmdb-1.7.5-py3-none-any.whl", hash = "sha256:fc344bb8bc0786c87c4ccb19b31f09a38c08bd159ada6f037d669426fea06f03", size = 148539, upload-time = "2025-10-15T03:39:42.982Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.10.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, - { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051, upload-time = "2025-10-09T00:26:25.041Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878, upload-time = "2025-10-09T00:26:27.478Z" }, - { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142, upload-time = "2025-10-09T00:26:29.774Z" }, - { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439, upload-time = "2025-10-09T00:26:40.32Z" }, - { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389, upload-time = "2025-10-09T00:26:42.474Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247, upload-time = "2025-10-09T00:26:44.77Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996, upload-time = "2025-10-09T00:26:46.792Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153, upload-time = "2025-10-09T00:26:49.07Z" }, - { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093, upload-time = "2025-10-09T00:26:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771, upload-time = "2025-10-09T00:26:53.296Z" }, - { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812, upload-time = "2025-10-09T00:26:54.882Z" }, - { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212, upload-time = "2025-10-09T00:26:56.752Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713, upload-time = "2025-10-09T00:26:59.001Z" }, - { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527, upload-time = "2025-10-09T00:27:00.69Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690, upload-time = "2025-10-09T00:27:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732, upload-time = "2025-10-09T00:27:04.653Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727, upload-time = "2025-10-09T00:27:06.814Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958, upload-time = "2025-10-09T00:27:08.567Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849, upload-time = "2025-10-09T00:27:10.254Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225, upload-time = "2025-10-09T00:27:12.241Z" }, - { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708, upload-time = "2025-10-09T00:27:13.879Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409, upload-time = "2025-10-09T00:27:15.684Z" }, - { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054, upload-time = "2025-10-09T00:27:17.547Z" }, - { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100, upload-time = "2025-10-09T00:27:20.039Z" }, - { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131, upload-time = "2025-10-09T00:27:21.608Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787, upload-time = "2025-10-09T00:27:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348, upload-time = "2025-10-09T00:27:24.926Z" }, - { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949, upload-time = "2025-10-09T00:27:26.704Z" }, - { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247, upload-time = "2025-10-09T00:27:28.514Z" }, - { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497, upload-time = "2025-10-09T00:27:30.418Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732, upload-time = "2025-10-09T00:27:32.332Z" }, - { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240, upload-time = "2025-10-09T00:27:33.94Z" }, - { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938, upload-time = "2025-10-09T00:27:35.534Z" }, - { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245, upload-time = "2025-10-09T00:27:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411, upload-time = "2025-10-09T00:27:39.387Z" }, - { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664, upload-time = "2025-10-09T00:27:41.492Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" }, - { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" }, - { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, -] - -[[package]] -name = "matplotlib-inline" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, -] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - -[[package]] -name = "numpy" -version = "2.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324, upload-time = "2025-11-16T22:49:22.582Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872, upload-time = "2025-11-16T22:49:25.408Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282, upload-time = "2025-11-16T22:49:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903, upload-time = "2025-11-16T22:49:34.191Z" }, - { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672, upload-time = "2025-11-16T22:49:37.2Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896, upload-time = "2025-11-16T22:49:39.727Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608, upload-time = "2025-11-16T22:49:42.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442, upload-time = "2025-11-16T22:49:43.99Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555, upload-time = "2025-11-16T22:49:47.092Z" }, - { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" }, - { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, - { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" }, - { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" }, - { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" }, - { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" }, - { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" }, - { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" }, - { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" }, - { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" }, - { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" }, - { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" }, - { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" }, - { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" }, - { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" }, - { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" }, - { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" }, - { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" }, - { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" }, - { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" }, - { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" }, - { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" }, - { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053, upload-time = "2025-11-16T22:52:26.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635, upload-time = "2025-11-16T22:52:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768, upload-time = "2025-11-16T22:52:33.593Z" }, - { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263, upload-time = "2025-11-16T22:52:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" }, -] - -[[package]] -name = "nvidia-cublas-cu12" -version = "12.6.4.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload-time = "2024-11-20T17:40:25.65Z" }, -] - -[[package]] -name = "nvidia-cuda-cupti-cu12" -version = "12.6.80" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/60/7b6497946d74bcf1de852a21824d63baad12cd417db4195fc1bfe59db953/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6768bad6cab4f19e8292125e5f1ac8aa7d1718704012a0e3272a6f61c4bce132", size = 8917980, upload-time = "2024-11-20T17:36:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/120ee57b218d9952c379d1e026c4479c9ece9997a4fb46303611ee48f038/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a3eff6cdfcc6a4c35db968a06fcadb061cbc7d6dde548609a941ff8701b98b73", size = 8917972, upload-time = "2024-10-01T16:58:06.036Z" }, -] - -[[package]] -name = "nvidia-cuda-nvrtc-cu12" -version = "12.6.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/2e/46030320b5a80661e88039f59060d1790298b4718944a65a7f2aeda3d9e9/nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53", size = 23650380, upload-time = "2024-10-01T17:00:14.643Z" }, -] - -[[package]] -name = "nvidia-cuda-runtime-cu12" -version = "12.6.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/23/e717c5ac26d26cf39a27fbc076240fad2e3b817e5889d671b67f4f9f49c5/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba3b56a4f896141e25e19ab287cd71e52a6a0f4b29d0d31609f60e3b4d5219b7", size = 897690, upload-time = "2024-11-20T17:35:30.697Z" }, - { url = "https://files.pythonhosted.org/packages/f0/62/65c05e161eeddbafeca24dc461f47de550d9fa8a7e04eb213e32b55cfd99/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a84d15d5e1da416dd4774cb42edf5e954a3e60cc945698dc1d5be02321c44dc8", size = 897678, upload-time = "2024-10-01T16:57:33.821Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu12" -version = "9.5.1.17" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/78/4535c9c7f859a64781e43c969a3a7e84c54634e319a996d43ef32ce46f83/nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2", size = 570988386, upload-time = "2024-10-25T19:54:26.39Z" }, -] - -[[package]] -name = "nvidia-cufft-cu12" -version = "11.3.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/16/73727675941ab8e6ffd86ca3a4b7b47065edcca7a997920b831f8147c99d/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ccba62eb9cef5559abd5e0d54ceed2d9934030f51163df018532142a8ec533e5", size = 200221632, upload-time = "2024-11-20T17:41:32.357Z" }, - { url = "https://files.pythonhosted.org/packages/60/de/99ec247a07ea40c969d904fc14f3a356b3e2a704121675b75c366b694ee1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.whl", hash = "sha256:768160ac89f6f7b459bee747e8d175dbf53619cfe74b2a5636264163138013ca", size = 200221622, upload-time = "2024-10-01T17:03:58.79Z" }, -] - -[[package]] -name = "nvidia-cufile-cu12" -version = "1.11.1.6" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/66/cc9876340ac68ae71b15c743ddb13f8b30d5244af344ec8322b449e35426/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159", size = 1142103, upload-time = "2024-11-20T17:42:11.83Z" }, -] - -[[package]] -name = "nvidia-curand-cu12" -version = "10.3.7.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/1b/44a01c4e70933637c93e6e1a8063d1e998b50213a6b65ac5a9169c47e98e/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf", size = 56279010, upload-time = "2024-11-20T17:42:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/4a/aa/2c7ff0b5ee02eaef890c0ce7d4f74bc30901871c5e45dee1ae6d0083cd80/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:99f1a32f1ac2bd134897fc7a203f779303261268a65762a623bf30cc9fe79117", size = 56279000, upload-time = "2024-10-01T17:04:45.274Z" }, -] - -[[package]] -name = "nvidia-cusolver-cu12" -version = "11.7.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/6e/c2cf12c9ff8b872e92b4a5740701e51ff17689c4d726fca91875b07f655d/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c", size = 158229790, upload-time = "2024-11-20T17:43:43.211Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/baba53585da791d043c10084cf9553e074548408e04ae884cfe9193bd484/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6cf28f17f64107a0c4d7802be5ff5537b2130bfc112f25d5a30df227058ca0e6", size = 158229780, upload-time = "2024-10-01T17:05:39.875Z" }, -] - -[[package]] -name = "nvidia-cusparse-cu12" -version = "12.5.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/1e/b8b7c2f4099a37b96af5c9bb158632ea9e5d9d27d7391d7eb8fc45236674/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7556d9eca156e18184b94947ade0fba5bb47d69cec46bf8660fd2c71a4b48b73", size = 216561367, upload-time = "2024-11-20T17:44:54.824Z" }, - { url = "https://files.pythonhosted.org/packages/43/ac/64c4316ba163e8217a99680c7605f779accffc6a4bcd0c778c12948d3707/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:23749a6571191a215cb74d1cdbff4a86e7b19f1200c071b3fcf844a5bea23a2f", size = 216561357, upload-time = "2024-10-01T17:06:29.861Z" }, -] - -[[package]] -name = "nvidia-cusparselt-cu12" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/9a/72ef35b399b0e183bc2e8f6f558036922d453c4d8237dab26c666a04244b/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46", size = 156785796, upload-time = "2024-10-15T21:29:17.709Z" }, -] - -[[package]] -name = "nvidia-nccl-cu12" -version = "2.26.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/ca/f42388aed0fddd64ade7493dbba36e1f534d4e6fdbdd355c6a90030ae028/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6", size = 201319755, upload-time = "2025-03-13T00:29:55.296Z" }, -] - -[[package]] -name = "nvidia-nvjitlink-cu12" -version = "12.6.85" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/d7/c5383e47c7e9bf1c99d5bd2a8c935af2b6d705ad831a7ec5c97db4d82f4f/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a", size = 19744971, upload-time = "2024-11-20T17:46:53.366Z" }, -] - -[[package]] -name = "nvidia-nvtx-cu12" -version = "12.6.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/9a/fff8376f8e3d084cd1530e1ef7b879bb7d6d265620c95c1b322725c694f4/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b90bed3df379fa79afbd21be8e04a0314336b8ae16768b58f2d34cb1d04cd7d2", size = 89276, upload-time = "2024-11-20T17:38:27.621Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/0d0c945463719429b7bd21dece907ad0bde437a2ff12b9b12fee94722ab0/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1", size = 89265, upload-time = "2024-10-01T17:00:38.172Z" }, -] - -[[package]] -name = "openbabel-wheel" -version = "3.1.1.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/55/c916675c49beb64256bd108f4a797111cedca592c9be0c931014b8654806/openbabel-wheel-3.1.1.22.tar.gz", hash = "sha256:d12e07f8e2b2a8a9007b486087fdeb731cedb5b14129bf93ad3d2c5a72e54d08", size = 13246, upload-time = "2025-05-20T14:27:41.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/de/be239b210500c0332007e18dd0a8ca4939d13f05433d453e3a0983defa70/openbabel_wheel-3.1.1.22-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:2499af11947d3454439182a75516bc4008fe0ff22a24f16b3b964317004d5e86", size = 11514597, upload-time = "2025-05-20T14:26:20.174Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cb/167d9bace82f82fa9231e562e723d489f5c07e789290e668b552874c69f8/openbabel_wheel-3.1.1.22-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:4beda0224caa906d1c2ed35652786da244757aaea361cdc0aef13e935c0149a7", size = 12381697, upload-time = "2025-05-20T14:26:22.251Z" }, - { url = "https://files.pythonhosted.org/packages/40/61/3f6f6e78c45d67453628596dff1640e8d56d58e6317ddace5c3be3d550f4/openbabel_wheel-3.1.1.22-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b33c21c987b3a22f7a4c812b8283edbe5ad6c6deae29e9568f1b16a8f5ad20b", size = 15633459, upload-time = "2025-05-20T14:26:24.554Z" }, - { url = "https://files.pythonhosted.org/packages/28/59/f7e7424da6a6ea81f987fac2093e440b8689f4e27929f92167f2a81fc01d/openbabel_wheel-3.1.1.22-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2ed691f3f5ac2cfebfaa0d9718acd58e41fcbc0e114735f518c92167f7c5e2f", size = 16076298, upload-time = "2025-05-20T14:26:26.823Z" }, - { url = "https://files.pythonhosted.org/packages/d5/27/c219228b4eb582ab11ac0ae2a8acd895e880a4f427e9bdf17d0e41783d91/openbabel_wheel-3.1.1.22-cp311-cp311-win_amd64.whl", hash = "sha256:1303272ecdcfcdfbe7648f2761aa6639c5a4ec3073b472f766d3d2567a44b523", size = 5095182, upload-time = "2025-05-20T14:26:29.03Z" }, - { url = "https://files.pythonhosted.org/packages/0e/91/df492446daefef9ba3c97e1555b1a7abb4ab9a21f00744689fec8e10d53e/openbabel_wheel-3.1.1.22-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:48846ac5c8dff5589fd7834fbc1ee0d3a5d67ed544ef81b716db6932a27c34c2", size = 11517993, upload-time = "2025-05-20T14:26:30.741Z" }, - { url = "https://files.pythonhosted.org/packages/64/35/b5ccaa112d253fb9089db9c5390d066cab08b5401cd2ec59709937f0a746/openbabel_wheel-3.1.1.22-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9189315a466da529e6422fa588163be230f0fcfb8d79e240fd9d8330fd2af8a6", size = 12390413, upload-time = "2025-05-20T14:26:33.304Z" }, - { url = "https://files.pythonhosted.org/packages/bc/ce/e9a9553a10ec6f0a3f1a3112cd00619bb77651779b59507ce1be4dcef2a1/openbabel_wheel-3.1.1.22-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17588f6823a1a2bc361fbdfec167e8d30d4764176b0b63a3e3127efae71aed8f", size = 15632153, upload-time = "2025-05-20T14:26:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/bc/79/a713470f17697b5ebee488929a88b2daaa3b20eb5be502d6c5cab9dbc4e2/openbabel_wheel-3.1.1.22-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc3d01459489334e9acf68526db48be91ce88692c59192a0d0fb9f422f2bc9a", size = 16075937, upload-time = "2025-05-20T14:26:39.362Z" }, - { url = "https://files.pythonhosted.org/packages/21/f3/12a0e194e735cb0f3fc6122cfd39d63f05ab2426ee4083b7de1bc4ea1242/openbabel_wheel-3.1.1.22-cp312-cp312-win_amd64.whl", hash = "sha256:e369179c9edfd35b79faa252be0b28b7b8932aee80acfced1922595b67594dbd", size = 5095492, upload-time = "2025-05-20T14:26:46.743Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/33947aadfba41478e04776d613c2f677bfe263e69653d406f13147b49373/openbabel_wheel-3.1.1.22-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:1d442a81e2b39c9b1d24a3bea010ef779542056160b8a6d9e7184d72a34152d4", size = 11518042, upload-time = "2025-05-20T14:26:48.543Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3c/287dd7f2233f83244314ed39968de0593140df052a350508318ce7caa13e/openbabel_wheel-3.1.1.22-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:537fa4e3a48e092eea3aeb3f5eb064330d38ac6e2d1db293b22d9832160c0f21", size = 12390390, upload-time = "2025-05-20T14:26:50.756Z" }, - { url = "https://files.pythonhosted.org/packages/44/3f/c72e325490393b463e5ae7446253aab140b5e5e407d97c7de064f341975e/openbabel_wheel-3.1.1.22-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21d1ea864f987f93033720c3847f653f60e502cb07cbb8bcb4965da11a4976f5", size = 15632171, upload-time = "2025-05-20T14:26:52.971Z" }, - { url = "https://files.pythonhosted.org/packages/13/ea/5f81cd018bf60301fcca9e133d3a47b8caca14576266dcdc9b38f96c7879/openbabel_wheel-3.1.1.22-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9986f9f5c64f69b27c416fd38f551bb00da037fe66f69dc6b0d2ef10dfc9f40", size = 16075915, upload-time = "2025-05-20T14:26:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/80/1d/8e88918fe6047d8eee9a53aa9b153bf9e098fc871cfd5c86a2a5b16e38a1/openbabel_wheel-3.1.1.22-cp313-cp313-win_amd64.whl", hash = "sha256:e98ccbc75c3fcfa225554e6def913e949aff1c6ac9bad178e47bc313ce16d596", size = 5094724, upload-time = "2025-05-20T14:26:57.818Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7b/bdb2b375120e762681f2607b402c16f0d61cb7e96904f6237b9c17cf5000/openbabel_wheel-3.1.1.22-cp313-cp313t-macosx_13_0_arm64.whl", hash = "sha256:d74da8ad712f1313dab74b5cddd40895581324f16d3a9116e8f193c926df3242", size = 11534313, upload-time = "2025-05-20T14:26:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/e8dbf08e562055ac4c7f9bf7afab4cd812eeae31a56fe17344388009873f/openbabel_wheel-3.1.1.22-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:f2666af5edf3085b8b2daa64597842efa74e817c94d20f0bb9a065123b13de0d", size = 12414740, upload-time = "2025-05-20T14:27:02.082Z" }, - { url = "https://files.pythonhosted.org/packages/08/68/d8c305508547a25800c07913e13ed99f91d840206b562393210c9d488e96/openbabel_wheel-3.1.1.22-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:336a8aa2f7a7fa6056a63c1d40d5c0e1e0d9bacae2c3d377468e9fab34e247a5", size = 15633699, upload-time = "2025-05-20T14:27:04.768Z" }, - { url = "https://files.pythonhosted.org/packages/4c/8b/8f4c902400ec1897afad8b27d7abf6b06ee0c00cfb73337016091a6d221b/openbabel_wheel-3.1.1.22-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23fada177808451a7d22f39f9e2e198c889d118ad4861b34d8fdbc1e987ad8b4", size = 16058611, upload-time = "2025-05-20T14:27:07.496Z" }, - { url = "https://files.pythonhosted.org/packages/de/35/3c9a03a69211d71e8a1be693a498553a9bc70f0c8eebd7cbbe7d5ae5c0f3/openbabel_wheel-3.1.1.22-cp313-cp313t-win_amd64.whl", hash = "sha256:28863c6b7e169f26d6d2d58c46ec6e51492f7a82712e22a2262b6255c0935417", size = 5183064, upload-time = "2025-05-20T14:27:10.089Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, -] - -[[package]] -name = "parso" -version = "0.8.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, -] - -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - -[[package]] -name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "psutil" -version = "7.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, - { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, - { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, - { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, - { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, -] - -[[package]] -name = "psycopg2-binary" -version = "2.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, - { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, - { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, - { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, - { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, - { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, - { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, - { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, - { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, - { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, - { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, - { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, - { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - -[[package]] -name = "py-cpuinfo" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, -] - -[[package]] -name = "py3dmol" -version = "2.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/11/a123e22c6797d9cfe64eef3113dd7219bb9bc675c4d28246a56a90fd5c60/py3dmol-2.5.3.tar.gz", hash = "sha256:d837168aae65d27abd607b21100803e7b60d6f7c0ec9aa48cea5b3c1e85c437f", size = 7853, upload-time = "2025-09-28T14:20:28.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a6/e2e8535d8146bce05de6e0ecf1099a7e2887d840ae2a7b3a09385543fd02/py3dmol-2.5.3-py2.py3-none-any.whl", hash = "sha256:5c1c9ee40bda82b91978e75f3c144be5b90cdf472e765bcef4890db76cc8f843", size = 7156, upload-time = "2025-09-28T14:20:26.941Z" }, -] - -[[package]] -name = "pyarrow" -version = "17.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/4e/ea6d43f324169f8aec0e57569443a38bab4b398d09769ca64f7b4d467de3/pyarrow-17.0.0.tar.gz", hash = "sha256:4beca9521ed2c0921c1023e68d097d0299b62c362639ea315572a58f3f50fd28", size = 1112479, upload-time = "2024-07-17T10:41:25.092Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/46/ce89f87c2936f5bb9d879473b9663ce7a4b1f4359acc2f0eb39865eaa1af/pyarrow-17.0.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:1c8856e2ef09eb87ecf937104aacfa0708f22dfeb039c363ec99735190ffb977", size = 29028748, upload-time = "2024-07-16T10:30:02.609Z" }, - { url = "https://files.pythonhosted.org/packages/8d/8e/ce2e9b2146de422f6638333c01903140e9ada244a2a477918a368306c64c/pyarrow-17.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e19f569567efcbbd42084e87f948778eb371d308e137a0f97afe19bb860ccb3", size = 27190965, upload-time = "2024-07-16T10:30:10.718Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c8/5675719570eb1acd809481c6d64e2136ffb340bc387f4ca62dce79516cea/pyarrow-17.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b244dc8e08a23b3e352899a006a26ae7b4d0da7bb636872fa8f5884e70acf15", size = 39269081, upload-time = "2024-07-16T10:30:18.878Z" }, - { url = "https://files.pythonhosted.org/packages/5e/78/3931194f16ab681ebb87ad252e7b8d2c8b23dad49706cadc865dff4a1dd3/pyarrow-17.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b72e87fe3e1db343995562f7fff8aee354b55ee83d13afba65400c178ab2597", size = 39864921, upload-time = "2024-07-16T10:30:27.008Z" }, - { url = "https://files.pythonhosted.org/packages/d8/81/69b6606093363f55a2a574c018901c40952d4e902e670656d18213c71ad7/pyarrow-17.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dc5c31c37409dfbc5d014047817cb4ccd8c1ea25d19576acf1a001fe07f5b420", size = 38740798, upload-time = "2024-07-16T10:30:34.814Z" }, - { url = "https://files.pythonhosted.org/packages/4c/21/9ca93b84b92ef927814cb7ba37f0774a484c849d58f0b692b16af8eebcfb/pyarrow-17.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e3343cb1e88bc2ea605986d4b94948716edc7a8d14afd4e2c097232f729758b4", size = 39871877, upload-time = "2024-07-16T10:30:42.672Z" }, - { url = "https://files.pythonhosted.org/packages/30/d1/63a7c248432c71c7d3ee803e706590a0b81ce1a8d2b2ae49677774b813bb/pyarrow-17.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:a27532c38f3de9eb3e90ecab63dfda948a8ca859a66e3a47f5f42d1e403c4d03", size = 25151089, upload-time = "2024-07-16T10:30:49.279Z" }, - { url = "https://files.pythonhosted.org/packages/d4/62/ce6ac1275a432b4a27c55fe96c58147f111d8ba1ad800a112d31859fae2f/pyarrow-17.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9b8a823cea605221e61f34859dcc03207e52e409ccf6354634143e23af7c8d22", size = 29019418, upload-time = "2024-07-16T10:30:55.573Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0a/dbd0c134e7a0c30bea439675cc120012337202e5fac7163ba839aa3691d2/pyarrow-17.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1e70de6cb5790a50b01d2b686d54aaf73da01266850b05e3af2a1bc89e16053", size = 27152197, upload-time = "2024-07-16T10:31:02.036Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/3f4a16498349db79090767620d6dc23c1ec0c658a668d61d76b87706c65d/pyarrow-17.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0071ce35788c6f9077ff9ecba4858108eebe2ea5a3f7cf2cf55ebc1dbc6ee24a", size = 39263026, upload-time = "2024-07-16T10:31:10.351Z" }, - { url = "https://files.pythonhosted.org/packages/c2/0c/ea2107236740be8fa0e0d4a293a095c9f43546a2465bb7df34eee9126b09/pyarrow-17.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:757074882f844411fcca735e39aae74248a1531367a7c80799b4266390ae51cc", size = 39880798, upload-time = "2024-07-16T10:31:17.66Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/b9164a8bc495083c10c281cc65064553ec87b7537d6f742a89d5953a2a3e/pyarrow-17.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ba11c4f16976e89146781a83833df7f82077cdab7dc6232c897789343f7891a", size = 38715172, upload-time = "2024-07-16T10:31:25.965Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c4/9625418a1413005e486c006e56675334929fad864347c5ae7c1b2e7fe639/pyarrow-17.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b0c6ac301093b42d34410b187bba560b17c0330f64907bfa4f7f7f2444b0cf9b", size = 39874508, upload-time = "2024-07-16T10:31:33.721Z" }, - { url = "https://files.pythonhosted.org/packages/ae/49/baafe2a964f663413be3bd1cf5c45ed98c5e42e804e2328e18f4570027c1/pyarrow-17.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:392bc9feabc647338e6c89267635e111d71edad5fcffba204425a7c8d13610d7", size = 25099235, upload-time = "2024-07-16T10:31:40.893Z" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - -[[package]] -name = "pydata-sphinx-theme" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "accessible-pygments" }, - { name = "babel" }, - { name = "beautifulsoup4" }, - { name = "docutils" }, - { name = "pygments" }, - { name = "sphinx" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pymol-remote" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/0e/266eb8409baea23f66f7d6b9ed4d139697009049eaec0c3720c60ad77326/pymol_remote-1.1.0.tar.gz", hash = "sha256:c5177133adf7bcc665077eb21ba0db0e2aa4333772fc1e4e203559365f8f8713", size = 766417, upload-time = "2025-02-16T11:43:24.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/16/9a5bb7ef3bcc5b5a0e708cec0bd08069b87223e77c429b6c0b294b3bae49/pymol_remote-1.1.0-py3-none-any.whl", hash = "sha256:32170b896bcf21e6c7d4716ff1f98d358bc80376c9e175aa5e8b86ba245813fc", size = 19416, upload-time = "2025-02-16T11:43:22.792Z" }, -] - -[[package]] -name = "pymysql" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.2.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest-benchmark" -version = "5.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "py-cpuinfo" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, -] - -[[package]] -name = "pytest-cov" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245, upload-time = "2023-05-24T18:44:56.845Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949, upload-time = "2023-05-24T18:44:54.079Z" }, -] - -[[package]] -name = "pytest-dotenv" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, -] - -[[package]] -name = "pytest-testmon" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/1d/3e4230cc67cd6205bbe03c3527500c0ccaf7f0c78b436537eac71590ee4a/pytest_testmon-2.2.0.tar.gz", hash = "sha256:01f488e955ed0e0049777bee598bf1f647dd524e06f544c31a24e68f8d775a51", size = 23108, upload-time = "2025-12-01T07:30:24.76Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/55/ebb3c2f59fb089f08d00f764830d35780fc4e4c41dffcadafa3264682b65/pytest_testmon-2.2.0-py3-none-any.whl", hash = "sha256:2604ca44a54d61a2e830d9ce828b41a837075e4ebc1f81b148add8e90d34815b", size = 25199, upload-time = "2025-12-01T07:30:23.623Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - -[[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, - { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, - { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, - { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, - { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, - { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, -] - -[[package]] -name = "rdkit" -version = "2025.3.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/de/4a9ecf9acdee20deed93ef3bdadc9b0cbacd09d5cb23a8620d0f8411bd31/rdkit-2025.3.6-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:3ac57fcddd17d6707139fb85782d11c2ac674b7f18d06750f5014dc43ece34e4", size = 31642866, upload-time = "2025-09-05T12:46:19.242Z" }, - { url = "https://files.pythonhosted.org/packages/db/88/b6f915ebdffbfdfa140cb4d515bbfe0cb08008e2ce94aa5c44414b68234a/rdkit-2025.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:682f74525260b177b7dd84ea235b3abc4e5db72e5c62c664c896c7c150299e2a", size = 29089754, upload-time = "2025-09-05T12:46:22.834Z" }, - { url = "https://files.pythonhosted.org/packages/f5/70/96953e5d85b981e5dfc14075a8baa41df91ade2b9b005b133a959f178925/rdkit-2025.3.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d3ad26d64d10dd3cf001c3dcf0b7e1e2eb7221334b1d0f6f3fbd5d5408c22d74", size = 34707581, upload-time = "2025-09-05T12:46:27.075Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/2622e01e38736a68d84b34c7b955e7ce32f0d19901e79620b80b1217300e/rdkit-2025.3.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:afc931d1be1a4ad2fc4f2f2732c10983b1a57fbde9c22870721beafe42be3ab0", size = 36180899, upload-time = "2025-09-05T12:46:31.335Z" }, - { url = "https://files.pythonhosted.org/packages/a2/45/8d07d92475bf4598f5e948c858d3e9f15a67df2ae04e4627013b9d86edc3/rdkit-2025.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:4339531a3c149087f6e39de75b7c71ac75c87e5eabb1b34c27cd0bef98a72814", size = 23506723, upload-time = "2025-09-05T12:46:35.119Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2f/23ec58616edd1bf467c6b7cc96dca017e9f4f9c9bb32dfa3b35637e6a745/rdkit-2025.3.6-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9c433eeba711144304b8febd8de4ec653f9fb2520923e9f55b3a617e953870af", size = 31719603, upload-time = "2025-09-05T12:46:39.746Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/bb352e512f20052d55fd0ec29483f122ef17228dacc103148d2eb8fb0938/rdkit-2025.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9df1b9b336f1e36f439e25dd816ec66bf78f979d473998514d92980ce2ed58a6", size = 29132323, upload-time = "2025-09-05T12:46:43.856Z" }, - { url = "https://files.pythonhosted.org/packages/6d/7e/0868837a10a4bb2c0ecbab30b29b79aedce93d61c3c14949123f8e527bad/rdkit-2025.3.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:24c5a3b5c367c174d169e0271576450aa2b83c2df95a1ccaed4662efe13b11bf", size = 34595105, upload-time = "2025-09-05T12:46:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/47/06/84f140ed025dd96c7ec36aa38555b466f8006bd36085abd3702d75c98160/rdkit-2025.3.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ed5565d4d6423def7c52eb20a6263afcd979383757086dec92f57e77d7c7f856", size = 36119142, upload-time = "2025-09-05T12:46:52.003Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/2d34338adca7f7932fcc83f6e9139c8ddc313b166ae18033ab7c996be836/rdkit-2025.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:632f89345c8e84306a21473fdaad3a58dbd5e6c0b128bef33dfb6090af326061", size = 23526290, upload-time = "2025-09-05T12:46:55.72Z" }, - { url = "https://files.pythonhosted.org/packages/66/c3/53ddc0f88dce2a0b5ac938d97326da25b4e9f6b6858ffc6797a95d824ddd/rdkit-2025.3.6-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:6439943172350151cd45e19b41acdf206397736e686a7717df91b07ee91fc174", size = 31718554, upload-time = "2025-09-05T12:46:59.379Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/1fb7644ff06817a9e3b67d70da53dea59b64c38badc49426e8338e36fbdd/rdkit-2025.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:760dc4d90cc30a96145062811136cd5d309a91a7eeacec0afcf0b079877f8be2", size = 29131323, upload-time = "2025-09-05T12:47:03.078Z" }, - { url = "https://files.pythonhosted.org/packages/e7/7d/2e65c55eb6da56c000f9a7b0b2cec6b4aa2a17ec70ef58879f59c036ca43/rdkit-2025.3.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:33de7c4c2c49d3ca9b44331b0d6591f4f3957605bd9271b845d358ffc9e1bc4b", size = 34593714, upload-time = "2025-09-05T12:47:06.832Z" }, - { url = "https://files.pythonhosted.org/packages/c1/82/b2330569fbff20c5f5495f69ae28d3e61dfbc055f00b7fe977905a574089/rdkit-2025.3.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8bb52f5fc9f1a93817264dffc0ac8b20cea71091f9b8a253ebd288aeae26032f", size = 36117945, upload-time = "2025-09-05T12:47:11.31Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ed/57104398c6efe7304668846cf92e38102cd9d3a9d249087893d68f595907/rdkit-2025.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:be1064d31630cbfbf2fc4b35cf9175a725378601bbd1b945047e9ba9c231fcc2", size = 23525366, upload-time = "2025-09-05T12:47:14.636Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d1/447534eca35ee67db94e3ec4a4e0504d6b0088d963ac5edd19d451f6f80c/rdkit-2025.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26c4a1cd658b9b7264f9b021366e6d26474646d9021033e1ec4b93e80d5215b6", size = 31720872, upload-time = "2025-09-05T12:47:18.259Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f7/b3e808851bd2961452ed672c694b38ec8c1fd3b30cb0f56b8ef722234f9e/rdkit-2025.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b2bbafc9034c058a539ba7f29647d2c45803019ca48ac0888024971082570b9", size = 29150282, upload-time = "2025-09-05T12:47:21.961Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bd/23aba3a54a3e79fafbf74d3b9dde7acda9ac82577b293949d825816ac9dc/rdkit-2025.3.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:cda846618360858a084bc096a9e1af2d6693f1577c0309f5760ae87c01d089bb", size = 34625860, upload-time = "2025-09-05T12:47:26.294Z" }, - { url = "https://files.pythonhosted.org/packages/88/76/8b2108aa2e4a202bd5e9e8f8c4719eb476be43123288ee0ffdc74f35261e/rdkit-2025.3.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7d10009d98a4c390cc14c5059a0c15f30f614f19b81ca665ff52a4fd8202bad9", size = 36125228, upload-time = "2025-09-05T12:47:30.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/1d/415fcfdc96c0453d02d8265684083bfac45425c52aeee64aadc330aa6f66/rdkit-2025.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:600d7fc3a2665baa9de7ec6870f33a53027f532f9a47e5df765ac91539c7e746", size = 24004467, upload-time = "2025-09-05T12:47:33.743Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, -] - -[[package]] -name = "roman-numerals-py" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, -] - -[[package]] -name = "ruff" -version = "0.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522, upload-time = "2024-12-12T15:17:56.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860, upload-time = "2024-12-12T15:16:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327, upload-time = "2024-12-12T15:17:02.88Z" }, - { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585, upload-time = "2024-12-12T15:17:05.629Z" }, - { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597, upload-time = "2024-12-12T15:17:08.657Z" }, - { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244, upload-time = "2024-12-12T15:17:11.603Z" }, - { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439, upload-time = "2024-12-12T15:17:14.605Z" }, - { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538, upload-time = "2024-12-12T15:17:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172, upload-time = "2024-12-12T15:17:22.919Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886, upload-time = "2024-12-12T15:17:26.693Z" }, - { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599, upload-time = "2024-12-12T15:17:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637, upload-time = "2024-12-12T15:17:34.31Z" }, - { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591, upload-time = "2024-12-12T15:17:37.518Z" }, - { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298, upload-time = "2024-12-12T15:17:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965, upload-time = "2024-12-12T15:17:45.971Z" }, - { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651, upload-time = "2024-12-12T15:17:48.588Z" }, - { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289, upload-time = "2024-12-12T15:17:51.265Z" }, - { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754, upload-time = "2024-12-12T15:17:53.954Z" }, -] - -[[package]] -name = "scipy" -version = "1.16.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload-time = "2025-10-28T17:31:47.104Z" }, - { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload-time = "2025-10-28T17:31:53.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload-time = "2025-10-28T17:31:57.361Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload-time = "2025-10-28T17:32:01.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload-time = "2025-10-28T17:32:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload-time = "2025-10-28T17:32:12.665Z" }, - { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload-time = "2025-10-28T17:32:17.961Z" }, - { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload-time = "2025-10-28T17:32:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload-time = "2025-10-28T17:32:29.921Z" }, - { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload-time = "2025-10-28T17:32:34.577Z" }, - { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload-time = "2025-10-28T17:32:40.285Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload-time = "2025-10-28T17:32:45.325Z" }, - { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload-time = "2025-10-28T17:32:49.277Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload-time = "2025-10-28T17:32:53.337Z" }, - { url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload-time = "2025-10-28T17:32:58.353Z" }, - { url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload-time = "2025-10-28T17:33:03.88Z" }, - { url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload-time = "2025-10-28T17:33:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload-time = "2025-10-28T17:33:15.809Z" }, - { url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload-time = "2025-10-28T17:33:21.388Z" }, - { url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload-time = "2025-10-28T17:33:25.975Z" }, - { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload-time = "2025-10-28T17:33:31.375Z" }, - { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload-time = "2025-10-28T17:33:36.516Z" }, - { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload-time = "2025-10-28T17:33:42.094Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload-time = "2025-10-28T17:33:48.483Z" }, - { url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload-time = "2025-10-28T17:33:56.495Z" }, - { url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload-time = "2025-10-28T17:34:04.722Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload-time = "2025-10-28T17:34:13.482Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload-time = "2025-10-28T17:34:22.384Z" }, - { url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload-time = "2025-10-28T17:35:51.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload-time = "2025-10-28T17:35:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload-time = "2025-10-28T17:34:31.451Z" }, - { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload-time = "2025-10-28T17:34:39.087Z" }, - { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload-time = "2025-10-28T17:34:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload-time = "2025-10-28T17:34:51.793Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload-time = "2025-10-28T17:34:59.8Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload-time = "2025-10-28T17:35:08.173Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload-time = "2025-10-28T17:35:16.7Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload-time = "2025-10-28T17:35:26.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload-time = "2025-10-28T17:35:35.304Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload-time = "2025-10-28T17:35:42.155Z" }, - { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload-time = "2025-10-28T17:36:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload-time = "2025-10-28T17:36:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload-time = "2025-10-28T17:36:22.966Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload-time = "2025-10-28T17:36:29.731Z" }, - { url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload-time = "2025-10-28T17:36:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload-time = "2025-10-28T17:36:46.547Z" }, - { url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload-time = "2025-10-28T17:36:55.161Z" }, - { url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload-time = "2025-10-28T17:37:04.1Z" }, - { url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload-time = "2025-10-28T17:38:20.005Z" }, - { url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload-time = "2025-10-28T17:38:24.966Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload-time = "2025-10-28T17:37:14.061Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload-time = "2025-10-28T17:37:21.709Z" }, - { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload-time = "2025-10-28T17:37:29.559Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload-time = "2025-10-28T17:37:36.306Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload-time = "2025-10-28T17:37:42.228Z" }, - { url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload-time = "2025-10-28T17:37:48.102Z" }, - { url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload-time = "2025-10-28T17:37:53.72Z" }, - { url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload-time = "2025-10-28T17:37:59.393Z" }, - { url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload-time = "2025-10-28T17:38:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload-time = "2025-10-28T17:38:11.34Z" }, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "snowballstemmer" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, -] - -[[package]] -name = "soupsieve" -version = "2.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, -] - -[[package]] -name = "sphinx" -version = "8.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, -] - -[[package]] -name = "sphinx-gallery" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pillow" }, - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/14/9238ac61932299b38c20c7c37dbfe60348c0348ea4d400f9ef25875b3bf7/sphinx_gallery-0.20.0.tar.gz", hash = "sha256:70281510c6183d812d3595957005ccf555c5a793f207410f6cd16a25bf08d735", size = 473502, upload-time = "2025-12-02T15:51:37.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/fd/818a53d4da56ef2da7b08f77bb3a825635941d1fcc6b6a490995dec1a81c/sphinx_gallery-0.20.0-py3-none-any.whl", hash = "sha256:188b7456e269649945825661b76cdbfbf0b70c2cfd5b75c9a11fe52519879e4d", size = 458655, upload-time = "2025-12-02T15:51:35.311Z" }, -] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, -] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, -] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, -] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, -] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, -] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, -] - -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "toolz" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, -] - -[[package]] -name = "torch" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.12'" }, - { name = "sympy" }, - { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/56/2eae3494e3d375533034a8e8cf0ba163363e996d85f0629441fa9d9843fe/torch-2.7.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:236f501f2e383f1cb861337bdf057712182f910f10aeaf509065d54d339e49b2", size = 99093039, upload-time = "2025-06-04T17:39:06.963Z" }, - { url = "https://files.pythonhosted.org/packages/e5/94/34b80bd172d0072c9979708ccd279c2da2f55c3ef318eceec276ab9544a4/torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:06eea61f859436622e78dd0cdd51dbc8f8c6d76917a9cf0555a333f9eac31ec1", size = 821174704, upload-time = "2025-06-04T17:37:03.799Z" }, - { url = "https://files.pythonhosted.org/packages/50/9e/acf04ff375b0b49a45511c55d188bcea5c942da2aaf293096676110086d1/torch-2.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:8273145a2e0a3c6f9fd2ac36762d6ee89c26d430e612b95a99885df083b04e52", size = 216095937, upload-time = "2025-06-04T17:39:24.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2b/d36d57c66ff031f93b4fa432e86802f84991477e522adcdffd314454326b/torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:aea4fc1bf433d12843eb2c6b2204861f43d8364597697074c8d38ae2507f8730", size = 68640034, upload-time = "2025-06-04T17:39:17.989Z" }, - { url = "https://files.pythonhosted.org/packages/87/93/fb505a5022a2e908d81fe9a5e0aa84c86c0d5f408173be71c6018836f34e/torch-2.7.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ea1e518df4c9de73af7e8a720770f3628e7f667280bce2be7a16292697e3fa", size = 98948276, upload-time = "2025-06-04T17:39:12.852Z" }, - { url = "https://files.pythonhosted.org/packages/56/7e/67c3fe2b8c33f40af06326a3d6ae7776b3e3a01daa8f71d125d78594d874/torch-2.7.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c33360cfc2edd976c2633b3b66c769bdcbbf0e0b6550606d188431c81e7dd1fc", size = 821025792, upload-time = "2025-06-04T17:34:58.747Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/a37495502bc7a23bf34f89584fa5a78e25bae7b8da513bc1b8f97afb7009/torch-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:d8bf6e1856ddd1807e79dc57e54d3335f2b62e6f316ed13ed3ecfe1fc1df3d8b", size = 216050349, upload-time = "2025-06-04T17:38:59.709Z" }, - { url = "https://files.pythonhosted.org/packages/3a/60/04b77281c730bb13460628e518c52721257814ac6c298acd25757f6a175c/torch-2.7.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:787687087412c4bd68d315e39bc1223f08aae1d16a9e9771d95eabbb04ae98fb", size = 68645146, upload-time = "2025-06-04T17:38:52.97Z" }, - { url = "https://files.pythonhosted.org/packages/66/81/e48c9edb655ee8eb8c2a6026abdb6f8d2146abd1f150979ede807bb75dcb/torch-2.7.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:03563603d931e70722dce0e11999d53aa80a375a3d78e6b39b9f6805ea0a8d28", size = 98946649, upload-time = "2025-06-04T17:38:43.031Z" }, - { url = "https://files.pythonhosted.org/packages/3a/24/efe2f520d75274fc06b695c616415a1e8a1021d87a13c68ff9dce733d088/torch-2.7.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d632f5417b6980f61404a125b999ca6ebd0b8b4bbdbb5fbbba44374ab619a412", size = 821033192, upload-time = "2025-06-04T17:38:09.146Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d9/9c24d230333ff4e9b6807274f6f8d52a864210b52ec794c5def7925f4495/torch-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:23660443e13995ee93e3d844786701ea4ca69f337027b05182f5ba053ce43b38", size = 216055668, upload-time = "2025-06-04T17:38:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/e086ee36ddcef9299f6e708d3b6c8487c1651787bb9ee2939eb2a7f74911/torch-2.7.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0da4f4dba9f65d0d203794e619fe7ca3247a55ffdcbd17ae8fb83c8b2dc9b585", size = 68925988, upload-time = "2025-06-04T17:38:29.273Z" }, - { url = "https://files.pythonhosted.org/packages/69/6a/67090dcfe1cf9048448b31555af6efb149f7afa0a310a366adbdada32105/torch-2.7.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e08d7e6f21a617fe38eeb46dd2213ded43f27c072e9165dc27300c9ef9570934", size = 99028857, upload-time = "2025-06-04T17:37:50.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/1c/48b988870823d1cc381f15ec4e70ed3d65e043f43f919329b0045ae83529/torch-2.7.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:30207f672328a42df4f2174b8f426f354b2baa0b7cca3a0adb3d6ab5daf00dc8", size = 821098066, upload-time = "2025-06-04T17:37:33.939Z" }, - { url = "https://files.pythonhosted.org/packages/7b/eb/10050d61c9d5140c5dc04a89ed3257ef1a6b93e49dd91b95363d757071e0/torch-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:79042feca1c634aaf6603fe6feea8c6b30dfa140a6bbc0b973e2260c7e79a22e", size = 216336310, upload-time = "2025-06-04T17:36:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/beb45cdf5c4fc3ebe282bf5eafc8dfd925ead7299b3c97491900fe5ed844/torch-2.7.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:988b0cbc4333618a1056d2ebad9eb10089637b659eb645434d0809d8d937b946", size = 68645708, upload-time = "2025-06-04T17:34:39.852Z" }, -] - -[[package]] -name = "tornado" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, -] - -[[package]] -name = "traitlets" -version = "5.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, -] - -[[package]] -name = "triton" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/2f/3e56ea7b58f80ff68899b1dbe810ff257c9d177d288c6b0f55bf2fe4eb50/triton-3.3.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b31e3aa26f8cb3cc5bf4e187bf737cbacf17311e1112b781d4a059353dfd731b", size = 155689937, upload-time = "2025-05-29T23:39:44.182Z" }, - { url = "https://files.pythonhosted.org/packages/24/5f/950fb373bf9c01ad4eb5a8cd5eaf32cdf9e238c02f9293557a2129b9c4ac/triton-3.3.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9999e83aba21e1a78c1f36f21bce621b77bcaa530277a50484a7cb4a822f6e43", size = 155669138, upload-time = "2025-05-29T23:39:51.771Z" }, - { url = "https://files.pythonhosted.org/packages/74/1f/dfb531f90a2d367d914adfee771babbd3f1a5b26c3f5fbc458dee21daa78/triton-3.3.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b89d846b5a4198317fec27a5d3a609ea96b6d557ff44b56c23176546023c4240", size = 155673035, upload-time = "2025-05-29T23:40:02.468Z" }, - { url = "https://files.pythonhosted.org/packages/28/71/bd20ffcb7a64c753dc2463489a61bf69d531f308e390ad06390268c4ea04/triton-3.3.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3198adb9d78b77818a5388bff89fa72ff36f9da0bc689db2f0a651a67ce6a42", size = 155735832, upload-time = "2025-05-29T23:40:10.522Z" }, -] - -[[package]] -name = "typer" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "tzdata" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/0f3a93cca1ac5e8287842ed4eebbd0f7a991315089b1a0b01c7788aa7b63/urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f", size = 432678, upload-time = "2025-12-08T15:25:26.773Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/56/190ceb8cb10511b730b564fb1e0293fa468363dbad26145c34928a60cb0c/urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b", size = 131138, upload-time = "2025-12-08T15:25:25.51Z" }, -] - -[[package]] -name = "wadler-lindig" -version = "0.1.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/67/cbae4bf7683a64755c2c1778c418fea96d00e34395bb91743f08bd951571/wadler_lindig-0.1.7.tar.gz", hash = "sha256:81d14d3fe77d441acf3ebd7f4aefac20c74128bf460e84b512806dccf7b2cd55", size = 15842, upload-time = "2025-06-18T07:00:42.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/96/04e7b441807b26b794da5b11e59ed7f83b2cf8af202bd7eba8ad2fa6046e/wadler_lindig-0.1.7-py3-none-any.whl", hash = "sha256:e3ec83835570fd0a9509f969162aeb9c65618f998b1f42918cfc8d45122fe953", size = 20516, upload-time = "2025-06-18T07:00:41.684Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.2.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, -] - -[[package]] -name = "zstandard" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, - { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, - { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, - { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, - { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, - { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, - { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, - { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, - { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, - { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, - { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, - { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, - { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, - { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, - { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, - { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, - { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, - { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, - { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, - { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, - { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, - { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, - { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, - { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, - { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, - { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, - { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, -]