@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.
@@ -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()