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,624 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Routes and rendering for the bindsight web interface.
|
|
4
|
+
|
|
5
|
+
Five sections, in the order the argument runs:
|
|
6
|
+
|
|
7
|
+
1. **Overview** — what it is, and what it has and has not shown.
|
|
8
|
+
2. **Evidence** — the study, the controls, the binders, the provenance chain.
|
|
9
|
+
3. **Try it** — the demo, with per-stage progress.
|
|
10
|
+
4. **Your data** — upload, validate, run.
|
|
11
|
+
5. **Runs** — browse what is on disk.
|
|
12
|
+
|
|
13
|
+
The data layer is :mod:`bindsight.report.showcase`, unchanged. This module owns
|
|
14
|
+
presentation only, and enforces the two presentation rules the previous
|
|
15
|
+
interface broke: a figure appears with what makes it interpretable, and an
|
|
16
|
+
absent measurement renders as an em dash rather than as zero.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from fastapi import FastAPI, Request
|
|
30
|
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
31
|
+
from fastapi.staticfiles import StaticFiles
|
|
32
|
+
from fastapi.templating import Jinja2Templates
|
|
33
|
+
from markupsafe import Markup
|
|
34
|
+
|
|
35
|
+
LOG = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
HERE = Path(__file__).resolve().parent
|
|
38
|
+
STATIC = HERE / "static"
|
|
39
|
+
TEMPLATES = HERE / "templates"
|
|
40
|
+
|
|
41
|
+
# Presentation rules live in `bindsight.report.format` so the standalone HTML
|
|
42
|
+
# report applies the same ones. Re-exported here because the templates and the
|
|
43
|
+
# tests reach for them through this module.
|
|
44
|
+
from bindsight.report.format import ( # noqa: E402
|
|
45
|
+
ABSENT,
|
|
46
|
+
fmt,
|
|
47
|
+
fmt_int,
|
|
48
|
+
fmt_p,
|
|
49
|
+
interval,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Background jobs
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
@dataclass
|
|
57
|
+
class Job:
|
|
58
|
+
"""A pipeline run in progress, and what it has done so far.
|
|
59
|
+
|
|
60
|
+
The Streamlit app showed a single indeterminate spinner for a download, a
|
|
61
|
+
DESeq2 fit, a surfaceome filter and two API sweeps -- minutes of a motionless
|
|
62
|
+
page on a cold cache, with no stage name and no way to tell a slow run from a
|
|
63
|
+
stuck one.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
id: str
|
|
67
|
+
kind: str
|
|
68
|
+
stages: list[dict[str, str]] = field(default_factory=list)
|
|
69
|
+
state: str = "running"
|
|
70
|
+
error: str = ""
|
|
71
|
+
run_dir: str = ""
|
|
72
|
+
|
|
73
|
+
def step(self, name: str, state: str = "running") -> None:
|
|
74
|
+
"""Record a stage, or move one already recorded to a new state."""
|
|
75
|
+
for stage in self.stages:
|
|
76
|
+
if stage["name"] == name:
|
|
77
|
+
stage["state"] = state
|
|
78
|
+
return
|
|
79
|
+
self.stages.append({"name": name, "state": state})
|
|
80
|
+
|
|
81
|
+
def finish_previous(self) -> None:
|
|
82
|
+
"""Close whatever is still running.
|
|
83
|
+
|
|
84
|
+
Called when the next stage begins, so a reader can tell a slow run
|
|
85
|
+
from a stuck one -- which a single indeterminate spinner cannot.
|
|
86
|
+
"""
|
|
87
|
+
for stage in self.stages:
|
|
88
|
+
if stage["state"] == "running":
|
|
89
|
+
stage["state"] = "done"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_JOBS: dict[str, Job] = {}
|
|
93
|
+
_JOBS_LOCK = threading.Lock()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _new_job(kind: str) -> Job:
|
|
97
|
+
job = Job(id=uuid.uuid4().hex[:12], kind=kind)
|
|
98
|
+
with _JOBS_LOCK:
|
|
99
|
+
_JOBS[job.id] = job
|
|
100
|
+
return job
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_job(job_id: str) -> Job | None:
|
|
104
|
+
with _JOBS_LOCK:
|
|
105
|
+
return _JOBS.get(job_id)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _tojson(obj: Any) -> Markup:
|
|
109
|
+
"""JSON for an HTML attribute, escaped for that context and marked safe.
|
|
110
|
+
|
|
111
|
+
The chart specs are emitted into ``data-spec='...'`` -- a single-quoted
|
|
112
|
+
attribute -- so an apostrophe in the data would close it, and a ``<`` would
|
|
113
|
+
matter the day one of these moves into a ``<script>`` block. Both are
|
|
114
|
+
escaped as JSON unicode escapes, which is still valid JSON and decodes
|
|
115
|
+
back to the same characters, so a reader of the attribute sees exactly
|
|
116
|
+
what was put there.
|
|
117
|
+
|
|
118
|
+
Marked ``Markup`` because the escaping is done here. Leaving it to the
|
|
119
|
+
autoescape that now covers these templates would turn every quote into
|
|
120
|
+
``"``: correct HTML, and browsers decode it, but it makes the
|
|
121
|
+
attribute unreadable and hides what is in it. ``report/html.py`` makes
|
|
122
|
+
the same trade for the mmCIF it embeds.
|
|
123
|
+
"""
|
|
124
|
+
text = json.dumps(obj)
|
|
125
|
+
for raw, escaped in (
|
|
126
|
+
("<", "\\u003c"),
|
|
127
|
+
(">", "\\u003e"),
|
|
128
|
+
("&", "\\u0026"),
|
|
129
|
+
("'", "\\u0027"),
|
|
130
|
+
):
|
|
131
|
+
text = text.replace(raw, escaped)
|
|
132
|
+
return Markup(text)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Application
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
def create_app(*, run_root: Path | None = None) -> Any:
|
|
139
|
+
"""Build the FastAPI application.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
run_root: where to look for existing runs. Defaults to ``runs/`` under
|
|
143
|
+
the current working directory.
|
|
144
|
+
"""
|
|
145
|
+
from bindsight import __version__
|
|
146
|
+
from bindsight.report import showcase, theme
|
|
147
|
+
|
|
148
|
+
app = FastAPI(title="bindsight", docs_url=None, redoc_url=None)
|
|
149
|
+
app.mount("/static", StaticFiles(directory=str(STATIC)), name="static")
|
|
150
|
+
templates = Jinja2Templates(directory=str(TEMPLATES))
|
|
151
|
+
# Explicit, because Jinja's own default would leave it off. Starlette
|
|
152
|
+
# hands escaping to `select_autoescape()`, which decides by filename and
|
|
153
|
+
# whose default extensions are html/htm/xml -- and every template here is
|
|
154
|
+
# `*.html.j2`, which ends in `.j2`. So the whole interface rendered
|
|
155
|
+
# unescaped: a design table's column names reach `runs.html.j2` through
|
|
156
|
+
# the ValueError pydeseq2_runner raises and the `repr(e)` discover writes
|
|
157
|
+
# into the manifest, which made a `.tsv` from a stranger a script tag on
|
|
158
|
+
# this page. No template here uses `|safe`, so this changes nothing but
|
|
159
|
+
# the escaping. tests/test_web_ui.py holds both halves.
|
|
160
|
+
templates.env.autoescape = True
|
|
161
|
+
|
|
162
|
+
# Everything a template may call. Kept explicit so a template cannot reach
|
|
163
|
+
# into arbitrary application state.
|
|
164
|
+
templates.env.globals.update(
|
|
165
|
+
fmt=fmt,
|
|
166
|
+
fmt_int=fmt_int,
|
|
167
|
+
fmt_p=fmt_p,
|
|
168
|
+
interval=interval,
|
|
169
|
+
ABSENT=ABSENT,
|
|
170
|
+
version=__version__,
|
|
171
|
+
tojson=_tojson,
|
|
172
|
+
citation=theme.citation_line,
|
|
173
|
+
github=theme.GITHUB_URL,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
runs_root = Path(run_root) if run_root else Path.cwd() / "runs"
|
|
177
|
+
|
|
178
|
+
def page(request: Request, name: str, **ctx: Any) -> HTMLResponse:
|
|
179
|
+
return templates.TemplateResponse(
|
|
180
|
+
request=request, name=name, context={"nav": name.split(".")[0], **ctx}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# -- 1. Overview --------------------------------------------------------
|
|
184
|
+
@app.get("/", response_class=HTMLResponse)
|
|
185
|
+
def overview(request: Request) -> HTMLResponse:
|
|
186
|
+
study = showcase.load_study()
|
|
187
|
+
designer = showcase.load_designer_benchmark()
|
|
188
|
+
return page(
|
|
189
|
+
request,
|
|
190
|
+
"overview.html.j2",
|
|
191
|
+
study=study,
|
|
192
|
+
designer=designer,
|
|
193
|
+
have_evidence=study is not None or designer is not None,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# -- 2. Evidence --------------------------------------------------------
|
|
197
|
+
@app.get("/evidence", response_class=HTMLResponse)
|
|
198
|
+
def evidence(request: Request) -> HTMLResponse:
|
|
199
|
+
study = showcase.load_study()
|
|
200
|
+
designer = showcase.load_designer_benchmark()
|
|
201
|
+
# Only designs whose predicted complex is actually committed. Offering a
|
|
202
|
+
# design with no structure gives the reader a viewer that stays empty
|
|
203
|
+
# and no way to tell whether that is the design's fault or the page's.
|
|
204
|
+
structures = designer.with_structures() if designer else []
|
|
205
|
+
return page(
|
|
206
|
+
request,
|
|
207
|
+
"evidence.html.j2",
|
|
208
|
+
structures=structures,
|
|
209
|
+
study=study,
|
|
210
|
+
designer=designer,
|
|
211
|
+
paired=_paired_chart(designer),
|
|
212
|
+
nulls=_nulls_chart(study),
|
|
213
|
+
recall=_recall_chart(study),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# -- 3. Try it ----------------------------------------------------------
|
|
217
|
+
@app.get("/try", response_class=HTMLResponse)
|
|
218
|
+
def try_it(request: Request) -> HTMLResponse:
|
|
219
|
+
return page(request, "try.html.j2", cohort=_demo_description())
|
|
220
|
+
|
|
221
|
+
@app.post("/try/run")
|
|
222
|
+
def try_run() -> JSONResponse:
|
|
223
|
+
job = _new_job("demo")
|
|
224
|
+
threading.Thread(target=_run_demo, args=(job,), daemon=True).start()
|
|
225
|
+
return JSONResponse({"job": job.id})
|
|
226
|
+
|
|
227
|
+
# -- 4. Your data -------------------------------------------------------
|
|
228
|
+
@app.get("/your-data", response_class=HTMLResponse)
|
|
229
|
+
def your_data(request: Request) -> HTMLResponse:
|
|
230
|
+
return page(request, "your_data.html.j2")
|
|
231
|
+
|
|
232
|
+
@app.get("/api/structure/{binder_id}")
|
|
233
|
+
def structure(binder_id: str) -> Any:
|
|
234
|
+
"""The predicted complex for one binder, as mmCIF text.
|
|
235
|
+
|
|
236
|
+
Resolved by matching ``binder_id`` against the designs the benchmark
|
|
237
|
+
actually loaded, never by joining it onto a directory: a path built from
|
|
238
|
+
a request parameter turns a structure viewer into a file-read
|
|
239
|
+
primitive, and ``..`` is a legal path segment.
|
|
240
|
+
"""
|
|
241
|
+
designer = showcase.load_designer_benchmark()
|
|
242
|
+
if designer is None:
|
|
243
|
+
return JSONResponse({"error": "no designer benchmark"}, status_code=404)
|
|
244
|
+
for binder in designer.with_structures():
|
|
245
|
+
if binder.binder_id == binder_id and binder.complex_cif is not None:
|
|
246
|
+
return Response(
|
|
247
|
+
binder.complex_cif.read_text(encoding="utf-8"),
|
|
248
|
+
media_type="chemical/x-cif",
|
|
249
|
+
)
|
|
250
|
+
return JSONResponse({"error": "no structure for that design"}, status_code=404)
|
|
251
|
+
|
|
252
|
+
# -- 5. Runs ------------------------------------------------------------
|
|
253
|
+
@app.get("/runs", response_class=HTMLResponse)
|
|
254
|
+
def runs(request: Request) -> HTMLResponse:
|
|
255
|
+
return page(request, "runs.html.j2", runs=_discover_runs(runs_root), root=runs_root)
|
|
256
|
+
|
|
257
|
+
# -- job status ---------------------------------------------------------
|
|
258
|
+
@app.get("/api/job/{job_id}")
|
|
259
|
+
def job_status(job_id: str) -> JSONResponse:
|
|
260
|
+
job = _get_job(job_id)
|
|
261
|
+
if job is None:
|
|
262
|
+
return JSONResponse({"error": "no such job"}, status_code=404)
|
|
263
|
+
return JSONResponse(
|
|
264
|
+
{
|
|
265
|
+
"id": job.id,
|
|
266
|
+
"state": job.state,
|
|
267
|
+
"stages": job.stages,
|
|
268
|
+
"error": job.error,
|
|
269
|
+
"run_dir": job.run_dir,
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return app
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Chart specifications, built server-side so the page states its own numbers
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
def _paired_chart(designer: Any) -> dict[str, Any] | None:
|
|
280
|
+
"""Designs against their own shuffles, one line per pair."""
|
|
281
|
+
if designer is None:
|
|
282
|
+
return None
|
|
283
|
+
root = None
|
|
284
|
+
try:
|
|
285
|
+
from bindsight.report.showcase import benchmarks_root
|
|
286
|
+
|
|
287
|
+
root = benchmarks_root()
|
|
288
|
+
except Exception: # pragma: no cover - defensive
|
|
289
|
+
return None
|
|
290
|
+
if root is None:
|
|
291
|
+
return None
|
|
292
|
+
path = root / "calibration" / "RESULTS.json"
|
|
293
|
+
if not path.is_file():
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
report = json.loads(path.read_text(encoding="utf-8"))
|
|
297
|
+
except (OSError, json.JSONDecodeError):
|
|
298
|
+
return None
|
|
299
|
+
pairs = report.get("pairs") or []
|
|
300
|
+
rows = [
|
|
301
|
+
{"id": p.get("binder_id", "?"), "a": float(p["design"]), "b": float(p["scramble"])}
|
|
302
|
+
for p in pairs
|
|
303
|
+
if p.get("design") is not None and p.get("scramble") is not None
|
|
304
|
+
]
|
|
305
|
+
if not rows:
|
|
306
|
+
return None
|
|
307
|
+
return {
|
|
308
|
+
"pairs": rows,
|
|
309
|
+
"labelA": "design",
|
|
310
|
+
"labelB": "its own shuffle",
|
|
311
|
+
"threshold": report.get("threshold"),
|
|
312
|
+
"thresholdLabel": f"ipTM {report.get('threshold')}",
|
|
313
|
+
"title": "Each design beside a shuffle of its own sequence",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _ci_label(confidence: Any) -> str:
|
|
318
|
+
"""``95% CI`` when the artifact recorded a level, plain ``CI`` when it did not.
|
|
319
|
+
|
|
320
|
+
The same rule :func:`bindsight.report.format.interval` applies, in the form
|
|
321
|
+
the chart spec needs.
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
return f"{float(confidence) * 100:g}% CI"
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
return "CI"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _nulls_chart(study: Any) -> dict[str, Any] | None:
|
|
330
|
+
"""The two null models as intervals against the value that means 'nothing'."""
|
|
331
|
+
if study is None:
|
|
332
|
+
return None
|
|
333
|
+
gap = study.indication_gap or {}
|
|
334
|
+
if gap.get("point") is None:
|
|
335
|
+
return None
|
|
336
|
+
return {
|
|
337
|
+
"rows": [
|
|
338
|
+
{
|
|
339
|
+
"label": "indication gap",
|
|
340
|
+
"point": float(gap["point"]),
|
|
341
|
+
"low": float(gap["low"]),
|
|
342
|
+
"high": float(gap["high"]),
|
|
343
|
+
"n": gap.get("n_clusters"),
|
|
344
|
+
# Read, not assumed. Hardcoding the level here and again as
|
|
345
|
+
# the renderer's fallback would have captioned a study
|
|
346
|
+
# computed at another level as 95% from two places at once.
|
|
347
|
+
"ciLabel": _ci_label(gap.get("confidence")),
|
|
348
|
+
}
|
|
349
|
+
],
|
|
350
|
+
"reference": 0.0,
|
|
351
|
+
"title": "Within-antigen difference, against zero",
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _recall_chart(study: Any) -> dict[str, Any] | None:
|
|
356
|
+
if study is None:
|
|
357
|
+
return None
|
|
358
|
+
cascade = (study.recall_cascade or {}).get("all", {}).get("at_k") or {}
|
|
359
|
+
rows = []
|
|
360
|
+
for key in sorted(cascade, key=lambda k: int(k.split("@")[-1])):
|
|
361
|
+
w = cascade[key].get("wilson") or {}
|
|
362
|
+
num, den = w.get("numerator"), w.get("denominator")
|
|
363
|
+
if num is None or den is None:
|
|
364
|
+
continue
|
|
365
|
+
# The level is recorded on every interval block in the study artifact.
|
|
366
|
+
# Dropping it printed a bare `CI` beside the number a reviewer reads
|
|
367
|
+
# first, and a 90% interval would have been indistinguishable from a 95%
|
|
368
|
+
# one.
|
|
369
|
+
ci = interval(w.get("low"), w.get("high"), digits=2, confidence=w.get("confidence"))
|
|
370
|
+
rows.append(
|
|
371
|
+
{
|
|
372
|
+
"label": key,
|
|
373
|
+
"value": num / den if den else 0,
|
|
374
|
+
"display": f"{num}/{den}",
|
|
375
|
+
"tip": f"{num} of {den} · {ci}",
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
if not rows:
|
|
379
|
+
return None
|
|
380
|
+
return {"rows": rows, "max": 1.0, "title": "Recall at each cutoff"}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
# Runs and the demo
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
def load_run_manifest(run: Path) -> dict[str, Any] | None:
|
|
387
|
+
"""A run's manifest as a dict, or ``None`` if there is not one to read.
|
|
388
|
+
|
|
389
|
+
``None`` for absent, unreadable, or not-an-object. Each of those is a
|
|
390
|
+
different way of having no manifest, and none of them is an empty manifest:
|
|
391
|
+
returning ``{}`` would let a caller conclude the run recorded no failed
|
|
392
|
+
stages when in fact nothing was read.
|
|
393
|
+
"""
|
|
394
|
+
path = run / "run_manifest.jsonld"
|
|
395
|
+
if not path.is_file():
|
|
396
|
+
return None
|
|
397
|
+
try:
|
|
398
|
+
body = json.loads(path.read_text(encoding="utf-8"))
|
|
399
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
400
|
+
return None
|
|
401
|
+
return body if isinstance(body, dict) else None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def stage_failures(manifest: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
405
|
+
"""Stages the manifest records as failed.
|
|
406
|
+
|
|
407
|
+
The distinction this exists for: *this cohort has no surface-antigen
|
|
408
|
+
candidates* is a finding, and *the differential-expression step crashed* is
|
|
409
|
+
a bug, and they leave behind the same empty tables. A run that failed must
|
|
410
|
+
never be presented in the layout that means "completed, and found nothing".
|
|
411
|
+
"""
|
|
412
|
+
if not isinstance(manifest, dict):
|
|
413
|
+
return []
|
|
414
|
+
failures: list[dict[str, str]] = []
|
|
415
|
+
for stage in manifest.get("stages") or []:
|
|
416
|
+
if not isinstance(stage, dict):
|
|
417
|
+
continue
|
|
418
|
+
if str(stage.get("status", "")).lower() == "failed":
|
|
419
|
+
failures.append(
|
|
420
|
+
{
|
|
421
|
+
"name": str(stage.get("name", "?")),
|
|
422
|
+
"error": str(stage.get("error") or "no error recorded"),
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
return failures
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# Open Targets reports this when a gene was mapped from a live record.
|
|
429
|
+
OT_STATUS_LIVE = "ok"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def open_targets_degradation(cand: Any) -> dict[str, Any] | None:
|
|
433
|
+
"""How far the gene -> protein mapping fell back, or ``None`` if it did not.
|
|
434
|
+
|
|
435
|
+
Open Targets is the only genome-wide Ensembl -> UniProt source in the
|
|
436
|
+
pipeline. When it is unreachable (or disabled), the only mapping left is the
|
|
437
|
+
bundled table of a few well-known genes, and every gene missing from it is
|
|
438
|
+
dropped before the surfaceome filter. The resulting shortlist is drawn from
|
|
439
|
+
that fixed handful, not from the cohort, so it must not be read as a
|
|
440
|
+
genome-wide discovery -- and an absence from it means nothing at all.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
cand: The candidates table, or ``None`` when the run produced none.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
``None`` when every candidate was mapped live (the normal case, which
|
|
447
|
+
must stay silent) or when there is no status column to judge by.
|
|
448
|
+
Otherwise the counts, the per-status breakdown, and whether the outage
|
|
449
|
+
was total -- because a total outage means the shortlist is not a
|
|
450
|
+
discovery at all, and a partial one means it is merely incomplete.
|
|
451
|
+
"""
|
|
452
|
+
if cand is None or "open_targets_status" not in getattr(cand, "columns", ()):
|
|
453
|
+
return None
|
|
454
|
+
statuses = [str(s) for s in cand["open_targets_status"]]
|
|
455
|
+
degraded = [s for s in statuses if s != OT_STATUS_LIVE]
|
|
456
|
+
if not degraded:
|
|
457
|
+
return None
|
|
458
|
+
return {
|
|
459
|
+
"degraded": len(degraded),
|
|
460
|
+
"total": len(statuses),
|
|
461
|
+
"breakdown": [{"status": s, "count": degraded.count(s)} for s in dict.fromkeys(degraded)],
|
|
462
|
+
# Nothing came back live, so the bundled table was the entire mapping.
|
|
463
|
+
"total_outage": len(degraded) == len(statuses),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _read_candidates(run: Path) -> Any:
|
|
468
|
+
"""A run's candidates table, or ``None`` when there is not one to read."""
|
|
469
|
+
path = run / "targets" / "candidates.parquet"
|
|
470
|
+
if not path.is_file():
|
|
471
|
+
return None
|
|
472
|
+
try:
|
|
473
|
+
import pandas as pd
|
|
474
|
+
|
|
475
|
+
return pd.read_parquet(path)
|
|
476
|
+
except Exception: # unreadable is not the same as absent, but both are None
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _discover_runs(root: Path) -> list[dict[str, Any]]:
|
|
481
|
+
if not root.is_dir():
|
|
482
|
+
return []
|
|
483
|
+
found: list[dict[str, Any]] = []
|
|
484
|
+
for path in sorted(root.iterdir()):
|
|
485
|
+
if not path.is_dir():
|
|
486
|
+
continue
|
|
487
|
+
if path.name.startswith("_"):
|
|
488
|
+
# `runs/_design` is the GPU work cache, keyed by cache_key, not a
|
|
489
|
+
# run. Listing it as one told the reader two untrue things at once:
|
|
490
|
+
# that a run called `_design` exists, and that it is missing the
|
|
491
|
+
# manifest it was never supposed to have. The leading underscore is
|
|
492
|
+
# what marks these internal, and `submit_via_runner` is what creates
|
|
493
|
+
# them.
|
|
494
|
+
continue
|
|
495
|
+
manifest = path / "run_manifest.jsonld"
|
|
496
|
+
candidates = path / "targets" / "candidates.parquet"
|
|
497
|
+
body = load_run_manifest(path)
|
|
498
|
+
failures = stage_failures(body)
|
|
499
|
+
found.append(
|
|
500
|
+
{
|
|
501
|
+
"name": path.name,
|
|
502
|
+
"path": str(path),
|
|
503
|
+
"has_manifest": manifest.is_file(),
|
|
504
|
+
"has_candidates": candidates.is_file(),
|
|
505
|
+
"failures": failures,
|
|
506
|
+
# Three states, not two. "completed" and "found nothing" are the
|
|
507
|
+
# same layout; "crashed" must not be.
|
|
508
|
+
"verdict": ("failed" if failures else "unknown" if body is None else "completed"),
|
|
509
|
+
"open_targets": open_targets_degradation(_read_candidates(path)),
|
|
510
|
+
"mtime": path.stat().st_mtime,
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
return sorted(found, key=lambda r: r["mtime"], reverse=True)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _demo_description() -> dict[str, str]:
|
|
517
|
+
return {
|
|
518
|
+
"cohort": "TCGA-BRCA, primary tumour against solid-tissue normal",
|
|
519
|
+
"source": "NIH/GDC, downloaded on first run and cached",
|
|
520
|
+
"hardware": "CPU only — no GPU, no account",
|
|
521
|
+
"duration": "a few minutes on a warm cache; longer on the first run, "
|
|
522
|
+
"which downloads the cohort",
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
_DEMO_STAGES = (
|
|
527
|
+
"fetch the cohort",
|
|
528
|
+
"differential expression",
|
|
529
|
+
"surface-exposure filter",
|
|
530
|
+
"target annotation",
|
|
531
|
+
"rank candidates",
|
|
532
|
+
"render the report",
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _run_demo(job: Job) -> None:
|
|
537
|
+
"""Run the demo, recording which stage is active.
|
|
538
|
+
|
|
539
|
+
Each stage is named as it starts, so a reader can tell a slow run from a
|
|
540
|
+
stuck one — which a single indeterminate spinner cannot.
|
|
541
|
+
"""
|
|
542
|
+
for name in _DEMO_STAGES:
|
|
543
|
+
job.step(name, "pending")
|
|
544
|
+
try:
|
|
545
|
+
from bindsight.pipelines import discover as discover_pipeline
|
|
546
|
+
from bindsight.report import render_run
|
|
547
|
+
|
|
548
|
+
cfg = _demo_config()
|
|
549
|
+
out_dir = Path.cwd() / "runs" / "demo"
|
|
550
|
+
|
|
551
|
+
job.step(_DEMO_STAGES[0], "running")
|
|
552
|
+
manifest = discover_pipeline.run(cfg, out_dir=out_dir)
|
|
553
|
+
job.finish_previous()
|
|
554
|
+
for name in _DEMO_STAGES[1:-1]:
|
|
555
|
+
job.step(name, "done")
|
|
556
|
+
|
|
557
|
+
job.step(_DEMO_STAGES[-1], "running")
|
|
558
|
+
render_run(out_dir)
|
|
559
|
+
job.finish_previous()
|
|
560
|
+
|
|
561
|
+
job.run_dir = str(out_dir)
|
|
562
|
+
job.state = "done"
|
|
563
|
+
LOG.info("demo complete: %s", manifest)
|
|
564
|
+
except FileNotFoundError as exc:
|
|
565
|
+
# Named separately: this is a packaging problem, and blaming the network
|
|
566
|
+
# for it told users to retry something that can never succeed.
|
|
567
|
+
job.state = "failed"
|
|
568
|
+
job.error = (
|
|
569
|
+
f"The bundled demo configuration is not available in this install "
|
|
570
|
+
f"({exc}). It ships with the repository rather than the wheel — "
|
|
571
|
+
"clone the repository to run the demo."
|
|
572
|
+
)
|
|
573
|
+
except Exception as exc: # surfaced to the page verbatim, not swallowed
|
|
574
|
+
job.state = "failed"
|
|
575
|
+
job.error = str(exc)
|
|
576
|
+
for stage in job.stages:
|
|
577
|
+
if stage["state"] == "running":
|
|
578
|
+
stage["state"] = "failed"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _demo_config() -> Any:
|
|
582
|
+
"""The bundled demo cohort's config.
|
|
583
|
+
|
|
584
|
+
``RunConfig.from_yaml``, not a ``load_config`` helper: that name does not
|
|
585
|
+
exist, so the demo raised ImportError into the broad handler below and
|
|
586
|
+
reported it to the page as a failed run. mypy had it -- the module was
|
|
587
|
+
outside the type-check scope until it was widened.
|
|
588
|
+
"""
|
|
589
|
+
from bindsight.config import RunConfig
|
|
590
|
+
from bindsight.report.showcase import benchmarks_root
|
|
591
|
+
|
|
592
|
+
root = benchmarks_root()
|
|
593
|
+
base = root.parent if root else Path.cwd()
|
|
594
|
+
path = base / "examples" / "demo" / "config.yaml"
|
|
595
|
+
if not path.is_file():
|
|
596
|
+
raise FileNotFoundError(str(path))
|
|
597
|
+
return RunConfig.from_yaml(path)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def serve(
|
|
601
|
+
*,
|
|
602
|
+
host: str = "127.0.0.1",
|
|
603
|
+
port: int = 8501,
|
|
604
|
+
open_browser: bool = True,
|
|
605
|
+
run_root: Path | None = None,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Run the interface.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
host: interface to bind. Loopback by default; nothing is exposed to
|
|
611
|
+
the network unless the caller asks for it.
|
|
612
|
+
port: port to bind. An occupied port is a failed launch, and uvicorn
|
|
613
|
+
exits non-zero for it rather than reporting success.
|
|
614
|
+
open_browser: open a tab once the server is up.
|
|
615
|
+
run_root: where to look for runs. ``None`` means ``runs/`` under the
|
|
616
|
+
working directory -- the same default ``create_app`` documents.
|
|
617
|
+
"""
|
|
618
|
+
import uvicorn
|
|
619
|
+
|
|
620
|
+
if open_browser:
|
|
621
|
+
import webbrowser
|
|
622
|
+
|
|
623
|
+
threading.Timer(1.0, lambda: webbrowser.open(f"http://{host}:{port}")).start()
|
|
624
|
+
uvicorn.run(create_app(run_root=run_root), host=host, port=port, log_level="warning")
|