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,250 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Normal-tissue expression from GTEx (on-target / off-tumor safety).
|
|
4
|
+
|
|
5
|
+
A good antibody / ADC target is over-expressed in tumour but **low in vital normal
|
|
6
|
+
tissues** — otherwise the binder attacks healthy heart, brain, liver or lung
|
|
7
|
+
(on-target/off-tumor toxicity). bindsight ranks candidates from tumour RNA-seq;
|
|
8
|
+
this module adds the missing other half: median expression across normal tissues,
|
|
9
|
+
straight from GTEx v8 (the reference normal-tissue RNA atlas), so a candidate's
|
|
10
|
+
vital-tissue expression can gate it.
|
|
11
|
+
|
|
12
|
+
Data (real, cached): GTEx v8 gene-level median TPM per tissue (GCT), ~7 MB:
|
|
13
|
+
https://storage.googleapis.com/adult-gtex/bulk-gex/v8/rna-seq/
|
|
14
|
+
GTEx_Analysis_2017-06-05_v8_RNASeQCv1.1.9_gene_median_tpm.gct.gz
|
|
15
|
+
|
|
16
|
+
Fails closed. The gate exists to keep unsafe targets out, so "we could not check"
|
|
17
|
+
must never read as "checked and safe": an unavailable reference raises
|
|
18
|
+
:class:`GTExUnavailableError`, and a gene the reference does not cover is reported
|
|
19
|
+
as ``unassessed`` (:meth:`GTExTissueExpression.assess`) rather than silently
|
|
20
|
+
passing the gate.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Literal
|
|
30
|
+
|
|
31
|
+
import pandas as pd
|
|
32
|
+
import requests
|
|
33
|
+
from tenacity import (
|
|
34
|
+
retry,
|
|
35
|
+
retry_if_exception_type,
|
|
36
|
+
stop_after_attempt,
|
|
37
|
+
wait_exponential,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from bindsight.io.paths import cache_dir
|
|
41
|
+
|
|
42
|
+
LOG = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
GTEX_MEDIAN_TPM_URL = (
|
|
45
|
+
"https://storage.googleapis.com/adult-gtex/bulk-gex/v8/rna-seq/"
|
|
46
|
+
"GTEx_Analysis_2017-06-05_v8_RNASeQCv1.1.9_gene_median_tpm.gct.gz"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GTExUnavailableError(RuntimeError):
|
|
51
|
+
"""The GTEx reference could not be obtained or parsed.
|
|
52
|
+
|
|
53
|
+
Raised instead of returning "no expression found", which a caller would read
|
|
54
|
+
as a clean safety verdict on a target whose normal-tissue expression was in
|
|
55
|
+
fact never checked.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TissueSafety:
|
|
61
|
+
"""One gene's normal-tissue verdict against a vital-tissue TPM ceiling."""
|
|
62
|
+
|
|
63
|
+
status: Literal["safe", "unsafe", "unassessed"]
|
|
64
|
+
max_tpm: float | None
|
|
65
|
+
reason: str
|
|
66
|
+
#: The vital tissues this verdict actually looked at, and those it could not.
|
|
67
|
+
#:
|
|
68
|
+
#: A requested tissue with no matching GTEx column was silently dropped and
|
|
69
|
+
#: the maximum taken over whatever remained, so a gene highly expressed in a
|
|
70
|
+
#: dropped tissue was reported "safe" by a check that never looked at it.
|
|
71
|
+
#: A safety verdict has to say what it examined.
|
|
72
|
+
tissues_checked: tuple[str, ...] = ()
|
|
73
|
+
tissues_unrecognised: tuple[str, ...] = ()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_tissue(name: str) -> str:
|
|
77
|
+
"""Normalise a GTEx tissue label to a config-friendly key.
|
|
78
|
+
|
|
79
|
+
'Heart - Left Ventricle' -> 'heart_left_ventricle'; 'Brain - Cortex' ->
|
|
80
|
+
'brain_cortex'; 'Liver' -> 'liver'.
|
|
81
|
+
"""
|
|
82
|
+
return name.strip().lower().replace(" - ", "_").replace(" ", "_").replace("-", "_")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GTExTissueExpression:
|
|
86
|
+
"""Cached GTEx median-TPM-by-tissue lookup, keyed by Ensembl gene id."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
cache_subdir: str = "gtex",
|
|
91
|
+
url: str = GTEX_MEDIAN_TPM_URL,
|
|
92
|
+
gct_path: Path | str | None = None,
|
|
93
|
+
timeout: float = 180.0,
|
|
94
|
+
session: requests.Session | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.cache = cache_dir(cache_subdir)
|
|
97
|
+
self.url = url
|
|
98
|
+
# When provided (tests / vendored data) the GCT is read directly, no network.
|
|
99
|
+
self.gct_path = Path(gct_path) if gct_path else None
|
|
100
|
+
self.timeout = timeout
|
|
101
|
+
self.session = session or requests.Session()
|
|
102
|
+
self.session.headers.setdefault(
|
|
103
|
+
"User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
|
|
104
|
+
)
|
|
105
|
+
self._df: pd.DataFrame | None = None
|
|
106
|
+
|
|
107
|
+
def _parquet_cache_path(self) -> Path:
|
|
108
|
+
return self.cache / "gene_median_tpm.parquet"
|
|
109
|
+
|
|
110
|
+
@retry(
|
|
111
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
112
|
+
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
113
|
+
stop=stop_after_attempt(5),
|
|
114
|
+
reraise=True,
|
|
115
|
+
)
|
|
116
|
+
def _download(self, url: str) -> bytes:
|
|
117
|
+
resp = self.session.get(url, timeout=self.timeout)
|
|
118
|
+
resp.raise_for_status()
|
|
119
|
+
return resp.content
|
|
120
|
+
|
|
121
|
+
def _ensure_gct(self) -> Path:
|
|
122
|
+
cached = self.cache / "gene_median_tpm.gct.gz"
|
|
123
|
+
if cached.exists():
|
|
124
|
+
return cached
|
|
125
|
+
try:
|
|
126
|
+
payload = self._download(self.url)
|
|
127
|
+
except requests.RequestException as e:
|
|
128
|
+
raise GTExUnavailableError(f"GTEx download failed: {e}") from e
|
|
129
|
+
cached.write_bytes(payload)
|
|
130
|
+
return cached
|
|
131
|
+
|
|
132
|
+
def _load(self) -> pd.DataFrame:
|
|
133
|
+
"""Return a DataFrame indexed by unversioned Ensembl id, columns = tissues.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
GTExUnavailableError: The reference is missing or unparseable, so no
|
|
137
|
+
normal-tissue claim can be made about any gene.
|
|
138
|
+
"""
|
|
139
|
+
if self._df is not None:
|
|
140
|
+
return self._df
|
|
141
|
+
# Fast path: parsed parquet cache (only for the live/downloaded source).
|
|
142
|
+
parq = self._parquet_cache_path()
|
|
143
|
+
if self.gct_path is None and parq.exists():
|
|
144
|
+
try:
|
|
145
|
+
self._df = pd.read_parquet(parq)
|
|
146
|
+
return self._df
|
|
147
|
+
except Exception as e: # corrupt cache — rebuild
|
|
148
|
+
LOG.warning("GTEx parquet cache unreadable (%s); rebuilding", e)
|
|
149
|
+
|
|
150
|
+
gct = self.gct_path or self._ensure_gct()
|
|
151
|
+
if not Path(gct).exists():
|
|
152
|
+
raise GTExUnavailableError(f"GTEx reference not found at {gct}")
|
|
153
|
+
try:
|
|
154
|
+
df = pd.read_csv(gct, sep="\t", skiprows=2)
|
|
155
|
+
df["__gene"] = df["Name"].str.split(".").str[0]
|
|
156
|
+
df = df.drop(columns=["Name", "Description"]).set_index("__gene")
|
|
157
|
+
df.columns = [normalize_tissue(c) for c in df.columns]
|
|
158
|
+
df = df[~df.index.duplicated(keep="first")]
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise GTExUnavailableError(f"GTEx GCT parse failed for {gct}: {e}") from e
|
|
161
|
+
self._df = df
|
|
162
|
+
if self.gct_path is None:
|
|
163
|
+
# parquet cache is an optimisation, not required
|
|
164
|
+
with contextlib.suppress(Exception):
|
|
165
|
+
df.to_parquet(parq)
|
|
166
|
+
return df
|
|
167
|
+
|
|
168
|
+
def max_expression(self, ensembl_id: str, tissues: list[str]) -> float | None:
|
|
169
|
+
"""Max median TPM of ``ensembl_id`` across ``tissues``.
|
|
170
|
+
|
|
171
|
+
``None`` means *not measured here* — no gene id, the gene isn't in GTEx,
|
|
172
|
+
or none of the requested tissues exist. It never means "low expression";
|
|
173
|
+
use :meth:`assess` to get that distinction as a verdict.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
GTExUnavailableError: The reference itself is unavailable.
|
|
177
|
+
"""
|
|
178
|
+
if not ensembl_id:
|
|
179
|
+
return None
|
|
180
|
+
df = self._load()
|
|
181
|
+
gene = ensembl_id.split(".")[0]
|
|
182
|
+
if gene not in df.index:
|
|
183
|
+
return None
|
|
184
|
+
cols = [c for c in (normalize_tissue(t) for t in tissues) if c in df.columns]
|
|
185
|
+
if not cols:
|
|
186
|
+
return None
|
|
187
|
+
return float(df.loc[gene, cols].max())
|
|
188
|
+
|
|
189
|
+
def coverage(self, tissues: list[str]) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
190
|
+
"""Split ``tissues`` into those GTEx can answer for and those it cannot.
|
|
191
|
+
|
|
192
|
+
Returns ``(checked, unrecognised)``, both in config spelling. The
|
|
193
|
+
unrecognised half used to be dropped without a word, which turned a
|
|
194
|
+
partial check into a clean bill of health.
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
columns = set(self._load().columns)
|
|
198
|
+
except Exception: # a reference that will not load answers for nothing
|
|
199
|
+
return (), tuple(tissues)
|
|
200
|
+
checked = tuple(t for t in tissues if normalize_tissue(t) in columns)
|
|
201
|
+
return checked, tuple(t for t in tissues if normalize_tissue(t) not in columns)
|
|
202
|
+
|
|
203
|
+
def assess(self, ensembl_id: str, tissues: list[str], max_tpm: float) -> TissueSafety:
|
|
204
|
+
"""Verdict for one gene against the vital-tissue ceiling ``max_tpm``.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
ensembl_id: Ensembl gene id (a version suffix is stripped).
|
|
208
|
+
tissues: Vital tissues to check, in config spelling.
|
|
209
|
+
max_tpm: Median-TPM ceiling above which the target is unsafe.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A :class:`TissueSafety` whose ``status`` separates a measured pass
|
|
213
|
+
(``safe``) from an absent measurement (``unassessed``). Only ``safe``
|
|
214
|
+
may be presented as having cleared the normal-tissue gate.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
GTExUnavailableError: The reference is unavailable, so the gate cannot
|
|
218
|
+
be applied to any candidate at all.
|
|
219
|
+
"""
|
|
220
|
+
value = self.max_expression(ensembl_id, tissues)
|
|
221
|
+
checked, unrecognised = self.coverage(tissues)
|
|
222
|
+
# Named in every verdict, not only the failing ones: a reader checking
|
|
223
|
+
# whether brain was examined should not have to infer it from silence.
|
|
224
|
+
gap = f" Not examined (no GTEx column): {', '.join(unrecognised)}." if unrecognised else ""
|
|
225
|
+
if value is None:
|
|
226
|
+
return TissueSafety(
|
|
227
|
+
status="unassessed",
|
|
228
|
+
max_tpm=None,
|
|
229
|
+
reason=f"{ensembl_id or '<no gene id>'} has no GTEx median-TPM entry for "
|
|
230
|
+
f"{', '.join(tissues) or '<no tissues>'}" + gap,
|
|
231
|
+
tissues_checked=checked,
|
|
232
|
+
tissues_unrecognised=unrecognised,
|
|
233
|
+
)
|
|
234
|
+
if value > max_tpm:
|
|
235
|
+
return TissueSafety(
|
|
236
|
+
status="unsafe",
|
|
237
|
+
max_tpm=value,
|
|
238
|
+
reason=f"median TPM {value:.1f} exceeds the {max_tpm:.1f} vital-tissue ceiling"
|
|
239
|
+
+ gap,
|
|
240
|
+
tissues_checked=checked,
|
|
241
|
+
tissues_unrecognised=unrecognised,
|
|
242
|
+
)
|
|
243
|
+
return TissueSafety(
|
|
244
|
+
status="safe",
|
|
245
|
+
max_tpm=value,
|
|
246
|
+
reason=f"median TPM {value:.1f} is at or below the {max_tpm:.1f} "
|
|
247
|
+
f"vital-tissue ceiling" + gap,
|
|
248
|
+
tissues_checked=checked,
|
|
249
|
+
tissues_unrecognised=unrecognised,
|
|
250
|
+
)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Open Targets Platform GraphQL client.
|
|
4
|
+
|
|
5
|
+
Open Targets aggregates target-disease association evidence from ~20 sources
|
|
6
|
+
(literature, genetic associations, somatic mutations, drug evidence, etc.).
|
|
7
|
+
We use it to enrich differentially-expressed candidates with druggability,
|
|
8
|
+
known disease links, safety liabilities, and tractability scores.
|
|
9
|
+
|
|
10
|
+
API docs: https://platform-docs.opentargets.org/data-access/graphql-api
|
|
11
|
+
Endpoint: https://api.platform.opentargets.org/api/v4/graphql
|
|
12
|
+
|
|
13
|
+
Rate limits are generous but not unlimited. We back off with tenacity.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import time as _time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
27
|
+
from tenacity import (
|
|
28
|
+
retry,
|
|
29
|
+
retry_if_exception_type,
|
|
30
|
+
stop_after_attempt,
|
|
31
|
+
wait_exponential,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from bindsight.io.paths import cache_dir
|
|
35
|
+
|
|
36
|
+
LOG = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
OPEN_TARGETS_URL = "https://api.platform.opentargets.org/api/v4/graphql"
|
|
39
|
+
|
|
40
|
+
# A focused query: per Ensembl gene ID, fetch the bits we actually use for
|
|
41
|
+
# target prioritization downstream. Kept small to stay friendly to the API.
|
|
42
|
+
_TARGET_QUERY = """
|
|
43
|
+
query Target($ensemblId: String!) {
|
|
44
|
+
target(ensemblId: $ensemblId) {
|
|
45
|
+
id
|
|
46
|
+
approvedSymbol
|
|
47
|
+
approvedName
|
|
48
|
+
biotype
|
|
49
|
+
proteinIds {
|
|
50
|
+
id
|
|
51
|
+
source
|
|
52
|
+
}
|
|
53
|
+
tractability {
|
|
54
|
+
modality
|
|
55
|
+
label
|
|
56
|
+
value
|
|
57
|
+
}
|
|
58
|
+
safetyLiabilities {
|
|
59
|
+
event
|
|
60
|
+
datasource
|
|
61
|
+
}
|
|
62
|
+
associatedDiseases(page: { index: 0, size: 20 }) {
|
|
63
|
+
count
|
|
64
|
+
rows {
|
|
65
|
+
score
|
|
66
|
+
disease {
|
|
67
|
+
id
|
|
68
|
+
name
|
|
69
|
+
therapeuticAreas {
|
|
70
|
+
id
|
|
71
|
+
name
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TargetEvidence(BaseModel):
|
|
82
|
+
"""Slim record of Open Targets evidence for a single gene."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="ignore")
|
|
85
|
+
|
|
86
|
+
ensembl_id: str
|
|
87
|
+
symbol: str | None = None
|
|
88
|
+
name: str | None = None
|
|
89
|
+
biotype: str | None = None
|
|
90
|
+
uniprot_ids: list[str] = Field(default_factory=list)
|
|
91
|
+
tractability_modalities: list[str] = Field(
|
|
92
|
+
default_factory=list,
|
|
93
|
+
description="e.g. ['Antibody', 'SmallMolecule', 'PROTAC']. Sourced from "
|
|
94
|
+
"tractability.value=true rows.",
|
|
95
|
+
)
|
|
96
|
+
safety_event_count: int = 0
|
|
97
|
+
top_disease_associations: list[dict[str, Any]] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: How long an Open Targets response may be reused. Their releases are roughly
|
|
101
|
+
#: quarterly and move association scores, which move the ranking; an entry older
|
|
102
|
+
#: than this describes a different release from the one a run claims to have
|
|
103
|
+
#: used. Refetching costs one request against a cache that is otherwise warm.
|
|
104
|
+
CACHE_MAX_AGE_DAYS = 30
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OpenTargetsClient:
|
|
108
|
+
"""Cached, retrying GraphQL client for Open Targets."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
endpoint: str = OPEN_TARGETS_URL,
|
|
113
|
+
cache_subdir: str = "opentargets",
|
|
114
|
+
timeout: float = 30.0,
|
|
115
|
+
session: requests.Session | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.endpoint = endpoint
|
|
118
|
+
self.cache = cache_dir(cache_subdir)
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
self.session = session or requests.Session()
|
|
121
|
+
self.session.headers.setdefault(
|
|
122
|
+
"User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _cache_key(self, query: str, variables: dict[str, Any]) -> Path:
|
|
126
|
+
"""Where a response is cached.
|
|
127
|
+
|
|
128
|
+
The endpoint is part of the key, and the file records when it was
|
|
129
|
+
fetched. Neither was true before: the key was a hash of the query text
|
|
130
|
+
and variables alone, and a hit was returned unconditionally however old
|
|
131
|
+
it was and whichever endpoint produced it. So a warm cache from an
|
|
132
|
+
earlier Open Targets release supplied that release's association scores
|
|
133
|
+
to today's run while the provenance recorded today's date -- two
|
|
134
|
+
machines, the same command, different candidate rankings, and nothing in
|
|
135
|
+
either manifest saying which.
|
|
136
|
+
|
|
137
|
+
The repository already accepted exactly this reasoning for the
|
|
138
|
+
surfaceome (``tests/test_silent_success.py`` -- "two runs on two machines
|
|
139
|
+
could use different surfaceome lists ... and nothing in either manifest
|
|
140
|
+
would say which"). Open Targets is a larger input to the ranking than
|
|
141
|
+
the surfaceome is.
|
|
142
|
+
"""
|
|
143
|
+
payload = json.dumps(
|
|
144
|
+
{"endpoint": self.endpoint, "query": query, "variables": variables},
|
|
145
|
+
sort_keys=True,
|
|
146
|
+
)
|
|
147
|
+
sig = hashlib.sha256(payload.encode()).hexdigest()
|
|
148
|
+
return self.cache / f"{sig}.json"
|
|
149
|
+
|
|
150
|
+
@retry(
|
|
151
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
152
|
+
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
153
|
+
stop=stop_after_attempt(5),
|
|
154
|
+
reraise=True,
|
|
155
|
+
)
|
|
156
|
+
def _post(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
|
|
157
|
+
resp = self.session.post(
|
|
158
|
+
self.endpoint,
|
|
159
|
+
json={"query": query, "variables": variables},
|
|
160
|
+
timeout=self.timeout,
|
|
161
|
+
)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
body = resp.json()
|
|
164
|
+
if "errors" in body:
|
|
165
|
+
raise RuntimeError(f"Open Targets GraphQL errors: {body['errors']}")
|
|
166
|
+
data: dict[str, Any] = body["data"]
|
|
167
|
+
return data
|
|
168
|
+
|
|
169
|
+
def query(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
"""Execute a raw GraphQL query, with on-disk caching.
|
|
171
|
+
|
|
172
|
+
A cached entry older than :data:`CACHE_MAX_AGE_DAYS` is refetched. The
|
|
173
|
+
cache used to have no expiry at all, so a response fetched months ago
|
|
174
|
+
was served to a run whose provenance stamped today -- the run was
|
|
175
|
+
reproducible against that machine's disk and against nothing else.
|
|
176
|
+
"""
|
|
177
|
+
key = self._cache_key(query, variables)
|
|
178
|
+
if key.exists():
|
|
179
|
+
try:
|
|
180
|
+
envelope = json.loads(key.read_text(encoding="utf-8"))
|
|
181
|
+
except (OSError, json.JSONDecodeError):
|
|
182
|
+
envelope = None
|
|
183
|
+
if isinstance(envelope, dict) and "fetched_at" in envelope:
|
|
184
|
+
age = _time.time() - float(envelope.get("fetched_at") or 0)
|
|
185
|
+
if age <= CACHE_MAX_AGE_DAYS * 86400:
|
|
186
|
+
cached: dict[str, Any] = envelope.get("data") or {}
|
|
187
|
+
return cached
|
|
188
|
+
LOG.info(
|
|
189
|
+
"Open Targets cache entry is %.0f days old; refetching",
|
|
190
|
+
age / 86400,
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
# An entry from before the cache recorded its age. Its release
|
|
194
|
+
# is unknown, so it cannot be trusted to describe today's run.
|
|
195
|
+
LOG.info("Open Targets cache entry predates age recording; refetching")
|
|
196
|
+
|
|
197
|
+
data = self._post(query, variables)
|
|
198
|
+
key.write_text(
|
|
199
|
+
json.dumps({"fetched_at": _time.time(), "endpoint": self.endpoint, "data": data}),
|
|
200
|
+
encoding="utf-8",
|
|
201
|
+
newline="\n",
|
|
202
|
+
)
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
def get_target(self, ensembl_id: str) -> TargetEvidence | None:
|
|
206
|
+
"""Fetch the target evidence record for an Ensembl gene ID.
|
|
207
|
+
|
|
208
|
+
Returns ``None`` if Open Targets has no record for the gene.
|
|
209
|
+
"""
|
|
210
|
+
data = self.query(_TARGET_QUERY, {"ensemblId": ensembl_id})
|
|
211
|
+
t = data.get("target")
|
|
212
|
+
if t is None:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
uniprot_ids = [
|
|
216
|
+
p["id"] for p in (t.get("proteinIds") or []) if p.get("source") == "uniprot_swissprot"
|
|
217
|
+
]
|
|
218
|
+
modalities = sorted(
|
|
219
|
+
{row["modality"] for row in (t.get("tractability") or []) if row.get("value") is True}
|
|
220
|
+
)
|
|
221
|
+
diseases = [
|
|
222
|
+
{
|
|
223
|
+
"score": row["score"],
|
|
224
|
+
"disease_id": row["disease"]["id"],
|
|
225
|
+
"disease_name": row["disease"]["name"],
|
|
226
|
+
"therapeutic_areas": [ta["name"] for ta in row["disease"]["therapeuticAreas"]],
|
|
227
|
+
}
|
|
228
|
+
for row in (t.get("associatedDiseases", {}) or {}).get("rows", [])
|
|
229
|
+
]
|
|
230
|
+
return TargetEvidence(
|
|
231
|
+
ensembl_id=t["id"],
|
|
232
|
+
symbol=t.get("approvedSymbol"),
|
|
233
|
+
name=t.get("approvedName"),
|
|
234
|
+
biotype=t.get("biotype"),
|
|
235
|
+
uniprot_ids=uniprot_ids,
|
|
236
|
+
tractability_modalities=modalities,
|
|
237
|
+
safety_event_count=len(t.get("safetyLiabilities") or []),
|
|
238
|
+
top_disease_associations=diseases,
|
|
239
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Validator plugin interface (Boltz-2, Chai-1r, AF2-IG)."""
|
|
4
|
+
|
|
5
|
+
from bindsight.validate.protocol import ValidationResult, Validator
|
|
6
|
+
|
|
7
|
+
__all__ = ["ValidationResult", "Validator"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""AF2-IG validator plugin (opt-in; PyRosetta is free only for non-commercial use).
|
|
4
|
+
|
|
5
|
+
AF2 with initial guess (`Bennett/Baker dl_binder_design
|
|
6
|
+
<https://github.com/nrbennet/dl_binder_design>`_) is the gold-standard binder
|
|
7
|
+
filter — but depends on PyRosetta, which is free only for non-commercial use; the
|
|
8
|
+
AlphaFold2 parameters themselves are CC BY 4.0. The CLI prints
|
|
9
|
+
a license banner before invoking this plugin. The GPU inference runs in
|
|
10
|
+
:mod:`bindsight.runners.job_exec`; this plugin's :meth:`validate` parses the
|
|
11
|
+
AF2 initial-guess score file the runner produced.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from bindsight.validate.boltz2 import MissingValidationError
|
|
19
|
+
from bindsight.validate.protocol import ValidationResult
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AF2IGValidator:
|
|
23
|
+
"""Plugin: AlphaFold2 with initial guess (PyRosetta: non-commercial use only)."""
|
|
24
|
+
|
|
25
|
+
name = "af2_ig"
|
|
26
|
+
version = "1.0"
|
|
27
|
+
license_notice = (
|
|
28
|
+
"AF2-IG depends on PyRosetta, which is free only for non-commercial use; "
|
|
29
|
+
"the AlphaFold2 parameters are CC BY 4.0. See LICENSING.md § 3."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def validate(
|
|
33
|
+
self,
|
|
34
|
+
target_uniprot: str,
|
|
35
|
+
binder_id: str,
|
|
36
|
+
binder_sequence: str,
|
|
37
|
+
target_structure_path: str,
|
|
38
|
+
) -> ValidationResult:
|
|
39
|
+
"""Parse AF2 initial-guess output for this binder from ``validate/<binder_id>/``."""
|
|
40
|
+
from bindsight.runners.tools import parse_af2ig_output
|
|
41
|
+
|
|
42
|
+
cwd = Path("validate") / binder_id
|
|
43
|
+
score_file = cwd / "af2_scores.sc"
|
|
44
|
+
if not score_file.exists():
|
|
45
|
+
raise MissingValidationError(
|
|
46
|
+
f"no AF2-IG output found at {score_file}; run the GPU validation step first "
|
|
47
|
+
"(bindsight validate --validator af2_ig)"
|
|
48
|
+
)
|
|
49
|
+
return parse_af2ig_output(score_file, binder_id=binder_id, target_uniprot=target_uniprot)
|