bindsight 0.3.5__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.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Protein structure retrieval — AlphaFoldDB (RCSB/PDBe clients are planned)."""
|
|
4
|
+
|
|
5
|
+
from bindsight.structures.alphafolddb import AlphaFoldDBClient
|
|
6
|
+
|
|
7
|
+
__all__ = ["AlphaFoldDBClient"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""AlphaFoldDB structure fetcher.
|
|
4
|
+
|
|
5
|
+
Pulls predicted structures by UniProt ID from EBI's AlphaFold Database.
|
|
6
|
+
All AlphaFoldDB models are CC BY 4.0; we cache them locally to avoid
|
|
7
|
+
hammering the public API.
|
|
8
|
+
|
|
9
|
+
Endpoint pattern (mmCIF):
|
|
10
|
+
https://alphafold.ebi.ac.uk/files/AF-{uniprot_id}-F1-model_v6.cif
|
|
11
|
+
|
|
12
|
+
API docs: https://alphafold.ebi.ac.uk/api-docs
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
from tenacity import (
|
|
22
|
+
retry,
|
|
23
|
+
retry_if_exception_type,
|
|
24
|
+
stop_after_attempt,
|
|
25
|
+
wait_exponential,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from bindsight.io.paths import cache_dir
|
|
29
|
+
|
|
30
|
+
LOG = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
ALPHAFOLDDB_BASE = "https://alphafold.ebi.ac.uk/files"
|
|
33
|
+
# AlphaFoldDB bumps the model version periodically (v4 → v6 as of 2026); the old
|
|
34
|
+
# URLs 404. Track the current release.
|
|
35
|
+
DEFAULT_MODEL_VERSION = "v6"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AlphaFoldDBClient:
|
|
39
|
+
"""Cached AlphaFoldDB mmCIF downloader."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
cache_subdir: str = "alphafolddb",
|
|
44
|
+
timeout: float = 60.0,
|
|
45
|
+
session: requests.Session | None = None,
|
|
46
|
+
model_version: str = DEFAULT_MODEL_VERSION,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.cache = cache_dir(cache_subdir)
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.session = session or requests.Session()
|
|
51
|
+
self.session.headers.setdefault(
|
|
52
|
+
"User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
|
|
53
|
+
)
|
|
54
|
+
self.model_version = model_version
|
|
55
|
+
|
|
56
|
+
def _url(self, uniprot_id: str) -> str:
|
|
57
|
+
return f"{ALPHAFOLDDB_BASE}/AF-{uniprot_id}-F1-model_{self.model_version}.cif"
|
|
58
|
+
|
|
59
|
+
def _cache_path(self, uniprot_id: str) -> Path:
|
|
60
|
+
return self.cache / f"AF-{uniprot_id}-F1-model_{self.model_version}.cif"
|
|
61
|
+
|
|
62
|
+
@retry(
|
|
63
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
64
|
+
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
65
|
+
stop=stop_after_attempt(5),
|
|
66
|
+
reraise=True,
|
|
67
|
+
)
|
|
68
|
+
def _download(self, url: str) -> bytes:
|
|
69
|
+
resp = self.session.get(url, timeout=self.timeout)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
return resp.content
|
|
72
|
+
|
|
73
|
+
def fetch(self, uniprot_id: str, *, force: bool = False) -> Path | None:
|
|
74
|
+
"""Download the AlphaFoldDB mmCIF for a UniProt ID.
|
|
75
|
+
|
|
76
|
+
Returns the path to the cached mmCIF file, or ``None`` if AlphaFoldDB
|
|
77
|
+
has no model for the given UniProt accession (HTTP 404).
|
|
78
|
+
"""
|
|
79
|
+
cached = self._cache_path(uniprot_id)
|
|
80
|
+
if cached.exists() and not force:
|
|
81
|
+
return cached
|
|
82
|
+
|
|
83
|
+
url = self._url(uniprot_id)
|
|
84
|
+
try:
|
|
85
|
+
payload = self._download(url)
|
|
86
|
+
except requests.HTTPError as e:
|
|
87
|
+
if e.response is not None and e.response.status_code == 404:
|
|
88
|
+
LOG.warning("AlphaFoldDB has no model for %s", uniprot_id)
|
|
89
|
+
return None
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
cached.write_bytes(payload)
|
|
93
|
+
return cached
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Per-residue confidence (pLDDT) from AlphaFold models.
|
|
4
|
+
|
|
5
|
+
AlphaFold stores its per-residue confidence estimate (pLDDT, 0–100) in the
|
|
6
|
+
B-factor column of every atom. pLDDT doubles as a practical disorder signal:
|
|
7
|
+
regions below ~50 are very likely intrinsically disordered, and 50–70 is low
|
|
8
|
+
confidence. Designing a binder against a disordered or low-confidence region
|
|
9
|
+
wastes GPU time, so bindsight reads pLDDT straight from the cached AlphaFoldDB
|
|
10
|
+
mmCIF and uses it to flag/avoid those regions.
|
|
11
|
+
|
|
12
|
+
Pure parsing on top of Biopython's ``MMCIF2Dict`` — no network, no heavy
|
|
13
|
+
structure objects. All functions degrade gracefully (return ``None``/empty) on a
|
|
14
|
+
missing or unparseable file so discovery never crashes on a bad model.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
LOG = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_list(value: object) -> list[str]:
|
|
26
|
+
"""``MMCIF2Dict`` returns a scalar for single-row loops and a list otherwise."""
|
|
27
|
+
if value is None:
|
|
28
|
+
return []
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return [str(v) for v in value]
|
|
31
|
+
return [str(value)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def per_residue_plddt(cif_path: Path | str | None) -> dict[int, float]:
|
|
35
|
+
"""Map residue number → pLDDT for an AlphaFold mmCIF.
|
|
36
|
+
|
|
37
|
+
Uses the representative (Cα) atom's B-factor per residue. Returns an empty
|
|
38
|
+
dict if the file is missing or cannot be parsed.
|
|
39
|
+
"""
|
|
40
|
+
if not cif_path:
|
|
41
|
+
return {}
|
|
42
|
+
p = Path(cif_path)
|
|
43
|
+
if not p.exists():
|
|
44
|
+
return {}
|
|
45
|
+
try:
|
|
46
|
+
from Bio.PDB.MMCIF2Dict import MMCIF2Dict
|
|
47
|
+
|
|
48
|
+
d = MMCIF2Dict(str(p))
|
|
49
|
+
except Exception as e: # malformed file, missing biopython, etc.
|
|
50
|
+
LOG.warning("pLDDT parse failed for %s: %s", p, e)
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
atom_ids = _as_list(d.get("_atom_site.label_atom_id"))
|
|
54
|
+
seq_ids = _as_list(d.get("_atom_site.label_seq_id"))
|
|
55
|
+
bfactors = _as_list(d.get("_atom_site.B_iso_or_equiv"))
|
|
56
|
+
if not (atom_ids and seq_ids and bfactors):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
out: dict[int, float] = {}
|
|
60
|
+
for atom, seq, bfac in zip(atom_ids, seq_ids, bfactors, strict=False):
|
|
61
|
+
if atom != "CA":
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
out[int(seq)] = float(bfac)
|
|
65
|
+
except (ValueError, TypeError):
|
|
66
|
+
continue
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mean_plddt(cif_path: Path | str | None) -> float | None:
|
|
71
|
+
"""Mean per-residue pLDDT over the whole model, or ``None`` if unavailable."""
|
|
72
|
+
vals = per_residue_plddt(cif_path)
|
|
73
|
+
if not vals:
|
|
74
|
+
return None
|
|
75
|
+
return sum(vals.values()) / len(vals)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def region_plddt(cif_path: Path | str | None, residues: list[int]) -> float | None:
|
|
79
|
+
"""Mean pLDDT over a specific set of residue numbers (e.g. an epitope).
|
|
80
|
+
|
|
81
|
+
Falls back to the whole-model mean when ``residues`` is empty (the
|
|
82
|
+
whole-surface design case). Returns ``None`` if no pLDDT is available.
|
|
83
|
+
"""
|
|
84
|
+
vals = per_residue_plddt(cif_path)
|
|
85
|
+
if not vals:
|
|
86
|
+
return None
|
|
87
|
+
if not residues:
|
|
88
|
+
return sum(vals.values()) / len(vals)
|
|
89
|
+
picked = [vals[r] for r in residues if r in vals]
|
|
90
|
+
if not picked:
|
|
91
|
+
return None
|
|
92
|
+
return sum(picked) / len(picked)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Membrane topology from UniProt (extracellular-domain awareness).
|
|
4
|
+
|
|
5
|
+
An antibody or a de novo mini-binder can only reach the **extracellular** part of
|
|
6
|
+
a cell-surface protein. Designing against the whole chain — which usually includes
|
|
7
|
+
a transmembrane helix and a cytoplasmic tail — wastes effort on regions a binder
|
|
8
|
+
can never touch. UniProt curates per-residue membrane topology; this module pulls
|
|
9
|
+
it and exposes the extracellular ranges so discovery can target the ECD.
|
|
10
|
+
|
|
11
|
+
Endpoint (JSON):
|
|
12
|
+
https://rest.uniprot.org/uniprotkb/{acc}.json?fields=ft_transmem,ft_topo_dom,ft_signal
|
|
13
|
+
|
|
14
|
+
Cached on disk like the AlphaFoldDB client. Degrades gracefully: a network/parse
|
|
15
|
+
failure or an un-annotated entry returns ``None`` so discovery falls back to
|
|
16
|
+
whole-surface design rather than crashing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
from tenacity import (
|
|
29
|
+
retry,
|
|
30
|
+
retry_if_exception_type,
|
|
31
|
+
stop_after_attempt,
|
|
32
|
+
wait_exponential,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from bindsight.io.paths import cache_dir
|
|
36
|
+
|
|
37
|
+
LOG = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
UNIPROT_BASE = "https://rest.uniprot.org/uniprotkb"
|
|
40
|
+
_FIELDS = "ft_transmem,ft_topo_dom,ft_signal"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Topology:
|
|
45
|
+
"""Parsed membrane topology for one UniProt accession (1-based residue ranges)."""
|
|
46
|
+
|
|
47
|
+
uniprot_id: str
|
|
48
|
+
extracellular_ranges: tuple[tuple[int, int], ...]
|
|
49
|
+
transmembrane_ranges: tuple[tuple[int, int], ...]
|
|
50
|
+
signal_peptide: tuple[int, int] | None
|
|
51
|
+
#: Omega-site residue of a GPI anchor, when UniProt annotates one.
|
|
52
|
+
#:
|
|
53
|
+
#: A GPI-anchored protein is tethered to the outer leaflet by a lipid rather
|
|
54
|
+
#: than by a transmembrane helix, so it has no transmembrane segment — and
|
|
55
|
+
#: UniProt annotates topological domains *relative to* transmembrane
|
|
56
|
+
#: segments, so it has none of those either. Reading extracellular extent
|
|
57
|
+
#: from topological domains alone therefore classified an entire class of
|
|
58
|
+
#: cell-surface protein as having nothing a binder can reach. MSLN
|
|
59
|
+
#: (Q13421) is the clearest case: no Transmembrane feature, no Topological
|
|
60
|
+
#: domain, one Lipidation reading "GPI-anchor amidated serine" — and it is
|
|
61
|
+
#: the target of multiple clinical antibody-drug conjugates and CAR-T
|
|
62
|
+
#: programmes. CEACAM5, FOLR1 and CD59 have the same shape.
|
|
63
|
+
gpi_anchor: int | None = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_gpi_anchored(self) -> bool:
|
|
67
|
+
"""True if UniProt annotates a GPI anchor and no transmembrane segment."""
|
|
68
|
+
return self.gpi_anchor is not None and not self.transmembrane_ranges
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_extracellular(self) -> bool:
|
|
72
|
+
"""True if any part of the chain is annotated as reachable from outside.
|
|
73
|
+
|
|
74
|
+
A GPI-anchored protein counts: its whole mature chain sits outside the
|
|
75
|
+
membrane. This returned ``False`` for every one of them, and with
|
|
76
|
+
``require_extracellular_domain`` enabled that dropped them from design
|
|
77
|
+
carry-forward as "not antibody-accessible" — the opposite of the truth
|
|
78
|
+
for some of the best-validated antibody targets there are.
|
|
79
|
+
"""
|
|
80
|
+
return bool(self.extracellular_ranges)
|
|
81
|
+
|
|
82
|
+
def extracellular_residues(self) -> set[int]:
|
|
83
|
+
"""All residue numbers inside extracellular topological domains."""
|
|
84
|
+
out: set[int] = set()
|
|
85
|
+
for s, e in self.extracellular_ranges:
|
|
86
|
+
out.update(range(s, e + 1))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
def fraction_extracellular(self, residues: list[int]) -> float | None:
|
|
90
|
+
"""Fraction of ``residues`` that fall in an extracellular domain.
|
|
91
|
+
|
|
92
|
+
``None`` if ``residues`` is empty; ``0.0`` if the protein has no annotated
|
|
93
|
+
extracellular domain (nothing a binder can reach).
|
|
94
|
+
"""
|
|
95
|
+
if not residues:
|
|
96
|
+
return None
|
|
97
|
+
ecd = self.extracellular_residues()
|
|
98
|
+
if not ecd:
|
|
99
|
+
return 0.0
|
|
100
|
+
return sum(1 for r in residues if r in ecd) / len(residues)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_topology(uniprot_id: str, data: dict[str, Any]) -> Topology:
|
|
104
|
+
"""Parse a UniProt entry JSON into a :class:`Topology` (pure; no I/O)."""
|
|
105
|
+
extracellular: list[tuple[int, int]] = []
|
|
106
|
+
transmembrane: list[tuple[int, int]] = []
|
|
107
|
+
signal: tuple[int, int] | None = None
|
|
108
|
+
gpi: int | None = None
|
|
109
|
+
propeptide: tuple[int, int] | None = None
|
|
110
|
+
for feat in data.get("features", []):
|
|
111
|
+
loc = feat.get("location", {})
|
|
112
|
+
start = loc.get("start", {}).get("value")
|
|
113
|
+
end = loc.get("end", {}).get("value")
|
|
114
|
+
if start is None or end is None:
|
|
115
|
+
continue # uncertain/fuzzy location — skip
|
|
116
|
+
rng = (int(start), int(end))
|
|
117
|
+
ftype = feat.get("type")
|
|
118
|
+
desc = (feat.get("description") or "").strip().lower()
|
|
119
|
+
if ftype == "Topological domain" and desc.startswith("extracellular"):
|
|
120
|
+
extracellular.append(rng)
|
|
121
|
+
elif ftype == "Transmembrane":
|
|
122
|
+
transmembrane.append(rng)
|
|
123
|
+
elif ftype == "Signal" and signal is None:
|
|
124
|
+
signal = rng
|
|
125
|
+
elif ftype == "Lipidation" and "gpi-anchor" in desc and gpi is None:
|
|
126
|
+
# The omega site: the residue the glycolipid is amidated onto.
|
|
127
|
+
gpi = rng[0]
|
|
128
|
+
elif ftype == "Propeptide" and propeptide is None:
|
|
129
|
+
propeptide = rng
|
|
130
|
+
|
|
131
|
+
if gpi is not None and not transmembrane and not extracellular:
|
|
132
|
+
# A GPI-anchored protein has no transmembrane helix, so UniProt gives it
|
|
133
|
+
# no topological domains either — the annotation is defined relative to
|
|
134
|
+
# a membrane-spanning segment. Its mature chain nonetheless sits wholly
|
|
135
|
+
# outside the cell, which is why MSLN and CEACAM5 are antibody targets.
|
|
136
|
+
#
|
|
137
|
+
# The reachable region is the mature chain: everything after the signal
|
|
138
|
+
# peptide, up to and including the omega site. Residues beyond it are
|
|
139
|
+
# the propeptide UniProt marks "Removed in mature form" — they are gone
|
|
140
|
+
# before the protein reaches the surface, so designing against them
|
|
141
|
+
# would target a sequence that does not exist on a cell. For MSLN that
|
|
142
|
+
# is 37-598 of 622: signal 1-36, omega site 598, propeptide 599-622.
|
|
143
|
+
start = (signal[1] + 1) if signal else 1
|
|
144
|
+
end = gpi
|
|
145
|
+
if propeptide is not None and propeptide[0] <= gpi:
|
|
146
|
+
# An omega site inside the cleaved region would make the mature
|
|
147
|
+
# chain end before it begins; trust the propeptide boundary.
|
|
148
|
+
end = propeptide[0] - 1
|
|
149
|
+
if end >= start:
|
|
150
|
+
extracellular.append((start, end))
|
|
151
|
+
|
|
152
|
+
return Topology(
|
|
153
|
+
uniprot_id=uniprot_id,
|
|
154
|
+
extracellular_ranges=tuple(extracellular),
|
|
155
|
+
transmembrane_ranges=tuple(transmembrane),
|
|
156
|
+
signal_peptide=signal,
|
|
157
|
+
gpi_anchor=gpi,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class UniProtTopologyClient:
|
|
162
|
+
"""Cached UniProt membrane-topology fetcher."""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
cache_subdir: str = "uniprot_topology",
|
|
167
|
+
timeout: float = 60.0,
|
|
168
|
+
session: requests.Session | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
self.cache = cache_dir(cache_subdir)
|
|
171
|
+
self.timeout = timeout
|
|
172
|
+
self.session = session or requests.Session()
|
|
173
|
+
self.session.headers.setdefault(
|
|
174
|
+
"User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _url(self, acc: str) -> str:
|
|
178
|
+
return f"{UNIPROT_BASE}/{acc}.json?fields={_FIELDS}"
|
|
179
|
+
|
|
180
|
+
def _cache_path(self, acc: str) -> Path:
|
|
181
|
+
return self.cache / f"{acc}.topology.json"
|
|
182
|
+
|
|
183
|
+
@retry(
|
|
184
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
185
|
+
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
186
|
+
stop=stop_after_attempt(5),
|
|
187
|
+
reraise=True,
|
|
188
|
+
)
|
|
189
|
+
def _download(self, url: str) -> bytes:
|
|
190
|
+
resp = self.session.get(url, timeout=self.timeout)
|
|
191
|
+
resp.raise_for_status()
|
|
192
|
+
return resp.content
|
|
193
|
+
|
|
194
|
+
def fetch(self, uniprot_id: str, *, force: bool = False) -> Topology | None:
|
|
195
|
+
"""Fetch + parse topology for a UniProt accession.
|
|
196
|
+
|
|
197
|
+
Returns ``None`` on a 404, a network failure, or unparseable JSON, so the
|
|
198
|
+
caller falls back to whole-surface design.
|
|
199
|
+
"""
|
|
200
|
+
if not uniprot_id:
|
|
201
|
+
return None
|
|
202
|
+
cached = self._cache_path(uniprot_id)
|
|
203
|
+
if cached.exists() and not force:
|
|
204
|
+
try:
|
|
205
|
+
return parse_topology(uniprot_id, json.loads(cached.read_text(encoding="utf-8")))
|
|
206
|
+
except Exception as e:
|
|
207
|
+
LOG.warning("cached topology unreadable for %s (%s); refetching", uniprot_id, e)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
payload = self._download(self._url(uniprot_id))
|
|
211
|
+
except requests.HTTPError as e:
|
|
212
|
+
if e.response is not None and e.response.status_code == 404:
|
|
213
|
+
LOG.warning("UniProt has no entry for %s", uniprot_id)
|
|
214
|
+
return None
|
|
215
|
+
LOG.warning("UniProt topology fetch failed for %s: %s", uniprot_id, e)
|
|
216
|
+
return None
|
|
217
|
+
except requests.RequestException as e:
|
|
218
|
+
LOG.warning("UniProt topology fetch failed for %s: %s", uniprot_id, e)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
data = json.loads(payload)
|
|
223
|
+
except json.JSONDecodeError as e:
|
|
224
|
+
LOG.warning("UniProt returned non-JSON for %s: %s", uniprot_id, e)
|
|
225
|
+
return None
|
|
226
|
+
cached.write_bytes(payload)
|
|
227
|
+
return parse_topology(uniprot_id, data)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Surfaceome membership — is this protein on the cell surface?
|
|
4
|
+
|
|
5
|
+
Two sources, unioned: the SURFY machine-learning surfaceome (2,886
|
|
6
|
+
accessions) and a UniProt cell-membrane extension built from the curated
|
|
7
|
+
SL-0039 annotation (1,915 further accessions, 4,801 in total). The extension
|
|
8
|
+
exists because SURFY omits CA9 and STEAP1, both established antigens, which
|
|
9
|
+
made them unreachable at any expression level.
|
|
10
|
+
|
|
11
|
+
Those three figures are checked against the lists themselves by
|
|
12
|
+
``tests/test_docs_claims.py``; they appear in several documents, and a number
|
|
13
|
+
restated in five places with nothing comparing it to the data is a number that
|
|
14
|
+
drifts.
|
|
15
|
+
|
|
16
|
+
Targetable-site lookup is a different question and lives in
|
|
17
|
+
``bindsight.epitopes``; this package answers membership only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from bindsight.surfaceome.surfy import (
|
|
21
|
+
SURFY_PROTEIN_COUNT,
|
|
22
|
+
is_surface_protein,
|
|
23
|
+
load_surfaceome,
|
|
24
|
+
load_surfaceome_extension,
|
|
25
|
+
load_surfaceome_gene_map,
|
|
26
|
+
load_surfy,
|
|
27
|
+
load_surfy_gene_map,
|
|
28
|
+
populate_surfy_cache,
|
|
29
|
+
surfaceome_source,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"SURFY_PROTEIN_COUNT",
|
|
34
|
+
"is_surface_protein",
|
|
35
|
+
"load_surfaceome",
|
|
36
|
+
"load_surfaceome_extension",
|
|
37
|
+
"load_surfaceome_gene_map",
|
|
38
|
+
"load_surfy",
|
|
39
|
+
"load_surfy_gene_map",
|
|
40
|
+
"populate_surfy_cache",
|
|
41
|
+
"surfaceome_source",
|
|
42
|
+
]
|