foldenv 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foldenv/__init__.py +50 -0
- foldenv/analysis.py +123 -0
- foldenv/config.py +68 -0
- foldenv/constants.py +68 -0
- foldenv/contacts.py +170 -0
- foldenv/context.py +324 -0
- foldenv/decisions.yaml +82 -0
- foldenv/dssp.py +171 -0
- foldenv/embedding.py +289 -0
- foldenv/fetch.py +198 -0
- foldenv/persist.py +251 -0
- foldenv/plm.py +134 -0
- foldenv/tool.py +186 -0
- foldenv/tool_spec.json +122 -0
- foldenv/validation.py +134 -0
- foldenv-0.1.1.dist-info/METADATA +623 -0
- foldenv-0.1.1.dist-info/RECORD +21 -0
- foldenv-0.1.1.dist-info/WHEEL +5 -0
- foldenv-0.1.1.dist-info/licenses/LICENSE +439 -0
- foldenv-0.1.1.dist-info/licenses/NOTICE +31 -0
- foldenv-0.1.1.dist-info/top_level.txt +1 -0
foldenv/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""foldenv — `get_structural_context(uniprot_id, position)`.
|
|
2
|
+
|
|
3
|
+
A residue's physical neighborhood in the AlphaFold-predicted fold — RSA, secondary
|
|
4
|
+
structure, packing/contacts, a PLM embedding, and a pLDDT confidence flag — to help
|
|
5
|
+
downstream tools separate buried structural residues (spandrels) from functional surface
|
|
6
|
+
residues.
|
|
7
|
+
|
|
8
|
+
Decisions and their rationale live in `decisions.yaml`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
import os as _os
|
|
13
|
+
import sys as _sys
|
|
14
|
+
|
|
15
|
+
# PLM embedding on Apple MPS relies on CPU fallback for a few ops the Metal backend does
|
|
16
|
+
# not implement. This must be set before torch initializes its MPS backend, so we do it at
|
|
17
|
+
# import — but only on macOS (where MPS exists) and only via setdefault, so non-Mac hosts
|
|
18
|
+
# and callers who never touch torch see no global env mutation.
|
|
19
|
+
if _sys.platform == "darwin":
|
|
20
|
+
_os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
|
21
|
+
|
|
22
|
+
from . import analysis, config, tool, validation
|
|
23
|
+
from .context import (
|
|
24
|
+
clear_cache,
|
|
25
|
+
get_contacts,
|
|
26
|
+
get_dssp,
|
|
27
|
+
get_sequence,
|
|
28
|
+
get_structural_context,
|
|
29
|
+
get_structure,
|
|
30
|
+
structural_profile,
|
|
31
|
+
)
|
|
32
|
+
from .fetch import NoAlphaFoldModelError, StructureRecord, fetch_structure
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"__version__",
|
|
36
|
+
"config",
|
|
37
|
+
"validation",
|
|
38
|
+
"analysis",
|
|
39
|
+
"tool",
|
|
40
|
+
"get_structural_context",
|
|
41
|
+
"get_structure",
|
|
42
|
+
"get_dssp",
|
|
43
|
+
"get_contacts",
|
|
44
|
+
"get_sequence",
|
|
45
|
+
"structural_profile",
|
|
46
|
+
"clear_cache",
|
|
47
|
+
"fetch_structure",
|
|
48
|
+
"StructureRecord",
|
|
49
|
+
"NoAlphaFoldModelError",
|
|
50
|
+
]
|
foldenv/analysis.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Biological analysis: do RSA + contacts separate functional from structural?
|
|
2
|
+
|
|
3
|
+
For a protein and a set of known functional residues, report each site's burial (RSA) and
|
|
4
|
+
packing (contact_count) together with its **percentile rank** within the protein, plus a
|
|
5
|
+
descriptive verdict. This is deliberately descriptive, not a benchmark — curated functional
|
|
6
|
+
sets are tiny (a handful of residues), so percentiles and medians are more honest than an AUC.
|
|
7
|
+
|
|
8
|
+
Key finding this exists to make explicit: enzyme catalytic
|
|
9
|
+
residues sit in **buried active-site clefts**, so they look structurally like buried-structural
|
|
10
|
+
"spandrels" on RSA/contacts alone — the structural axis is *complementary* to conservation,
|
|
11
|
+
not a standalone functional-site classifier.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import bisect
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from . import config as _config
|
|
19
|
+
from . import context as _ctx
|
|
20
|
+
|
|
21
|
+
# Known functional residues in UniProt numbering (map literature/Ambler numbers first!).
|
|
22
|
+
# Example data for the descriptive helper below, not a curated benchmark — pass `sites=` for
|
|
23
|
+
# anything else. The three TP53 entries are not one category, and the difference matters here.
|
|
24
|
+
#
|
|
25
|
+
# TEM-1 β-lactamase (P62593): the class A catalytic triad, S70/K73/E166 in *Ambler* numbering
|
|
26
|
+
# (Ambler et al. 1991) → UniProt 68/71/164. Ambler numbering carries insertions relative to the
|
|
27
|
+
# sequence, so the offset is not derivable from the sequence alone; the identity self-check in
|
|
28
|
+
# functional_site_stats is what stops a wrong one from reporting a neighbouring residue.
|
|
29
|
+
#
|
|
30
|
+
# TP53 (P04637): already UniProt numbering. **R248 and R273 are DNA-contact residues; R175 is
|
|
31
|
+
# not** — it is a conformational mutant that distorts the DNA-binding domain fold rather than
|
|
32
|
+
# losing a contact (the contact-vs-structural split from the DNA-binding domain crystal
|
|
33
|
+
# structure). All three are cancer *hotspots*, which is a mutation-frequency claim from the TP53
|
|
34
|
+
# mutation databases rather than a structural one.
|
|
35
|
+
#
|
|
36
|
+
# R175 therefore sits on both sides of the comparison this module exists to make: the finding in
|
|
37
|
+
# the docstring is that catalytic residues look structurally like buried spandrels, and R175 is a
|
|
38
|
+
# functional site whose mechanism *is* structural. Kept, because it is a genuine hotspot and this
|
|
39
|
+
# helper is descriptive — but read its row knowing that, and do not treat the set as three
|
|
40
|
+
# equivalent "DNA-contact" sites.
|
|
41
|
+
KNOWN_FUNCTIONAL_SITES: dict[str, dict[int, str]] = {
|
|
42
|
+
"P62593": {68: "S", 71: "K", 164: "E"},
|
|
43
|
+
"P04637": {175: "R", 248: "R", 273: "R"},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SiteStat:
|
|
49
|
+
position: int
|
|
50
|
+
aa: str
|
|
51
|
+
rsa: float | None
|
|
52
|
+
rsa_percentile: float | None # fraction of residues as-or-more buried (RSA ≤ this)
|
|
53
|
+
contact_count: int
|
|
54
|
+
contact_percentile: float # fraction of residues with ≤ this many contacts
|
|
55
|
+
buried: bool # rsa < config buried_threshold
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _percentile(sorted_vals: list, v) -> float:
|
|
59
|
+
"""Fraction of values ≤ v (0..1); ties inclusive (bisect_right)."""
|
|
60
|
+
return bisect.bisect_right(sorted_vals, v) / len(sorted_vals) if sorted_vals else float("nan")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def functional_site_stats(
|
|
64
|
+
uniprot_id: str,
|
|
65
|
+
sites: dict[int, str] | None = None,
|
|
66
|
+
cfg: dict | None = None,
|
|
67
|
+
) -> list[SiteStat]:
|
|
68
|
+
"""Structural signature (RSA + contacts, with percentile ranks) for known functional sites."""
|
|
69
|
+
cfg = cfg or _config.load()
|
|
70
|
+
sites = sites if sites is not None else KNOWN_FUNCTIONAL_SITES.get(uniprot_id, {})
|
|
71
|
+
if not sites:
|
|
72
|
+
raise ValueError(f"no known functional sites for {uniprot_id}; pass `sites=`")
|
|
73
|
+
|
|
74
|
+
profile = _ctx.structural_profile(uniprot_id, cfg)
|
|
75
|
+
rsas = sorted(p["rsa"] for p in profile.values() if p["rsa"] is not None)
|
|
76
|
+
ccs = sorted(p["contact_count"] for p in profile.values())
|
|
77
|
+
buried_thr = cfg["rsa"]["buried_threshold"]
|
|
78
|
+
|
|
79
|
+
out = []
|
|
80
|
+
for pos, expect in sorted(sites.items()):
|
|
81
|
+
if pos not in profile:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"position {pos} out of range 1..{len(profile)} for {uniprot_id}"
|
|
84
|
+
)
|
|
85
|
+
p = profile[pos]
|
|
86
|
+
# Self-check the numbering mapping: the residue in the structure must match the
|
|
87
|
+
# expected identity. Guards against a wrong Ambler→UniProt offset silently reporting
|
|
88
|
+
# a neighboring residue (the central M7 risk). Pass expect="" / "X" to skip.
|
|
89
|
+
if expect and expect not in ("X", "?") and p["aa"] != expect:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{uniprot_id} site {pos}: expected {expect} but structure has {p['aa']} — "
|
|
92
|
+
"check the functional-site numbering (literature vs UniProt)."
|
|
93
|
+
)
|
|
94
|
+
rsa = p["rsa"]
|
|
95
|
+
out.append(
|
|
96
|
+
SiteStat(
|
|
97
|
+
position=pos,
|
|
98
|
+
aa=p["aa"],
|
|
99
|
+
rsa=rsa,
|
|
100
|
+
rsa_percentile=_percentile(rsas, rsa) if rsa is not None else None,
|
|
101
|
+
contact_count=p["contact_count"],
|
|
102
|
+
contact_percentile=_percentile(ccs, p["contact_count"]),
|
|
103
|
+
buried=(rsa is not None and rsa < buried_thr),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def summarize(uniprot_id: str, cfg: dict | None = None) -> dict:
|
|
110
|
+
"""Descriptive summary: are the known functional sites buried, and how packed vs typical?"""
|
|
111
|
+
stats = functional_site_stats(uniprot_id, cfg=cfg)
|
|
112
|
+
n_buried = sum(s.buried for s in stats)
|
|
113
|
+
present_rsa = [s.rsa for s in stats if s.rsa is not None]
|
|
114
|
+
return {
|
|
115
|
+
"uniprot_id": uniprot_id,
|
|
116
|
+
"n_sites": len(stats),
|
|
117
|
+
"n_buried": n_buried,
|
|
118
|
+
"all_buried": n_buried == len(stats),
|
|
119
|
+
# mean over sites that actually have a DSSP RSA (don't fold None → 0.0)
|
|
120
|
+
"mean_rsa": (sum(present_rsa) / len(present_rsa)) if present_rsa else float("nan"),
|
|
121
|
+
"mean_contact_percentile": sum(s.contact_percentile for s in stats) / len(stats),
|
|
122
|
+
"sites": stats,
|
|
123
|
+
}
|
foldenv/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Load the locked decisions (D1–D6) from `decisions.yaml`.
|
|
2
|
+
|
|
3
|
+
The YAML holds the reasonable defaults; `load()` returns a nested dict and lets callers
|
|
4
|
+
override any leaf without touching the file. Keeping the decisions in data (not code) means
|
|
5
|
+
a results writeup can point at one file for "which cutoff / table / mask did you use?".
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
_DECISIONS_PATH = Path(__file__).with_name("decisions.yaml")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_cache_dir() -> Path:
|
|
20
|
+
"""Default on-disk cache location, resolved fresh on each call.
|
|
21
|
+
|
|
22
|
+
Overridable via the ``FOLDENV_CACHE_DIR`` env var or the ``cache.dir`` config leaf.
|
|
23
|
+
Defaults under the current working directory so the package works the same whether
|
|
24
|
+
installed into site-packages or run from a checkout. Resolving here (not at import
|
|
25
|
+
time) means the env var and the working directory are read when ``load()`` runs, not
|
|
26
|
+
when the module is first imported.
|
|
27
|
+
"""
|
|
28
|
+
return Path(os.environ.get("FOLDENV_CACHE_DIR", Path.cwd() / ".foldenv_cache"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _deep_update(base: dict, overrides: Mapping[str, Any]) -> dict:
|
|
32
|
+
"""Recursively merge `overrides` into `base` (mutating and returning `base`)."""
|
|
33
|
+
for key, value in overrides.items():
|
|
34
|
+
if isinstance(value, Mapping) and isinstance(base.get(key), dict):
|
|
35
|
+
_deep_update(base[key], value)
|
|
36
|
+
else:
|
|
37
|
+
base[key] = value
|
|
38
|
+
return base
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(overrides: Mapping[str, Any] | None = None) -> dict:
|
|
42
|
+
"""Return the decisions dict, with `cache.dir` resolved to a concrete path.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
overrides: nested dict merged over the file defaults, e.g.
|
|
46
|
+
``{"contacts": {"primary": "cb"}}``.
|
|
47
|
+
"""
|
|
48
|
+
with open(_DECISIONS_PATH) as f:
|
|
49
|
+
cfg = yaml.safe_load(f)
|
|
50
|
+
if overrides:
|
|
51
|
+
_deep_update(cfg, copy.deepcopy(dict(overrides)))
|
|
52
|
+
|
|
53
|
+
# Resolve the caching default here so downstream code always sees a real directory.
|
|
54
|
+
if cfg["cache"].get("dir") is None:
|
|
55
|
+
cfg["cache"]["dir"] = str(_default_cache_dir())
|
|
56
|
+
|
|
57
|
+
# Allow the AlphaFold API base to be overridden by env (e.g. to point tests at an
|
|
58
|
+
# unreachable host for deterministic, offline CI). An explicit `overrides` value wins.
|
|
59
|
+
if (overrides is None or "alphafold" not in overrides) and os.environ.get(
|
|
60
|
+
"FOLDENV_ALPHAFOLD_API_BASE"
|
|
61
|
+
):
|
|
62
|
+
cfg["alphafold"]["api_base"] = os.environ["FOLDENV_ALPHAFOLD_API_BASE"]
|
|
63
|
+
return cfg
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decisions_path() -> Path:
|
|
67
|
+
"""Path to the backing YAML (for logging which config a run used)."""
|
|
68
|
+
return _DECISIONS_PATH
|
foldenv/constants.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Amino-acid tables and the PLM-encoder registry.
|
|
2
|
+
|
|
3
|
+
Vendored so ``foldenv`` has no import dependency on any external package for these
|
|
4
|
+
facts. The amino-acid lookups are standard reference data; ``PLM_ENCODERS`` maps each
|
|
5
|
+
supported embedding backend to its HuggingFace Hub id.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Single letter, three letter, and full amino acid names.
|
|
9
|
+
aa_names = (
|
|
10
|
+
('A', 'ALA', 'alanine'),
|
|
11
|
+
('R', 'ARG', 'arginine'),
|
|
12
|
+
('N', 'ASN', 'asparagine'),
|
|
13
|
+
('D', 'ASP', 'aspartic acid'),
|
|
14
|
+
('C', 'CYS', 'cysteine'),
|
|
15
|
+
('E', 'GLU', 'glutamic acid'),
|
|
16
|
+
('Q', 'GLN', 'glutamine'),
|
|
17
|
+
('G', 'GLY', 'glycine'),
|
|
18
|
+
('H', 'HIS', 'histidine'),
|
|
19
|
+
('I', 'ILE', 'isoleucine'),
|
|
20
|
+
('L', 'LEU', 'leucine'),
|
|
21
|
+
('K', 'LYS', 'lysine'),
|
|
22
|
+
('M', 'MET', 'methionine'),
|
|
23
|
+
('F', 'PHE', 'phenylalanine'),
|
|
24
|
+
('P', 'PRO', 'proline'),
|
|
25
|
+
('S', 'SER', 'serine'),
|
|
26
|
+
('T', 'THR', 'threonine'),
|
|
27
|
+
('W', 'TRP', 'tryptophan'),
|
|
28
|
+
('Y', 'TYR', 'tyrosine'),
|
|
29
|
+
('V', 'VAL', 'valine'),
|
|
30
|
+
# Extended AAs
|
|
31
|
+
('B', 'ASX', 'asparagine or aspartic acid'),
|
|
32
|
+
('Z', 'GLX', 'glutamine or glutamic acid'),
|
|
33
|
+
('X', 'XAA', 'Any'),
|
|
34
|
+
('J', 'XLE', 'Leucine or isoleucine'),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Indices of standard amino acids in `aa_names`.
|
|
38
|
+
standard_indices = tuple(range(20))
|
|
39
|
+
|
|
40
|
+
# Single letter codes of standard amino acids.
|
|
41
|
+
standard_aas = tuple(aa_names[i][0] for i in standard_indices)
|
|
42
|
+
AAs = tuple(sorted(standard_aas))
|
|
43
|
+
|
|
44
|
+
# aa_to_idx and idx_to_aa
|
|
45
|
+
aa2idx = dict(zip(AAs, standard_indices))
|
|
46
|
+
idx2aa = {v: k for k, v in aa2idx.items()}
|
|
47
|
+
|
|
48
|
+
# dictionaries for aa name conversion
|
|
49
|
+
one2three = dict(aa_names[i][:2] for i in standard_indices)
|
|
50
|
+
three2one = {v: k for k, v in one2three.items()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# PLM encoders → HuggingFace Hub ids
|
|
54
|
+
PLM_ENCODERS = {
|
|
55
|
+
"esm": "facebook/esm2_t36_3B_UR50D",
|
|
56
|
+
"ankh": "ElnaggarLab/ankh-large",
|
|
57
|
+
"esm_35M": "facebook/esm2_t12_35M_UR50D",
|
|
58
|
+
"esm_650M": "facebook/esm2_t33_650M_UR50D",
|
|
59
|
+
"ankh_base": "ElnaggarLab/ankh-base",
|
|
60
|
+
"protbert": "Rostlab/prot_bert",
|
|
61
|
+
"prott5_xl_half": "Rostlab/prot_t5_xl_half_uniref50-enc",
|
|
62
|
+
"prostt5": "Rostlab/ProstT5",
|
|
63
|
+
"esmc_6b": "EvolutionaryScale/esmc-6b-2024-12", # ESM Cambrian 6B, 2560-dim (needs transformers>=4.57)
|
|
64
|
+
"ankh3_large": "ElnaggarLab/ankh3-large", # Ankh3-large, T5 encoder, 1536-dim
|
|
65
|
+
"ankh3_xl": "ElnaggarLab/ankh3-xl", # Ankh3-XL, T5 encoder, 2560-dim
|
|
66
|
+
"saprot": "westlake-repl/SaProt_650M_AF2", # SaProt 650M, ESM2-650M arch + SA (AA+3Di) vocab, 1280-dim
|
|
67
|
+
"saprot_1.3b": "westlake-repl/SaProt_1.3B_AFDB_OMG_NCBI", # SaProt 1.3B, ESM arch, 1280-dim
|
|
68
|
+
}
|
foldenv/contacts.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Contacts via KD-tree (Biopython NeighborSearch).
|
|
2
|
+
|
|
3
|
+
For a queried residue: count neighbors under the D1 cutoff (Cα–Cα ≤ 8 Å primary, or Cβ–Cβ ≤
|
|
4
|
+
5 Å), excluding self, and return the top-N nearest as `nearest_contacts`. Partners whose
|
|
5
|
+
pLDDT is below the D2 mask threshold are dropped as candidates (AF disordered loops otherwise
|
|
6
|
+
produce spurious contacts). Glycine has no Cβ → fall back to Cα (config
|
|
7
|
+
`contacts.glycine_cb_fallback`). pLDDT lives in the AF B-factor column.
|
|
8
|
+
|
|
9
|
+
`contact_count` is a burial/packing proxy: buried residues ~10–20 Cα-neighbors, surface ~4–8.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from .constants import three2one
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Contact:
|
|
20
|
+
resnum: int # partner residue number (UniProt/AF numbering)
|
|
21
|
+
aa: str # 1-letter amino acid
|
|
22
|
+
distance: float # Å between representative atoms
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ContactResult:
|
|
27
|
+
contact_count: int # neighbors under cutoff (self excluded, mask applied)
|
|
28
|
+
nearest_contacts: list[Contact] # up to n_nearest, ascending distance
|
|
29
|
+
atom_mode: str # "CA" or "CB" — which atom actually anchored the query
|
|
30
|
+
cutoff: float # Å cutoff used
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _one_letter(resname: str) -> str:
|
|
34
|
+
return three2one.get(resname.strip().upper(), "X")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _plddt(residue) -> float | None:
|
|
38
|
+
"""AF pLDDT for a residue = its B-factor (shared across the residue's atoms)."""
|
|
39
|
+
if "CA" in residue:
|
|
40
|
+
return float(residue["CA"].get_bfactor())
|
|
41
|
+
for atom in residue:
|
|
42
|
+
return float(atom.get_bfactor())
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rep_atom(residue, mode: str, glycine_cb_fallback: str):
|
|
47
|
+
"""Representative atom for contacts: CA in ca-mode; CB in cb-mode (→ CA for glycine /
|
|
48
|
+
any residue missing CB when `glycine_cb_fallback == 'ca'`). Returns (atom, atom_name)."""
|
|
49
|
+
if mode == "ca":
|
|
50
|
+
return (residue["CA"], "CA") if "CA" in residue else (None, None)
|
|
51
|
+
if "CB" in residue:
|
|
52
|
+
return residue["CB"], "CB"
|
|
53
|
+
if glycine_cb_fallback == "ca" and "CA" in residue:
|
|
54
|
+
return residue["CA"], "CA"
|
|
55
|
+
return None, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_amino_acid(residue) -> bool:
|
|
59
|
+
"""Standard residue (not HETATM/water). AF models are all-standard, but be defensive."""
|
|
60
|
+
return not residue.id[0].strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _chain(structure, chain_id: str | None):
|
|
64
|
+
model = next(structure.get_models())
|
|
65
|
+
return model[chain_id] if chain_id is not None else next(model.get_chains())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_contact_index(
|
|
69
|
+
structure,
|
|
70
|
+
*,
|
|
71
|
+
chain_id: str | None = None,
|
|
72
|
+
mode: str = "ca",
|
|
73
|
+
plddt_mask_below: float = 50.0,
|
|
74
|
+
glycine_cb_fallback: str = "ca",
|
|
75
|
+
):
|
|
76
|
+
"""One KD-tree of representative atoms for a chain, reusable across positions.
|
|
77
|
+
|
|
78
|
+
The tree covers every standard residue passing the pLDDT mask (self is NOT excluded
|
|
79
|
+
here — `compute_contacts` filters the query residue out per call by object identity). The
|
|
80
|
+
agent queries many positions of one protein, so caching this (per protein+mode+mask) turns
|
|
81
|
+
per-position work from O(N log N) tree builds into a single search. Returns a Biopython
|
|
82
|
+
`NeighborSearch`, or None if no atoms qualify.
|
|
83
|
+
"""
|
|
84
|
+
from Bio.PDB import NeighborSearch
|
|
85
|
+
|
|
86
|
+
atoms = []
|
|
87
|
+
for r in _chain(structure, chain_id):
|
|
88
|
+
if not _is_amino_acid(r):
|
|
89
|
+
continue
|
|
90
|
+
plddt = _plddt(r)
|
|
91
|
+
if plddt is not None and plddt < plddt_mask_below:
|
|
92
|
+
continue
|
|
93
|
+
atom, _ = _rep_atom(r, mode, glycine_cb_fallback)
|
|
94
|
+
if atom is not None:
|
|
95
|
+
atoms.append(atom)
|
|
96
|
+
return NeighborSearch(atoms) if atoms else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_contacts(
|
|
100
|
+
structure,
|
|
101
|
+
resnum: int,
|
|
102
|
+
*,
|
|
103
|
+
chain_id: str | None = None,
|
|
104
|
+
mode: str = "ca",
|
|
105
|
+
ca_cutoff: float = 8.0,
|
|
106
|
+
cb_cutoff: float = 5.0,
|
|
107
|
+
n_nearest: int = 5,
|
|
108
|
+
plddt_mask_below: float = 50.0,
|
|
109
|
+
glycine_cb_fallback: str = "ca",
|
|
110
|
+
index=None,
|
|
111
|
+
) -> ContactResult:
|
|
112
|
+
"""Contacts of residue `resnum` (1-based UniProt numbering) in one chain.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
mode: "ca" (Cα–Cα ≤ ca_cutoff) or "cb" (Cβ–Cβ ≤ cb_cutoff).
|
|
116
|
+
plddt_mask_below: drop partner residues with pLDDT below this (D2).
|
|
117
|
+
chain_id: chain to search; None → first chain (AF monomers are single-chain).
|
|
118
|
+
index: a prebuilt `build_contact_index` result to reuse (must share mode / mask /
|
|
119
|
+
glycine_cb_fallback). If None, a one-off tree is built for this call.
|
|
120
|
+
|
|
121
|
+
Note: in cb-mode the glycine fallback measures a glycine partner at its Cα, so
|
|
122
|
+
`nearest_contacts` distances can mix Cβ–Cβ and Cβ–Cα; `atom_mode` reflects only the query
|
|
123
|
+
atom. Default config is ca-mode, where this does not arise.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
KeyError: `resnum` not present in the chain.
|
|
127
|
+
ValueError: query residue lacks a representative atom, or bad `mode`.
|
|
128
|
+
"""
|
|
129
|
+
if mode not in ("ca", "cb"):
|
|
130
|
+
raise ValueError(f"mode must be 'ca' or 'cb', got {mode!r}")
|
|
131
|
+
|
|
132
|
+
chain = _chain(structure, chain_id)
|
|
133
|
+
cutoff = ca_cutoff if mode == "ca" else cb_cutoff
|
|
134
|
+
|
|
135
|
+
query_res = next(
|
|
136
|
+
(r for r in chain if _is_amino_acid(r) and r.id[1] == resnum), None
|
|
137
|
+
)
|
|
138
|
+
if query_res is None:
|
|
139
|
+
raise KeyError(f"residue {resnum} not found in chain {chain.id}")
|
|
140
|
+
q_atom, q_name = _rep_atom(query_res, mode, glycine_cb_fallback)
|
|
141
|
+
if q_atom is None:
|
|
142
|
+
raise ValueError(f"residue {resnum} has no {mode.upper()} atom for contacts")
|
|
143
|
+
|
|
144
|
+
if index is None:
|
|
145
|
+
index = build_contact_index(
|
|
146
|
+
structure, chain_id=chain_id, mode=mode,
|
|
147
|
+
plddt_mask_below=plddt_mask_below, glycine_cb_fallback=glycine_cb_fallback,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
contacts: list[Contact] = []
|
|
151
|
+
if index is not None:
|
|
152
|
+
for atom in index.search(q_atom.coord, cutoff, level="A"):
|
|
153
|
+
r = atom.get_parent()
|
|
154
|
+
# Exclude self by residue id (het, resnum, icode) rather than object identity: the
|
|
155
|
+
# cached KD-tree can be built from a different structure *instance* than query_res
|
|
156
|
+
# (e.g. cache.in_memory=false), so `is` would miss it and count self as a contact.
|
|
157
|
+
# Comparing .id still distinguishes insertion codes within the chain.
|
|
158
|
+
if r.id == query_res.id:
|
|
159
|
+
continue
|
|
160
|
+
contacts.append(
|
|
161
|
+
Contact(resnum=r.id[1], aa=_one_letter(r.resname), distance=float(atom - q_atom))
|
|
162
|
+
)
|
|
163
|
+
contacts.sort(key=lambda c: c.distance)
|
|
164
|
+
|
|
165
|
+
return ContactResult(
|
|
166
|
+
contact_count=len(contacts),
|
|
167
|
+
nearest_contacts=contacts[:n_nearest],
|
|
168
|
+
atom_mode=q_name,
|
|
169
|
+
cutoff=cutoff,
|
|
170
|
+
)
|