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,1110 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Headless design+validation executor — the real GPU work, runnable anywhere.
|
|
4
|
+
|
|
5
|
+
This module is what actually runs RFdiffusion → ProteinMPNN → Boltz-2 (and the
|
|
6
|
+
BindCraft / BoltzGen / Chai-1 / AF2-IG alternatives) end-to-end on a CUDA
|
|
7
|
+
machine. It is invoked identically by:
|
|
8
|
+
|
|
9
|
+
- the **local/Docker runner** (`python -m bindsight.runners.job_exec spec.json out.tar.gz`),
|
|
10
|
+
- the **Modal runner** (the same function, executed inside a GPU container), and
|
|
11
|
+
- the **Kaggle runner** (the same module, run inside a pushed kernel).
|
|
12
|
+
|
|
13
|
+
It reads a :class:`bindsight.design.protocol.DesignSpec` JSON plus the
|
|
14
|
+
co-located target structure, runs the chosen designer + validator as
|
|
15
|
+
subprocesses (commands from :mod:`bindsight.runners.tools` — one source of
|
|
16
|
+
truth shared with the Colab notebook), and writes a ``results.tar.gz`` laid out
|
|
17
|
+
so the local orchestrator consumes it directly:
|
|
18
|
+
|
|
19
|
+
<work>/metrics.jsonl # one ValidationResult per line
|
|
20
|
+
<work>/validate/<binder_id>/confidence_*.json + affinity_*.json
|
|
21
|
+
<work>/design/<binder_id>.pdb + .fasta
|
|
22
|
+
|
|
23
|
+
No GPU libraries are imported at module load (torch/boltz live only inside the
|
|
24
|
+
subprocesses), so ``import bindsight`` stays clean on a CPU box. ``_run`` is the
|
|
25
|
+
single subprocess seam, monkeypatched in tests to exercise the dispatch + output
|
|
26
|
+
assembly without a GPU.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import tarfile
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from bindsight.runners import tools
|
|
42
|
+
|
|
43
|
+
LOG = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Design:
|
|
48
|
+
"""One designed binder: id, sequence, and backbone PDB path."""
|
|
49
|
+
|
|
50
|
+
binder_id: str
|
|
51
|
+
sequence: str
|
|
52
|
+
pdb_path: Path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: Wall clock for one external tool invocation, in seconds. Six hours is longer
|
|
56
|
+
#: than any single RFdiffusion / ProteinMPNN / Boltz-2 step this project has
|
|
57
|
+
#: measured, and shorter than a free Kaggle session -- so it bounds a hang
|
|
58
|
+
#: without cutting real work short. It exists because this is the one seam
|
|
59
|
+
#: through which those tools run on rented or quota-limited hardware, and it had
|
|
60
|
+
#: no timeout at all: a tool that stopped making progress burned the entire
|
|
61
|
+
#: session with nothing to stop it and nothing in the log to say so.
|
|
62
|
+
#:
|
|
63
|
+
#: Override with ``BINDSIGHT_TOOL_TIMEOUT`` for a genuinely longer job.
|
|
64
|
+
_TOOL_TIMEOUT_S = float(os.environ.get("BINDSIGHT_TOOL_TIMEOUT", 6 * 60 * 60))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
|
68
|
+
"""Run a subprocess, capturing output; raise on non-zero exit.
|
|
69
|
+
|
|
70
|
+
The single subprocess seam — monkeypatched in tests so the dispatch +
|
|
71
|
+
output-assembly logic is exercised without a GPU.
|
|
72
|
+
"""
|
|
73
|
+
LOG.info("exec: %s", " ".join(cmd))
|
|
74
|
+
try:
|
|
75
|
+
proc = subprocess.run(
|
|
76
|
+
cmd,
|
|
77
|
+
cwd=cwd,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
check=False,
|
|
81
|
+
timeout=_TOOL_TIMEOUT_S,
|
|
82
|
+
)
|
|
83
|
+
except subprocess.TimeoutExpired as exc:
|
|
84
|
+
LOG.error("command exceeded %.0f s and was killed: %s", _TOOL_TIMEOUT_S, " ".join(cmd))
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
f"{cmd[0]} exceeded the {_TOOL_TIMEOUT_S:.0f}s tool timeout and was killed. "
|
|
87
|
+
"Raise BINDSIGHT_TOOL_TIMEOUT if the job genuinely needs longer."
|
|
88
|
+
) from exc
|
|
89
|
+
if proc.returncode != 0:
|
|
90
|
+
LOG.error(
|
|
91
|
+
"command failed (%d): %s\n%s", proc.returncode, " ".join(cmd), proc.stderr[-2000:]
|
|
92
|
+
)
|
|
93
|
+
raise RuntimeError(f"{cmd[0]} failed with code {proc.returncode}")
|
|
94
|
+
return proc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Tool setup (idempotent; no-ops when an image already ships the tools)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
def _git_clone(repo: str, commit: str, dest: Path, *, submodules: bool = False) -> Path:
|
|
101
|
+
"""Clone ``repo`` at ``commit`` into ``dest``, optionally with submodules.
|
|
102
|
+
|
|
103
|
+
``submodules`` matters for dl_binder_design: its ``af2_initial_guess``
|
|
104
|
+
predict.py imports ``silent_tools`` at module scope, and that is a git
|
|
105
|
+
submodule. A plain clone leaves the directory empty, so the AF2
|
|
106
|
+
initial-guess validator fails on import rather than at a legible point.
|
|
107
|
+
"""
|
|
108
|
+
if dest.exists():
|
|
109
|
+
return dest
|
|
110
|
+
_run(["git", "clone", "--quiet", repo, str(dest)])
|
|
111
|
+
_run(["git", "checkout", "--quiet", commit], cwd=dest)
|
|
112
|
+
if submodules:
|
|
113
|
+
_run(["git", "submodule", "update", "--init", "--recursive", "--quiet"], cwd=dest)
|
|
114
|
+
return dest
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _verify_checkpoint(path: Path, expected_sha256: str | None) -> str:
|
|
118
|
+
"""Hash a downloaded checkpoint, log it, and fail on a pinned mismatch.
|
|
119
|
+
|
|
120
|
+
Always hashing, rather than only when a pin exists, is what makes pinning
|
|
121
|
+
possible at all: the first run publishes the digest into its own log, and
|
|
122
|
+
that is where the pinned value comes from. Inventing one locally would
|
|
123
|
+
certify whatever this machine downloaded.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path: the checkpoint on disk.
|
|
127
|
+
expected_sha256: the pinned digest, or None if none is established.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The hex digest of the file.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
RuntimeError: if a pinned digest is present and does not match.
|
|
134
|
+
"""
|
|
135
|
+
import hashlib
|
|
136
|
+
|
|
137
|
+
if not path.is_file():
|
|
138
|
+
# wget exiting 0 without producing a file is a silent failure. Before
|
|
139
|
+
# this check the run continued to RFdiffusion, which failed later with a
|
|
140
|
+
# message about the model rather than about the download.
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"{path.name} is missing after the download step. The fetch reported "
|
|
143
|
+
f"no error but produced no file at {path}. Re-run with network access, "
|
|
144
|
+
"or place the checkpoint there yourself."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
h = hashlib.sha256()
|
|
148
|
+
with open(path, "rb") as fh:
|
|
149
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
150
|
+
h.update(chunk)
|
|
151
|
+
digest = h.hexdigest()
|
|
152
|
+
size = path.stat().st_size
|
|
153
|
+
LOG.info("checkpoint %s: %d bytes sha256=%s", path.name, size, digest)
|
|
154
|
+
if expected_sha256 and digest != expected_sha256:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"{path.name}: sha256 {digest} does not match the pinned "
|
|
157
|
+
f"{expected_sha256}. Refusing to design against a checkpoint that is "
|
|
158
|
+
"not the one this result claims. Delete the file and re-fetch; if the "
|
|
159
|
+
"mismatch persists, upstream has republished and the pin needs review."
|
|
160
|
+
)
|
|
161
|
+
return digest
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ensure_rfdiff_mpnn(tools_root: Path) -> tuple[Path, Path]:
|
|
165
|
+
rfdiff_dir = tools_root / "RFdiffusion"
|
|
166
|
+
fresh = not rfdiff_dir.exists()
|
|
167
|
+
rfdiff = _git_clone(tools.RFDIFF_REPO, tools.RFDIFF_COMMIT, rfdiff_dir)
|
|
168
|
+
weights = rfdiff / "models"
|
|
169
|
+
weights.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
for name, url in tools.RFDIFF_WEIGHTS.items():
|
|
171
|
+
dst = weights / name
|
|
172
|
+
if not dst.exists():
|
|
173
|
+
_run(["wget", "-q", url, "-O", str(dst)])
|
|
174
|
+
_verify_checkpoint(dst, tools.RFDIFF_WEIGHT_SHA256.get(name))
|
|
175
|
+
if fresh:
|
|
176
|
+
# Install RFdiffusion's SE3-Transformer deps so the executor is
|
|
177
|
+
# self-sufficient on a *bare* GPU (headless Kaggle / a fresh box), not
|
|
178
|
+
# only inside the prebuilt Docker image. Mirrors the proven Colab
|
|
179
|
+
# install cell; no-ops when the image already ships an installed tree.
|
|
180
|
+
req = rfdiff / "env" / "SE3Transformer" / "requirements.txt"
|
|
181
|
+
if req.exists():
|
|
182
|
+
_run([sys.executable, "-m", "pip", "install", "-q", "-r", str(req)])
|
|
183
|
+
_run([sys.executable, "-m", "pip", "install", "-q", "-e", str(rfdiff)])
|
|
184
|
+
mpnn = _git_clone(tools.PROTEINMPNN_REPO, tools.PROTEINMPNN_COMMIT, tools_root / "ProteinMPNN")
|
|
185
|
+
return rfdiff, mpnn
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Identity + target sequence (shared by every designer and validator)
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
def _binder_id_prefix(spec: dict[str, Any]) -> str:
|
|
192
|
+
"""Per-**site** namespace for binder ids.
|
|
193
|
+
|
|
194
|
+
RFdiffusion writes every trajectory under a fixed ``binder_*`` prefix, so
|
|
195
|
+
without a namespace two different jobs both produce ``binder_0_seq0``. That
|
|
196
|
+
collides in ``validated.parquet`` and, worse, makes one job's
|
|
197
|
+
``validate/<binder_id>/`` overwrite another's on disk. ``binder_id`` is the
|
|
198
|
+
key the provenance chain is walked by, so it has to be unique across the
|
|
199
|
+
whole run.
|
|
200
|
+
|
|
201
|
+
The accession alone was not enough. Discovery emits **one epitopes row per
|
|
202
|
+
qualifying targetable site**, and ``_top_targets`` turns each row into its
|
|
203
|
+
own design job — so two sites of one receptor are two genuinely different
|
|
204
|
+
jobs, against different residues, producing different binders, and both
|
|
205
|
+
named ``P04626_binder_0_seq0``. The concatenated metrics then carry two
|
|
206
|
+
rows with one id and different ipTMs, and whichever job finished last owns
|
|
207
|
+
the directory. The cross-target case was fixed here; the cross-site case is
|
|
208
|
+
the same collision one level in.
|
|
209
|
+
|
|
210
|
+
The epitope is therefore part of the namespace, digested rather than spelled
|
|
211
|
+
out because a residue list does not fit in a filename. Sorted first, so the
|
|
212
|
+
same site always gives the same id however the residues were ordered.
|
|
213
|
+
|
|
214
|
+
A spec with **no** epitope residues keeps the bare accession. That is not a
|
|
215
|
+
special case for its own sake: whole-surface design is one namespace per
|
|
216
|
+
target by definition, and it keeps the ids of every already-published
|
|
217
|
+
artifact exactly as they are.
|
|
218
|
+
"""
|
|
219
|
+
import hashlib
|
|
220
|
+
|
|
221
|
+
raw = str(spec.get("target_uniprot") or "").strip()
|
|
222
|
+
safe = "".join(ch if (ch.isalnum() or ch in "-_") else "_" for ch in raw)
|
|
223
|
+
prefix = safe or "target"
|
|
224
|
+
|
|
225
|
+
residues = spec.get("epitope_residues") or []
|
|
226
|
+
if not residues:
|
|
227
|
+
return prefix
|
|
228
|
+
chain = str(spec.get("epitope_chain") or "")
|
|
229
|
+
token = f"{chain}:{','.join(str(int(r)) for r in sorted(residues))}"
|
|
230
|
+
return f"{prefix}_s{hashlib.sha256(token.encode()).hexdigest()[:8]}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _target_sequence_for_design(spec: dict[str, Any], work: Path, chain: str) -> str:
|
|
234
|
+
"""The target sequence the designer actually saw: the kept ranges only.
|
|
235
|
+
|
|
236
|
+
``design_ranges`` carries the extracellular region discovery annotated from
|
|
237
|
+
UniProt topology, and the designer is given only that. Validating against the
|
|
238
|
+
full-length chain instead would score the binder against a surface it was
|
|
239
|
+
never designed for, so every validator must use this same sequence.
|
|
240
|
+
"""
|
|
241
|
+
target_pdb = work / "target.pdb"
|
|
242
|
+
ranges = _target_ranges(spec, target_pdb, chain)
|
|
243
|
+
return "".join(
|
|
244
|
+
aa
|
|
245
|
+
for resi, aa in tools.chain_residues_from_pdb(target_pdb, chain)
|
|
246
|
+
if any(lo <= resi <= hi for lo, hi in ranges)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Designers
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
def _design_rfdiff_mpnn(spec: dict[str, Any], work: Path, tools_root: Path) -> list[Design]:
|
|
254
|
+
rfdiff_dir, mpnn_dir = _ensure_rfdiff_mpnn(tools_root)
|
|
255
|
+
target_pdb = work / "target.pdb"
|
|
256
|
+
chain = spec.get("epitope_chain", "A")
|
|
257
|
+
residues = [int(r) for r in spec.get("epitope_residues", [])]
|
|
258
|
+
ranges = _target_ranges(spec, target_pdb, chain)
|
|
259
|
+
# The sequence RFdiffusion is given — the kept segments only, which is what
|
|
260
|
+
# the output backbone's target chain will carry, and what the validator
|
|
261
|
+
# must later score against.
|
|
262
|
+
target_seq = _target_sequence_for_design(spec, work, chain)
|
|
263
|
+
|
|
264
|
+
rfdiff_out = work / "rfdiff_out"
|
|
265
|
+
rfdiff_out.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
cmd = tools.build_rfdiff_cmd(
|
|
267
|
+
rfdiff_dir=rfdiff_dir,
|
|
268
|
+
input_pdb=target_pdb,
|
|
269
|
+
output_prefix=rfdiff_out / "binder",
|
|
270
|
+
num_designs=int(spec.get("n_trajectories", 5)),
|
|
271
|
+
hotspot=tools.build_hotspot_str(chain, residues),
|
|
272
|
+
contig=tools.build_ranges_contig_str(
|
|
273
|
+
chain,
|
|
274
|
+
ranges,
|
|
275
|
+
int(spec.get("binder_length_min", 50)),
|
|
276
|
+
int(spec.get("binder_length_max", 100)),
|
|
277
|
+
),
|
|
278
|
+
# The first stochastic stage, and until now the only one the configured
|
|
279
|
+
# seed never reached. A reproducibly folded sequence is no use when the
|
|
280
|
+
# backbone that produced it was an unseeded draw.
|
|
281
|
+
seed=int(spec.get("seed", 0)),
|
|
282
|
+
)
|
|
283
|
+
if not residues:
|
|
284
|
+
# whole-target design: drop the empty ppi.hotspot_res token
|
|
285
|
+
cmd = [a for a in cmd if not a.startswith("ppi.hotspot_res=")]
|
|
286
|
+
_run(cmd, cwd=rfdiff_dir)
|
|
287
|
+
|
|
288
|
+
designs: list[Design] = []
|
|
289
|
+
design_dir = work / "design"
|
|
290
|
+
design_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
for backbone in sorted(rfdiff_out.glob("binder_*.pdb")):
|
|
292
|
+
mpnn_out = work / "mpnn_out" / backbone.stem
|
|
293
|
+
mpnn_out.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
# Design the binder chain only; the target is context ProteinMPNN must
|
|
295
|
+
# not rewrite (its default is to design every chain).
|
|
296
|
+
binder_chain = tools.binder_chain_from_backbone(backbone, target_sequence=target_seq)
|
|
297
|
+
_run(
|
|
298
|
+
tools.build_mpnn_cmd(
|
|
299
|
+
mpnn_dir=mpnn_dir,
|
|
300
|
+
pdb_path=backbone,
|
|
301
|
+
out_folder=mpnn_out,
|
|
302
|
+
designed_chains=[binder_chain],
|
|
303
|
+
seed=int(spec.get("seed", 0)),
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
fasta = next((mpnn_out / "seqs").glob("*.fa"), None)
|
|
307
|
+
if fasta is None:
|
|
308
|
+
continue
|
|
309
|
+
seqs = tools.mpnn_design_sequences(fasta)
|
|
310
|
+
for i, seq in enumerate(seqs):
|
|
311
|
+
binder_id = f"{_binder_id_prefix(spec)}_{backbone.stem}_seq{i}"
|
|
312
|
+
pdb_copy = design_dir / f"{binder_id}.pdb"
|
|
313
|
+
# Write the design, not the backbone it came from. Copying the
|
|
314
|
+
# backbone verbatim gave every sequence from a trajectory a
|
|
315
|
+
# byte-identical file under a different name, each carrying the
|
|
316
|
+
# residues diffusion emitted rather than the ones ProteinMPNN chose.
|
|
317
|
+
tools.write_designed_backbone(backbone, pdb_copy, chain=binder_chain, sequence=seq)
|
|
318
|
+
(design_dir / f"{binder_id}.fasta").write_text(
|
|
319
|
+
f">{binder_id}\n{seq}\n", encoding="utf-8", newline="\n"
|
|
320
|
+
)
|
|
321
|
+
designs.append(Design(binder_id=binder_id, sequence=seq, pdb_path=pdb_copy))
|
|
322
|
+
return designs
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _design_boltzgen(spec: dict[str, Any], work: Path, tools_root: Path) -> list[Design]:
|
|
326
|
+
"""Run BoltzGen against the target and collect its ranked designs.
|
|
327
|
+
|
|
328
|
+
BoltzGen is driven by a design-specification YAML rather than command-line
|
|
329
|
+
hotspot flags, and it resolves file references relative to that YAML, so the
|
|
330
|
+
spec and the target structure are written into one directory together.
|
|
331
|
+
"""
|
|
332
|
+
import yaml
|
|
333
|
+
|
|
334
|
+
_git_clone(tools.BOLTZGEN_REPO, tools.BOLTZGEN_COMMIT, tools_root / "boltzgen")
|
|
335
|
+
out = work / "boltzgen_out"
|
|
336
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
chain = spec.get("epitope_chain", "A")
|
|
338
|
+
residues = [int(r) for r in spec.get("epitope_residues", [])]
|
|
339
|
+
|
|
340
|
+
spec_dir = work / "boltzgen_spec"
|
|
341
|
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
target = _target_structure_for_design(spec, work, chain)
|
|
343
|
+
staged_target = spec_dir / "target.pdb"
|
|
344
|
+
staged_target.write_bytes(target.read_bytes())
|
|
345
|
+
|
|
346
|
+
# BoltzGen indexes residues by their canonical 1-based position in the chain,
|
|
347
|
+
# not by author numbering. Converting is the difference between binding the
|
|
348
|
+
# intended epitope and binding an arbitrary stretch of the receptor.
|
|
349
|
+
binding = tools.label_indices_for_residues(staged_target, chain, residues)
|
|
350
|
+
if residues and not binding:
|
|
351
|
+
LOG.warning(
|
|
352
|
+
"none of the %d epitope residue(s) are present in the target chain; "
|
|
353
|
+
"BoltzGen will design against the whole surface",
|
|
354
|
+
len(residues),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
extra = spec.get("extra_params", {}) or {}
|
|
358
|
+
design_spec = spec_dir / "design_spec.yaml"
|
|
359
|
+
design_spec.write_text(
|
|
360
|
+
yaml.safe_dump(
|
|
361
|
+
tools.build_boltzgen_spec(
|
|
362
|
+
target_file=staged_target.name,
|
|
363
|
+
target_chain=chain,
|
|
364
|
+
binding_indices=binding,
|
|
365
|
+
binder_length_min=int(spec.get("binder_length_min", 50)),
|
|
366
|
+
binder_length_max=int(spec.get("binder_length_max", 100)),
|
|
367
|
+
),
|
|
368
|
+
sort_keys=False,
|
|
369
|
+
),
|
|
370
|
+
encoding="utf-8",
|
|
371
|
+
newline="\n",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
n = int(spec.get("n_trajectories", 5))
|
|
375
|
+
_run(
|
|
376
|
+
tools.build_boltzgen_cmd(
|
|
377
|
+
design_spec=design_spec,
|
|
378
|
+
out_dir=out,
|
|
379
|
+
num_designs=n,
|
|
380
|
+
protocol=str(extra.get("boltzgen_protocol", "protein-anything")),
|
|
381
|
+
# Triton kernels need compute capability 8.0 or newer. Every free-tier
|
|
382
|
+
# GPU is older, and BoltzGen's "auto" default would try to enable them.
|
|
383
|
+
use_kernels=str(extra.get("boltzgen_use_kernels", "auto")),
|
|
384
|
+
budget=n,
|
|
385
|
+
diffusion_batch_size=1 if n < 10 else None,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
return _collect_designs_from_dir(out, work, prefix="boltzgen", spec=spec)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _design_bindcraft(spec: dict[str, Any], work: Path, tools_root: Path) -> list[Design]:
|
|
392
|
+
bindcraft = _git_clone(tools.BINDCRAFT_REPO, tools.BINDCRAFT_COMMIT, tools_root / "BindCraft")
|
|
393
|
+
out = work / "bindcraft_out"
|
|
394
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
settings = work / "bindcraft_settings.json"
|
|
396
|
+
settings.write_text(
|
|
397
|
+
json.dumps(
|
|
398
|
+
{
|
|
399
|
+
"design_path": str(out),
|
|
400
|
+
"binder_name": spec.get("target_uniprot", "binder"),
|
|
401
|
+
"starting_pdb": str(
|
|
402
|
+
_target_structure_for_design(spec, work, spec.get("epitope_chain", "A"))
|
|
403
|
+
),
|
|
404
|
+
"chains": spec.get("epitope_chain", "A"),
|
|
405
|
+
"target_hotspot_residues": ",".join(
|
|
406
|
+
str(r) for r in spec.get("epitope_residues", [])
|
|
407
|
+
),
|
|
408
|
+
"lengths": [
|
|
409
|
+
int(spec.get("binder_length_min", 50)),
|
|
410
|
+
int(spec.get("binder_length_max", 100)),
|
|
411
|
+
],
|
|
412
|
+
"number_of_final_designs": int(spec.get("n_trajectories", 5)),
|
|
413
|
+
}
|
|
414
|
+
),
|
|
415
|
+
encoding="utf-8",
|
|
416
|
+
newline="\n",
|
|
417
|
+
)
|
|
418
|
+
_run(
|
|
419
|
+
tools.build_bindcraft_cmd(
|
|
420
|
+
bindcraft_dir=bindcraft,
|
|
421
|
+
settings_json=settings,
|
|
422
|
+
filters_json=bindcraft / "settings_filters" / "default_filters.json",
|
|
423
|
+
advanced_json=bindcraft / "settings_advanced" / "default_4stage_multimer.json",
|
|
424
|
+
),
|
|
425
|
+
cwd=bindcraft,
|
|
426
|
+
)
|
|
427
|
+
return _collect_designs_from_dir(out, work, prefix="bindcraft", spec=spec)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
_DESIGNERS = {
|
|
431
|
+
"rfdiff_mpnn": _design_rfdiff_mpnn,
|
|
432
|
+
"boltzgen": _design_boltzgen,
|
|
433
|
+
"bindcraft": _design_bindcraft,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
#: Designers whose pinned upstream offers no way to set the random seed, with
|
|
438
|
+
#: the evidence for each. ``spec["seed"]`` is folded into the design cache key,
|
|
439
|
+
#: which is a promise that the same seed means the same work; for these two that
|
|
440
|
+
#: promise cannot be kept, and saying so here is the difference between a
|
|
441
|
+
#: documented limitation and a silent one.
|
|
442
|
+
#:
|
|
443
|
+
#: Checked against the pinned commits, not from memory:
|
|
444
|
+
#:
|
|
445
|
+
#: * **BindCraft** ``b971db42``: ``bindcraft.py`` line 91 draws its own seed with
|
|
446
|
+
#: ``np.random.randint(0, high=999999, ...)`` on every trajectory. There is no
|
|
447
|
+
#: ``--seed`` flag, and no seed field in ``settings_advanced/`` — and because
|
|
448
|
+
#: bindsight runs it as a subprocess, seeding numpy in this process cannot
|
|
449
|
+
#: reach it.
|
|
450
|
+
#: * **BoltzGen** ``a3149cf1``: ``src/boltzgen/cli/boltzgen.py`` contains no
|
|
451
|
+
#: occurrence of "seed" among its 30-odd options, and neither do the
|
|
452
|
+
#: ``design``/``fold``/``inverse_fold`` configs that ``--config`` overrides.
|
|
453
|
+
#:
|
|
454
|
+
#: Neither backend has been executed end to end (the README's runner table is the
|
|
455
|
+
#: authority), so no published number rests on this. It is the reproducibility
|
|
456
|
+
#: claim that has to stay accurate, not a result.
|
|
457
|
+
UNSEEDABLE_DESIGNERS = {
|
|
458
|
+
"bindcraft": (
|
|
459
|
+
"BindCraft draws its own seed per trajectory (bindcraft.py:91 at commit "
|
|
460
|
+
"b971db42) and exposes no flag or settings field to fix it"
|
|
461
|
+
),
|
|
462
|
+
"boltzgen": (
|
|
463
|
+
"the boltzgen CLI at commit a3149cf1 has no --seed option and no seed "
|
|
464
|
+
"field in the configs --config overrides"
|
|
465
|
+
),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def warn_if_unseeded(designer: str, seed: int) -> str | None:
|
|
470
|
+
"""Warn, once per job, that ``seed`` will not reach ``designer``.
|
|
471
|
+
|
|
472
|
+
Returns the reason when the designer is unseedable, so callers that record
|
|
473
|
+
run metadata can carry it too; ``None`` when the seed is honoured.
|
|
474
|
+
"""
|
|
475
|
+
reason = UNSEEDABLE_DESIGNERS.get(designer)
|
|
476
|
+
if reason is None:
|
|
477
|
+
return None
|
|
478
|
+
LOG.warning(
|
|
479
|
+
"designer %s ignores the configured seed (%s): %s. This run is not "
|
|
480
|
+
"reproducible from its seed; re-running it will produce different "
|
|
481
|
+
"binders even with an identical spec.",
|
|
482
|
+
designer,
|
|
483
|
+
seed,
|
|
484
|
+
reason,
|
|
485
|
+
)
|
|
486
|
+
return reason
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
# Validators
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
def _validate_boltz2(
|
|
493
|
+
spec: dict[str, Any], designs: list[Design], work: Path
|
|
494
|
+
) -> list[dict[str, Any]]:
|
|
495
|
+
target_seq = _target_sequence_for_design(spec, work, spec.get("epitope_chain", "A"))
|
|
496
|
+
boltz_root = work / "boltz_out"
|
|
497
|
+
validate_root = work / "validate"
|
|
498
|
+
metrics: list[dict[str, Any]] = []
|
|
499
|
+
for d in designs:
|
|
500
|
+
# predict_affinity=False: Boltz-2 affinity prediction is ligand-only
|
|
501
|
+
# ("Chain B is not a ligand!"), and bindsight designs *protein* binders —
|
|
502
|
+
# requesting it makes Boltz-2 skip the whole input. ipTM + PAE-interaction
|
|
503
|
+
# from the protein–protein structure prediction are the binder-quality
|
|
504
|
+
# metrics we want; affinity_pred_value stays None for protein binders.
|
|
505
|
+
yaml_spec = tools.build_boltz_yaml(
|
|
506
|
+
target_id="T",
|
|
507
|
+
target_sequence=target_seq,
|
|
508
|
+
binder_id=d.binder_id,
|
|
509
|
+
binder_sequence=d.sequence,
|
|
510
|
+
predict_affinity=False,
|
|
511
|
+
)
|
|
512
|
+
from bindsight.validate.boltz2 import write_boltz_yaml
|
|
513
|
+
|
|
514
|
+
yaml_path = boltz_root / f"{d.binder_id}.yaml"
|
|
515
|
+
write_boltz_yaml(yaml_spec, yaml_path)
|
|
516
|
+
out_dir = boltz_root / d.binder_id
|
|
517
|
+
# The configured seed reaches the designer at the top of this module and
|
|
518
|
+
# stopped there, so the stochastic half of the pipeline — the diffusion
|
|
519
|
+
# model that produces every published confidence number — ran unseeded.
|
|
520
|
+
# Derived per binder rather than shared: one seed across every design
|
|
521
|
+
# would correlate their draws, and each binder's number is read on its
|
|
522
|
+
# own. Stable across reruns because it comes from the id, not from
|
|
523
|
+
# enumeration order.
|
|
524
|
+
proc = _run(
|
|
525
|
+
tools.build_boltz_cmd(
|
|
526
|
+
yaml_path=yaml_path,
|
|
527
|
+
out_dir=out_dir,
|
|
528
|
+
seed=_binder_seed(int(spec.get("seed", 0)), d.binder_id),
|
|
529
|
+
diffusion_samples=int(spec.get("extra_params", {}).get("diffusion_samples", 1)),
|
|
530
|
+
max_parallel_samples=int(
|
|
531
|
+
spec.get("extra_params", {}).get("max_parallel_samples", 1)
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
result = tools.parse_boltz_output(
|
|
536
|
+
output_dir=out_dir,
|
|
537
|
+
binder_id=d.binder_id,
|
|
538
|
+
target_uniprot=str(spec.get("target_uniprot", "")),
|
|
539
|
+
)
|
|
540
|
+
# Fill PAE-interaction from the predicted PAE matrix (mean inter-chain PAE)
|
|
541
|
+
# when Boltz's confidence JSON didn't carry it. Token order is target then
|
|
542
|
+
# binder (per the YAML), so the chain lengths split the matrix.
|
|
543
|
+
if result.pae_interaction is None:
|
|
544
|
+
pae_i = _boltz_pae_interaction(
|
|
545
|
+
out_dir, target_len=len(target_seq), binder_len=len(d.sequence)
|
|
546
|
+
)
|
|
547
|
+
if pae_i is not None:
|
|
548
|
+
result = result.model_copy(update={"pae_interaction": pae_i})
|
|
549
|
+
if result.iptm is None:
|
|
550
|
+
# Boltz-2 exits 0 even when it *skips* a bad input, so surface its own
|
|
551
|
+
# output here rather than recording a silent null.
|
|
552
|
+
LOG.warning(
|
|
553
|
+
"boltz2 produced no confidence for %s (no confidence_*.json under %s).\n"
|
|
554
|
+
"boltz stdout tail:\n%s\nboltz stderr tail:\n%s",
|
|
555
|
+
d.binder_id,
|
|
556
|
+
out_dir,
|
|
557
|
+
(proc.stdout or "")[-2000:],
|
|
558
|
+
(proc.stderr or "")[-2000:],
|
|
559
|
+
)
|
|
560
|
+
_stage_validate_outputs(out_dir, validate_root / d.binder_id)
|
|
561
|
+
metrics.append(result.model_dump())
|
|
562
|
+
return metrics
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _boltz_pae_interaction(out_dir: Path, *, target_len: int, binder_len: int) -> float | None:
|
|
566
|
+
"""Mean inter-chain PAE (Å) from a Boltz-2 ``pae_*.npz``, or None if unavailable.
|
|
567
|
+
|
|
568
|
+
The PAE matrix is over all residue tokens in chain order (target, then binder),
|
|
569
|
+
so the off-diagonal blocks [target × binder] and [binder × target] are the
|
|
570
|
+
interface PAE — lower means a more confident interface.
|
|
571
|
+
|
|
572
|
+
Averaged over every diffusion draw, for the same reason ipTM is: Boltz ranks
|
|
573
|
+
its samples best-first, so taking whichever ``pae_*.npz`` turned up first
|
|
574
|
+
reported the most confident draw rather than a representative one, and that
|
|
575
|
+
number would improve with every extra sample bought.
|
|
576
|
+
"""
|
|
577
|
+
import numpy as np
|
|
578
|
+
|
|
579
|
+
values: list[float] = []
|
|
580
|
+
for npz in sorted(Path(out_dir).rglob("pae_*.npz")):
|
|
581
|
+
try:
|
|
582
|
+
data = np.load(npz)
|
|
583
|
+
pae = data["pae"] if "pae" in data.files else data[data.files[0]]
|
|
584
|
+
n = target_len + binder_len
|
|
585
|
+
if pae.ndim != 2 or pae.shape != (n, n):
|
|
586
|
+
continue
|
|
587
|
+
t = target_len
|
|
588
|
+
inter = np.concatenate([pae[:t, t:].ravel(), pae[t:, :t].ravel()])
|
|
589
|
+
if inter.size:
|
|
590
|
+
values.append(float(inter.mean()))
|
|
591
|
+
except Exception as e: # a malformed npz must not abort the job
|
|
592
|
+
LOG.warning("failed to compute PAE-interaction from %s: %s", npz, e)
|
|
593
|
+
return sum(values) / len(values) if values else None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _validate_chai1r(
|
|
597
|
+
spec: dict[str, Any], designs: list[Design], work: Path
|
|
598
|
+
) -> list[dict[str, Any]]:
|
|
599
|
+
target_seq = _target_sequence_for_design(spec, work, spec.get("epitope_chain", "A"))
|
|
600
|
+
chai_root = work / "chai_out"
|
|
601
|
+
metrics: list[dict[str, Any]] = []
|
|
602
|
+
for d in designs:
|
|
603
|
+
fasta = chai_root / f"{d.binder_id}.fasta"
|
|
604
|
+
fasta.parent.mkdir(parents=True, exist_ok=True)
|
|
605
|
+
fasta.write_text(
|
|
606
|
+
f">protein|T\n{target_seq}\n>protein|{d.binder_id}\n{d.sequence}\n",
|
|
607
|
+
encoding="utf-8",
|
|
608
|
+
newline="\n",
|
|
609
|
+
)
|
|
610
|
+
out_dir = chai_root / d.binder_id
|
|
611
|
+
_run(
|
|
612
|
+
tools.build_chai_cmd(
|
|
613
|
+
fasta_path=fasta,
|
|
614
|
+
out_dir=out_dir,
|
|
615
|
+
# Per binder, from the run's seed — the same derivation the
|
|
616
|
+
# Boltz-2 path uses, so the two validators are reproducible in
|
|
617
|
+
# the same way rather than one of them by accident.
|
|
618
|
+
seed=_binder_seed(int(spec.get("seed", 0)), d.binder_id),
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
result = tools.parse_chai_output(
|
|
622
|
+
out_dir, binder_id=d.binder_id, target_uniprot=str(spec.get("target_uniprot", ""))
|
|
623
|
+
)
|
|
624
|
+
metrics.append(result.model_dump())
|
|
625
|
+
return metrics
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _validate_af2_ig(
|
|
629
|
+
spec: dict[str, Any], designs: list[Design], work: Path
|
|
630
|
+
) -> list[dict[str, Any]]:
|
|
631
|
+
tools_root = work / "_tools"
|
|
632
|
+
dl = _git_clone(
|
|
633
|
+
tools.DL_BINDER_DESIGN_REPO,
|
|
634
|
+
tools.DL_BINDER_DESIGN_COMMIT,
|
|
635
|
+
tools_root / "dl_binder_design",
|
|
636
|
+
# predict.py does `from silent_tools import silent_tools` at import time,
|
|
637
|
+
# and silent_tools is a submodule.
|
|
638
|
+
submodules=True,
|
|
639
|
+
)
|
|
640
|
+
af2_root = work / "af2_out"
|
|
641
|
+
metrics: list[dict[str, Any]] = []
|
|
642
|
+
for d in designs:
|
|
643
|
+
out_dir = af2_root / d.binder_id
|
|
644
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
645
|
+
_run(
|
|
646
|
+
tools.build_af2ig_cmd(
|
|
647
|
+
dl_binder_design_dir=dl, silent_or_pdb=d.pdb_path, out_dir=out_dir
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
result = tools.parse_af2ig_output(
|
|
651
|
+
out_dir / "af2_scores.sc",
|
|
652
|
+
binder_id=d.binder_id,
|
|
653
|
+
target_uniprot=str(spec.get("target_uniprot", "")),
|
|
654
|
+
)
|
|
655
|
+
metrics.append(result.model_dump())
|
|
656
|
+
return metrics
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
_VALIDATORS = {
|
|
660
|
+
"boltz2": _validate_boltz2,
|
|
661
|
+
"chai1r": _validate_chai1r,
|
|
662
|
+
"af2_ig": _validate_af2_ig,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
# Helpers
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
def _target_ranges(spec: dict[str, Any], target_pdb: Path, chain: str) -> list[tuple[int, int]]:
|
|
670
|
+
"""Target residue ranges to present to the designer, clipped to the structure.
|
|
671
|
+
|
|
672
|
+
``spec['design_ranges']`` carries the extracellular ranges discovery annotated
|
|
673
|
+
from UniProt topology. Absent (or empty) means no topology was available, and
|
|
674
|
+
the whole chain — transmembrane helix and cytoplasmic tail included — becomes
|
|
675
|
+
the design surface; that is recorded loudly rather than assumed.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
spec: The design spec dict.
|
|
679
|
+
target_pdb: The materialised target structure.
|
|
680
|
+
chain: The target chain id.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Inclusive ``(lo, hi)`` ranges in the structure's numbering.
|
|
684
|
+
|
|
685
|
+
Raises:
|
|
686
|
+
ValueError: When ``design_ranges`` is given but overlaps no modelled residue.
|
|
687
|
+
"""
|
|
688
|
+
lo, hi = _chain_span(target_pdb, chain)
|
|
689
|
+
raw = spec.get("design_ranges") or []
|
|
690
|
+
if not raw:
|
|
691
|
+
LOG.warning(
|
|
692
|
+
"no design_ranges in the spec: designing against the full length of chain %s "
|
|
693
|
+
"(%d-%d), including any transmembrane and cytoplasmic regions",
|
|
694
|
+
chain,
|
|
695
|
+
lo,
|
|
696
|
+
hi,
|
|
697
|
+
)
|
|
698
|
+
return [(lo, hi)]
|
|
699
|
+
clipped = [
|
|
700
|
+
(max(int(r[0]), lo), min(int(r[1]), hi)) for r in raw if int(r[0]) <= hi and int(r[1]) >= lo
|
|
701
|
+
]
|
|
702
|
+
if not clipped:
|
|
703
|
+
raise ValueError(
|
|
704
|
+
f"design_ranges {[list(r) for r in raw]} do not overlap chain {chain} "
|
|
705
|
+
f"residues {lo}-{hi} of {target_pdb}"
|
|
706
|
+
)
|
|
707
|
+
LOG.info(
|
|
708
|
+
"designing against %d extracellular range(s) of chain %s: %s", len(clipped), chain, clipped
|
|
709
|
+
)
|
|
710
|
+
return clipped
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _target_structure_for_design(spec: dict[str, Any], work: Path, chain: str) -> Path:
|
|
714
|
+
"""Target structure trimmed to the spec's ``design_ranges``.
|
|
715
|
+
|
|
716
|
+
The one-shot designers take a structure rather than a contig, so the ranges
|
|
717
|
+
are applied by writing a trimmed copy. Returns ``target.pdb`` unchanged when
|
|
718
|
+
the ranges already span the whole chain.
|
|
719
|
+
"""
|
|
720
|
+
target_pdb = work / "target.pdb"
|
|
721
|
+
ranges = _target_ranges(spec, target_pdb, chain)
|
|
722
|
+
if ranges == [_chain_span(target_pdb, chain)]:
|
|
723
|
+
return target_pdb
|
|
724
|
+
kept: list[str] = []
|
|
725
|
+
for line in target_pdb.read_text(encoding="utf-8").splitlines():
|
|
726
|
+
if line.startswith(("ATOM", "HETATM")) and line[21] == chain:
|
|
727
|
+
try:
|
|
728
|
+
resi = int(line[22:26])
|
|
729
|
+
except ValueError:
|
|
730
|
+
continue
|
|
731
|
+
if not any(lo <= resi <= hi for lo, hi in ranges):
|
|
732
|
+
continue
|
|
733
|
+
kept.append(line)
|
|
734
|
+
trimmed = work / "target_design_region.pdb"
|
|
735
|
+
trimmed.write_text("\n".join(kept) + "\n", encoding="utf-8", newline="\n")
|
|
736
|
+
return trimmed
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _chain_span(pdb_path: Path, chain: str) -> tuple[int, int]:
|
|
740
|
+
"""Return (min, max) residue number for a chain in a PDB (default 1, 9999)."""
|
|
741
|
+
nums: list[int] = []
|
|
742
|
+
if pdb_path.exists():
|
|
743
|
+
for line in pdb_path.read_text(encoding="utf-8").splitlines():
|
|
744
|
+
if line.startswith(("ATOM", "HETATM")) and line[21] == chain:
|
|
745
|
+
try:
|
|
746
|
+
nums.append(int(line[22:26]))
|
|
747
|
+
except ValueError:
|
|
748
|
+
continue
|
|
749
|
+
return (min(nums), max(nums)) if nums else (1, 9999)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _binder_seed(run_seed: int, binder_id: str) -> int:
|
|
753
|
+
"""A per-binder RNG seed, derived from the run's seed and the binder's id.
|
|
754
|
+
|
|
755
|
+
Two properties matter. It must be **stable**: the same run seed and the same
|
|
756
|
+
binder must give the same draw on a rerun, which rules out anything derived
|
|
757
|
+
from enumeration order or from a counter. And it must **differ per binder**:
|
|
758
|
+
handing every design the same seed would make their draws share a random
|
|
759
|
+
state, and these numbers are read one design at a time.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
run_seed: the spec's seed.
|
|
763
|
+
binder_id: the design's identifier.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
A non-negative seed inside the 32-bit range Lightning accepts.
|
|
767
|
+
"""
|
|
768
|
+
import hashlib
|
|
769
|
+
|
|
770
|
+
digest = hashlib.sha256(f"{run_seed}:{binder_id}".encode()).digest()
|
|
771
|
+
return int.from_bytes(digest[:4], "big")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def load_existing_designs(work: Path) -> list[Design]:
|
|
775
|
+
"""Reconstruct designs from a staged ``<work>/design/`` directory.
|
|
776
|
+
|
|
777
|
+
Validation-only jobs ship the designs a previous run produced rather than
|
|
778
|
+
generating new ones, so a user can point a different validator at the same
|
|
779
|
+
binders instead of paying to redesign them. Each design is a ``<id>.fasta``
|
|
780
|
+
with its ``<id>.pdb`` backbone alongside; a FASTA with no backbone is
|
|
781
|
+
skipped and named, because a validator that needs the structure would
|
|
782
|
+
otherwise fail obscurely.
|
|
783
|
+
"""
|
|
784
|
+
design_dir = work / "design"
|
|
785
|
+
designs: list[Design] = []
|
|
786
|
+
if not design_dir.is_dir():
|
|
787
|
+
return designs
|
|
788
|
+
for fasta in sorted(design_dir.glob("*.fasta")):
|
|
789
|
+
binder_id = fasta.stem
|
|
790
|
+
seq = "".join(
|
|
791
|
+
ln.strip()
|
|
792
|
+
for ln in fasta.read_text(encoding="utf-8").splitlines()
|
|
793
|
+
if not ln.startswith(">")
|
|
794
|
+
)
|
|
795
|
+
pdb = design_dir / f"{binder_id}.pdb"
|
|
796
|
+
if not seq:
|
|
797
|
+
LOG.warning("skipping %s: no sequence", fasta.name)
|
|
798
|
+
continue
|
|
799
|
+
if not pdb.exists():
|
|
800
|
+
LOG.warning("skipping %s: no backbone PDB alongside it", fasta.name)
|
|
801
|
+
continue
|
|
802
|
+
designs.append(Design(binder_id=binder_id, sequence=seq, pdb_path=pdb))
|
|
803
|
+
LOG.info("loaded %d existing design(s) from %s", len(designs), design_dir)
|
|
804
|
+
return designs
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _collect_designs_from_dir(
|
|
808
|
+
out: Path, work: Path, *, prefix: str, spec: dict[str, Any] | None = None
|
|
809
|
+
) -> list[Design]:
|
|
810
|
+
"""Collect (pdb + sequence) designs a one-shot designer wrote to ``out``.
|
|
811
|
+
|
|
812
|
+
``spec`` supplies the per-target namespace; see :func:`_binder_id_prefix`.
|
|
813
|
+
"""
|
|
814
|
+
design_dir = work / "design"
|
|
815
|
+
design_dir.mkdir(parents=True, exist_ok=True)
|
|
816
|
+
target = _binder_id_prefix(spec or {})
|
|
817
|
+
designs: list[Design] = []
|
|
818
|
+
for i, structure in enumerate(_designer_output_structures(out)):
|
|
819
|
+
binder_id = f"{target}_{prefix}_{i}"
|
|
820
|
+
pdb_copy = design_dir / f"{binder_id}.pdb"
|
|
821
|
+
if structure.suffix.lower() in {".cif", ".mmcif"}:
|
|
822
|
+
# BoltzGen writes mmCIF; the rest of the pipeline expects PDB, so
|
|
823
|
+
# normalise here rather than at every downstream reader.
|
|
824
|
+
try:
|
|
825
|
+
_cif_to_pdb(structure, pdb_copy)
|
|
826
|
+
except Exception as e:
|
|
827
|
+
LOG.warning("could not convert %s to PDB: %s", structure, e)
|
|
828
|
+
continue
|
|
829
|
+
else:
|
|
830
|
+
pdb_copy.write_bytes(structure.read_bytes())
|
|
831
|
+
seq = tools.chain_sequence_from_pdb(pdb_copy, _last_chain(pdb_copy))
|
|
832
|
+
if not seq:
|
|
833
|
+
LOG.warning("no chain sequence recovered from %s; skipping", structure)
|
|
834
|
+
pdb_copy.unlink(missing_ok=True)
|
|
835
|
+
continue
|
|
836
|
+
(design_dir / f"{binder_id}.fasta").write_text(
|
|
837
|
+
f">{binder_id}\n{seq}\n", encoding="utf-8", newline="\n"
|
|
838
|
+
)
|
|
839
|
+
designs.append(Design(binder_id=binder_id, sequence=seq, pdb_path=pdb_copy))
|
|
840
|
+
return designs
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _designer_output_structures(out: Path) -> list[Path]:
|
|
844
|
+
"""Structures a one-shot designer produced, most-refined stage first.
|
|
845
|
+
|
|
846
|
+
BoltzGen lays its output out in stages and only the last one is filtered
|
|
847
|
+
and ranked, so globbing the whole output root would mix ranked final
|
|
848
|
+
designs with unfiltered intermediates. The preference order below follows
|
|
849
|
+
the upstream pipeline; anything unrecognised falls back to a plain
|
|
850
|
+
recursive search so a different designer still collects.
|
|
851
|
+
"""
|
|
852
|
+
preferred = (
|
|
853
|
+
"final_ranked_designs",
|
|
854
|
+
"intermediate_designs_inverse_folded/refold_design_cif",
|
|
855
|
+
"intermediate_designs_inverse_folded",
|
|
856
|
+
"intermediate_designs",
|
|
857
|
+
)
|
|
858
|
+
for rel in preferred:
|
|
859
|
+
sub = out / Path(rel)
|
|
860
|
+
if sub.is_dir():
|
|
861
|
+
found = sorted(p for p in sub.rglob("*") if p.suffix.lower() in {".cif", ".pdb"})
|
|
862
|
+
if found:
|
|
863
|
+
return found
|
|
864
|
+
return sorted(p for p in out.rglob("*") if p.suffix.lower() in {".cif", ".pdb"})
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _last_chain(pdb_path: Path) -> str:
|
|
868
|
+
chains = [
|
|
869
|
+
line[21]
|
|
870
|
+
for line in pdb_path.read_text(encoding="utf-8").splitlines()
|
|
871
|
+
if line.startswith("ATOM")
|
|
872
|
+
]
|
|
873
|
+
return chains[-1] if chains else "A"
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _stage_validate_outputs(src_dir: Path, dst_dir: Path) -> None:
|
|
877
|
+
"""Copy Boltz outputs into <work>/validate/<binder_id>/ for the results tarball.
|
|
878
|
+
|
|
879
|
+
Retains not just the confidence/affinity JSONs but the **predicted complex
|
|
880
|
+
structure** (``*_model_0.cif``/``.pdb``) and the PAE / pLDDT arrays — the real,
|
|
881
|
+
inspectable folded binder–target complex and its per-residue confidence. (The
|
|
882
|
+
earlier version kept only the JSONs, so the structures were lost.)
|
|
883
|
+
"""
|
|
884
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
885
|
+
patterns = (
|
|
886
|
+
"confidence_*.json",
|
|
887
|
+
"affinity_*.json",
|
|
888
|
+
"*_model_0.cif",
|
|
889
|
+
"*_model_0.pdb",
|
|
890
|
+
"pae_*.npz",
|
|
891
|
+
"plddt_*.npz",
|
|
892
|
+
)
|
|
893
|
+
for pattern in patterns:
|
|
894
|
+
for path in src_dir.rglob(pattern):
|
|
895
|
+
(dst_dir / path.name).write_bytes(path.read_bytes())
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _apply_prescreen(
|
|
899
|
+
spec: dict[str, Any], designs: list[Design]
|
|
900
|
+
) -> tuple[list[Design], str | None]:
|
|
901
|
+
"""Screen designs before validation, if the spec asks for it.
|
|
902
|
+
|
|
903
|
+
Args:
|
|
904
|
+
spec: The design spec; ``extra_params['prescreen_top_k']`` opts in.
|
|
905
|
+
designs: Designs produced by the designer, in design order.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
``(designs_to_validate, note)`` where ``note`` is a provenance-ready
|
|
909
|
+
summary, or ``None`` when no screen was requested.
|
|
910
|
+
"""
|
|
911
|
+
raw = spec.get("extra_params", {}).get("prescreen_top_k")
|
|
912
|
+
if raw in (None, "", 0, "0"):
|
|
913
|
+
return designs, None
|
|
914
|
+
try:
|
|
915
|
+
top_k = int(raw)
|
|
916
|
+
except (TypeError, ValueError):
|
|
917
|
+
LOG.warning("ignoring non-integer prescreen_top_k=%r", raw)
|
|
918
|
+
return designs, None
|
|
919
|
+
|
|
920
|
+
from bindsight.design.prescreen import prescreen_report, select_representative
|
|
921
|
+
|
|
922
|
+
result = select_representative([d.sequence for d in designs], top_k)
|
|
923
|
+
note = prescreen_report(result, [d.binder_id for d in designs])
|
|
924
|
+
LOG.info("%s", note)
|
|
925
|
+
if not result.applied:
|
|
926
|
+
return designs, note
|
|
927
|
+
return [designs[i] for i in result.kept], note
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def run_job(spec: dict[str, Any], work_dir: Path, *, tarball: Path | None = None) -> Path:
|
|
931
|
+
"""Run design + validation for one spec; write the results tarball.
|
|
932
|
+
|
|
933
|
+
Args:
|
|
934
|
+
spec: a ``DesignSpec.model_dump()`` dict (with the target structure
|
|
935
|
+
materialised next to it, see :func:`materialise_target`).
|
|
936
|
+
work_dir: scratch dir; outputs are assembled here then tarred.
|
|
937
|
+
tarball: output ``.tar.gz`` path (default ``<work_dir>.tar.gz``).
|
|
938
|
+
|
|
939
|
+
Returns:
|
|
940
|
+
Path to the results tarball.
|
|
941
|
+
"""
|
|
942
|
+
work_dir = Path(work_dir)
|
|
943
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
944
|
+
# A pre-provisioned image/notebook can install the design tools once and
|
|
945
|
+
# point here via BINDSIGHT_TOOLS_ROOT so we reuse them instead of re-cloning.
|
|
946
|
+
tools_root = Path(os.environ.get("BINDSIGHT_TOOLS_ROOT") or (work_dir / "_tools"))
|
|
947
|
+
|
|
948
|
+
designer = str(spec.get("extra_params", {}).get("designer") or "rfdiff_mpnn")
|
|
949
|
+
validator = str(spec.get("extra_params", {}).get("validator") or "boltz2")
|
|
950
|
+
mode = str(spec.get("extra_params", {}).get("mode") or "design_and_validate")
|
|
951
|
+
if mode not in {"design_and_validate", "validate_only"}:
|
|
952
|
+
raise ValueError(f"unknown mode: {mode}")
|
|
953
|
+
if mode == "design_and_validate" and designer not in _DESIGNERS:
|
|
954
|
+
raise ValueError(f"unknown designer: {designer}")
|
|
955
|
+
if validator not in _VALIDATORS:
|
|
956
|
+
raise ValueError(f"unknown validator: {validator}")
|
|
957
|
+
|
|
958
|
+
LOG.info(
|
|
959
|
+
"job: mode=%s designer=%s validator=%s target=%s",
|
|
960
|
+
mode,
|
|
961
|
+
designer if mode == "design_and_validate" else "-",
|
|
962
|
+
validator,
|
|
963
|
+
spec.get("target_uniprot"),
|
|
964
|
+
)
|
|
965
|
+
prescreen_note: str | None = None
|
|
966
|
+
if mode == "validate_only":
|
|
967
|
+
# Re-validating binders that already exist: no designer runs, so the
|
|
968
|
+
# pre-screen is skipped too (it exists to cut GPU spend before
|
|
969
|
+
# validation, and here the caller has explicitly chosen what to validate).
|
|
970
|
+
designs = load_existing_designs(work_dir)
|
|
971
|
+
if not designs:
|
|
972
|
+
raise ValueError(
|
|
973
|
+
f"validate_only: no staged designs under {work_dir / 'design'}; "
|
|
974
|
+
"run `bindsight design` first, or ship the design directory with the spec"
|
|
975
|
+
)
|
|
976
|
+
else:
|
|
977
|
+
warn_if_unseeded(designer, int(spec.get("seed", 0)))
|
|
978
|
+
designs = _DESIGNERS[designer](spec, work_dir, tools_root)
|
|
979
|
+
LOG.info("designer produced %d designs", len(designs))
|
|
980
|
+
if not designs:
|
|
981
|
+
# The validate_only branch already refuses an empty set. This one
|
|
982
|
+
# did not, so a designer that wrote nothing — a bad contig, an OOM,
|
|
983
|
+
# a tool exiting 0 after failing — produced an empty metrics.jsonl,
|
|
984
|
+
# a valid tarball and a zero exit, and the caller recorded a
|
|
985
|
+
# completed design stage. A run that designed nothing is not a run
|
|
986
|
+
# that found nothing worth keeping.
|
|
987
|
+
raise ValueError(
|
|
988
|
+
f"{designer} produced no designs. The job cannot be reported as "
|
|
989
|
+
"completed: an empty result here is a failed designer, not a "
|
|
990
|
+
"negative finding. Check the designer's output above for the "
|
|
991
|
+
"cause (contig, hotspots, or a tool that exited 0 without "
|
|
992
|
+
"writing)."
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# ESM-2 pre-screen, between design and validation — the only point where
|
|
996
|
+
# dropping a design actually saves GPU time. Off unless prescreen_top_k is
|
|
997
|
+
# set, and it keeps everything if the optional `embed` extra is absent.
|
|
998
|
+
designs, prescreen_note = _apply_prescreen(spec, designs)
|
|
999
|
+
|
|
1000
|
+
metrics = _VALIDATORS[validator](spec, designs, work_dir)
|
|
1001
|
+
# Written unconditionally. A missing file used to mean either "the screen
|
|
1002
|
+
# ran and had nothing to report" or "the screen was never reached", and a
|
|
1003
|
+
# reader could not tell which.
|
|
1004
|
+
(work_dir / "prescreen.txt").write_text(
|
|
1005
|
+
(prescreen_note or "pre-screen not requested (no prescreen_top_k)") + "\n",
|
|
1006
|
+
encoding="utf-8",
|
|
1007
|
+
newline="\n",
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
tools.write_metrics_jsonl(metrics, work_dir / "metrics.jsonl")
|
|
1011
|
+
|
|
1012
|
+
out_tar = Path(tarball) if tarball else work_dir.with_suffix(".tar.gz")
|
|
1013
|
+
out_tar.parent.mkdir(parents=True, exist_ok=True)
|
|
1014
|
+
with tarfile.open(out_tar, "w:gz", encoding="utf-8") as tf:
|
|
1015
|
+
# ``prescreen.txt`` is in this list because it is the only record that
|
|
1016
|
+
# the ESM-2 screen did not run. The screen fails open by design -- losing
|
|
1017
|
+
# an already-successful GPU run would be worse -- but ``prescreen_top_k``
|
|
1018
|
+
# is part of the cache key, so a job whose embedding died is cached under
|
|
1019
|
+
# a key asserting it kept the top k, and the next run with a working
|
|
1020
|
+
# embedder is served the unscreened set. Without this file leaving the
|
|
1021
|
+
# host, nothing anywhere said which of the two happened.
|
|
1022
|
+
for sub in ("design", "validate", "metrics.jsonl", "prescreen.txt"):
|
|
1023
|
+
p = work_dir / sub
|
|
1024
|
+
if p.exists():
|
|
1025
|
+
tf.add(p, arcname=sub)
|
|
1026
|
+
LOG.info("wrote %s", out_tar)
|
|
1027
|
+
return out_tar
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def materialise_target(spec: dict[str, Any], spec_dir: Path, work_dir: Path) -> None:
|
|
1031
|
+
"""Copy the target structure referenced by the spec into ``work_dir/target.pdb``.
|
|
1032
|
+
|
|
1033
|
+
The designer's ``submit`` writes the structure next to the spec JSON and
|
|
1034
|
+
records its filename in ``extra_params['target_structure_name']``; this
|
|
1035
|
+
reconstructs it on the remote/work side (the structure is never embedded in
|
|
1036
|
+
the spec).
|
|
1037
|
+
"""
|
|
1038
|
+
name = spec.get("extra_params", {}).get("target_structure_name")
|
|
1039
|
+
src = (spec_dir / name) if name else Path(spec.get("target_structure_path", ""))
|
|
1040
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
1041
|
+
dst = work_dir / "target.pdb"
|
|
1042
|
+
if not src or not Path(src).exists():
|
|
1043
|
+
# Raise rather than warn-and-continue. This logged that the designer
|
|
1044
|
+
# "will fail without it" and then let the job run anyway, so the GPU
|
|
1045
|
+
# session was spent reaching an unrelated-looking error somewhere
|
|
1046
|
+
# downstream instead of stopping here, where the cause is known.
|
|
1047
|
+
raise FileNotFoundError(
|
|
1048
|
+
f"target structure not found: {src!r}. It is resolved from "
|
|
1049
|
+
"extra_params['target_structure_name'] relative to the spec "
|
|
1050
|
+
"directory, then from target_structure_path. Re-run "
|
|
1051
|
+
"`bindsight design` so the structure ships with the spec, or place "
|
|
1052
|
+
"the file at that path before submitting."
|
|
1053
|
+
)
|
|
1054
|
+
src = Path(src)
|
|
1055
|
+
if src.suffix.lower() in {".cif", ".mmcif"}:
|
|
1056
|
+
_cif_to_pdb(src, dst)
|
|
1057
|
+
else:
|
|
1058
|
+
dst.write_bytes(src.read_bytes())
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def materialise_designs(spec_dir: Path, work_dir: Path) -> int:
|
|
1062
|
+
"""Copy a ``design/`` directory shipped next to the spec into ``work_dir``.
|
|
1063
|
+
|
|
1064
|
+
Validation-only jobs carry the binders to validate alongside the spec, the
|
|
1065
|
+
same way ``materialise_target`` carries the receptor. Returns how many
|
|
1066
|
+
design files were staged.
|
|
1067
|
+
"""
|
|
1068
|
+
src = Path(spec_dir) / "design"
|
|
1069
|
+
if not src.is_dir():
|
|
1070
|
+
return 0
|
|
1071
|
+
dst = Path(work_dir) / "design"
|
|
1072
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
1073
|
+
n = 0
|
|
1074
|
+
for f in sorted(src.iterdir()):
|
|
1075
|
+
if f.is_file():
|
|
1076
|
+
(dst / f.name).write_bytes(f.read_bytes())
|
|
1077
|
+
n += 1
|
|
1078
|
+
LOG.info("staged %d design file(s) from %s", n, src)
|
|
1079
|
+
return n
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _cif_to_pdb(cif_path: Path, pdb_path: Path) -> None:
|
|
1083
|
+
"""Convert an mmCIF (e.g. AlphaFoldDB) to PDB; RFdiffusion needs PDB input."""
|
|
1084
|
+
from Bio.PDB import PDBIO, MMCIFParser # lazy: biopython only on the GPU side
|
|
1085
|
+
|
|
1086
|
+
parser = MMCIFParser(QUIET=True)
|
|
1087
|
+
structure = parser.get_structure("target", str(cif_path))
|
|
1088
|
+
io = PDBIO()
|
|
1089
|
+
io.set_structure(structure)
|
|
1090
|
+
io.save(str(pdb_path))
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1094
|
+
"""CLI entry: ``python -m bindsight.runners.job_exec <spec.json> <out.tar.gz>``."""
|
|
1095
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
1096
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
1097
|
+
if len(args) < 2:
|
|
1098
|
+
print("usage: job_exec <spec.json> <out.tar.gz>", file=sys.stderr)
|
|
1099
|
+
return 2
|
|
1100
|
+
spec_path, out_tar = Path(args[0]), Path(args[1])
|
|
1101
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
1102
|
+
work_dir = out_tar.parent / (out_tar.stem.replace(".tar", "") + "_work")
|
|
1103
|
+
materialise_target(spec, spec_path.parent, work_dir)
|
|
1104
|
+
materialise_designs(spec_path.parent, work_dir)
|
|
1105
|
+
run_job(spec, work_dir, tarball=out_tar)
|
|
1106
|
+
return 0
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1110
|
+
raise SystemExit(main())
|