← Files NVIDIA BioNeMo Agent ToolkitARCHIVED FILE

skills/complexa-binder-design/scripts/complexa_design.py

6.52 KB · Sep 30, 2026 · 23:14 UTC

↓ Download file

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0
"""Drive the open Proteina-Complexa `complexa` CLI: build argv -> run -> discover -> extract.

Transport-agnostic and stdlib-only. Runs the upstream `complexa` Hydra CLI inside
your local checkout ($COMPLEXA_REPO) and reads the generated complex PDBs from
`./inference/`. Hotspots and binder length are target-dict-driven upstream, so
register the target first (`complexa target add ...`) and select it with --task-name.

Examples
  COMPLEXA_REPO=/path/to/Proteina-Complexa \
    python complexa_design.py run --task-name <task-name> --run-name <run> \
      --algorithm best-of-n --num-samples 8 --seed 0 --out outputs/<run>
  python complexa_design.py extract outputs/<run>/inference/**/complex_0.pdb

See references/complexa-cli.md for the full override list.
"""
from __future__ import annotations
import argparse, os, shutil, subprocess, sys, time
from pathlib import Path

THREE_TO_ONE = {
    "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q",
    "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K",
    "MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W",
    "TYR": "Y", "VAL": "V",
}
DEFAULT_CONFIG = "configs/search_binder_local_pipeline.yaml"


def repo_root() -> Path:
    r = os.environ.get("COMPLEXA_REPO")
    if not r:
        sys.exit("Set COMPLEXA_REPO to your Proteina-Complexa checkout "
                 "(https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa).")
    p = Path(r).expanduser()
    if not p.is_dir():
        sys.exit(f"COMPLEXA_REPO is not a directory: {p}")
    return p


def build_argv(a) -> list[str]:
    verb = "generate" if a.mode == "generate" else "design"
    argv = [a.cli_bin, verb, a.config, f"++run_name={a.run_name}"]
    if a.task_name:
        argv.append(f"++generation.task_name={a.task_name}")
    if a.algorithm:
        argv.append(f"++generation.search.algorithm={a.algorithm}")
    if a.num_samples is not None:
        argv.append(f"++generation.dataloader.dataset.nres.nsamples={a.num_samples}")
    if a.seed is not None:
        argv.append(f"++seed={a.seed}")
    if a.gen_njobs is not None:
        argv.append(f"++gen_njobs={a.gen_njobs}")
    if a.eval_njobs is not None:
        argv.append(f"++eval_njobs={a.eval_njobs}")
    if a.ckpt_path:
        argv.append(f"++ckpt_path={a.ckpt_path}")
    if a.ckpt_name:
        argv.append(f"++ckpt_name={a.ckpt_name}")
    if a.autoencoder_ckpt_path:
        argv.append(f"++autoencoder_ckpt_path={a.autoencoder_ckpt_path}")
    argv.extend(a.override or [])  # caller escape hatch, appended last so it wins
    return argv


def discover_complex_pdbs(root: Path, since: float | None = None) -> list[Path]:
    inf = root / "inference"
    if not inf.is_dir():
        return []
    pdbs = [p for p in inf.rglob("*.pdb") if p.is_file()]
    if since is not None:
        pdbs = [p for p in pdbs if p.stat().st_mtime >= since - 1]
    return sorted(pdbs)


def extract(pdb_path: str) -> dict:
    """Return {chain: sequence} from CA records (binder chain carries the seq)."""
    chains: dict[str, list[str]] = {}
    for line in Path(pdb_path).read_text().splitlines():
        if line[:6].strip() in ("ATOM", "HETATM") and line[12:16].strip() == "CA":
            ch = line[21]
            chains.setdefault(ch, []).append(THREE_TO_ONE.get(line[17:20].strip(), "X"))
    seqs = {c: "".join(r) for c, r in chains.items()}
    order = sorted(seqs, key=lambda c: len(seqs[c]))  # shorter chain ~ binder
    return {"file": pdb_path,
            "chains": {c: {"len": len(s), "seq": s} for c, s in seqs.items()},
            "binder_chain_guess": order[0] if order else None}


def cmd_run(a) -> None:
    root = repo_root()
    argv = build_argv(a)
    print("cwd:", root, file=sys.stderr)
    print("cmd:", " ".join(argv), file=sys.stderr)
    t0 = time.time()
    proc = subprocess.run(argv, cwd=root)
    if proc.returncode != 0:
        sys.exit(f"complexa exited with code {proc.returncode}")
    pdbs = discover_complex_pdbs(root, since=t0)
    out = Path(a.out) if a.out else None
    saved = []
    if out:
        (out / "inference").mkdir(parents=True, exist_ok=True)
        for p in pdbs:
            dest = out / "inference" / p.name
            shutil.copy2(p, dest)
            saved.append(str(dest))
        (out / "command.txt").write_text(" ".join(argv) + "\n")
    import json
    report = [extract(p) for p in (saved or [str(p) for p in pdbs])]
    print(json.dumps({"n_complexes": len(report), "binders": report}, indent=1))


def cmd_extract(a) -> None:
    import json
    print(json.dumps([extract(p) for p in a.pdb], indent=1))


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    def add_run_args(sp):
        sp.add_argument("--config", default=DEFAULT_CONFIG)
        # 'generate' (default) = lean path: reward-guided search (best-of-n) already
        # AF2-selects during generation, emits co-designed seq+structure PDBs, and
        # avoids the full pipeline's redundant re-fold (evaluate) + foldseek/sc (analyze).
        # Use 'design' only if you specifically want Complexa's internal evaluate/analyze.
        sp.add_argument("--mode", choices=["design", "generate"], default="generate")
        sp.add_argument("--task-name")
        sp.add_argument("--run-name", default="complexa_run")
        sp.add_argument("--algorithm", default="best-of-n")
        sp.add_argument("--num-samples", type=int)
        sp.add_argument("--seed", type=int, default=0)
        sp.add_argument("--gen-njobs", type=int)
        sp.add_argument("--eval-njobs", type=int)
        sp.add_argument("--ckpt-path"); sp.add_argument("--ckpt-name")
        sp.add_argument("--autoencoder-ckpt-path")
        sp.add_argument("--cli-bin", default=os.environ.get("COMPLEXA_BIN", "complexa"))
        sp.add_argument("--override", nargs="*", help="extra ++key=value Hydra overrides")
        sp.add_argument("--out", help="copy discovered complex PDBs here")

    sp = sub.add_parser("run", help="build + run complexa, then discover/extract")
    add_run_args(sp)
    sp = sub.add_parser("extract", help="extract per-chain sequences from complex PDB(s)")
    sp.add_argument("pdb", nargs="+")

    a = p.parse_args()
    if a.cmd == "run":
        cmd_run(a)
    elif a.cmd == "extract":
        cmd_extract(a)


if __name__ == "__main__":
    main()

SHA-256: fac847c0de13148c702a828b77c9135762f0dffd1da9f830c14e4c62a6b19f1c