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
bindsight/report/html.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Self-contained HTML report renderer.
|
|
4
|
+
|
|
5
|
+
Reads a finished bindsight run directory (``deg/results.parquet``,
|
|
6
|
+
``targets/candidates.parquet``, ``epitopes/epitopes.parquet``,
|
|
7
|
+
``run_manifest.jsonld``) and emits one ``report.html`` you can email to a
|
|
8
|
+
collaborator or attach to a paper.
|
|
9
|
+
|
|
10
|
+
Design choices:
|
|
11
|
+
|
|
12
|
+
- **No Quarto / Jupyter dependency.** Pure Python with stdlib, jinja2, and
|
|
13
|
+
matplotlib (which is already in the report extras). The output is one
|
|
14
|
+
genuinely self-contained HTML file — CSS embedded, volcano plot embedded as
|
|
15
|
+
a base64 PNG, and no external requests at all, so it survives being emailed
|
|
16
|
+
or opened offline -- including the predicted complexes, drawn in 3-D by the
|
|
17
|
+
same viewer the web interface uses. That used to be excluded on the grounds
|
|
18
|
+
that "a structure viewer needs a script from a CDN", which was never true
|
|
19
|
+
here: 3Dmol.js is vendored into the package. The real constraint is size, and
|
|
20
|
+
it is handled as a budget rather than an exclusion -- see
|
|
21
|
+
``_STRUCTURE_BUDGET_BYTES``.
|
|
22
|
+
- **Looks like a paper, not a dashboard.** Sections, tables, captions,
|
|
23
|
+
citations to the upstream tools — readable as a methods + results pair.
|
|
24
|
+
- **Provenance front and center.** The manifest table shows every stage's
|
|
25
|
+
tool, version, license, and SHA-256 inputs/outputs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import io
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
from collections.abc import Iterable, Mapping
|
|
35
|
+
from pathlib import Path, PurePosixPath
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
40
|
+
|
|
41
|
+
from bindsight.pipelines.caveats import DISCOVERY_LIMITATIONS
|
|
42
|
+
from bindsight.report.format import fmt_p
|
|
43
|
+
|
|
44
|
+
LOG = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_run(
|
|
50
|
+
run_dir: Path | str,
|
|
51
|
+
out_path: Path | str | None = None,
|
|
52
|
+
*,
|
|
53
|
+
include_binders: bool = False,
|
|
54
|
+
embed_structures: bool = True,
|
|
55
|
+
) -> Path:
|
|
56
|
+
"""Render a finished run as a single self-contained HTML file.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
run_dir: directory produced by ``bindsight discover``.
|
|
60
|
+
out_path: destination file. Defaults to ``<run_dir>/report.html``.
|
|
61
|
+
include_binders: also embed each ranked binder's designed sequence.
|
|
62
|
+
The sequences live in the design tarballs rather than the ranking
|
|
63
|
+
table, so reading them costs a pass over those archives.
|
|
64
|
+
embed_structures: embed the predicted complexes and the 3-D viewer,
|
|
65
|
+
within ``_STRUCTURE_BUDGET_BYTES``. Off makes a much smaller file
|
|
66
|
+
that shows the numbers without the structures behind them.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Path to the rendered HTML.
|
|
70
|
+
"""
|
|
71
|
+
run_dir = Path(run_dir)
|
|
72
|
+
out_path = Path(out_path) if out_path else (run_dir / "report.html")
|
|
73
|
+
|
|
74
|
+
deg_df = _maybe_read_parquet(run_dir / "deg" / "results.parquet")
|
|
75
|
+
candidates_df = _maybe_read_parquet(run_dir / "targets" / "candidates.parquet")
|
|
76
|
+
epitopes_df = _maybe_read_parquet(run_dir / "epitopes" / "epitopes.parquet")
|
|
77
|
+
taxonomy_df = _maybe_read_parquet(run_dir / "taxonomy" / "failure_taxonomy.parquet")
|
|
78
|
+
ranking_df = _maybe_read_parquet(run_dir / "rank" / "ranking.parquet")
|
|
79
|
+
manifest = _maybe_read_jsonld(run_dir / "run_manifest.jsonld")
|
|
80
|
+
|
|
81
|
+
deg_fdr, deg_log2fc = _deg_thresholds(manifest)
|
|
82
|
+
volcano_b64 = (
|
|
83
|
+
_render_volcano(deg_df, fdr_threshold=deg_fdr, log2fc_threshold=deg_log2fc)
|
|
84
|
+
if deg_df is not None and len(deg_df)
|
|
85
|
+
else ""
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
env = Environment(
|
|
89
|
+
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
90
|
+
undefined=StrictUndefined,
|
|
91
|
+
autoescape=True,
|
|
92
|
+
trim_blocks=True,
|
|
93
|
+
lstrip_blocks=True,
|
|
94
|
+
)
|
|
95
|
+
template = env.get_template("report.html.j2")
|
|
96
|
+
# The shared design system first, then the report's own layer. One
|
|
97
|
+
# stylesheet for both surfaces is the point: a reader who has seen the
|
|
98
|
+
# served interface should recognise this file, and a rule enforced in one
|
|
99
|
+
# place should not be absent from the other.
|
|
100
|
+
_design_system = Path(__file__).resolve().parent / "web" / "static" / "bindsight.css"
|
|
101
|
+
css = (
|
|
102
|
+
_design_system.read_text(encoding="utf-8")
|
|
103
|
+
+ "\n"
|
|
104
|
+
+ (_TEMPLATES_DIR / "report.css").read_text(encoding="utf-8")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Best-first, so a budget that cannot hold every complex holds the ones a
|
|
108
|
+
# reader opens the report to see.
|
|
109
|
+
ranked_ids = (
|
|
110
|
+
[str(v) for v in ranking_df["binder_id"].tolist()]
|
|
111
|
+
if ranking_df is not None and "binder_id" in ranking_df.columns
|
|
112
|
+
else []
|
|
113
|
+
)
|
|
114
|
+
structures: dict[str, str] = {}
|
|
115
|
+
structures_omitted = 0
|
|
116
|
+
if embed_structures and ranked_ids:
|
|
117
|
+
# Passed rather than defaulted: a default argument binds at import
|
|
118
|
+
# time, so the end-to-end behaviour of a full budget could not be
|
|
119
|
+
# exercised without monkeypatching the function itself.
|
|
120
|
+
structures, structures_omitted = _binder_structures(
|
|
121
|
+
run_dir, ranked_ids, _STRUCTURE_BUDGET_BYTES
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
viewer_js = ""
|
|
125
|
+
viewer_lib = ""
|
|
126
|
+
if structures:
|
|
127
|
+
_static = Path(__file__).resolve().parent / "web" / "static"
|
|
128
|
+
viewer_js = (_static / "binder_viewer.js").read_text(encoding="utf-8")
|
|
129
|
+
viewer_lib = (_static / "vendor" / "3Dmol-min.js").read_text(encoding="utf-8")
|
|
130
|
+
|
|
131
|
+
html = template.render(
|
|
132
|
+
# Escaped so a structure can never close the <script> that carries
|
|
133
|
+
# it. mmCIF has no reason to contain the sequence, which is exactly
|
|
134
|
+
# when an assumption like that stops being checked.
|
|
135
|
+
structures_json=json.dumps(structures).replace("<", "\\u003c"),
|
|
136
|
+
structure_ids=list(structures),
|
|
137
|
+
structures_omitted=structures_omitted,
|
|
138
|
+
viewer_js=viewer_js,
|
|
139
|
+
viewer_lib=viewer_lib,
|
|
140
|
+
run_name=manifest.get("name") if manifest else run_dir.name,
|
|
141
|
+
run_id=manifest.get("run_id", "") if manifest else "",
|
|
142
|
+
created_at=manifest.get("created_at", "") if manifest else "",
|
|
143
|
+
css=css,
|
|
144
|
+
volcano_b64=volcano_b64,
|
|
145
|
+
deg_fdr=deg_fdr,
|
|
146
|
+
deg_log2fc=deg_log2fc,
|
|
147
|
+
n_deg=len(deg_df) if deg_df is not None else 0,
|
|
148
|
+
n_significant=(
|
|
149
|
+
int(deg_df["significant"].sum())
|
|
150
|
+
if deg_df is not None and "significant" in deg_df.columns
|
|
151
|
+
else 0
|
|
152
|
+
),
|
|
153
|
+
candidates_table=_df_to_records(candidates_df, _CANDIDATE_DISPLAY_COLS, head=20),
|
|
154
|
+
epitopes_table=_df_to_records(epitopes_df, _EPITOPE_DISPLAY_COLS, head=20),
|
|
155
|
+
# The run's counts, not the display tables'. The KPI read
|
|
156
|
+
# ``candidates_table|length``, which is capped at 20, so every run with
|
|
157
|
+
# more than twenty candidates published "20" as its candidate count.
|
|
158
|
+
n_candidates=len(candidates_df) if candidates_df is not None else 0,
|
|
159
|
+
# "could not be read" and "read, and empty" are different findings, and
|
|
160
|
+
# the template said the second for both.
|
|
161
|
+
candidates_unreadable=candidates_df is None,
|
|
162
|
+
n_epitopes=len(epitopes_df) if epitopes_df is not None else 0,
|
|
163
|
+
binders_table=_binders_table(ranking_df, run_dir, include_sequences=include_binders),
|
|
164
|
+
n_binders=len(ranking_df) if ranking_df is not None else 0,
|
|
165
|
+
include_binders=include_binders,
|
|
166
|
+
taxonomy_counts=_disposition_counts(taxonomy_df),
|
|
167
|
+
n_taxonomy=len(taxonomy_df) if taxonomy_df is not None else 0,
|
|
168
|
+
manifest=manifest,
|
|
169
|
+
stages=manifest.get("stages", []) if manifest else [],
|
|
170
|
+
failed_stages=_failed_stages(manifest),
|
|
171
|
+
limitations=[{"title": t, "body": b} for t, b in DISCOVERY_LIMITATIONS],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
out_path.write_text(html, encoding="utf-8", newline="\n")
|
|
176
|
+
LOG.info("wrote %s", out_path)
|
|
177
|
+
return out_path
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Helpers
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
_BINDER_DISPLAY_COLS = [
|
|
184
|
+
"rank",
|
|
185
|
+
"binder_id",
|
|
186
|
+
"symbol",
|
|
187
|
+
"target_uniprot",
|
|
188
|
+
"iptm",
|
|
189
|
+
"pae_interaction",
|
|
190
|
+
"score",
|
|
191
|
+
"passes_thresholds",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
#: How many bytes of mmCIF a report may carry. A complex is ~150 kB and the
|
|
196
|
+
#: vendored viewer another ~525 kB, against a renderer whose whole purpose is a
|
|
197
|
+
#: file that survives being emailed. Structures are embedded best-first until
|
|
198
|
+
#: this is reached, and the report says how many it left out -- a report that
|
|
199
|
+
#: silently showed three of twenty would be the defect this project keeps
|
|
200
|
+
#: removing, wearing a size limit as an excuse.
|
|
201
|
+
_STRUCTURE_BUDGET_BYTES = 4 * 1024 * 1024
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _binder_structures(
|
|
205
|
+
run_dir: Path, order: list[str], budget: int = _STRUCTURE_BUDGET_BYTES
|
|
206
|
+
) -> tuple[dict[str, str], int]:
|
|
207
|
+
"""Predicted complexes for ``order``, best-first, within a byte budget.
|
|
208
|
+
|
|
209
|
+
Reads the per-target design archives, the same place ``_binder_sequences``
|
|
210
|
+
reads from, because a validated complex is an output of the design stage
|
|
211
|
+
rather than of the ranking table.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
run_dir: the finished run.
|
|
215
|
+
order: binder ids, best-ranked first.
|
|
216
|
+
budget: hard ceiling on the embedded mmCIF, in bytes. Never exceeded,
|
|
217
|
+
not even by the best-ranked structure -- a report too large to
|
|
218
|
+
send is the failure this budget exists to prevent. A structure
|
|
219
|
+
that does not fit is skipped, not treated as a stopping point.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The embedded structures, best-ranked first among those that fit, and
|
|
223
|
+
how many ranked binders have a complex in the archives that was left
|
|
224
|
+
out. The second number counts only structures that really are in
|
|
225
|
+
``design/_targets/``, because that is what the report says about them;
|
|
226
|
+
a ranked binder the design stage produced no complex for is not in the
|
|
227
|
+
tally, since it is not there to be found.
|
|
228
|
+
"""
|
|
229
|
+
import tarfile
|
|
230
|
+
|
|
231
|
+
found: dict[str, str] = {}
|
|
232
|
+
targets_dir = run_dir / "design" / "_targets"
|
|
233
|
+
if not targets_dir.is_dir():
|
|
234
|
+
return {}, 0
|
|
235
|
+
|
|
236
|
+
for archive in sorted(targets_dir.glob("*.tar.gz")):
|
|
237
|
+
try:
|
|
238
|
+
with tarfile.open(archive, encoding="utf-8") as tf:
|
|
239
|
+
for member in tf.getmembers():
|
|
240
|
+
name = PurePosixPath(member.name)
|
|
241
|
+
if name.suffix != ".cif" or not member.isfile():
|
|
242
|
+
continue
|
|
243
|
+
handle = tf.extractfile(member)
|
|
244
|
+
if handle is None:
|
|
245
|
+
continue
|
|
246
|
+
text = handle.read().decode("utf-8", errors="replace")
|
|
247
|
+
if text.strip():
|
|
248
|
+
# <id>_model_0.cif, and anything else keyed by its stem.
|
|
249
|
+
found[name.stem.removesuffix("_model_0")] = text
|
|
250
|
+
except (OSError, tarfile.TarError) as e: # a damaged archive is not fatal
|
|
251
|
+
LOG.warning("could not read binder structures from %s (%s)", archive, e)
|
|
252
|
+
|
|
253
|
+
embedded: dict[str, str] = {}
|
|
254
|
+
spent = 0
|
|
255
|
+
considered: set[str] = set()
|
|
256
|
+
for binder_id in order:
|
|
257
|
+
# A ranking table is a column of ids, not a set, and nothing promises
|
|
258
|
+
# the same binder appears once. A repeat used to be charged to the
|
|
259
|
+
# budget twice -- `embedded` is a dict and absorbed it silently while
|
|
260
|
+
# `spent` did not -- which starved a lower-ranked structure that fit.
|
|
261
|
+
# It was also counted twice in the tally below, because that counted
|
|
262
|
+
# `order` against `embedded`: a list against a dict. With ample budget
|
|
263
|
+
# and one duplicate, the report said a structure had been left out for
|
|
264
|
+
# size when it was embedded and sitting in the picker.
|
|
265
|
+
if binder_id in considered:
|
|
266
|
+
continue
|
|
267
|
+
considered.add(binder_id)
|
|
268
|
+
structure = found.get(binder_id)
|
|
269
|
+
if structure is None:
|
|
270
|
+
continue
|
|
271
|
+
cost = len(structure.encode("utf-8"))
|
|
272
|
+
if spent + cost > budget:
|
|
273
|
+
# Skip this one and keep going. Two defects lived in the line this
|
|
274
|
+
# replaced, ``if spent + cost > budget and embedded: break``.
|
|
275
|
+
#
|
|
276
|
+
# ``and embedded`` made the documented ceiling a floor: the first
|
|
277
|
+
# structure was embedded whatever its size, so one oversized
|
|
278
|
+
# complex produced a report larger than the budget it claims to
|
|
279
|
+
# respect -- and "up to _STRUCTURE_BUDGET_BYTES" was false in the
|
|
280
|
+
# one case the budget exists for.
|
|
281
|
+
#
|
|
282
|
+
# ``break`` charged every lower-ranked structure for that one. A
|
|
283
|
+
# 5 KiB complex was dropped because a 50 KiB complex outranked it,
|
|
284
|
+
# even with room to spare, so the report showed less than the
|
|
285
|
+
# budget allowed and the reader lost a structure for no reason.
|
|
286
|
+
continue
|
|
287
|
+
embedded[binder_id] = structure
|
|
288
|
+
spent += cost
|
|
289
|
+
return embedded, max(0, len(considered & found.keys()) - len(embedded))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _binder_sequences(run_dir: Path) -> dict[str, str]:
|
|
293
|
+
"""Designed sequence per binder id, read from the per-target design archives.
|
|
294
|
+
|
|
295
|
+
The ranking table carries every metric and no sequence, because the sequence
|
|
296
|
+
is an output of the design stage rather than of validation. A reader who
|
|
297
|
+
wants to order a peptide needs it, so it is pulled from the archives on
|
|
298
|
+
request rather than being absent from the report entirely.
|
|
299
|
+
"""
|
|
300
|
+
import tarfile
|
|
301
|
+
|
|
302
|
+
sequences: dict[str, str] = {}
|
|
303
|
+
targets_dir = run_dir / "design" / "_targets"
|
|
304
|
+
if not targets_dir.is_dir():
|
|
305
|
+
return sequences
|
|
306
|
+
for archive in sorted(targets_dir.glob("*.tar.gz")):
|
|
307
|
+
try:
|
|
308
|
+
with tarfile.open(archive, encoding="utf-8") as tf:
|
|
309
|
+
for member in tf.getmembers():
|
|
310
|
+
name = PurePosixPath(member.name)
|
|
311
|
+
if name.suffix != ".fasta" or not member.isfile():
|
|
312
|
+
continue
|
|
313
|
+
handle = tf.extractfile(member)
|
|
314
|
+
if handle is None:
|
|
315
|
+
continue
|
|
316
|
+
body = handle.read().decode("utf-8", errors="replace")
|
|
317
|
+
seq = "".join(
|
|
318
|
+
line.strip()
|
|
319
|
+
for line in body.splitlines()
|
|
320
|
+
if line.strip() and not line.startswith(">")
|
|
321
|
+
)
|
|
322
|
+
if seq:
|
|
323
|
+
sequences[name.stem] = seq
|
|
324
|
+
except (OSError, tarfile.TarError) as e: # a damaged archive is not fatal
|
|
325
|
+
LOG.warning("could not read binder sequences from %s (%s)", archive, e)
|
|
326
|
+
return sequences
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _binders_table(
|
|
330
|
+
ranking: pd.DataFrame | None,
|
|
331
|
+
run_dir: Path,
|
|
332
|
+
*,
|
|
333
|
+
include_sequences: bool = False,
|
|
334
|
+
head: int = 40,
|
|
335
|
+
) -> list[dict[str, Any]]:
|
|
336
|
+
"""The ranked binders, which are the run's actual output.
|
|
337
|
+
|
|
338
|
+
The report rendered the discovery half and stopped: a run that designed
|
|
339
|
+
forty binders, validated and ranked them produced a paper-style HTML in
|
|
340
|
+
which none of them appeared. ``--include-binders`` was accepted, documented
|
|
341
|
+
and recorded in the manifest, and never reached this module at all.
|
|
342
|
+
"""
|
|
343
|
+
rows = _df_to_records(ranking, _BINDER_DISPLAY_COLS, head=head)
|
|
344
|
+
if not rows or not include_sequences:
|
|
345
|
+
return rows
|
|
346
|
+
sequences = _binder_sequences(run_dir)
|
|
347
|
+
for row in rows:
|
|
348
|
+
row["sequence"] = sequences.get(str(row.get("binder_id", "")), "")
|
|
349
|
+
return rows
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
_CANDIDATE_DISPLAY_COLS = [
|
|
353
|
+
"rank",
|
|
354
|
+
"symbol",
|
|
355
|
+
"uniprot_id",
|
|
356
|
+
"log2fc",
|
|
357
|
+
"padj",
|
|
358
|
+
"tractable_modalities",
|
|
359
|
+
"n_safety_events",
|
|
360
|
+
"max_vital_tissue_tpm",
|
|
361
|
+
"has_alphafold_structure",
|
|
362
|
+
"mean_plddt",
|
|
363
|
+
"rank_in_top_n",
|
|
364
|
+
]
|
|
365
|
+
_EPITOPE_DISPLAY_COLS = [
|
|
366
|
+
"symbol",
|
|
367
|
+
"uniprot_id",
|
|
368
|
+
"structure_path",
|
|
369
|
+
"site_id",
|
|
370
|
+
"epitope_status",
|
|
371
|
+
"mean_epitope_plddt",
|
|
372
|
+
"fraction_extracellular",
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Funnel order for the negative-result taxonomy (display only; the canonical list
|
|
377
|
+
# lives in bindsight.pipelines.discover.TAXONOMY_DISPOSITIONS — duplicated here so the
|
|
378
|
+
# report renders without importing the heavy discovery module).
|
|
379
|
+
#
|
|
380
|
+
# The copy had drifted by three: uniprot_lookup_failed, normal_tissue_unassessed
|
|
381
|
+
# and structure_not_queried were all missing, which are precisely the
|
|
382
|
+
# dispositions that separate "not measured" from "measured and rejected". The
|
|
383
|
+
# taxonomy is documented as exhaustive, so a funnel omitting them did not sum to
|
|
384
|
+
# the gene count it claimed to. tests/test_report_html.py fails if the two lists
|
|
385
|
+
# diverge again.
|
|
386
|
+
_DISPOSITION_ORDER = (
|
|
387
|
+
"not_significant",
|
|
388
|
+
"significance_unassessed",
|
|
389
|
+
"down_regulated",
|
|
390
|
+
"below_enrichment_cutoff",
|
|
391
|
+
"uniprot_lookup_failed",
|
|
392
|
+
"no_uniprot",
|
|
393
|
+
"not_surfaceome",
|
|
394
|
+
"fails_tractability",
|
|
395
|
+
"fails_safety",
|
|
396
|
+
"safety_unassessed",
|
|
397
|
+
"high_normal_tissue_expression",
|
|
398
|
+
"normal_tissue_unassessed",
|
|
399
|
+
"no_extracellular_domain",
|
|
400
|
+
"structure_not_queried",
|
|
401
|
+
"no_alphafold_model",
|
|
402
|
+
"low_confidence_structure",
|
|
403
|
+
"structure_confidence_unassessed",
|
|
404
|
+
"not_top_n",
|
|
405
|
+
"no_surface_bind_site",
|
|
406
|
+
"surface_bind_lookup_failed",
|
|
407
|
+
"surfaced",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _failed_stages(manifest: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
412
|
+
"""Return ``{name, error}`` for every stage the manifest records as failed.
|
|
413
|
+
|
|
414
|
+
A crashed run leaves the same empty tables behind as a run that genuinely
|
|
415
|
+
surfaced nothing. Only the manifest separates them, so the report reads it
|
|
416
|
+
and refuses to offer "loosen the thresholds" advice for a pipeline that
|
|
417
|
+
never got as far as applying them.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
manifest: Parsed ``run_manifest.jsonld`` body, or ``None``.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
The failed stages in manifest order; empty when nothing failed.
|
|
424
|
+
"""
|
|
425
|
+
stages = (manifest or {}).get("stages") or []
|
|
426
|
+
return [
|
|
427
|
+
{"name": str(s.get("name") or "?"), "error": s.get("error")}
|
|
428
|
+
for s in stages
|
|
429
|
+
if isinstance(s, dict) and s.get("status") == "failed"
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _disposition_counts(df: pd.DataFrame | None) -> list[dict[str, Any]]:
|
|
434
|
+
"""Per-disposition counts for the report, in funnel order (deepest drop → surfaced)."""
|
|
435
|
+
if df is None or "disposition" not in df.columns or len(df) == 0:
|
|
436
|
+
return []
|
|
437
|
+
vc = df["disposition"].value_counts().to_dict()
|
|
438
|
+
total = sum(vc.values()) or 1
|
|
439
|
+
order = {d: i for i, d in enumerate(_DISPOSITION_ORDER)}
|
|
440
|
+
items = sorted(vc.items(), key=lambda kv: order.get(kv[0], 999))
|
|
441
|
+
return [{"disposition": k, "count": int(v), "pct": f"{100 * v / total:.0f}%"} for k, v in items]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _maybe_read_parquet(path: Path) -> pd.DataFrame | None:
|
|
445
|
+
"""Read a Parquet file if it exists and is non-empty; else None."""
|
|
446
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
return pd.read_parquet(path)
|
|
450
|
+
except Exception as e:
|
|
451
|
+
LOG.warning("failed to read %s: %s", path, e)
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _maybe_read_jsonld(path: Path) -> dict[str, Any] | None:
|
|
456
|
+
if not path.exists():
|
|
457
|
+
return None
|
|
458
|
+
try:
|
|
459
|
+
body: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
460
|
+
body.pop("@context", None)
|
|
461
|
+
return body
|
|
462
|
+
except Exception as e:
|
|
463
|
+
LOG.warning("failed to read %s: %s", path, e)
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
#: Columns whose zero is a floor, not a measurement.
|
|
468
|
+
_P_VALUE_COLS = frozenset({"padj", "pvalue", "p_value", "padj_adjusted", "fdr"})
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _fmt_cell(value: Any) -> str:
|
|
472
|
+
"""A float for a table cell, or empty when there is nothing behind it."""
|
|
473
|
+
if value is None or (isinstance(value, float) and value != value):
|
|
474
|
+
return ""
|
|
475
|
+
if not pd.notna(value):
|
|
476
|
+
return ""
|
|
477
|
+
return f"{float(value):.3g}"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _df_to_records(
|
|
481
|
+
df: pd.DataFrame | None, cols: list[str], head: int = 20
|
|
482
|
+
) -> list[dict[str, Any]]:
|
|
483
|
+
"""Return up to ``head`` rows of ``df`` as a list of dicts (only ``cols``)."""
|
|
484
|
+
if df is None or len(df) == 0:
|
|
485
|
+
return []
|
|
486
|
+
present_cols = [c for c in cols if c in df.columns]
|
|
487
|
+
sub = df[present_cols].head(head).copy()
|
|
488
|
+
# The same rules the served interface applies. `f"{v:.3g}"` printed CA9's
|
|
489
|
+
# padj -- literally 0.0 in the artifact, below floating-point resolution --
|
|
490
|
+
# as "0", which is not a p-value, in the one row a reader looks at first.
|
|
491
|
+
for c in sub.select_dtypes(include="float").columns:
|
|
492
|
+
rule = fmt_p if c in _P_VALUE_COLS else _fmt_cell
|
|
493
|
+
sub[c] = sub[c].apply(rule)
|
|
494
|
+
records: list[dict[str, Any]] = sub.to_dict(orient="records")
|
|
495
|
+
return records
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
#: The conventional defaults, used only when a run did not record what it used.
|
|
499
|
+
#: They match :class:`bindsight.config` so a default run is labelled correctly;
|
|
500
|
+
#: they are never silently substituted for a run that chose something else.
|
|
501
|
+
_DEFAULT_FDR = 0.05
|
|
502
|
+
|
|
503
|
+
#: Points the volcano annotates, chosen by |log2FC| x -log10(padj). Labelling
|
|
504
|
+
#: every significant gene produced an unreadable figure on real cohorts, and
|
|
505
|
+
#: an unreadable figure communicates less than an unlabelled one.
|
|
506
|
+
_MAX_VOLCANO_LABELS = 20
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _deg_thresholds(manifest: Mapping[str, Any] | None) -> tuple[float | None, float | None]:
|
|
510
|
+
"""The FDR and |log2FC| cutoffs the ``deg`` stage ran with, if recorded.
|
|
511
|
+
|
|
512
|
+
The results parquet carries a ``significant`` column but not the numbers
|
|
513
|
+
behind it, so the plot used to draw its guide lines at the library defaults
|
|
514
|
+
whatever the run had configured -- putting dashed lines through the middle of
|
|
515
|
+
the red points for any run that chose something else. The manifest does
|
|
516
|
+
record them, per stage; this reads them from there.
|
|
517
|
+
"""
|
|
518
|
+
if not manifest:
|
|
519
|
+
return (None, None)
|
|
520
|
+
for stage in manifest.get("stages", []) or []:
|
|
521
|
+
params = stage.get("params") or {}
|
|
522
|
+
if "fdr_threshold" in params or "log2fc_threshold" in params:
|
|
523
|
+
fdr = params.get("fdr_threshold")
|
|
524
|
+
lfc = params.get("log2fc_threshold")
|
|
525
|
+
return (
|
|
526
|
+
float(fdr) if fdr is not None else None,
|
|
527
|
+
float(lfc) if lfc is not None else None,
|
|
528
|
+
)
|
|
529
|
+
return (None, None)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _significance_basis(
|
|
533
|
+
columns: Iterable[str], fdr_threshold: float | None
|
|
534
|
+
) -> tuple[str, float | None]:
|
|
535
|
+
"""How the plot decides which points are significant.
|
|
536
|
+
|
|
537
|
+
Returns ``(basis, cutoff)`` where basis is one of:
|
|
538
|
+
|
|
539
|
+
* ``"column"`` -- the analysis' own ``significant`` column; authoritative,
|
|
540
|
+
since it already applied both cutoffs.
|
|
541
|
+
* ``"padj"`` -- no column, but the run recorded its FDR cutoff.
|
|
542
|
+
* ``"assumed"`` -- neither; the conventional 0.05 is used and the report
|
|
543
|
+
says so rather than presenting an assumption as the run's own choice.
|
|
544
|
+
"""
|
|
545
|
+
if "significant" in set(columns):
|
|
546
|
+
return ("column", None)
|
|
547
|
+
if fdr_threshold is not None:
|
|
548
|
+
return ("padj", fdr_threshold)
|
|
549
|
+
return ("assumed", _DEFAULT_FDR)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _render_volcano(
|
|
553
|
+
deg_df: pd.DataFrame,
|
|
554
|
+
*,
|
|
555
|
+
fdr_threshold: float | None = None,
|
|
556
|
+
log2fc_threshold: float | None = None,
|
|
557
|
+
) -> str:
|
|
558
|
+
"""Render a volcano plot as a base64-encoded PNG embedded in the HTML.
|
|
559
|
+
|
|
560
|
+
``fdr_threshold`` and ``log2fc_threshold`` are the cutoffs the run recorded.
|
|
561
|
+
A guide line is drawn only for a cutoff that is known: an unlabelled dashed
|
|
562
|
+
line at a value the run did not use is worse than no line at all.
|
|
563
|
+
"""
|
|
564
|
+
try:
|
|
565
|
+
import matplotlib
|
|
566
|
+
|
|
567
|
+
matplotlib.use("Agg")
|
|
568
|
+
import matplotlib.pyplot as plt
|
|
569
|
+
except ImportError:
|
|
570
|
+
return "" # matplotlib not installed; skip plot
|
|
571
|
+
|
|
572
|
+
import numpy as np
|
|
573
|
+
|
|
574
|
+
fig, ax = plt.subplots(figsize=(7, 5), dpi=120)
|
|
575
|
+
log2fc = deg_df["log2fc"].astype(float)
|
|
576
|
+
padj = deg_df["padj"].astype(float).fillna(1.0)
|
|
577
|
+
nlp = -np.log10(padj.clip(lower=1e-300))
|
|
578
|
+
basis, cutoff = _significance_basis(deg_df.columns, fdr_threshold)
|
|
579
|
+
# ``cutoff is None`` happens only on the column branch, where the analysis
|
|
580
|
+
# already applied both cutoffs. Testing for it narrows the type without a
|
|
581
|
+
# cast and says the same thing the branch means.
|
|
582
|
+
if basis == "column" or cutoff is None:
|
|
583
|
+
sig = deg_df["significant"].astype(bool)
|
|
584
|
+
sig_label = "significant"
|
|
585
|
+
else:
|
|
586
|
+
sig = padj < cutoff
|
|
587
|
+
sig_label = (
|
|
588
|
+
f"significant (padj < {cutoff:g})"
|
|
589
|
+
if basis == "padj"
|
|
590
|
+
else f"significant (padj < {cutoff:g}, assumed)"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
ax.scatter(log2fc[~sig], nlp[~sig], s=18, alpha=0.45, c="#888", label="ns")
|
|
594
|
+
ax.scatter(log2fc[sig], nlp[sig], s=22, alpha=0.85, c="#d62728", label=sig_label)
|
|
595
|
+
# Only the strongest few. Labelling every significant gene put ~4,400
|
|
596
|
+
# overlapping annotations on a real run, which renders as a black bar across
|
|
597
|
+
# the figure and identifies nothing.
|
|
598
|
+
if "symbol" in deg_df.columns:
|
|
599
|
+
labelled = deg_df[sig].copy()
|
|
600
|
+
if not labelled.empty:
|
|
601
|
+
labelled["_weight"] = labelled["log2fc"].abs() * nlp[sig]
|
|
602
|
+
labelled = labelled.sort_values("_weight", ascending=False).head(_MAX_VOLCANO_LABELS)
|
|
603
|
+
for _, row in labelled.iterrows():
|
|
604
|
+
label = str(row.get("symbol") or "")[:20]
|
|
605
|
+
if not label:
|
|
606
|
+
continue
|
|
607
|
+
ax.annotate(
|
|
608
|
+
label,
|
|
609
|
+
(row["log2fc"], -np.log10(max(row["padj"], 1e-300))),
|
|
610
|
+
fontsize=7,
|
|
611
|
+
alpha=0.75,
|
|
612
|
+
xytext=(3, 3),
|
|
613
|
+
textcoords="offset points",
|
|
614
|
+
)
|
|
615
|
+
# A guide line only where the run told us where the line is.
|
|
616
|
+
if fdr_threshold is not None:
|
|
617
|
+
ax.axhline(
|
|
618
|
+
-np.log10(max(float(fdr_threshold), 1e-300)),
|
|
619
|
+
color="grey",
|
|
620
|
+
linestyle="--",
|
|
621
|
+
linewidth=0.8,
|
|
622
|
+
alpha=0.6,
|
|
623
|
+
label=f"padj = {float(fdr_threshold):g}",
|
|
624
|
+
)
|
|
625
|
+
if log2fc_threshold is not None:
|
|
626
|
+
for i, x in enumerate((float(log2fc_threshold), -float(log2fc_threshold))):
|
|
627
|
+
ax.axvline(
|
|
628
|
+
x,
|
|
629
|
+
color="grey",
|
|
630
|
+
linestyle="--",
|
|
631
|
+
linewidth=0.8,
|
|
632
|
+
alpha=0.6,
|
|
633
|
+
label=f"|log2FC| = {abs(float(log2fc_threshold)):g}" if i == 0 else None,
|
|
634
|
+
)
|
|
635
|
+
ax.set_xlabel("log2 fold-change (tumor vs. normal)")
|
|
636
|
+
ax.set_ylabel("-log10(padj)")
|
|
637
|
+
ax.set_title("Differential expression — volcano")
|
|
638
|
+
ax.legend(loc="best", fontsize=8)
|
|
639
|
+
ax.grid(alpha=0.2)
|
|
640
|
+
fig.tight_layout()
|
|
641
|
+
|
|
642
|
+
buf = io.BytesIO()
|
|
643
|
+
fig.savefig(buf, format="png")
|
|
644
|
+
plt.close(fig)
|
|
645
|
+
return base64.b64encode(buf.getvalue()).decode()
|