@platforma-open/milaboratories.3d-structure-prediction.software 1.1.0 → 1.1.1
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.
- package/dist/artifacts/py-archive/archive.json +1 -1
- package/dist/artifacts/py-docker/docker_x64.json +1 -1
- package/dist/tengo/software/immunebuilder-predict.sw.json +1 -1
- package/package.json +9 -6
- package/.turbo/turbo-build.log +0 -423
- package/CHANGELOG.md +0 -65
- package/Dockerfile +0 -63
- package/pkg-platforma-open-milaboratories.3d-structure-prediction.software-py-archive-1.1.0.tgz +0 -0
- package/src_python/numbering.py +0 -103
- package/src_python/pdb_writer.py +0 -136
- package/src_python/requirements.txt +0 -5
- package/src_python/run_immunebuilder.py +0 -676
- package/src_python/sanitize.py +0 -150
|
@@ -1,676 +0,0 @@
|
|
|
1
|
-
"""ImmuneBuilder batch runner for the Platforma 3D Structure Prediction block.
|
|
2
|
-
|
|
3
|
-
Reads a batch TSV of clonotypes and predicts structures via ABodyBuilder2 or
|
|
4
|
-
NanoBodyBuilder2 (spec R22). Emits:
|
|
5
|
-
|
|
6
|
-
- Per-clonotype PDB files named `<sha1(clonotypeKey)>.pdb` (R30).
|
|
7
|
-
- `manifest.tsv` : (clonotypeKey, pdb_filename) — confident clonotypes only
|
|
8
|
-
(success AND selected metric ≤ threshold); drives the
|
|
9
|
-
exported/UI PDB ResourceMap.
|
|
10
|
-
- `confidence.tsv`: aggregate + per-residue confidence (Å error, R32-R36)
|
|
11
|
-
plus failureReason (R40) and warning columns — every row,
|
|
12
|
-
confident or not.
|
|
13
|
-
|
|
14
|
-
Dependencies (ImmuneBuilder, torch) ride the venv that pl-pkg's install-deps
|
|
15
|
-
creates. ANARCI and pdbfixer are not on PyPI; the atls runenv builds them
|
|
16
|
-
from source and stages them in the runenv's site-packages. The SDK's venv
|
|
17
|
-
is created without --system-site-packages, so we bootstrap the runenv's
|
|
18
|
-
site-packages onto sys.path here before any project-level imports run.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import os as _os
|
|
24
|
-
import sys as _sys
|
|
25
|
-
import sysconfig as _sysconfig
|
|
26
|
-
|
|
27
|
-
_runenv_root = _os.environ.get("PYTHONHOME")
|
|
28
|
-
if _runenv_root:
|
|
29
|
-
# Pick up packages staged via runenv-python-builder's `copyFiles` directive
|
|
30
|
-
# (e.g. `{site-packages}/anarci`, `{site-packages}/pdbfixer`).
|
|
31
|
-
_py_ver_short = f"python{_sys.version_info.major}.{_sys.version_info.minor}"
|
|
32
|
-
_candidates = [
|
|
33
|
-
_os.path.join(_runenv_root, "lib", _py_ver_short, "site-packages"),
|
|
34
|
-
_os.path.join(_runenv_root, "Lib", "site-packages"), # Windows layout
|
|
35
|
-
]
|
|
36
|
-
_platlib = _sysconfig.get_paths().get("platlib")
|
|
37
|
-
if _platlib:
|
|
38
|
-
_candidates.append(_platlib)
|
|
39
|
-
for _candidate in _candidates:
|
|
40
|
-
if _os.path.isdir(_candidate) and _candidate not in _sys.path:
|
|
41
|
-
_sys.path.append(_candidate)
|
|
42
|
-
|
|
43
|
-
# ANARCI shells out to `hmmscan` (HMMER); the binary ships in the runenv's
|
|
44
|
-
# bin/ via copyFiles, but only the venv's bin is on PATH by default.
|
|
45
|
-
_runenv_bin = _os.path.join(_runenv_root, "bin")
|
|
46
|
-
if _os.path.isdir(_runenv_bin):
|
|
47
|
-
_path_env = _os.environ.get("PATH", "")
|
|
48
|
-
if _runenv_bin not in _path_env.split(_os.pathsep):
|
|
49
|
-
_os.environ["PATH"] = _runenv_bin + _os.pathsep + _path_env
|
|
50
|
-
|
|
51
|
-
import argparse
|
|
52
|
-
import csv
|
|
53
|
-
import hashlib
|
|
54
|
-
import json
|
|
55
|
-
import os
|
|
56
|
-
import sys
|
|
57
|
-
import traceback
|
|
58
|
-
from dataclasses import asdict, dataclass, field
|
|
59
|
-
from pathlib import Path
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _log(message: str) -> None:
|
|
63
|
-
"""Line-buffered log entry to stderr.
|
|
64
|
-
|
|
65
|
-
The exec template saves the stdout stream as a regular file output, so
|
|
66
|
-
the saved-file blob's content hash flows into the resource CID. Wall
|
|
67
|
-
clock timestamps and elapsed-time durations would make the saved log
|
|
68
|
-
byte-different on every run and break the platforma backend's
|
|
69
|
-
content-addressed caching. Keep log lines fully determined by inputs.
|
|
70
|
-
"""
|
|
71
|
-
print(message, file=sys.stderr, flush=True)
|
|
72
|
-
|
|
73
|
-
from numbering import cdrh3_length as imgt_cdrh3_length
|
|
74
|
-
from numbering import extract_numbered_residues, vhh_hallmarks_present
|
|
75
|
-
from pdb_writer import augment_pdb
|
|
76
|
-
from sanitize import sanitize_pair
|
|
77
|
-
|
|
78
|
-
# `clonotypeKey` is a placeholder — the actual column name we write back is
|
|
79
|
-
# the same name we read from the input TSV (the orchestrator uses the axis
|
|
80
|
-
# spec name, e.g. `pl7.app/vdj/clonotypeKey`, as the column header). See
|
|
81
|
-
# build_confidence_fields().
|
|
82
|
-
KEY_COLUMN_PLACEHOLDER = "clonotypeKey"
|
|
83
|
-
|
|
84
|
-
CONFIDENCE_FIELDS = [
|
|
85
|
-
KEY_COLUMN_PLACEHOLDER,
|
|
86
|
-
"clonotypeLabel",
|
|
87
|
-
"meanError",
|
|
88
|
-
"cdrh1Error",
|
|
89
|
-
"cdrh2Error",
|
|
90
|
-
"cdrh3Error",
|
|
91
|
-
"cdrl1Error",
|
|
92
|
-
"cdrl2Error",
|
|
93
|
-
"cdrl3Error",
|
|
94
|
-
"perResidueError",
|
|
95
|
-
"cdrh3Length",
|
|
96
|
-
"failureReason",
|
|
97
|
-
"failureReasonText",
|
|
98
|
-
"warning",
|
|
99
|
-
"warningText",
|
|
100
|
-
]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def build_confidence_fields(key_col: str) -> list[str]:
|
|
104
|
-
return [key_col if f == KEY_COLUMN_PLACEHOLDER else f for f in CONFIDENCE_FIELDS]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Code → human-readable label maps. The code columns stay (hidden by default)
|
|
108
|
-
# so downstream blocks / future failure-stats logic can group by enum value;
|
|
109
|
-
# the *Text columns are what the user actually sees in the table.
|
|
110
|
-
FAILURE_REASON_LABELS: dict[str, str] = {
|
|
111
|
-
"empty_sequence": "Empty sequence",
|
|
112
|
-
"stop_codon_mid_sequence": "Stop codon in sequence",
|
|
113
|
-
"non_standard_aa_only_after_strip": "No standard amino acids after cleanup",
|
|
114
|
-
"non_standard_aa_residue": "Non-standard amino acid residue",
|
|
115
|
-
"length_out_of_range": "Length outside expected VH/VL range",
|
|
116
|
-
"light_chain_missing_in_paired_mode":
|
|
117
|
-
"Light chain missing — switch to NanoBodyBuilder2 or pick a light chain",
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
WARNING_LABELS: dict[str, str] = {
|
|
121
|
-
"probable_signal_peptide": "Possible N-terminal signal peptide",
|
|
122
|
-
"long_cdrh3": "Long CDR-H3 (≥20 aa) — confidence may be reduced",
|
|
123
|
-
"vhh_hallmarks_missing": "VHH hallmark residues not detected",
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _failure_reason_label(code: str) -> str:
|
|
128
|
-
if not code:
|
|
129
|
-
return ""
|
|
130
|
-
if code in FAILURE_REASON_LABELS:
|
|
131
|
-
return FAILURE_REASON_LABELS[code]
|
|
132
|
-
# Structured prefixes — preserve the suffix for triage rather than
|
|
133
|
-
# collapsing to a generic message.
|
|
134
|
-
if code.startswith("immunebuilder_exception:"):
|
|
135
|
-
return f"ImmuneBuilder error: {code.split(':', 1)[1]}"
|
|
136
|
-
if code.startswith("unknown_mode:"):
|
|
137
|
-
return f"Unknown prediction mode: {code.split(':', 1)[1]}"
|
|
138
|
-
return code
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _warning_label(code: str) -> str:
|
|
142
|
-
return WARNING_LABELS.get(code, code)
|
|
143
|
-
|
|
144
|
-
# Failure-reason code for a successful prediction whose selected error metric
|
|
145
|
-
# is above the confidence threshold (so it's excluded from the PDB map). The
|
|
146
|
-
# code is stable for grouping; the human text (with the threshold value) is set
|
|
147
|
-
# per-run via RowResult.failure_reason_text.
|
|
148
|
-
CONFIDENCE_ABOVE_THRESHOLD_REASON = "confidence_above_threshold"
|
|
149
|
-
|
|
150
|
-
# Failure-reason code for a successful prediction whose selected confidence
|
|
151
|
-
# metric could not be computed (so no threshold comparison was possible). Kept
|
|
152
|
-
# distinct from CONFIDENCE_ABOVE_THRESHOLD_REASON so the table doesn't imply a
|
|
153
|
-
# numeric comparison that never happened.
|
|
154
|
-
CONFIDENCE_METRIC_UNAVAILABLE_REASON = "confidence_metric_unavailable"
|
|
155
|
-
|
|
156
|
-
MANIFEST_FIELDS = [KEY_COLUMN_PLACEHOLDER, "pdb_filename"]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def indexed_filename(row_idx: int) -> str:
|
|
160
|
-
return f"pdb_{row_idx:05d}.pdb"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def sha1_filename(key: str) -> str:
|
|
164
|
-
"""Retained for manifest metadata — downstream blocks can SHA-1 keys to cross-reference."""
|
|
165
|
-
return hashlib.sha1(key.encode("utf-8")).hexdigest() + ".pdb"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def get_immunebuilder_version() -> str:
|
|
169
|
-
try:
|
|
170
|
-
from importlib.metadata import version as _version
|
|
171
|
-
return _version("ImmuneBuilder")
|
|
172
|
-
except Exception:
|
|
173
|
-
return "unknown"
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def get_block_version() -> str:
|
|
177
|
-
return os.environ.get("BLOCK_VERSION", "unknown")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def pick_key_column(fieldnames: list[str]) -> str:
|
|
181
|
-
for name in fieldnames:
|
|
182
|
-
if name.lower() == "clonotypekey" or name.lower().endswith("clonotypekey"):
|
|
183
|
-
return name
|
|
184
|
-
return fieldnames[0]
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
@dataclass
|
|
188
|
-
class RowResult:
|
|
189
|
-
clonotype_key: str
|
|
190
|
-
clonotype_label: str = ""
|
|
191
|
-
mean_error: str = ""
|
|
192
|
-
cdrh1: str = ""
|
|
193
|
-
cdrh2: str = ""
|
|
194
|
-
cdrh3: str = ""
|
|
195
|
-
cdrl1: str = ""
|
|
196
|
-
cdrl2: str = ""
|
|
197
|
-
cdrl3: str = ""
|
|
198
|
-
per_residue_json: str = ""
|
|
199
|
-
cdrh3_len: str = ""
|
|
200
|
-
failure_reason: str = ""
|
|
201
|
-
# Optional human text override; when set, used verbatim instead of the
|
|
202
|
-
# static FAILURE_REASON_LABELS lookup (lets us embed runtime values such as
|
|
203
|
-
# the confidence threshold).
|
|
204
|
-
failure_reason_text: str = ""
|
|
205
|
-
warnings: list[str] = field(default_factory=list)
|
|
206
|
-
pdb_filename: str = ""
|
|
207
|
-
|
|
208
|
-
@property
|
|
209
|
-
def warning_str(self) -> str:
|
|
210
|
-
return ";".join(self.warnings)
|
|
211
|
-
|
|
212
|
-
@property
|
|
213
|
-
def warning_text(self) -> str:
|
|
214
|
-
return "; ".join(_warning_label(w) for w in self.warnings)
|
|
215
|
-
|
|
216
|
-
def to_tsv_row(self, key_col: str) -> dict[str, str]:
|
|
217
|
-
return {
|
|
218
|
-
key_col: self.clonotype_key,
|
|
219
|
-
"clonotypeLabel": self.clonotype_label,
|
|
220
|
-
"meanError": self.mean_error,
|
|
221
|
-
"cdrh1Error": self.cdrh1,
|
|
222
|
-
"cdrh2Error": self.cdrh2,
|
|
223
|
-
"cdrh3Error": self.cdrh3,
|
|
224
|
-
"cdrl1Error": self.cdrl1,
|
|
225
|
-
"cdrl2Error": self.cdrl2,
|
|
226
|
-
"cdrl3Error": self.cdrl3,
|
|
227
|
-
"perResidueError": self.per_residue_json,
|
|
228
|
-
"cdrh3Length": self.cdrh3_len,
|
|
229
|
-
"failureReason": self.failure_reason,
|
|
230
|
-
"failureReasonText": self.failure_reason_text or _failure_reason_label(self.failure_reason),
|
|
231
|
-
"warning": self.warning_str,
|
|
232
|
-
"warningText": self.warning_text,
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _mean(values: list[float]) -> float | None:
|
|
237
|
-
valid = [v for v in values if v is not None]
|
|
238
|
-
return sum(valid) / len(valid) if valid else None
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def _region_errors(per_residue, chain: str, cdr_name: str) -> list[float]:
|
|
242
|
-
target = f"CDR{cdr_name[-1]}" # "CDR1" or similar
|
|
243
|
-
return [
|
|
244
|
-
r["errorAngstroms"]
|
|
245
|
-
for r in per_residue
|
|
246
|
-
if r["chain"] == chain and r.get("_region") == target
|
|
247
|
-
]
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def _load_predictor(mode: str, weights_dir: str | None = None):
|
|
251
|
-
# weights_dir points at the asset mounted into the workdir (see
|
|
252
|
-
# predict-batch.tpl.tengo). ImmuneBuilder loads `<weights_dir>/<model_file>`
|
|
253
|
-
# and only downloads from Zenodo when the file is missing — which, with the
|
|
254
|
-
# asset present, never happens. weights_dir=None preserves upstream
|
|
255
|
-
# download-to-site-packages behaviour for local/manual runs.
|
|
256
|
-
if mode == "ABodyBuilder2":
|
|
257
|
-
from ImmuneBuilder import ABodyBuilder2
|
|
258
|
-
return ABodyBuilder2(weights_dir=weights_dir)
|
|
259
|
-
if mode == "NanoBodyBuilder2":
|
|
260
|
-
from ImmuneBuilder import NanoBodyBuilder2
|
|
261
|
-
return NanoBodyBuilder2(weights_dir=weights_dir)
|
|
262
|
-
raise ValueError(f"unknown mode: {mode}")
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _set_seed(seed: int) -> None:
|
|
266
|
-
import random
|
|
267
|
-
import numpy as np
|
|
268
|
-
random.seed(seed)
|
|
269
|
-
np.random.seed(seed)
|
|
270
|
-
try:
|
|
271
|
-
import torch
|
|
272
|
-
torch.manual_seed(seed)
|
|
273
|
-
if torch.cuda.is_available():
|
|
274
|
-
torch.cuda.manual_seed_all(seed)
|
|
275
|
-
except Exception:
|
|
276
|
-
pass
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _predict_one(predictor, mode: str, vh: str, vl: str | None):
|
|
280
|
-
sequences = {"H": vh}
|
|
281
|
-
if mode == "ABodyBuilder2" and vl:
|
|
282
|
-
sequences["L"] = vl
|
|
283
|
-
return predictor.predict(sequences)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _per_residue_records(antibody) -> list[dict]:
|
|
287
|
-
"""Extract per-residue error records.
|
|
288
|
-
|
|
289
|
-
ImmuneBuilder stores ensemble disagreement on `antibody.error_estimates`;
|
|
290
|
-
`error_estimates.mean(0).sqrt().cpu().numpy()` produces a 1D array of
|
|
291
|
-
per-residue RMSD in Å indexed in the order of `numbered_sequences` flattened
|
|
292
|
-
(heavy chain then light). This is the same array that gets written to the
|
|
293
|
-
B-factor column of the saved PDB (see `ImmuneBuilder.util.add_errors_as_bfactors`).
|
|
294
|
-
|
|
295
|
-
Returns a list of dicts shaped per spec R34:
|
|
296
|
-
{"pos": "<string>", "chain": "H"|"L", "errorAngstroms": <float>}
|
|
297
|
-
Stores an additional `_region` marker (stripped before JSON emission) so
|
|
298
|
-
we can aggregate per-CDR quickly in `_region_errors`.
|
|
299
|
-
"""
|
|
300
|
-
records: list[dict] = []
|
|
301
|
-
residues = extract_numbered_residues(antibody)
|
|
302
|
-
|
|
303
|
-
err_array = None
|
|
304
|
-
if hasattr(antibody, "error_estimates"):
|
|
305
|
-
try:
|
|
306
|
-
err_array = antibody.error_estimates.mean(0).sqrt().cpu().numpy()
|
|
307
|
-
except Exception:
|
|
308
|
-
err_array = None
|
|
309
|
-
|
|
310
|
-
for idx, r in enumerate(residues):
|
|
311
|
-
if err_array is not None and idx < len(err_array):
|
|
312
|
-
err_val = float(err_array[idx])
|
|
313
|
-
else:
|
|
314
|
-
err_val = 0.0
|
|
315
|
-
records.append({
|
|
316
|
-
"pos": r.pos,
|
|
317
|
-
"chain": r.chain,
|
|
318
|
-
"errorAngstroms": err_val,
|
|
319
|
-
"_region": r.region,
|
|
320
|
-
})
|
|
321
|
-
return records
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def _json_safe_per_residue(records: list[dict]) -> str:
|
|
325
|
-
cleaned = [
|
|
326
|
-
{"pos": r["pos"], "chain": r["chain"], "errorAngstroms": r["errorAngstroms"]}
|
|
327
|
-
for r in records
|
|
328
|
-
]
|
|
329
|
-
return json.dumps(cleaned, separators=(",", ":"))
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def _format_number(value: float | None, digits: int = 3) -> str:
|
|
333
|
-
if value is None:
|
|
334
|
-
return ""
|
|
335
|
-
return f"{value:.{digits}f}"
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _format_int(value: int | None) -> str:
|
|
339
|
-
return "" if value is None else str(value)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def _metric_value(result: RowResult, metric: str) -> float | None:
|
|
343
|
-
src = result.cdrh3 if metric == "cdrh3Mean" else result.mean_error
|
|
344
|
-
if not src:
|
|
345
|
-
return None
|
|
346
|
-
try:
|
|
347
|
-
return float(src)
|
|
348
|
-
except ValueError:
|
|
349
|
-
return None
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def _build_summary(
|
|
353
|
-
results: list[RowResult],
|
|
354
|
-
metric: str,
|
|
355
|
-
threshold: float,
|
|
356
|
-
) -> dict:
|
|
357
|
-
"""Aggregate per-row stats into the schema the model + UI consume.
|
|
358
|
-
|
|
359
|
-
Schema is part of the contract between this script and the block model
|
|
360
|
-
(model.failureStats); keep the field names stable.
|
|
361
|
-
"""
|
|
362
|
-
by_failure: dict[str, int] = {}
|
|
363
|
-
by_warning: dict[str, int] = {}
|
|
364
|
-
metric_values: list[float] = []
|
|
365
|
-
confident = 0
|
|
366
|
-
succeeded = 0
|
|
367
|
-
|
|
368
|
-
for r in results:
|
|
369
|
-
if r.failure_reason:
|
|
370
|
-
by_failure[r.failure_reason] = by_failure.get(r.failure_reason, 0) + 1
|
|
371
|
-
else:
|
|
372
|
-
succeeded += 1
|
|
373
|
-
v = _metric_value(r, metric)
|
|
374
|
-
if v is not None:
|
|
375
|
-
metric_values.append(v)
|
|
376
|
-
if v <= threshold:
|
|
377
|
-
confident += 1
|
|
378
|
-
for w in r.warnings:
|
|
379
|
-
by_warning[w] = by_warning.get(w, 0) + 1
|
|
380
|
-
|
|
381
|
-
summary: dict = {
|
|
382
|
-
"totalRows": len(results),
|
|
383
|
-
"succeeded": succeeded,
|
|
384
|
-
"failed": len(results) - succeeded,
|
|
385
|
-
"byFailureReason": by_failure,
|
|
386
|
-
"byWarning": by_warning,
|
|
387
|
-
"metric": metric,
|
|
388
|
-
"thresholdAngstroms": threshold,
|
|
389
|
-
"confidentCount": confident,
|
|
390
|
-
}
|
|
391
|
-
if metric_values:
|
|
392
|
-
summary["metricMean"] = sum(metric_values) / len(metric_values)
|
|
393
|
-
summary["metricMin"] = min(metric_values)
|
|
394
|
-
summary["metricMax"] = max(metric_values)
|
|
395
|
-
return summary
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def process_batch(
|
|
399
|
-
input_tsv: Path,
|
|
400
|
-
pdb_dir: Path,
|
|
401
|
-
manifest_tsv: Path,
|
|
402
|
-
confidence_tsv: Path,
|
|
403
|
-
summary_json: Path | None,
|
|
404
|
-
mode: str,
|
|
405
|
-
seed: int,
|
|
406
|
-
metric: str,
|
|
407
|
-
threshold: float,
|
|
408
|
-
weights_dir: str | None = None,
|
|
409
|
-
) -> None:
|
|
410
|
-
pdb_dir.mkdir(parents=True, exist_ok=True)
|
|
411
|
-
manifest_tsv.parent.mkdir(parents=True, exist_ok=True)
|
|
412
|
-
confidence_tsv.parent.mkdir(parents=True, exist_ok=True)
|
|
413
|
-
|
|
414
|
-
with open(input_tsv, newline="") as f:
|
|
415
|
-
reader = csv.DictReader(f, delimiter="\t")
|
|
416
|
-
fieldnames = reader.fieldnames or []
|
|
417
|
-
key_col = pick_key_column(fieldnames)
|
|
418
|
-
rows = list(reader)
|
|
419
|
-
|
|
420
|
-
ib_version = get_immunebuilder_version()
|
|
421
|
-
block_version = get_block_version()
|
|
422
|
-
|
|
423
|
-
_log(
|
|
424
|
-
f"start mode={mode} rows={len(rows)} seed={seed} metric={metric} "
|
|
425
|
-
f"threshold={threshold} immunebuilder={ib_version} block={block_version}"
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
if not rows:
|
|
429
|
-
_log("no input rows; skipping ImmuneBuilder load and emitting empty outputs")
|
|
430
|
-
|
|
431
|
-
_set_seed(seed)
|
|
432
|
-
if rows:
|
|
433
|
-
_log(f"loading {mode} ensemble (4 models)")
|
|
434
|
-
predictor = _load_predictor(mode, weights_dir)
|
|
435
|
-
_log("predictor ready")
|
|
436
|
-
else:
|
|
437
|
-
predictor = None
|
|
438
|
-
|
|
439
|
-
# Intra-batch dedup cache (R16). Key: (clean_vh, clean_vl).
|
|
440
|
-
prediction_cache: dict[tuple[str, str], object] = {}
|
|
441
|
-
results: list[RowResult] = []
|
|
442
|
-
|
|
443
|
-
n = len(rows)
|
|
444
|
-
log_every = max(1, n // 20) if n > 20 else 1
|
|
445
|
-
fail_count = 0
|
|
446
|
-
success_count = 0
|
|
447
|
-
cache_hits = 0
|
|
448
|
-
|
|
449
|
-
for row_idx, row in enumerate(rows):
|
|
450
|
-
key = row.get(key_col, "")
|
|
451
|
-
# Use the human-readable label (e.g. CDR3 sequence) for log lines
|
|
452
|
-
# AND echo it into confidence.tsv as the `clonotypeLabel` column —
|
|
453
|
-
# the workflow's xsv import surfaces that column as `pl7.app/label`
|
|
454
|
-
# so the V3 structures table substitutes it into the row-axis cells.
|
|
455
|
-
label_from_row = row.get("clonotypeLabel") or ""
|
|
456
|
-
result = RowResult(clonotype_key=key, clonotype_label=label_from_row)
|
|
457
|
-
prefix = f"[{row_idx + 1}/{n}] {label_from_row or key}"
|
|
458
|
-
|
|
459
|
-
sanitized = sanitize_pair(
|
|
460
|
-
row.get("heavyChain", ""),
|
|
461
|
-
row.get("lightChain", "") if mode == "ABodyBuilder2" else None,
|
|
462
|
-
mode,
|
|
463
|
-
)
|
|
464
|
-
result.warnings.extend(sanitized.warnings)
|
|
465
|
-
|
|
466
|
-
if not sanitized.success:
|
|
467
|
-
result.failure_reason = sanitized.failure_reason
|
|
468
|
-
fail_count += 1
|
|
469
|
-
_log(f"{prefix} FAIL sanitize reason={sanitized.failure_reason}")
|
|
470
|
-
results.append(result)
|
|
471
|
-
continue
|
|
472
|
-
|
|
473
|
-
if sanitized.warnings:
|
|
474
|
-
_log(f"{prefix} warning {','.join(sanitized.warnings)}")
|
|
475
|
-
|
|
476
|
-
cache_key = (sanitized.vh, sanitized.vl)
|
|
477
|
-
antibody = prediction_cache.get(cache_key)
|
|
478
|
-
if antibody is None:
|
|
479
|
-
try:
|
|
480
|
-
antibody = _predict_one(predictor, mode, sanitized.vh, sanitized.vl)
|
|
481
|
-
prediction_cache[cache_key] = antibody
|
|
482
|
-
if (row_idx + 1) % log_every == 0 or row_idx == 0:
|
|
483
|
-
_log(f"{prefix} predicted")
|
|
484
|
-
except Exception as exc: # noqa: BLE001
|
|
485
|
-
fail_count += 1
|
|
486
|
-
_log(f"{prefix} FAIL ImmuneBuilder {type(exc).__name__}: {exc}")
|
|
487
|
-
traceback.print_exc(file=sys.stderr)
|
|
488
|
-
result.failure_reason = f"immunebuilder_exception:{type(exc).__name__}"
|
|
489
|
-
results.append(result)
|
|
490
|
-
continue
|
|
491
|
-
else:
|
|
492
|
-
cache_hits += 1
|
|
493
|
-
_log(f"{prefix} dedup-cache hit (skipping ImmuneBuilder call)")
|
|
494
|
-
|
|
495
|
-
pdb_filename = indexed_filename(row_idx)
|
|
496
|
-
raw_pdb_path = pdb_dir / ("_raw_" + pdb_filename)
|
|
497
|
-
final_pdb_path = pdb_dir / pdb_filename
|
|
498
|
-
try:
|
|
499
|
-
antibody.save(str(raw_pdb_path))
|
|
500
|
-
augment_pdb(
|
|
501
|
-
raw_pdb_path,
|
|
502
|
-
final_pdb_path,
|
|
503
|
-
mode=mode,
|
|
504
|
-
immunebuilder_version=ib_version,
|
|
505
|
-
torch_seed=seed,
|
|
506
|
-
block_version=block_version,
|
|
507
|
-
numbering_scheme="imgt",
|
|
508
|
-
)
|
|
509
|
-
try:
|
|
510
|
-
raw_pdb_path.unlink()
|
|
511
|
-
except FileNotFoundError:
|
|
512
|
-
pass
|
|
513
|
-
except Exception as exc: # noqa: BLE001
|
|
514
|
-
fail_count += 1
|
|
515
|
-
_log(f"{prefix} FAIL save/augment {type(exc).__name__}: {exc}")
|
|
516
|
-
traceback.print_exc(file=sys.stderr)
|
|
517
|
-
result.failure_reason = f"immunebuilder_exception:{type(exc).__name__}"
|
|
518
|
-
results.append(result)
|
|
519
|
-
continue
|
|
520
|
-
|
|
521
|
-
residues = _per_residue_records(antibody)
|
|
522
|
-
per_res_json = _json_safe_per_residue(residues)
|
|
523
|
-
all_err = [r["errorAngstroms"] for r in residues]
|
|
524
|
-
mean_err = _mean(all_err)
|
|
525
|
-
cdrh1 = _mean(_region_errors(residues, "H", "CDR1"))
|
|
526
|
-
cdrh2 = _mean(_region_errors(residues, "H", "CDR2"))
|
|
527
|
-
cdrh3 = _mean(_region_errors(residues, "H", "CDR3"))
|
|
528
|
-
|
|
529
|
-
result.per_residue_json = per_res_json
|
|
530
|
-
result.mean_error = _format_number(mean_err)
|
|
531
|
-
result.cdrh1 = _format_number(cdrh1)
|
|
532
|
-
result.cdrh2 = _format_number(cdrh2)
|
|
533
|
-
result.cdrh3 = _format_number(cdrh3)
|
|
534
|
-
|
|
535
|
-
if mode == "ABodyBuilder2":
|
|
536
|
-
result.cdrl1 = _format_number(_mean(_region_errors(residues, "L", "CDR1")))
|
|
537
|
-
result.cdrl2 = _format_number(_mean(_region_errors(residues, "L", "CDR2")))
|
|
538
|
-
result.cdrl3 = _format_number(_mean(_region_errors(residues, "L", "CDR3")))
|
|
539
|
-
|
|
540
|
-
numbered = extract_numbered_residues(antibody)
|
|
541
|
-
nb = imgt_cdrh3_length(numbered)
|
|
542
|
-
result.cdrh3_len = _format_int(nb)
|
|
543
|
-
if nb >= 20:
|
|
544
|
-
result.warnings.append("long_cdrh3")
|
|
545
|
-
if mode == "NanoBodyBuilder2" and not vhh_hallmarks_present(numbered):
|
|
546
|
-
result.warnings.append("vhh_hallmarks_missing")
|
|
547
|
-
result.pdb_filename = pdb_filename
|
|
548
|
-
|
|
549
|
-
success_count += 1
|
|
550
|
-
if (row_idx + 1) % log_every == 0 or row_idx == 0 or row_idx == n - 1:
|
|
551
|
-
_log(
|
|
552
|
-
f"{prefix} OK mean={result.mean_error}Å cdrh3={result.cdrh3}Å "
|
|
553
|
-
f"cdrh3Length={result.cdrh3_len}"
|
|
554
|
-
)
|
|
555
|
-
|
|
556
|
-
results.append(result)
|
|
557
|
-
|
|
558
|
-
# Preserve the input key-column header in our outputs. The batch
|
|
559
|
-
# orchestrator hands us TSVs whose key column is named after the axis spec
|
|
560
|
-
# (e.g. `pl7.app/vdj/clonotypeKey`); when batches are concatenated, the
|
|
561
|
-
# downstream xsv import expects that same header back.
|
|
562
|
-
manifest_fields = [key_col if f == KEY_COLUMN_PLACEHOLDER else f for f in MANIFEST_FIELDS]
|
|
563
|
-
confidence_fields = build_confidence_fields(key_col)
|
|
564
|
-
|
|
565
|
-
# Build the summary BEFORE the confident-marking loop below, so its
|
|
566
|
-
# semantics stay stable: `succeeded` = a structure was produced (regardless
|
|
567
|
-
# of confidence) and `confidentCount` = the within-threshold subset. The
|
|
568
|
-
# loop then tags above-threshold / metric-unavailable rows with a failure
|
|
569
|
-
# reason for the per-row table; that must not retroactively inflate the
|
|
570
|
-
# summary's failure count or collapse `succeeded` onto `confidentCount`.
|
|
571
|
-
summary = _build_summary(results, metric, threshold)
|
|
572
|
-
|
|
573
|
-
# A structure was produced, but it isn't confident enough to export. Two
|
|
574
|
-
# distinct cases, surfaced as distinct failure reasons so the user (and any
|
|
575
|
-
# downstream tooling) can tell them apart — the confidence values still
|
|
576
|
-
# appear in the table either way. This is the single confident filter for
|
|
577
|
-
# the block.
|
|
578
|
-
for r in results:
|
|
579
|
-
if r.failure_reason or not r.pdb_filename:
|
|
580
|
-
continue
|
|
581
|
-
v = _metric_value(r, metric)
|
|
582
|
-
if v is None:
|
|
583
|
-
# The selected metric couldn't be computed (e.g. the CDR-H3 region
|
|
584
|
-
# produced no numbered residues) — no comparison was made.
|
|
585
|
-
r.failure_reason = CONFIDENCE_METRIC_UNAVAILABLE_REASON
|
|
586
|
-
r.failure_reason_text = "Confidence metric unavailable"
|
|
587
|
-
elif v > threshold:
|
|
588
|
-
r.failure_reason = CONFIDENCE_ABOVE_THRESHOLD_REASON
|
|
589
|
-
r.failure_reason_text = f"Prediction confidence above threshold ({threshold} Å)"
|
|
590
|
-
|
|
591
|
-
# The manifest selects which PDBs become the exported/UI ResourceMap: only
|
|
592
|
-
# clonotypes that have a structure and no failure reason. Failed and the
|
|
593
|
-
# low-confidence rows just marked are excluded, so the map is confident-only
|
|
594
|
-
# by construction.
|
|
595
|
-
with open(manifest_tsv, "w", newline="") as f:
|
|
596
|
-
writer = csv.DictWriter(
|
|
597
|
-
f, fieldnames=manifest_fields, delimiter="\t", lineterminator="\n"
|
|
598
|
-
)
|
|
599
|
-
writer.writeheader()
|
|
600
|
-
for r in results:
|
|
601
|
-
if r.pdb_filename and not r.failure_reason:
|
|
602
|
-
writer.writerow({key_col: r.clonotype_key, "pdb_filename": r.pdb_filename})
|
|
603
|
-
|
|
604
|
-
with open(confidence_tsv, "w", newline="") as f:
|
|
605
|
-
writer = csv.DictWriter(f, fieldnames=confidence_fields, delimiter="\t")
|
|
606
|
-
writer.writeheader()
|
|
607
|
-
for r in results:
|
|
608
|
-
writer.writerow(r.to_tsv_row(key_col))
|
|
609
|
-
|
|
610
|
-
if summary_json is not None:
|
|
611
|
-
summary_json.parent.mkdir(parents=True, exist_ok=True)
|
|
612
|
-
with open(summary_json, "w") as f:
|
|
613
|
-
json.dump(summary, f)
|
|
614
|
-
|
|
615
|
-
_log(
|
|
616
|
-
f"done total={summary['totalRows']} succeeded={summary['succeeded']} "
|
|
617
|
-
f"failed={summary['failed']} confident={summary['confidentCount']} "
|
|
618
|
-
f"cache_hits={cache_hits}"
|
|
619
|
-
)
|
|
620
|
-
if summary["byFailureReason"]:
|
|
621
|
-
for reason, n_fail in sorted(
|
|
622
|
-
summary["byFailureReason"].items(), key=lambda kv: -kv[1]
|
|
623
|
-
):
|
|
624
|
-
_log(f" failure: {n_fail} × {reason}")
|
|
625
|
-
if summary["byWarning"]:
|
|
626
|
-
for warning, n_warn in sorted(
|
|
627
|
-
summary["byWarning"].items(), key=lambda kv: -kv[1]
|
|
628
|
-
):
|
|
629
|
-
_log(f" warning: {n_warn} × {warning}")
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def main() -> None:
|
|
633
|
-
parser = argparse.ArgumentParser()
|
|
634
|
-
parser.add_argument("--mode", choices=["ABodyBuilder2", "NanoBodyBuilder2"], required=True)
|
|
635
|
-
parser.add_argument("--weights-dir", default=None,
|
|
636
|
-
help="Directory holding the ImmuneBuilder model weights "
|
|
637
|
-
"(mounted from the weights asset). When omitted, "
|
|
638
|
-
"ImmuneBuilder downloads them on first use.")
|
|
639
|
-
parser.add_argument("--input", help="Batch TSV with clonotypeKey, heavyChain[, lightChain]")
|
|
640
|
-
parser.add_argument("--output-dir", help="Directory for per-clonotype PDB files")
|
|
641
|
-
parser.add_argument("--manifest", help="Path to manifest.tsv")
|
|
642
|
-
parser.add_argument("--confidence", help="Path to confidence.tsv")
|
|
643
|
-
parser.add_argument("--summary", default=None, help="Path to summary.json (aggregate stats for the model)")
|
|
644
|
-
parser.add_argument("--seed", type=int, default=42)
|
|
645
|
-
parser.add_argument("--metric", choices=["cdrh3Mean", "overallMean"], default="cdrh3Mean")
|
|
646
|
-
parser.add_argument("--threshold", type=float, default=2.5,
|
|
647
|
-
help="Confidence threshold (Å) used to derive confidentCount in summary.json")
|
|
648
|
-
args = parser.parse_args()
|
|
649
|
-
|
|
650
|
-
missing = [
|
|
651
|
-
name for name, value in [
|
|
652
|
-
("--input", args.input),
|
|
653
|
-
("--output-dir", args.output_dir),
|
|
654
|
-
("--manifest", args.manifest),
|
|
655
|
-
("--confidence", args.confidence),
|
|
656
|
-
] if not value
|
|
657
|
-
]
|
|
658
|
-
if missing:
|
|
659
|
-
parser.error(f"the following arguments are required: {', '.join(missing)}")
|
|
660
|
-
|
|
661
|
-
process_batch(
|
|
662
|
-
input_tsv=Path(args.input),
|
|
663
|
-
pdb_dir=Path(args.output_dir),
|
|
664
|
-
manifest_tsv=Path(args.manifest),
|
|
665
|
-
confidence_tsv=Path(args.confidence),
|
|
666
|
-
summary_json=Path(args.summary) if args.summary else None,
|
|
667
|
-
mode=args.mode,
|
|
668
|
-
seed=args.seed,
|
|
669
|
-
metric=args.metric,
|
|
670
|
-
threshold=args.threshold,
|
|
671
|
-
weights_dir=args.weights_dir,
|
|
672
|
-
)
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
if __name__ == "__main__":
|
|
676
|
-
main()
|