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.
Files changed (51) hide show
  1. shellde/__init__.py +36 -0
  2. shellde/acquisition.py +135 -0
  3. shellde/advisor.py +158 -0
  4. shellde/bench/__init__.py +17 -0
  5. shellde/bench/falsification.py +95 -0
  6. shellde/bench/harness.py +134 -0
  7. shellde/bench/stats.py +46 -0
  8. shellde/campaign.py +257 -0
  9. shellde/candidates.py +341 -0
  10. shellde/cli.py +1517 -0
  11. shellde/colab.py +257 -0
  12. shellde/conformal.py +160 -0
  13. shellde/consensus.py +52 -0
  14. shellde/design_space.py +141 -0
  15. shellde/embeddings/__init__.py +6 -0
  16. shellde/embeddings/esm2.py +76 -0
  17. shellde/embeddings/esmc.py +94 -0
  18. shellde/embeddings/provider.py +105 -0
  19. shellde/features/__init__.py +22 -0
  20. shellde/features/base.py +49 -0
  21. shellde/features/defaults.py +11 -0
  22. shellde/features/embedding.py +80 -0
  23. shellde/features/inverse_folding.py +66 -0
  24. shellde/features/matrix.py +50 -0
  25. shellde/features/naturalness.py +65 -0
  26. shellde/features/onehot.py +55 -0
  27. shellde/features/pairwise.py +64 -0
  28. shellde/funclib.py +331 -0
  29. shellde/gating.py +181 -0
  30. shellde/holo.py +175 -0
  31. shellde/hotspots.py +108 -0
  32. shellde/loop.py +161 -0
  33. shellde/msa.py +186 -0
  34. shellde/naturalness.py +142 -0
  35. shellde/oracle.py +94 -0
  36. shellde/plm.py +145 -0
  37. shellde/prereg.py +41 -0
  38. shellde/protocols.py +87 -0
  39. shellde/rank.py +103 -0
  40. shellde/report.py +132 -0
  41. shellde/selector.py +63 -0
  42. shellde/sitefinder.py +465 -0
  43. shellde/structure.py +356 -0
  44. shellde/surrogate.py +323 -0
  45. shellde/types.py +66 -0
  46. shellde/zero_shot.py +160 -0
  47. shellde-0.2.0.dist-info/METADATA +285 -0
  48. shellde-0.2.0.dist-info/RECORD +51 -0
  49. shellde-0.2.0.dist-info/WHEEL +5 -0
  50. shellde-0.2.0.dist-info/entry_points.txt +2 -0
  51. 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