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.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -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()