bio_tools 0.1.3

Install, run, and inspect computational biology and chemistry tools, e.g. AlphaFold, Boltz, RFdiffusion3, and ProteinMPNN
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
"""Chai-1 biomolecular structure prediction."""

from __future__ import annotations

import hashlib
import os
import re
import string
import tempfile
from pathlib import Path
from typing import Any

import bio_tools

from . import (
    PROCESS_EXECUTABLES,
    ToolInputError,
    ToolUnavailable,
    catalog_spec,
    preset_payload,
    readable_files,
    run_command,
    tool_fields,
    tool_script,
    torch_device,
)
from .environments import environment_python
from .field_processing import (
    boolean,
    decimal,
    document_input,
    integer,
    json_list,
    lines,
    molecule_boxes,
)
from .status_check import CheckResult, ToolStatus, probe_cli, require_gpu_status

SPEC = catalog_spec(
    "chai1",
    fields=tool_fields("chai1"),
)


_FASTA_HEADER = re.compile(
    r"^>(protein|dna|rna|ligand|glycan)\|(?:name=)?([^|\s]+)$", re.IGNORECASE
)
_REST_HEADER = "chainA,res_idxA,chainB,res_idxB,connection_type,confidence,min_distance_angstrom,max_distance_angstrom,comment,restraint_id"


def _chain_name(index: int) -> str:
    name = ""
    while index >= 0:
        index, remainder = divmod(index, 26)
        name = string.ascii_uppercase[remainder] + name
        index -= 1
    return name


def from_boxes(payload: dict[str, Any]) -> str:
    """The molecule boxes as Chai's FASTA, for "Set parameters here".

    A box's name is its record's name, because that is what the restraints
    below refer to an entity by; an unnamed box falls back to its position,
    which is A, B, C in the order the boxes are in.
    """

    records: list[str] = []
    # A record's name is free text rather than a chain letter, so it is not held
    # to the short entity IDs the JSON and YAML dialects want.
    for box in molecule_boxes(payload, allow_ids=True, maximum_id_length=64):
        if box.kind == "ligand":
            if box.ligand.upper().startswith("CCD_"):
                raise ToolInputError(
                    f'Molecule "{box.chain}": Chai-1 only accepts SMILES ligands, not CCD codes.'
                )
            records.append(f">ligand|name={box.chain}\n{box.ligand}\n")
            continue

        if box.cyclic:
            raise ToolInputError(
                f'Molecule "{box.chain}" is cyclic, but Chai-1 does not support cyclic chains.'
            )

        sequence = box.sequence
        if box.modifications:
            mods_by_position = {mod.position: mod.residue for mod in box.modifications}
            sequence = "".join(
                f"({mods_by_position[index]})" if index in mods_by_position else char
                for index, char in enumerate(box.sequence, start=1)
            )
        records.append(f">{box.kind}|name={box.chain}\n{sequence}\n")
    return "".join(records)


def _legacy_fasta(payload: dict[str, Any], task: str) -> str:
    records: list[tuple[str, str, str]] = []
    if task == "list":
        for field, kind in (("proteins", "protein"), ("dnas", "dna"), ("rnas", "rna")):
            for sequence in lines(
                payload, field, required=field == "proteins", maximum=26
            ):
                records.append((kind, _chain_name(len(records)), sequence))
    else:
        for molecule in json_list(payload, "molecules", maximum=26):
            if not isinstance(molecule, dict) or molecule.get("type") not in {
                "protein",
                "dna",
                "rna",
            }:
                raise ToolInputError("Molecules need protein, DNA, or RNA entries.")
            name, sequence = molecule.get("chain"), molecule.get("sequence")
            if not isinstance(name, str) or not isinstance(sequence, str):
                raise ToolInputError("Molecules need chain and sequence strings.")
            records.append((molecule["type"], name, sequence))
        if not records:
            raise ToolInputError("Molecules need at least one polymer entry.")
    used = {name for _, name, _ in records}
    for smiles in lines(payload, "ligands", required=False, maximum=20):
        index = len(records)
        while _chain_name(index) in used:
            index += 1
        name = _chain_name(index)
        used.add(name)
        records.append(("ligand", name, smiles))
    if not records or len(records) > 26:
        raise ToolInputError(
            "The chain-list and molecule modes accept 1 to 26 entities."
        )
    return "".join(
        f">{kind}|name={name}\n{sequence}\n" for kind, name, sequence in records
    )


def _read_fasta(raw: str) -> tuple[str, dict[str, str], set[str]]:
    records: list[tuple[str, str, str]] = []
    name: str | None = None
    kind = ""
    parts: list[str] = []
    used: set[str] = set()

    def finish() -> None:
        if name is None:
            return
        sequence = "".join(parts).strip()
        if not sequence or (
            kind != "glycan" and any(char.isspace() for char in sequence)
        ):
            raise ToolInputError(
                f'Entity "{name}" needs a non-empty, continuous sequence.'
            )
        records.append((kind, name, sequence))

    for line in raw.splitlines():
        line = line.strip()
        if not line:
            continue
        if line.startswith(">"):
            finish()
            match = _FASTA_HEADER.fullmatch(line)
            if match is None:
                raise ToolInputError(f"Invalid Chai FASTA header: {line}.")
            kind, name = match.group(1).lower(), match.group(2)
            if name in used:
                raise ToolInputError(f'Entity name "{name}" is repeated.')
            used.add(name)
            parts = []
        else:
            if name is None:
                raise ToolInputError("FASTA sequence appears before its header.")
            parts.append(line)
    finish()
    if not records or len(records) > 64:
        raise ToolInputError("Chai FASTA needs 1 to 64 entities.")
    normalized = "".join(
        f">{kind}|name={name}\n{sequence}\n" for kind, name, sequence in records
    )
    sequences = {
        _chain_name(index): re.sub(r"\([A-Za-z0-9]+\)", "X", sequence).upper()
        for index, (kind, _, sequence) in enumerate(records)
        if kind in {"protein", "dna", "rna"}
    }
    nonpolymers = {
        _chain_name(index)
        for index, (kind, _, _) in enumerate(records)
        if kind in {"ligand", "glycan"}
    }
    return normalized, sequences, nonpolymers


def _residue_code(
    sequences: dict[str, str], chain: str, position: Any, field_name: str
) -> str:
    if chain not in sequences:
        raise ToolInputError(f'{field_name} references unknown chain "{chain}".')
    try:
        index = int(position)
    except (TypeError, ValueError) as exc:
        raise ToolInputError(f"{field_name} residue index must be an integer.") from exc
    sequence = sequences[chain]
    if not 1 <= index <= len(sequence):
        raise ToolInputError(
            f'{field_name} residue {index} is out of range for chain "{chain}".'
        )
    return f"{sequence[index - 1]}{index}"


def _restraints_csv(
    payload: dict[str, Any],
    sequences: dict[str, str],
    nonpolymer_chains: set[str],
    min_distance: float,
    max_distance: float,
) -> str:
    rows = [_REST_HEADER]
    restraint_id = 0

    def next_id(prefix: str) -> str:
        nonlocal restraint_id
        restraint_id += 1
        return f"{prefix}_{restraint_id}"

    for item in json_list(payload, "pocket_restraints", maximum=50):
        if (
            not isinstance(item, dict)
            or not {"chainA", "res_idxA", "chainB"} <= item.keys()
        ):
            raise ToolInputError(
                "pocket_restraints entries need chainA, res_idxA, and chainB."
            )
        chain_b = str(item["chainB"])
        if chain_b not in sequences and chain_b not in nonpolymer_chains:
            raise ToolInputError(
                f'pocket_restraints references unknown chain "{chain_b}".'
            )
        res_a = _residue_code(
            sequences, str(item["chainA"]), item["res_idxA"], "pocket_restraints"
        )
        # Chai's pocket restraints are asymmetric the other way round from how
        # this field is described: the chain-level ("any residue") side must
        # be column A and the specific pocket residue must be column B.
        rows.append(
            f"{chain_b},,{item['chainA']},{res_a},pocket,1.0,"
            f"{min_distance},{max_distance},-,{next_id('pocket')}"
        )

    for item in json_list(payload, "contact_restraints", maximum=50):
        required = ("chainA", "res_idxA", "chainB", "res_idxB")
        if not isinstance(item, dict) or any(key not in item for key in required):
            raise ToolInputError(
                "contact_restraints entries need chainA, res_idxA, chainB, and res_idxB."
            )
        res_a = _residue_code(
            sequences, str(item["chainA"]), item["res_idxA"], "contact_restraints"
        )
        res_b = _residue_code(
            sequences, str(item["chainB"]), item["res_idxB"], "contact_restraints"
        )
        rows.append(
            f"{item['chainA']},{res_a},{item['chainB']},{res_b},contact,1.0,"
            f"{min_distance},{max_distance},-,{next_id('contact')}"
        )

    for item in json_list(payload, "covalent_restraints", maximum=20):
        required = ("chainA", "covalentAtomA", "chainB", "covalentAtomB")
        if not isinstance(item, dict) or any(key not in item for key in required):
            raise ToolInputError(
                "covalent_restraints entries need chainA, covalentAtomA, chainB, and covalentAtomB. "
                "Polymer partners also need a residue index."
            )
        chain_a, chain_b = str(item["chainA"]), str(item["chainB"])
        for key in ("covalentAtomA", "covalentAtomB"):
            if not re.fullmatch(r"[A-Za-z0-9']{1,8}", str(item[key])):
                raise ToolInputError(f"{key} must be an atom name.")

        def _side(chain: str, position: Any, atom: str) -> str:
            if chain in nonpolymer_chains:
                return f"@{atom}"
            code = _residue_code(sequences, chain, position, "covalent_restraints")
            return f"{code}@{atom}"

        side_a = _side(chain_a, item.get("res_idxA"), str(item["covalentAtomA"]))
        side_b = _side(chain_b, item.get("res_idxB"), str(item["covalentAtomB"]))
        rows.append(
            f"{chain_a},{side_a},{chain_b},{side_b},covalent,1.0,0.0,0.0,-,{next_id('covalent')}"
        )

    return "\n".join(rows) + "\n"


def run(payload: dict[str, Any]) -> dict[str, Any]:
    payload = preset_payload("chai1", payload)
    task = str(payload.get("task") or "")
    if (
        task in {"list", "molecules"}
        and not payload.get("input_mode")
        and not payload.get("input_fasta")
    ):
        payload = {
            **payload,
            "input_mode": "text",
            "input_fasta": _legacy_fasta(payload, task),
        }
    payload = document_input(
        payload,
        "input_fasta",
        from_boxes=from_boxes,
        upload_field="input_file",
        max_length=500_000,
    )
    fasta, sequences, nonpolymers = _read_fasta(str(payload["input_fasta"]))

    use_msa = boolean(payload, "use_msa_server", False)
    use_templates = boolean(payload, "use_templates_server", False)
    msa_directory = str(payload.get("msa_directory") or "").strip()
    use_example_msas = boolean(payload, "use_example_msas", False)
    template_hits_path = str(payload.get("template_hits_path") or "").strip()
    if sum((bool(use_msa), bool(msa_directory), use_example_msas)) > 1:
        raise ToolInputError(
            "Choose one MSA source: server, local directory, or supplied examples."
        )
    if use_example_msas:
        # Installing Chai-1 downloads these; an older installation gets them on first use.
        try:
            example_msas = Path(bio_tools.chai1_example_msas(PROCESS_EXECUTABLES))
        except RuntimeError as exc:
            raise ToolUnavailable(
                f"Chai’s supplied MSA files could not be downloaded: {exc}"
            ) from exc
        filenames = {path.name for path in example_msas.glob("*.aligned.pqt")}
        hashes = {
            hashlib.sha256(sequence.encode()).hexdigest() + ".aligned.pqt"
            for sequence in sequences.values()
        }
        if len(filenames) != 2 or not filenames <= hashes:
            raise ToolInputError(
                "The supplied MSAs require Chai’s official example protein sequences."
            )
        msa_directory = str(example_msas)
    if use_templates and template_hits_path:
        raise ToolInputError(
            "Choose either the template server or a template hits file."
        )
    if use_templates and not use_msa:
        raise ToolInputError("The template server requires the MSA server.")
    msa_server_url = str(
        payload.get("msa_server_url") or "https://api.colabfold.com"
    ).strip()
    if not re.fullmatch(r"https://[^\s]+", msa_server_url):
        raise ToolInputError("msa_server_url must be an HTTPS URL.")
    device = str(payload.get("device") or "cuda:0").strip()
    if not re.fullmatch(r"cuda:\d+", device):
        raise ToolInputError("Chai-1 needs a CUDA device such as cuda:0.")
    if msa_directory:
        directory = Path(msa_directory).resolve()
        if not directory.is_dir():
            raise ToolInputError(
                "msa_directory must be a directory on the compute node."
            )
        msa_directory = str(directory)
    if template_hits_path:
        hits = Path(template_hits_path).resolve()
        if not hits.is_file():
            raise ToolInputError(
                "template_hits_path must be a file on the compute node."
            )
        template_hits_path = str(hits)
    kalign_directory = ""
    if use_templates or template_hits_path:
        # Every template hit is aligned to the query by shelling out to `kalign`, which
        # chai_lab looks up on PATH and asserts on if it is missing. Installing Chai-1
        # builds it; an older installation gets it on first use.
        try:
            kalign_directory = str(Path(bio_tools.chai1_kalign(PROCESS_EXECUTABLES)))
        except RuntimeError as exc:
            raise ToolUnavailable(
                f"Templates need Kalign, which could not be built: {exc}"
            ) from exc
    min_distance = decimal(
        payload, "restraints_min_distance", default=0, minimum=0, maximum=100
    )
    max_distance = decimal(
        payload, "restraints_max_distance", default=5, minimum=0, maximum=100
    )
    if min_distance > max_distance:
        raise ToolInputError("Restraint minimum distance exceeds maximum distance.")
    num_samples = integer(payload, "num_samples", default=5, minimum=1, maximum=25)
    num_trunk_samples = integer(
        payload, "num_trunk_samples", default=1, minimum=1, maximum=10
    )
    num_recycles = integer(payload, "num_recycles", default=3, minimum=0, maximum=20)
    num_diffn_timesteps = integer(
        payload, "num_diffn_timesteps", default=200, minimum=1, maximum=1_000
    )
    seed = integer(payload, "seed", default=0, minimum=0, maximum=2_147_483_647)
    recycle_msa_subsample = integer(
        payload, "recycle_msa_subsample", default=0, minimum=0, maximum=1_000
    )

    runner = tool_script("chai1", "chai-lab", "CHAI1_EXECUTABLE")
    restraints_csv = _restraints_csv(
        payload, sequences, nonpolymers, min_distance, max_distance
    )
    raw_restraints = str(payload.get("restraints_csv") or "").strip()
    uploaded_restraints = str(payload.get("restraints_file") or "").strip()
    if raw_restraints and uploaded_restraints:
        raise ToolInputError("Paste or upload one restraint table, not both.")
    raw_restraints = raw_restraints or uploaded_restraints
    if raw_restraints and restraints_csv.strip() != _REST_HEADER:
        raise ToolInputError(
            "Use either the restraint table or the guided-restraint fields."
        )
    if raw_restraints:
        if not raw_restraints.startswith(_REST_HEADER):
            raise ToolInputError("Restraint table needs the official Chai CSV header.")
        restraints_csv = raw_restraints + "\n"
    has_restraints = restraints_csv.strip() != _REST_HEADER

    with tempfile.TemporaryDirectory(prefix="bio-web-chai1-") as temporary:
        workdir = Path(temporary)
        (workdir / "input.fasta").write_text(fasta, encoding="utf-8")
        command = [runner, "fold"]
        if use_msa:
            command.append("--use-msa-server")
            command += ["--msa-server-url", msa_server_url]
        if use_templates:
            command.append("--use-templates-server")
        if msa_directory:
            command += ["--msa-directory", msa_directory]
        if template_hits_path:
            command += ["--template-hits-path", template_hits_path]
        command += [
            "--use-esm-embeddings"
            if boolean(payload, "use_esm_embeddings", True)
            else "--no-use-esm-embeddings",
            "--low-memory"
            if boolean(payload, "low_memory", True)
            else "--no-low-memory",
            "--recycle-msa-subsample",
            str(recycle_msa_subsample),
            "--device",
            device,
        ]
        command += [
            "--num-trunk-recycles",
            str(num_recycles),
            "--num-diffn-timesteps",
            str(num_diffn_timesteps),
            "--num-diffn-samples",
            str(num_samples),
            "--num-trunk-samples",
            str(num_trunk_samples),
            "--seed",
            str(seed),
        ]
        if has_restraints:
            (workdir / "restraints.restraints").write_text(
                restraints_csv, encoding="utf-8"
            )
            command += ["--constraint-path", "restraints.restraints"]
        command.extend(["input.fasta", "output"])
        environment = None
        if kalign_directory:
            environment = {
                "PATH": os.pathsep.join(
                    filter(None, (kalign_directory, os.environ.get("PATH", "")))
                )
            }
        result = run_command(command, cwd=workdir, env=environment)
        generated = readable_files(workdir / "output")
    return {
        "status": "completed",
        "input": {"fasta": fasta},
        "generated_files": generated,
        **result,
    }


def check_status() -> ToolStatus:
    try:
        runner = tool_script("chai1", "chai-lab", "CHAI1_EXECUTABLE")
    except ToolUnavailable as exc:
        return ToolStatus(CheckResult.NOT_INSTALLED, str(exc))
    status = probe_cli([runner])
    if status.result != CheckResult.PASS:
        return status
    return require_gpu_status(
        status, torch_device(environment_python("chai1")), "Chai-1"
    )