shellde 0.2.0__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.
- shellde/__init__.py +36 -0
- shellde/acquisition.py +135 -0
- shellde/advisor.py +158 -0
- shellde/bench/__init__.py +17 -0
- shellde/bench/falsification.py +95 -0
- shellde/bench/harness.py +134 -0
- shellde/bench/stats.py +46 -0
- shellde/campaign.py +257 -0
- shellde/candidates.py +341 -0
- shellde/cli.py +1517 -0
- shellde/colab.py +257 -0
- shellde/conformal.py +160 -0
- shellde/consensus.py +52 -0
- shellde/design_space.py +141 -0
- shellde/embeddings/__init__.py +6 -0
- shellde/embeddings/esm2.py +76 -0
- shellde/embeddings/esmc.py +94 -0
- shellde/embeddings/provider.py +105 -0
- shellde/features/__init__.py +22 -0
- shellde/features/base.py +49 -0
- shellde/features/defaults.py +11 -0
- shellde/features/embedding.py +80 -0
- shellde/features/inverse_folding.py +66 -0
- shellde/features/matrix.py +50 -0
- shellde/features/naturalness.py +65 -0
- shellde/features/onehot.py +55 -0
- shellde/features/pairwise.py +64 -0
- shellde/funclib.py +331 -0
- shellde/gating.py +181 -0
- shellde/holo.py +175 -0
- shellde/hotspots.py +108 -0
- shellde/loop.py +161 -0
- shellde/msa.py +186 -0
- shellde/naturalness.py +142 -0
- shellde/oracle.py +94 -0
- shellde/plm.py +145 -0
- shellde/prereg.py +41 -0
- shellde/protocols.py +87 -0
- shellde/rank.py +103 -0
- shellde/report.py +132 -0
- shellde/selector.py +63 -0
- shellde/sitefinder.py +465 -0
- shellde/structure.py +356 -0
- shellde/surrogate.py +323 -0
- shellde/types.py +66 -0
- shellde/zero_shot.py +160 -0
- shellde-0.2.0.dist-info/METADATA +285 -0
- shellde-0.2.0.dist-info/RECORD +51 -0
- shellde-0.2.0.dist-info/WHEEL +5 -0
- shellde-0.2.0.dist-info/entry_points.txt +2 -0
- shellde-0.2.0.dist-info/top_level.txt +1 -0
shellde/report.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Write a ranking to JSON / CSV / markdown / SVG (em-dash-free, dependency-light).
|
|
2
|
+
|
|
3
|
+
The markdown and CSV surface the calibrated risk flag so an experimentalist can see,
|
|
4
|
+
per recommendation, whether the predicted gain is robust to uncertainty (confident)
|
|
5
|
+
or speculative (high-risk / high-reward). SVG is a hand-written bar of the global
|
|
6
|
+
top-N by acquisition (no matplotlib dependency).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from shellde.rank import RankingResult
|
|
15
|
+
from shellde.types import Candidate
|
|
16
|
+
|
|
17
|
+
_EM_DASH = "\u2014"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _entry_dict(c: Candidate) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"variant": c.variant,
|
|
23
|
+
"n_mut": c.n_mut,
|
|
24
|
+
"mu": c.mu,
|
|
25
|
+
"sigma": c.sigma,
|
|
26
|
+
"acq": c.acq,
|
|
27
|
+
"mutations": list(c.mutations),
|
|
28
|
+
"baseline_gain": c.evidence.get("baseline_gain"),
|
|
29
|
+
"risk": c.evidence.get("risk"),
|
|
30
|
+
"abstain": c.evidence.get("abstain"),
|
|
31
|
+
"conformal_lo": c.evidence.get("conformal_lo"),
|
|
32
|
+
"conformal_scope": c.evidence.get("conformal_scope"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _json(ranking: RankingResult, prereg_hash: str | None) -> str:
|
|
37
|
+
payload = {
|
|
38
|
+
"prereg_hash": prereg_hash,
|
|
39
|
+
"global_is_exploratory": ranking.global_is_exploratory,
|
|
40
|
+
"per_count": {str(k): [_entry_dict(c) for c in v] for k, v in ranking.per_count.items()},
|
|
41
|
+
"global_ranking": [_entry_dict(c) for c in ranking.global_ranking],
|
|
42
|
+
}
|
|
43
|
+
return json.dumps(payload, indent=2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _abstain_cell(c: Candidate) -> str:
|
|
47
|
+
"""Render the abstain flag for tabular output; '' when the conformal gate is off."""
|
|
48
|
+
a = c.evidence.get("abstain")
|
|
49
|
+
return "" if a is None else str(bool(a))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _csv_rows(ranking: RankingResult) -> list[list]:
|
|
53
|
+
rows: list[list[object]] = [["rank", "n_mut", "variant", "mutations", "mu", "sigma", "acq", "baseline_gain", "risk", "abstain"]]
|
|
54
|
+
for i, c in enumerate(ranking.global_ranking, start=1):
|
|
55
|
+
rows.append(
|
|
56
|
+
[i, c.n_mut, c.variant, " ".join(c.mutations), f"{c.mu:.4f}", f"{c.sigma:.4f}",
|
|
57
|
+
f"{c.acq:.4f}", f"{c.evidence.get('baseline_gain', 0.0):.4f}", c.evidence.get("risk", ""),
|
|
58
|
+
_abstain_cell(c)]
|
|
59
|
+
)
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _md(ranking: RankingResult, prereg_hash: str | None) -> str:
|
|
64
|
+
lines = ["# Mutation candidate ranking", ""]
|
|
65
|
+
if prereg_hash:
|
|
66
|
+
lines += [f"prereg hash: `{prereg_hash}`", ""]
|
|
67
|
+
for count, tier in ranking.per_count.items():
|
|
68
|
+
lines += [f"## {count}-mutation tier", ""]
|
|
69
|
+
lines += ["| rank | variant | mutations | mu | sigma | acq | gain | risk |",
|
|
70
|
+
"|--:|:--|:--|--:|--:|--:|--:|:--|"]
|
|
71
|
+
for i, c in enumerate(tier, start=1):
|
|
72
|
+
muts = ",".join(c.mutations) or "WT"
|
|
73
|
+
lines.append(
|
|
74
|
+
f"| {i} | {c.variant} | {muts} | {c.mu:.3f} | {c.sigma:.3f} | {c.acq:.3f} | "
|
|
75
|
+
f"{c.evidence.get('baseline_gain', 0.0):+.3f} | {c.evidence.get('risk', '')} |"
|
|
76
|
+
)
|
|
77
|
+
lines.append("")
|
|
78
|
+
lines += ["## global ranking (exploratory: mixes mutation counts)", ""]
|
|
79
|
+
lines += ["| rank | count | variant | mu | sigma | acq | risk | abstain |",
|
|
80
|
+
"|--:|--:|:--|--:|--:|--:|:--|:--|"]
|
|
81
|
+
for i, c in enumerate(ranking.global_ranking, start=1):
|
|
82
|
+
lines.append(
|
|
83
|
+
f"| {i} | {c.n_mut} | {c.variant} | {c.mu:.3f} | {c.sigma:.3f} | {c.acq:.3f} | "
|
|
84
|
+
f"{c.evidence.get('risk', '')} | {_abstain_cell(c)} |"
|
|
85
|
+
)
|
|
86
|
+
lines.append("")
|
|
87
|
+
text = "\n".join(lines)
|
|
88
|
+
if _EM_DASH in text: # repo convention: generated markdown must be em-dash-free
|
|
89
|
+
raise ValueError("generated markdown contains an em-dash")
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _svg(ranking: RankingResult) -> str:
|
|
94
|
+
top = ranking.global_ranking[:15]
|
|
95
|
+
if not top:
|
|
96
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>'
|
|
97
|
+
acqs = [c.acq for c in top]
|
|
98
|
+
lo, hi = min(acqs), max(acqs)
|
|
99
|
+
span = (hi - lo) or 1.0
|
|
100
|
+
row_h, bar_w, pad = 18, 240, 140
|
|
101
|
+
h = row_h * len(top) + 10
|
|
102
|
+
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{pad + bar_w + 60}" height="{h}" font-family="monospace" font-size="11">']
|
|
103
|
+
for i, c in enumerate(top):
|
|
104
|
+
y = 8 + i * row_h
|
|
105
|
+
w = int(bar_w * (c.acq - lo) / span) + 1
|
|
106
|
+
colour = "#1f4e79" if c.evidence.get("risk") == "confident" else "#a8c4e0"
|
|
107
|
+
parts.append(f'<text x="2" y="{y + 10}">{c.variant}</text>')
|
|
108
|
+
parts.append(f'<rect x="{pad}" y="{y}" width="{w}" height="12" fill="{colour}"/>')
|
|
109
|
+
parts.append(f'<text x="{pad + w + 4}" y="{y + 10}">{c.acq:.2f}</text>')
|
|
110
|
+
parts.append("</svg>")
|
|
111
|
+
return "\n".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_report(ranking: RankingResult, outdir: str | Path, *, prereg_hash: str | None = None) -> dict[str, str]:
|
|
115
|
+
"""Write report.json / report.csv / report.md / report.svg; return {kind: path}."""
|
|
116
|
+
out = Path(outdir)
|
|
117
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
paths: dict[str, str] = {}
|
|
119
|
+
|
|
120
|
+
(out / "report.json").write_text(_json(ranking, prereg_hash))
|
|
121
|
+
paths["json"] = str(out / "report.json")
|
|
122
|
+
|
|
123
|
+
with (out / "report.csv").open("w", newline="") as fh:
|
|
124
|
+
csv.writer(fh).writerows(_csv_rows(ranking))
|
|
125
|
+
paths["csv"] = str(out / "report.csv")
|
|
126
|
+
|
|
127
|
+
(out / "report.md").write_text(_md(ranking, prereg_hash))
|
|
128
|
+
paths["markdown"] = str(out / "report.md")
|
|
129
|
+
|
|
130
|
+
(out / "report.svg").write_text(_svg(ranking))
|
|
131
|
+
paths["svg"] = str(out / "report.svg")
|
|
132
|
+
return paths
|
shellde/selector.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Method-level selector: let each protein's own held-out data choose the surrogate.
|
|
2
|
+
|
|
3
|
+
No-Free-Lunch means no single surrogate is best for every landscape. Rather than hardcode
|
|
4
|
+
one, run k-fold CV (held-out Spearman) over the candidate surrogates on the measured data
|
|
5
|
+
and pick the best. This is the general, adaptive core: the data selects the method. It is
|
|
6
|
+
approximately-dominant (tracks the best single surrogate), not strictly; at very low N or
|
|
7
|
+
flat signal it returns the first candidate and the caller should lean on abstain.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from sklearn.model_selection import KFold
|
|
15
|
+
|
|
16
|
+
from shellde.protocols import Surrogate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _spearman(a: np.ndarray, b: np.ndarray) -> float:
|
|
20
|
+
ra = np.argsort(np.argsort(a)).astype(float)
|
|
21
|
+
rb = np.argsort(np.argsort(b)).astype(float)
|
|
22
|
+
if np.std(ra) < 1e-12 or np.std(rb) < 1e-12:
|
|
23
|
+
return 0.0
|
|
24
|
+
r = float(np.corrcoef(ra, rb)[0, 1])
|
|
25
|
+
return r if np.isfinite(r) else 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cv_spearman(
|
|
29
|
+
make_surrogate: Callable[[], Surrogate], x: np.ndarray, y: np.ndarray, *, k: int = 4, seed: int = 0
|
|
30
|
+
) -> float:
|
|
31
|
+
"""k-fold held-out Spearman for one surrogate factory on (x, y)."""
|
|
32
|
+
x = np.asarray(x, dtype=float)
|
|
33
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
34
|
+
n = len(y)
|
|
35
|
+
if n < 8 or np.std(y) < 1e-9:
|
|
36
|
+
return 0.0
|
|
37
|
+
pred = np.zeros(n)
|
|
38
|
+
for tr, va in KFold(n_splits=min(k, n), shuffle=True, random_state=seed).split(x):
|
|
39
|
+
if np.std(y[tr]) < 1e-9:
|
|
40
|
+
continue
|
|
41
|
+
m = make_surrogate().fit(x[tr], y[tr])
|
|
42
|
+
pred[va] = np.asarray(m.predict(x[va]).mean, dtype=float)
|
|
43
|
+
return _spearman(pred, y)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def select_surrogate(
|
|
47
|
+
candidates: Mapping[str, Callable[[], Surrogate]],
|
|
48
|
+
x: np.ndarray,
|
|
49
|
+
y: np.ndarray,
|
|
50
|
+
*,
|
|
51
|
+
k: int = 4,
|
|
52
|
+
seed: int = 0,
|
|
53
|
+
) -> tuple[str, dict[str, float]]:
|
|
54
|
+
"""Return (best surrogate name, per-candidate CV Spearman). Deterministic; ties -> first.
|
|
55
|
+
|
|
56
|
+
At N too small for CV (all scores 0.0) the first candidate wins by max() stability; the
|
|
57
|
+
caller should treat that as low-confidence (abstain regime).
|
|
58
|
+
"""
|
|
59
|
+
if not candidates:
|
|
60
|
+
raise ValueError("no candidate surrogates")
|
|
61
|
+
scores = {name: cv_spearman(f, x, y, k=k, seed=seed) for name, f in candidates.items()}
|
|
62
|
+
best = max(candidates, key=lambda n: scores[n])
|
|
63
|
+
return best, scores
|
shellde/sitefinder.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Resolve active-site / binding positions from EXPERIMENTAL curation first (UniProt), not prediction.
|
|
2
|
+
|
|
3
|
+
Evidence ladder for "where do I mutate?": curated UniProt features (active/binding/metal site) ->
|
|
4
|
+
[future: linked PDB cocrystal -> AlphaFold + pocket]. The point is to ground the design anchor in
|
|
5
|
+
experimental data when it exists, and abstain (defer to a structure or manual --positions) when it
|
|
6
|
+
does not -- never to fabricate positions.
|
|
7
|
+
|
|
8
|
+
CRITICAL (the numbering trap): UniProt numbering (often a Precursor with a transit/signal peptide)
|
|
9
|
+
need NOT match the user's construct numbering (e.g. PtIspS 1-560 with an "AA" prefix vs the 558-aa
|
|
10
|
+
mature form -> a +2 offset). So every UniProt feature position is mapped through a sequence
|
|
11
|
+
ALIGNMENT of the user's WT against the UniProt sequence; positions that do not align are reported
|
|
12
|
+
unmapped, never guessed. Residue identity at the mapped position is verified by construction
|
|
13
|
+
(positions come only from exact matching blocks).
|
|
14
|
+
|
|
15
|
+
Network (urllib, stdlib) is used only by ``fetch_uniprot``; parsing/alignment/mapping are pure and
|
|
16
|
+
unit-tested offline.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import difflib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
from shellde.structure import shells_from_pdb
|
|
29
|
+
|
|
30
|
+
_AA3 = {
|
|
31
|
+
"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q", "GLU": "E",
|
|
32
|
+
"GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K", "MET": "M", "PHE": "F",
|
|
33
|
+
"PRO": "P", "SER": "S", "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_SITE_TYPES = ("Active site", "Binding site", "Metal binding", "Site")
|
|
37
|
+
# ECO evidence code -> human confidence (experimental curation outranks by-similarity / predicted)
|
|
38
|
+
_CONFIDENCE = {
|
|
39
|
+
"ECO:0000269": "experimental",
|
|
40
|
+
"ECO:0007744": "experimental-combined",
|
|
41
|
+
"ECO:0000305": "curator-inferred",
|
|
42
|
+
"ECO:0000250": "by-similarity",
|
|
43
|
+
"ECO:0000255": "predicted",
|
|
44
|
+
"ECO:0000256": "predicted",
|
|
45
|
+
}
|
|
46
|
+
_CONF_RANK = {
|
|
47
|
+
"experimental": 0, "experimental-combined": 1, "curator-inferred": 2,
|
|
48
|
+
"by-similarity": 3, "predicted": 4, "unknown": 5,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Feature:
|
|
54
|
+
"""One UniProt site feature in UNIPROT numbering."""
|
|
55
|
+
|
|
56
|
+
type: str
|
|
57
|
+
start: int
|
|
58
|
+
end: int
|
|
59
|
+
ligand: str = ""
|
|
60
|
+
evidence: str = ""
|
|
61
|
+
description: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ResolvedSite:
|
|
66
|
+
"""A UniProt site mapped onto the user's WT numbering (one row per WT position)."""
|
|
67
|
+
|
|
68
|
+
wt_position: int
|
|
69
|
+
residue: str
|
|
70
|
+
source_position: int # UniProt residue no. (features) or PDB residue no. (structure)
|
|
71
|
+
types: tuple[str, ...]
|
|
72
|
+
ligands: tuple[str, ...]
|
|
73
|
+
confidence: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ResolveResult:
|
|
78
|
+
source: str = ""
|
|
79
|
+
coverage: float = 0.0
|
|
80
|
+
sites: list[ResolvedSite] = field(default_factory=list)
|
|
81
|
+
positions: list[int] = field(default_factory=list)
|
|
82
|
+
unmapped: list[Feature] = field(default_factory=list)
|
|
83
|
+
abstained: bool = False
|
|
84
|
+
reason: str = ""
|
|
85
|
+
consensus: list[dict] = field(default_factory=list) # per-position evidence map (build_consensus)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _confidence(eco: str) -> str:
|
|
89
|
+
return _CONFIDENCE.get(eco, "unknown")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_uniprot_entry(data: dict) -> tuple[str, str, list[Feature]]:
|
|
93
|
+
"""(sequence, accession, site features) from a UniProt entry JSON. Pure."""
|
|
94
|
+
seq = (data.get("sequence") or {}).get("value", "") or ""
|
|
95
|
+
acc = data.get("primaryAccession", "") or ""
|
|
96
|
+
feats: list[Feature] = []
|
|
97
|
+
for f in data.get("features", []) or []:
|
|
98
|
+
t = f.get("type")
|
|
99
|
+
if t not in _SITE_TYPES:
|
|
100
|
+
continue
|
|
101
|
+
loc = f.get("location") or {}
|
|
102
|
+
s = (loc.get("start") or {}).get("value")
|
|
103
|
+
e = (loc.get("end") or {}).get("value")
|
|
104
|
+
if s is None:
|
|
105
|
+
continue
|
|
106
|
+
ev = ((f.get("evidences") or [{}])[0]).get("evidenceCode", "")
|
|
107
|
+
lig = (f.get("ligand") or {}).get("name", "")
|
|
108
|
+
feats.append(Feature(type=t, start=int(s), end=int(e if e is not None else s),
|
|
109
|
+
ligand=lig, evidence=ev, description=f.get("description", "")))
|
|
110
|
+
return seq, acc, feats
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _alignment_map(wt: str, up: str) -> tuple[dict[int, int], float]:
|
|
114
|
+
"""Map UniProt 0-based index -> WT 0-based index over exact matching blocks; + WT coverage."""
|
|
115
|
+
sm = difflib.SequenceMatcher(a=wt, b=up, autojunk=False)
|
|
116
|
+
m: dict[int, int] = {}
|
|
117
|
+
matched = 0
|
|
118
|
+
for a0, b0, size in sm.get_matching_blocks():
|
|
119
|
+
for k in range(size):
|
|
120
|
+
m[b0 + k] = a0 + k
|
|
121
|
+
matched += size
|
|
122
|
+
coverage = matched / len(wt) if wt else 0.0
|
|
123
|
+
return m, coverage
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_from_entry(wt_seq: str, data: dict, *, min_coverage: float = 0.6) -> ResolveResult:
|
|
127
|
+
"""Map a UniProt entry's site features onto ``wt_seq`` numbering. Pure (no network)."""
|
|
128
|
+
up_seq, acc, feats = parse_uniprot_entry(data)
|
|
129
|
+
res = ResolveResult(source=f"UniProt:{acc}" if acc else "UniProt")
|
|
130
|
+
if not up_seq:
|
|
131
|
+
res.abstained = True
|
|
132
|
+
res.reason = "UniProt entry has no sequence"
|
|
133
|
+
return res
|
|
134
|
+
idx_map, coverage = _alignment_map(wt_seq, up_seq)
|
|
135
|
+
res.coverage = coverage
|
|
136
|
+
if coverage < min_coverage:
|
|
137
|
+
res.abstained = True
|
|
138
|
+
res.reason = (f"WT aligns to only {coverage:.0%} of the UniProt sequence "
|
|
139
|
+
f"(< {min_coverage:.0%}): likely the wrong entry/isoform -- not mapping")
|
|
140
|
+
return res
|
|
141
|
+
# aggregate features per mapped WT position
|
|
142
|
+
agg: dict[int, dict] = {}
|
|
143
|
+
for f in feats:
|
|
144
|
+
mapped = False
|
|
145
|
+
for up_pos in range(f.start, f.end + 1):
|
|
146
|
+
wt0 = idx_map.get(up_pos - 1)
|
|
147
|
+
if wt0 is None:
|
|
148
|
+
continue
|
|
149
|
+
mapped = True
|
|
150
|
+
d = agg.setdefault(wt0 + 1, {"uni": up_pos, "res": wt_seq[wt0], "types": set(),
|
|
151
|
+
"ligands": set(), "confs": set()})
|
|
152
|
+
d["types"].add(f.type)
|
|
153
|
+
if f.ligand:
|
|
154
|
+
d["ligands"].add(f.ligand)
|
|
155
|
+
d["confs"].add(_confidence(f.evidence))
|
|
156
|
+
if not mapped:
|
|
157
|
+
res.unmapped.append(f)
|
|
158
|
+
for wtp in sorted(agg):
|
|
159
|
+
d = agg[wtp]
|
|
160
|
+
best = min(d["confs"], key=lambda c: _CONF_RANK.get(c, 99)) if d["confs"] else "unknown"
|
|
161
|
+
res.sites.append(ResolvedSite(
|
|
162
|
+
wt_position=wtp, residue=d["res"], source_position=d["uni"],
|
|
163
|
+
types=tuple(sorted(d["types"])), ligands=tuple(sorted(d["ligands"])), confidence=best,
|
|
164
|
+
))
|
|
165
|
+
res.positions = sorted(agg)
|
|
166
|
+
if not res.sites:
|
|
167
|
+
res.abstained = True
|
|
168
|
+
res.reason = ("no active/binding/metal-site features mapped onto WT "
|
|
169
|
+
"(entry has none, or they fell outside the aligned region)")
|
|
170
|
+
return res
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_UA = {"User-Agent": "Mozilla/5.0 (compatible; shellde-resolve-site)"}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_json(url: str, timeout: float = 20.0) -> dict:
|
|
177
|
+
req = urllib.request.Request(url, headers=_UA)
|
|
178
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted host)
|
|
179
|
+
return json.loads(r.read().decode())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def fetch_uniprot(accession: str | None = None, query: str | None = None,
|
|
183
|
+
*, timeout: float = 20.0) -> dict | None:
|
|
184
|
+
"""Fetch a UniProt entry JSON by accession, or the top search hit for a text query. Network."""
|
|
185
|
+
if accession:
|
|
186
|
+
return _get_json(f"https://rest.uniprot.org/uniprotkb/{urllib.parse.quote(accession)}.json", timeout)
|
|
187
|
+
if query:
|
|
188
|
+
q = urllib.parse.urlencode({"query": query, "format": "json", "size": "1"})
|
|
189
|
+
hits = _get_json(f"https://rest.uniprot.org/uniprotkb/search?{q}", timeout)
|
|
190
|
+
results = hits.get("results") or []
|
|
191
|
+
if not results:
|
|
192
|
+
return None
|
|
193
|
+
acc = results[0].get("primaryAccession")
|
|
194
|
+
return _get_json(f"https://rest.uniprot.org/uniprotkb/{acc}.json", timeout) if acc else results[0]
|
|
195
|
+
raise ValueError("provide an accession or a query")
|
|
196
|
+
|
|
197
|
+
def _uniparc_accessions(data: dict) -> list[str]:
|
|
198
|
+
"""UniProtKB accessions cross-referenced by a UniParc entry (exact-sequence match), ordered
|
|
199
|
+
Swiss-Prot (reviewed, has curated features) before TrEMBL, active entries first. Pure/offline."""
|
|
200
|
+
refs = data.get("dbReference") or data.get("uniParcCrossReferences") or []
|
|
201
|
+
|
|
202
|
+
def rank(x: dict) -> tuple:
|
|
203
|
+
typ = x.get("type") or x.get("database") or ""
|
|
204
|
+
active = str(x.get("active", "Y")).upper()
|
|
205
|
+
return (0 if active != "N" else 1, 0 if "Swiss-Prot" in typ else 1)
|
|
206
|
+
|
|
207
|
+
out: list[str] = []
|
|
208
|
+
for x in sorted(refs, key=rank):
|
|
209
|
+
typ = x.get("type") or x.get("database") or ""
|
|
210
|
+
acc = x.get("id")
|
|
211
|
+
if "UniProtKB" in typ and acc and acc not in out:
|
|
212
|
+
out.append(acc)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def uniprot_from_sequence(seq: str, *, timeout: float = 20.0) -> list[str]:
|
|
217
|
+
"""UniProtKB accession(s) whose sequence EXACTLY matches ``seq`` (100% identity over the whole
|
|
218
|
+
sequence, via the EBI Proteins API over UniParc), Swiss-Prot first. Returns [] when there is no
|
|
219
|
+
exact match (e.g. an engineered construct with tags/point mutations) or on any network/HTTP error
|
|
220
|
+
-- the caller must then abstain or ask for an accession, never guess. Network.
|
|
221
|
+
"""
|
|
222
|
+
clean = "".join(seq.split()).upper()
|
|
223
|
+
if not clean:
|
|
224
|
+
return []
|
|
225
|
+
try:
|
|
226
|
+
req = urllib.request.Request(
|
|
227
|
+
"https://www.ebi.ac.uk/proteins/api/uniparc/sequence",
|
|
228
|
+
data=clean.encode(), method="POST",
|
|
229
|
+
headers={**_UA, "Content-Type": "text/plain", "Accept": "application/json"},
|
|
230
|
+
)
|
|
231
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted EBI host)
|
|
232
|
+
data = json.loads(r.read().decode())
|
|
233
|
+
except Exception: # noqa: BLE001 (no exact match / network / HTTP -> abstain, return [])
|
|
234
|
+
return []
|
|
235
|
+
return _uniparc_accessions(data)
|
|
236
|
+
|
|
237
|
+
# --------------------------------------------------------------------------- #
|
|
238
|
+
# Layer 2: UniProt-linked PDB cocrystal -> structure-derived active-site shells
|
|
239
|
+
# --------------------------------------------------------------------------- #
|
|
240
|
+
def pdb_ids_from_uniprot(data: dict) -> list[str]:
|
|
241
|
+
"""PDB IDs cross-referenced by a UniProt entry, X-ray first then best resolution."""
|
|
242
|
+
rows = []
|
|
243
|
+
for x in data.get("uniProtKBCrossReferences", []) or []:
|
|
244
|
+
if x.get("database") != "PDB":
|
|
245
|
+
continue
|
|
246
|
+
props = {p.get("key"): p.get("value") for p in (x.get("properties") or [])}
|
|
247
|
+
rows.append((x.get("id", ""), props.get("Method", ""), props.get("Resolution", "")))
|
|
248
|
+
|
|
249
|
+
def rank(t: tuple) -> tuple:
|
|
250
|
+
_, method, resol = t
|
|
251
|
+
try:
|
|
252
|
+
r = float(str(resol).split()[0])
|
|
253
|
+
except (ValueError, IndexError):
|
|
254
|
+
r = 9e9
|
|
255
|
+
return (0 if "X-ray" in method else 1, r)
|
|
256
|
+
|
|
257
|
+
return [pid for pid, _, _ in sorted(rows, key=rank) if pid]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _pdb_chain_residues(pdb_text: str, chain: str | None = None) -> tuple[str | None, list[tuple[int, str]]]:
|
|
261
|
+
"""(chain id used, [(residue_number, aa1)]) from CA ATOM records of one chain (first if None)."""
|
|
262
|
+
target = chain
|
|
263
|
+
out: list[tuple[int, str]] = []
|
|
264
|
+
for line in pdb_text.splitlines():
|
|
265
|
+
if not line.startswith("ATOM") or line[12:16].strip() != "CA":
|
|
266
|
+
continue
|
|
267
|
+
if line[16] not in (" ", "A"):
|
|
268
|
+
continue
|
|
269
|
+
c = line[21]
|
|
270
|
+
if target is None:
|
|
271
|
+
target = c
|
|
272
|
+
if c != target:
|
|
273
|
+
continue
|
|
274
|
+
try:
|
|
275
|
+
out.append((int(line[22:26]), _AA3.get(line[17:20].strip(), "X")))
|
|
276
|
+
except ValueError:
|
|
277
|
+
continue
|
|
278
|
+
return target, out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def fetch_pdb(pdb_id: str, *, timeout: float = 20.0) -> str:
|
|
282
|
+
"""Download a PDB file from RCSB. Network."""
|
|
283
|
+
url = f"https://files.rcsb.org/download/{pdb_id.upper()}.pdb"
|
|
284
|
+
req = urllib.request.Request(url, headers=_UA)
|
|
285
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted RCSB host)
|
|
286
|
+
return r.read().decode("latin-1")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def fetch_alphafold(accession: str, *, timeout: float = 20.0) -> str | None:
|
|
290
|
+
"""Fetch the AlphaFold DB predicted model (PDB text) for a UniProt accession, or None.
|
|
291
|
+
|
|
292
|
+
Uses the AFDB API to resolve the current file URL (version changes over time; do not hardcode),
|
|
293
|
+
then downloads the model. Returns None on any failure/absence. The model is APO (no ligand), so it
|
|
294
|
+
yields a structure for inspection / --holo-with-a-docked-ligand / external pocket tools, not
|
|
295
|
+
active-site positions by itself. Network.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
meta = _get_json(f"https://alphafold.ebi.ac.uk/api/prediction/{urllib.parse.quote(accession)}", timeout)
|
|
299
|
+
except Exception: # noqa: BLE001
|
|
300
|
+
return None
|
|
301
|
+
if not meta or not isinstance(meta, list):
|
|
302
|
+
return None
|
|
303
|
+
url = meta[0].get("pdbUrl")
|
|
304
|
+
if not url:
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
req = urllib.request.Request(url, headers=_UA)
|
|
308
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted AFDB host)
|
|
309
|
+
return r.read().decode("latin-1")
|
|
310
|
+
except Exception: # noqa: BLE001
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def run_fpocket(pdb_text: str, *, fpocket_bin: str = "fpocket", top: int = 1, timeout: float = 180.0):
|
|
315
|
+
"""Run fpocket on a structure; return the top-``top`` pockets' atom-PDB texts, or None.
|
|
316
|
+
|
|
317
|
+
Requires the external ``fpocket`` binary (conda-forge). None if it is not installed or finds no
|
|
318
|
+
pocket -- callers then abstain (no fabricated pocket). Apo-structure pocket detection is a
|
|
319
|
+
heuristic prior, tagged low-confidence downstream.
|
|
320
|
+
"""
|
|
321
|
+
import shutil
|
|
322
|
+
import subprocess
|
|
323
|
+
exe = shutil.which(fpocket_bin) or (fpocket_bin if os.path.isfile(fpocket_bin) else None)
|
|
324
|
+
if not exe:
|
|
325
|
+
return None
|
|
326
|
+
d = tempfile.mkdtemp()
|
|
327
|
+
try:
|
|
328
|
+
p = os.path.join(d, "model.pdb")
|
|
329
|
+
with open(p, "w") as fh:
|
|
330
|
+
fh.write(pdb_text)
|
|
331
|
+
subprocess.run([exe, "-f", p], capture_output=True, timeout=timeout, cwd=d, check=False)
|
|
332
|
+
pockets = []
|
|
333
|
+
for i in range(1, top + 1):
|
|
334
|
+
f = os.path.join(d, "model_out", "pockets", f"pocket{i}_atm.pdb")
|
|
335
|
+
if os.path.isfile(f):
|
|
336
|
+
pockets.append(open(f).read())
|
|
337
|
+
return pockets or None
|
|
338
|
+
except Exception: # noqa: BLE001
|
|
339
|
+
return None
|
|
340
|
+
finally:
|
|
341
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def resolve_from_apo(
|
|
345
|
+
wt_seq: str, apo_pdb_text: str, *, source: str = "AlphaFold+fpocket",
|
|
346
|
+
fpocket_bin: str = "fpocket", top_pockets: int = 1, min_coverage: float = 0.6,
|
|
347
|
+
) -> ResolveResult:
|
|
348
|
+
"""Predicted pocket residues from an APO structure (fpocket), mapped onto WT. LOW confidence.
|
|
349
|
+
|
|
350
|
+
For novel proteins with no experimental site/structure: detect the pocket geometrically on a
|
|
351
|
+
predicted (AlphaFold) model, then map the pocket-lining residues onto WT by alignment. Abstains
|
|
352
|
+
if fpocket is unavailable or nothing maps. Tagged ``predicted-pocket`` -- a heuristic prior to
|
|
353
|
+
verify, not curated evidence.
|
|
354
|
+
"""
|
|
355
|
+
res = ResolveResult(source=source)
|
|
356
|
+
pockets = run_fpocket(apo_pdb_text, fpocket_bin=fpocket_bin, top=top_pockets)
|
|
357
|
+
if not pockets:
|
|
358
|
+
res.abstained = True
|
|
359
|
+
res.reason = "fpocket unavailable or found no pocket"
|
|
360
|
+
return res
|
|
361
|
+
_, residues = _pdb_chain_residues(apo_pdb_text)
|
|
362
|
+
if not residues:
|
|
363
|
+
res.abstained = True
|
|
364
|
+
res.reason = "no ATOM CA records in apo model"
|
|
365
|
+
return res
|
|
366
|
+
obs_seq = "".join(a for _, a in residues)
|
|
367
|
+
idx_map, coverage = _alignment_map(wt_seq, obs_seq)
|
|
368
|
+
res.coverage = coverage
|
|
369
|
+
if coverage < min_coverage:
|
|
370
|
+
res.abstained = True
|
|
371
|
+
res.reason = f"WT aligns to only {coverage:.0%} of the apo model (< {min_coverage:.0%})"
|
|
372
|
+
return res
|
|
373
|
+
resnum_to_obs = {rn: i for i, (rn, _) in enumerate(residues)}
|
|
374
|
+
agg: dict[int, dict] = {}
|
|
375
|
+
for rank, pk in enumerate(pockets, 1):
|
|
376
|
+
pk_resnums = set()
|
|
377
|
+
for line in pk.splitlines():
|
|
378
|
+
if line.startswith("ATOM"): # protein residues only (skip STP alpha-sphere HETATM)
|
|
379
|
+
try:
|
|
380
|
+
pk_resnums.add(int(line[22:26]))
|
|
381
|
+
except ValueError:
|
|
382
|
+
continue
|
|
383
|
+
for rn in pk_resnums:
|
|
384
|
+
obs_i = resnum_to_obs.get(rn)
|
|
385
|
+
if obs_i is None:
|
|
386
|
+
continue
|
|
387
|
+
wt0 = idx_map.get(obs_i)
|
|
388
|
+
if wt0 is None:
|
|
389
|
+
continue
|
|
390
|
+
agg.setdefault(wt0 + 1, {"uni": rn, "res": wt_seq[wt0], "rank": rank})
|
|
391
|
+
for wtp in sorted(agg):
|
|
392
|
+
d = agg[wtp]
|
|
393
|
+
res.sites.append(ResolvedSite(
|
|
394
|
+
wt_position=wtp, residue=d["res"], source_position=d["uni"],
|
|
395
|
+
types=(f"predicted-pocket-{d['rank']}",), ligands=(), confidence="predicted-pocket",
|
|
396
|
+
))
|
|
397
|
+
res.positions = sorted(agg)
|
|
398
|
+
if not res.sites:
|
|
399
|
+
res.abstained = True
|
|
400
|
+
res.reason = "fpocket pocket residues did not align onto WT"
|
|
401
|
+
return res
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def resolve_from_pdb(
|
|
405
|
+
wt_seq: str, pdb_text: str, *, pdb_id: str = "", chain: str | None = None,
|
|
406
|
+
ligand_resnames=None, contact_cutoff: float = 4.5, min_coverage: float = 0.6,
|
|
407
|
+
) -> ResolveResult:
|
|
408
|
+
"""Active-site shells from a (holo) PDB, mapped onto WT numbering. Pure (no network).
|
|
409
|
+
|
|
410
|
+
Runs :func:`structure.shells_from_pdb` (needs a ligand pose), then maps the PDB-numbered shell
|
|
411
|
+
residues onto WT positions through a sequence alignment of the chosen chain -- so PDB numbering
|
|
412
|
+
gaps/offsets are handled the same way as the UniProt path. Abstains when there is no ligand or
|
|
413
|
+
the chain does not align to WT.
|
|
414
|
+
"""
|
|
415
|
+
res = ResolveResult(source=f"PDB:{pdb_id}" if pdb_id else "PDB")
|
|
416
|
+
used_chain, residues = _pdb_chain_residues(pdb_text, chain)
|
|
417
|
+
if not residues:
|
|
418
|
+
res.abstained = True
|
|
419
|
+
res.reason = "no ATOM CA records in PDB"
|
|
420
|
+
return res
|
|
421
|
+
tf = tempfile.NamedTemporaryFile("w", suffix=".pdb", delete=False)
|
|
422
|
+
try:
|
|
423
|
+
tf.write(pdb_text)
|
|
424
|
+
tf.close()
|
|
425
|
+
shells = shells_from_pdb(tf.name, ligand_resnames=ligand_resnames, chain=used_chain,
|
|
426
|
+
contact_cutoff=contact_cutoff)
|
|
427
|
+
except ValueError as exc:
|
|
428
|
+
res.abstained = True
|
|
429
|
+
res.reason = f"no ligand/active-site shell in PDB ({exc})"
|
|
430
|
+
return res
|
|
431
|
+
finally:
|
|
432
|
+
try:
|
|
433
|
+
os.unlink(tf.name)
|
|
434
|
+
except OSError:
|
|
435
|
+
pass
|
|
436
|
+
obs_seq = "".join(a for _, a in residues)
|
|
437
|
+
idx_map, coverage = _alignment_map(wt_seq, obs_seq)
|
|
438
|
+
res.coverage = coverage
|
|
439
|
+
if coverage < min_coverage:
|
|
440
|
+
res.abstained = True
|
|
441
|
+
res.reason = f"WT aligns to only {coverage:.0%} of the PDB chain (< {min_coverage:.0%})"
|
|
442
|
+
return res
|
|
443
|
+
resnum_to_obs = {rn: i for i, (rn, _) in enumerate(residues)}
|
|
444
|
+
agg: dict[int, dict] = {}
|
|
445
|
+
for level, label in ((1, "active-site shell 1"), (2, "active-site shell 2")):
|
|
446
|
+
for rn in shells.get(level, []):
|
|
447
|
+
obs_i = resnum_to_obs.get(rn)
|
|
448
|
+
if obs_i is None:
|
|
449
|
+
continue
|
|
450
|
+
wt0 = idx_map.get(obs_i)
|
|
451
|
+
if wt0 is None:
|
|
452
|
+
continue
|
|
453
|
+
d = agg.setdefault(wt0 + 1, {"uni": rn, "res": wt_seq[wt0], "types": set()})
|
|
454
|
+
d["types"].add(label)
|
|
455
|
+
for wtp in sorted(agg):
|
|
456
|
+
d = agg[wtp]
|
|
457
|
+
res.sites.append(ResolvedSite(
|
|
458
|
+
wt_position=wtp, residue=d["res"], source_position=d["uni"],
|
|
459
|
+
types=tuple(sorted(d["types"])), ligands=(), confidence="structure",
|
|
460
|
+
))
|
|
461
|
+
res.positions = sorted(agg)
|
|
462
|
+
if not res.sites:
|
|
463
|
+
res.abstained = True
|
|
464
|
+
res.reason = "active-site shells did not align onto WT"
|
|
465
|
+
return res
|