CoolFace
Modelpublic

Synthyra/ESMFold2

sourceHugging Facemitupdated 16h agoView on Hugging Face
0likes505downloads
esmfold2_molecular_complex.py1227 linesDownload Raw Back to root
1from __future__ import annotations
2
3import io
4import os
5import re
6from dataclasses import asdict, dataclass
7from pathlib import Path
8from subprocess import check_output
9from tempfile import TemporaryDirectory
10from typing import TYPE_CHECKING, Any
11
12import biotite.structure as bs
13import biotite.structure.io.pdbx as pdbx
14import brotli
15import msgpack
16import numpy as np
17import torch
18from biotite.structure.io.pdbx import (
19    CIFCategory,
20    CIFColumn,
21    CIFData,
22    CIFFile,
23    set_structure,
24)
25
26from . import esmfold2_residue_constants as residue_constants
27from .esmfold2_metrics import compute_lddt, compute_rmsd
28from .esmfold2_protein_complex import ProteinComplex, ProteinComplexMetadata
29
30
31@dataclass
32class MolecularComplexResult:
33    """Result of molecular complex folding"""
34
35    complex: MolecularComplex
36    plddt: torch.Tensor | None = None
37    ptm: float | None = None
38    iptm: float | None = None
39    pae: torch.Tensor | None = None
40    distogram: torch.Tensor | None = None
41    pair_chains_iptm: torch.Tensor | None = None
42    output_embedding_sequence: torch.Tensor | None = None
43    output_embedding_pair_pooled: torch.Tensor | None = None
44    residue_index: torch.Tensor | None = None
45    entity_id: torch.Tensor | None = None
46    sae_features: np.ndarray | None = None  # [L, n_features]
47    ttt_metrics: dict[str, Any] | None = None
48
49
50@dataclass
51class MolecularComplexMetadata:
52    """Metadata for MolecularComplex objects."""
53
54    entity_lookup: dict[int, str]
55    chain_lookup: dict[int, str]
56    assembly_composition: dict[str, list[str]] | None = None
57
58
59@dataclass
60class Molecule:
61    """Represents a single molecule/token within a MolecularComplex."""
62
63    token: str
64    token_idx: int
65    atom_positions: np.ndarray  # [N_atoms, 3]
66    atom_elements: np.ndarray  # [N_atoms] element strings
67    atom_names: np.ndarray | None = None  # [N_atoms] atom names (optional)
68    atom_hetero: np.ndarray | None = None  # [N_atoms] hetero flags (optional)
69    residue_type: int = 0
70    molecule_type: int = 0  # PROTEIN=0, RNA=1, DNA=2, LIGAND=3
71    confidence: float = 0.0
72
73
74@dataclass(frozen=True)
75class MolecularComplex:
76    """
77    Dataclass representing a molecular complex with support for proteins, nucleic acids, and ligands.
78
79    Uses a flat atom representation with token-based sequence indexing, supporting all atom types
80    beyond the traditional atom37 protein representation.
81    """
82
83    id: str
84    sequence: list[str]  # Token sequence like ['MET', 'LYS', 'A', 'G', 'ATP']
85
86    # Flat atom arrays - simplified representation
87    atom_positions: np.ndarray  # [N_atoms, 3] 3D coordinates
88    atom_elements: np.ndarray  # [N_atoms] element strings
89
90    # Token-to-atom mapping for efficient access
91    token_to_atoms: np.ndarray  # [N_tokens, 2] start/end indices into atoms array
92
93    # Chain information
94    chain_id: np.ndarray  # [N_tokens] chain identifier for each token
95
96    # Confidence data
97    plddt: np.ndarray  # Per-token confidence scores [N_tokens]
98
99    # Metadata
100    metadata: MolecularComplexMetadata
101
102    # Optional atom names and hetero flags (preserved from original structures)
103    atom_names: np.ndarray | None = None  # [N_atoms] atom names (optional)
104    atom_hetero: np.ndarray | None = None  # [N_atoms] hetero flags (optional)
105
106    def __post_init__(self):
107        """Validate array dimensions."""
108        n_tokens = len(self.sequence)
109        n_atoms = len(self.atom_positions)
110        assert (
111            self.token_to_atoms.shape[0] == n_tokens
112        ), f"token_to_atoms shape {self.token_to_atoms.shape} != {n_tokens} tokens"
113        assert (
114            self.chain_id.shape[0] == n_tokens
115        ), f"chain_id shape {self.chain_id.shape} != {n_tokens} tokens"
116        assert (
117            self.plddt.shape[0] == n_tokens
118        ), f"plddt shape {self.plddt.shape} != {n_tokens} tokens"
119        if self.atom_names is not None:
120            assert (
121                self.atom_names.shape[0] == n_atoms
122            ), f"atom_names shape {self.atom_names.shape} != {n_atoms} atoms"
123        if self.atom_hetero is not None:
124            assert (
125                self.atom_hetero.shape[0] == n_atoms
126            ), f"atom_hetero shape {self.atom_hetero.shape} != {n_atoms} atoms"
127
128    def __len__(self) -> int:
129        """Return number of tokens."""
130        return len(self.sequence)
131
132    def __getitem__(self, idx: int) -> Molecule:
133        """Access individual molecules/tokens by index."""
134        if idx >= len(self.sequence) or idx < 0:
135            raise IndexError(
136                f"Token index {idx} out of range for {len(self.sequence)} tokens"
137            )
138
139        token = self.sequence[idx]
140        start_atom, end_atom = self.token_to_atoms[idx]
141
142        # Extract atom data for this token
143        token_atom_positions = self.atom_positions[start_atom:end_atom]
144        token_atom_elements = self.atom_elements[start_atom:end_atom]
145        token_atom_names = None
146        if self.atom_names is not None:
147            token_atom_names = self.atom_names[start_atom:end_atom]
148        token_atom_hetero = None
149        if self.atom_hetero is not None:
150            token_atom_hetero = self.atom_hetero[start_atom:end_atom]
151
152        # Default values for residue/molecule type (would be extended based on actual implementation)
153        residue_type = 0  # Default to standard residue
154        molecule_type = 0  # Default to protein
155
156        return Molecule(
157            token=token,
158            token_idx=idx,
159            atom_positions=token_atom_positions,
160            atom_elements=token_atom_elements,
161            atom_names=token_atom_names,
162            atom_hetero=token_atom_hetero,
163            residue_type=residue_type,
164            molecule_type=molecule_type,
165            confidence=self.plddt[idx],
166        )
167
168    @property
169    def atom_coordinates(self) -> np.ndarray:
170        """Get flat array of all atom coordinates [N_atoms, 3]."""
171        return self.atom_positions
172
173    # Conversion methods
174    @classmethod
175    def from_protein_complex(cls, pc: ProteinComplex) -> "MolecularComplex":
176        """Convert a ProteinComplex to MolecularComplex.
177
178        Args:
179            pc: ProteinComplex object with atom37 representation
180
181        Returns:
182            MolecularComplex with flat atom arrays and token-based indexing
183        """
184        from . import esmfold2_residue_constants
185
186        # Extract sequence without chain breaks
187        sequence_no_breaks = pc.sequence.replace("|", "")
188        sequence_tokens = [
189            residue_constants.restype_1to3.get(aa, "UNK") for aa in sequence_no_breaks
190        ]
191
192        # Convert atom37 to flat arrays
193        flat_positions = []
194        flat_elements = []
195        flat_names = []
196        flat_hetero = []
197        token_to_atoms = []
198
199        atom_idx = 0
200
201        for i, aa in enumerate(pc.sequence):
202            if aa == "|":
203                # Skip chain break tokens
204                continue
205
206            # Get atom37 positions and mask for this residue.
207            # ProteinComplex arrays are indexed by sequence position (including |),
208            # so use `i` not a separate residue counter.
209            res_positions = pc.atom37_positions[i]  # [37, 3]
210            res_mask = pc.atom37_mask[i]  # [37]
211
212            # Track start position for this token
213            token_start = atom_idx
214
215            # Process each atom type in atom37 representation
216            for atom_type_idx, atom_name in enumerate(residue_constants.atom_types):
217                if res_mask[atom_type_idx]:  # Atom is present
218                    # Add position
219                    flat_positions.append(res_positions[atom_type_idx])
220
221                    # Determine element from atom name
222                    element = (
223                        atom_name[0] if atom_name else "C"
224                    )  # First character is element
225                    flat_elements.append(element)
226
227                    # Add atom name
228                    flat_names.append(atom_name)
229
230                    # Add hetero flag (all proteins are non-hetero)
231                    flat_hetero.append(False)
232
233                    atom_idx += 1
234
235            # Record token-to-atom mapping [start_idx, end_idx)
236            token_to_atoms.append([token_start, atom_idx])
237
238        # Convert to numpy arrays
239        atom_positions = np.array(flat_positions, dtype=np.float32)
240        atom_elements = np.array(flat_elements, dtype=object)
241        atom_names = np.array(flat_names, dtype=object)
242        atom_hetero = np.array(flat_hetero, dtype=bool)
243        token_to_atoms_array = np.array(token_to_atoms, dtype=np.int32)
244
245        # Extract confidence scores and chain_ids (skip chain breaks)
246        confidence_scores = []
247        chain_ids = []
248        for seq_idx, aa in enumerate(pc.sequence):
249            if aa != "|":
250                confidence_scores.append(pc.confidence[seq_idx])
251                chain_ids.append(pc.chain_id[seq_idx])
252
253        confidence_array = np.array(confidence_scores, dtype=np.float32)
254        chain_id_array = np.array(chain_ids, dtype=np.int64)
255
256        # Create metadata - convert entity IDs to strings for MolecularComplexMetadata
257        entity_lookup_str = {k: str(v) for k, v in pc.metadata.entity_lookup.items()}
258        metadata = MolecularComplexMetadata(
259            entity_lookup=entity_lookup_str,
260            chain_lookup=pc.metadata.chain_lookup,
261            assembly_composition=pc.metadata.assembly_composition,
262        )
263
264        return cls(
265            id=pc.id,
266            sequence=sequence_tokens,
267            atom_positions=atom_positions,
268            atom_elements=atom_elements,
269            token_to_atoms=token_to_atoms_array,
270            chain_id=chain_id_array,
271            plddt=confidence_array,
272            metadata=metadata,
273            atom_names=atom_names,
274            atom_hetero=atom_hetero,
275        )
276
277    def to_protein_complex(self) -> ProteinComplex:
278        """Convert MolecularComplex back to ProteinComplex format.
279
280        Extracts only protein tokens and converts from flat atom representation
281        back to atom37 format used by ProteinComplex.
282
283        Returns:
284            ProteinComplex with protein residues only, excluding ligands/nucleic acids
285        """
286        from . import esmfold2_residue_constants
287
288        # No need for element mapping - already using element characters
289
290        # Filter for protein tokens only (skip ligands, nucleic acids)
291        protein_tokens = []
292        protein_indices = []
293
294        for i, token in enumerate(self.sequence):
295            # Check if token is a standard 3-letter amino acid code
296            if token in residue_constants.restype_3to1:
297                protein_tokens.append(token)
298                protein_indices.append(i)
299
300        if not protein_tokens:
301            raise ValueError("No protein tokens found in MolecularComplex")
302
303        n_residues = len(protein_tokens)
304
305        # Initialize atom37 arrays
306        atom37_positions = np.full((n_residues, 37, 3), np.nan, dtype=np.float32)
307        atom37_mask = np.zeros((n_residues, 37), dtype=bool)
308
309        # Extract confidence scores and chain_ids for protein residues only
310        protein_confidence = self.plddt[protein_indices]
311        protein_chain_ids = self.chain_id[protein_indices]
312
313        # Convert tokens back to single-letter sequence with chain breaks
314        single_letter_residues = []
315        prev_chain_id = None
316
317        for i, (token, chain_id_val) in enumerate(
318            zip(protein_tokens, protein_chain_ids)
319        ):
320            # Add chain break if we're switching to a new chain
321            if prev_chain_id is not None and chain_id_val != prev_chain_id:
322                single_letter_residues.append("|")
323            single_letter_residues.append(residue_constants.restype_3to1[token])
324            prev_chain_id = chain_id_val
325
326        single_letter_sequence = "".join(single_letter_residues)
327
328        # Calculate final sequence length (includes chain breaks)
329        sequence_length = len(single_letter_sequence)
330
331        # Convert flat atoms back to atom37 representation using atom names
332        for res_idx, token_idx in enumerate(protein_indices):
333            token = self.sequence[token_idx]
334            start_atom, end_atom = self.token_to_atoms[token_idx]
335
336            res_atom_positions = self.atom_positions[start_atom:end_atom]
337            res_atom_names = (
338                np.array(self.atom_names[start_atom:end_atom], dtype=str)
339                if self.atom_names is not None
340                else np.array([], dtype=str)
341            )
342
343            # Build a mapping from normalized atom name -> position for this residue
344            # Normalize to uppercase and strip whitespace for robust matching
345            name_to_pos: dict[str, np.ndarray] = {}
346            for i, nm in enumerate(res_atom_names):
347                key = nm.upper().strip()
348                # Prefer first occurrence; ignore duplicates/altlocs
349                if key not in name_to_pos:
350                    name_to_pos[key] = res_atom_positions[i]
351
352            # Place atoms into atom37 by matching stored atom names to atom37 indices.
353            # This handles all atoms present in the flat representation, not just
354            # the canonical residue_atoms for this residue type. This preserves
355            # atoms that were in the original atom37_mask even if they're atypical
356            # for the residue (e.g., from alternate conformations or data quirks).
357            for atom_name_str, pos in name_to_pos.items():
358                idx37 = residue_constants.atom_order.get(atom_name_str)
359                if idx37 is not None:
360                    atom37_positions[res_idx, idx37] = pos
361                    atom37_mask[res_idx, idx37] = True
362
363        # Create arrays that match sequence length (including chain breaks)
364        # Initialize arrays with proper size
365        chain_id_expanded = np.full(sequence_length, -1, dtype=np.int64)
366        entity_id_expanded = np.full(sequence_length, -1, dtype=np.int64)
367        sym_id_expanded = np.zeros(sequence_length, dtype=np.int64)
368        residue_index_expanded = np.zeros(sequence_length, dtype=np.int64)
369        insertion_code_expanded = np.array([""] * sequence_length, dtype=object)
370        confidence_expanded = np.zeros(sequence_length, dtype=np.float32)
371        atom37_positions_expanded = np.full(
372            (sequence_length, 37, 3), np.nan, dtype=np.float32
373        )
374        atom37_mask_expanded = np.zeros((sequence_length, 37), dtype=bool)
375
376        # Map residue data to sequence positions (skipping chain breaks)
377        residue_idx = 0
378        residue_counter_per_chain = {}
379
380        for seq_pos, char in enumerate(single_letter_sequence):
381            if char != "|":
382                # This is a residue position
383                chain_id_val = protein_chain_ids[residue_idx]
384
385                chain_id_expanded[seq_pos] = chain_id_val
386                entity_id_expanded[seq_pos] = chain_id_val  # Simplified mapping
387
388                # Track residue numbering per chain
389                if chain_id_val not in residue_counter_per_chain:
390                    residue_counter_per_chain[chain_id_val] = 1
391                else:
392                    residue_counter_per_chain[chain_id_val] += 1
393
394                residue_index_expanded[seq_pos] = residue_counter_per_chain[
395                    chain_id_val
396                ]
397                confidence_expanded[seq_pos] = protein_confidence[residue_idx]
398                atom37_positions_expanded[seq_pos] = atom37_positions[residue_idx]
399                atom37_mask_expanded[seq_pos] = atom37_mask[residue_idx]
400
401                residue_idx += 1
402            # Chain break positions keep default values (-1, False, etc.)
403
404        # Use the expanded arrays
405        chain_id = chain_id_expanded
406        entity_id = entity_id_expanded
407        sym_id = sym_id_expanded
408        residue_index = residue_index_expanded
409        insertion_code = insertion_code_expanded
410        protein_confidence = confidence_expanded
411        atom37_positions = atom37_positions_expanded
412        atom37_mask = atom37_mask_expanded
413
414        # Create protein complex metadata preserving chain information
415        # Convert MolecularComplex metadata to ProteinComplex format
416        unique_chain_ids = np.unique(protein_chain_ids)
417        entity_lookup = {int(cid): int(cid) for cid in unique_chain_ids}
418        chain_lookup = {
419            int(cid): self.metadata.chain_lookup.get(int(cid), chr(65 + int(cid)))
420            for cid in unique_chain_ids
421        }
422
423        protein_metadata = ProteinComplexMetadata(
424            entity_lookup=entity_lookup,
425            chain_lookup=chain_lookup,
426            assembly_composition=self.metadata.assembly_composition,
427        )
428
429        return ProteinComplex(
430            id=self.id,
431            sequence=single_letter_sequence,
432            entity_id=entity_id,
433            chain_id=chain_id,
434            sym_id=sym_id,
435            residue_index=residue_index,
436            insertion_code=insertion_code,
437            atom37_positions=atom37_positions,
438            atom37_mask=atom37_mask,
439            confidence=protein_confidence,
440            metadata=protein_metadata,
441        )
442
443    @classmethod
444    def from_mmcif(cls, inp: str, id: str | None = None) -> "MolecularComplex":
445        """Read MolecularComplex from mmcif file or string.
446
447        Args:
448            inp: Path to mmCIF file or mmCIF content as string
449            id: Optional identifier to assign to the complex
450
451        Returns:
452            MolecularComplex with all molecules (proteins, ligands, nucleic acids)
453        """
454        from io import StringIO
455
456        # Check if input is a file path or mmCIF string content
457        if os.path.exists(inp):
458            # Input is a file path
459            mmcif_file = pdbx.CIFFile.read(inp)
460        else:
461            # Input is mmCIF string content
462            mmcif_file = pdbx.CIFFile.read(StringIO(inp))
463
464        # Get structure - handle missing model information gracefully
465        try:
466            structure = pdbx.get_structure(
467                mmcif_file, model=1, extra_fields=["b_factor"]
468            )
469        except (KeyError, ValueError):
470            # Fallback for mmCIF files without model information
471            try:
472                structure = pdbx.get_structure(mmcif_file)
473            except Exception:
474                # Last resort: use the first available model or all atoms
475                structure = pdbx.get_structure(mmcif_file, model=None)
476        # Type hint for pyright - structure is an AtomArray which is iterable
477        if TYPE_CHECKING:
478            structure: Any = structure
479
480        # Read label_asym_id from the raw CIF atom_site category.
481        # Biotite's atom.chain_id uses auth_asym_id, which collapses ligands
482        # onto their parent protein chain. label_asym_id gives each entity a
483        # distinct chain identifier.
484        block = mmcif_file.block
485        label_asym_ids: list[str] | None = None
486        if "atom_site" in block:
487            atom_site = block["atom_site"]
488            if "label_asym_id" in atom_site:
489                _col = atom_site["label_asym_id"]
490                _raw = (
491                    _col.as_array(str)
492                    if hasattr(_col, "as_array")
493                    else np.array(list(_col), dtype=str)  # type: ignore[arg-type]
494                )
495                # biotite's get_structure(model=1) filters to model 1 AND
496                # removes alternate conformations. We must apply the same
497                # filters to label_asym_id to keep arrays aligned.
498                keep = np.ones(len(_raw), dtype=bool)
499                if "pdbx_PDB_model_num" in atom_site:
500                    _mc = atom_site["pdbx_PDB_model_num"]
501                    _models = (
502                        _mc.as_array(str)
503                        if hasattr(_mc, "as_array")
504                        else np.array(list(_mc), dtype=str)  # type: ignore[arg-type]
505                    )
506                    keep &= _models == "1"
507                if "label_alt_id" in atom_site:
508                    _ac = atom_site["label_alt_id"]
509                    _alts = (
510                        _ac.as_array(str)
511                        if hasattr(_ac, "as_array")
512                        else np.array(list(_ac), dtype=str)  # type: ignore[arg-type]
513                    )
514                    keep &= np.isin(_alts, [".", "?", "", "A"])
515                filtered = _raw[keep]
516                if len(filtered) == len(structure):
517                    label_asym_ids = filtered.tolist()
518                # If lengths still don't match, fall back to atom.chain_id
519
520        # Get entity information from mmCIF
521        entity_info = {}
522        try:
523            if "entity" in block:
524                entity_category = block["entity"]
525                if "id" in entity_category and "type" in entity_category:
526                    entity_ids = entity_category["id"]
527                    entity_types = entity_category["type"]
528                    # Convert CIFColumn to list for iteration
529                    if hasattr(entity_ids, "__iter__") and hasattr(
530                        entity_types, "__iter__"
531                    ):
532                        # Type annotation to help pyright understand these are iterable
533                        entity_ids_list = list(entity_ids)  # type: ignore
534                        entity_types_list = list(entity_types)  # type: ignore
535                        for eid, etype in zip(entity_ids_list, entity_types_list):
536                            entity_info[eid] = etype
537        except Exception:
538            pass
539
540        # Initialize arrays for flat atom representation
541        sequence_tokens = []
542        flat_positions = []
543        flat_elements = []
544        flat_names = []
545        flat_hetero = []
546        token_to_atoms = []
547        confidence_scores = []
548        chain_ids = []  # Track chain IDs for each token
549
550        atom_idx = 0
551
552        # Group atoms by chain and residue.
553        # Use label_asym_id (distinct per entity) when available, otherwise
554        # fall back to biotite's chain_id (auth_asym_id).
555        chain_residue_groups: dict[str, dict[tuple[int, str], dict]] = {}
556        for atom_i, atom in enumerate(structure):
557            chain_id = (
558                label_asym_ids[atom_i] if label_asym_ids is not None else atom.chain_id
559            )
560            res_id = atom.res_id
561            res_name = atom.res_name
562
563            if chain_id not in chain_residue_groups:
564                chain_residue_groups[chain_id] = {}
565            # Key by (res_id, res_name) to distinguish residues that share
566            # the same res_id but have different res_name (e.g. a protein
567            # residue and a ligand that were on the same auth chain).
568            res_key = (res_id, res_name)
569            if res_key not in chain_residue_groups[chain_id]:
570                chain_residue_groups[chain_id][res_key] = {
571                    "atoms": [],
572                    "res_name": res_name,
573                    "is_hetero": atom.hetero,
574                }
575            chain_residue_groups[chain_id][res_key]["atoms"].append(atom)
576
577        # Create a mapping from chain_id to numeric indices
578        chain_id_to_numeric = {
579            chain_id: idx
580            for idx, chain_id in enumerate(sorted(chain_residue_groups.keys()))
581        }
582
583        # Process each chain and residue
584        for chain_id in sorted(chain_residue_groups.keys()):
585            residues = chain_residue_groups[chain_id]
586            numeric_chain_id = chain_id_to_numeric[chain_id]
587
588            for res_key in sorted(residues.keys()):
589                residue_data = residues[res_key]
590                res_name = residue_data["res_name"]
591                atoms = residue_data["atoms"]
592                is_hetero = residue_data["is_hetero"]
593
594                # Skip water molecules
595                if res_name == "HOH":
596                    continue
597
598                # Determine token name
599                if not is_hetero and res_name in residue_constants.restype_3to1:
600                    # Standard amino acid
601                    token_name = res_name
602                elif res_name in ["A", "T", "G", "C", "U", "DA", "DT", "DG", "DC"]:
603                    # Nucleotide
604                    token_name = res_name
605                else:
606                    # Ligand or other molecule
607                    token_name = res_name
608
609                sequence_tokens.append(token_name)
610                chain_ids.append(
611                    numeric_chain_id
612                )  # Store the numeric chain ID for this token
613                token_start = atom_idx
614
615                # Add all atoms from this residue
616                for atom in atoms:
617                    flat_positions.append(atom.coord)
618
619                    # Get element character
620                    element = atom.element
621                    flat_elements.append(element)
622
623                    # Get atom name
624                    atom_name = atom.atom_name
625                    flat_names.append(atom_name)
626
627                    # Get hetero flag
628                    hetero_flag = atom.hetero
629                    flat_hetero.append(hetero_flag)
630
631                    atom_idx += 1
632
633                # Record token-to-atom mapping
634                token_to_atoms.append([token_start, atom_idx])
635
636                # Add confidence score (B-factor if available, otherwise 1.0)
637                bfactor = getattr(atoms[0], "b_factor", 50.0) if atoms else 50.0
638                confidence_scores.append(min(bfactor / 100.0, 1.0))
639
640        # Convert to numpy arrays
641        if not flat_positions:
642            # Create minimal arrays if no atoms found
643            atom_positions = np.zeros((0, 3), dtype=np.float32)
644            atom_elements = np.zeros(0, dtype=object)
645            atom_names = np.zeros(0, dtype=object)
646            atom_hetero = np.zeros(0, dtype=bool)
647            token_to_atoms_array = np.zeros((len(sequence_tokens), 2), dtype=np.int32)
648            chain_id_array = (
649                np.array(chain_ids, dtype=np.int64)
650                if chain_ids
651                else np.zeros(len(sequence_tokens), dtype=np.int64)
652            )
653        else:
654            atom_positions = np.array(flat_positions, dtype=np.float32)
655            atom_elements = np.array(flat_elements, dtype=object)
656            atom_names = np.array(flat_names, dtype=object)
657            atom_hetero = np.array(flat_hetero, dtype=bool)
658            token_to_atoms_array = np.array(token_to_atoms, dtype=np.int32)
659            chain_id_array = np.array(chain_ids, dtype=np.int64)
660
661        confidence_array = np.array(confidence_scores, dtype=np.float32)
662
663        # Create metadata using the chain_id_to_numeric mapping
664        if chain_residue_groups:
665            chain_lookup = {
666                numeric_id: chain_id
667                for chain_id, numeric_id in chain_id_to_numeric.items()
668            }
669        else:
670            chain_lookup = {}
671
672        metadata = MolecularComplexMetadata(
673            entity_lookup=entity_info,
674            chain_lookup=chain_lookup,
675            assembly_composition=None,
676        )
677
678        # Set complex ID - if input was a path, use the stem; otherwise use default
679        if os.path.exists(inp):
680            complex_id = id or Path(inp).stem
681        else:
682            complex_id = id or "complex_from_string"
683
684        return cls(
685            id=complex_id,
686            sequence=sequence_tokens,
687            atom_positions=atom_positions,
688            atom_elements=atom_elements,
689            token_to_atoms=token_to_atoms_array,
690            chain_id=chain_id_array,
691            plddt=confidence_array,
692            metadata=metadata,
693            atom_names=atom_names,
694            atom_hetero=atom_hetero,
695        )
696
697    def _get_entity_mapping(
698        self,
699    ) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]:
700        """Compute chain→sequence, chain→entity_id, and entity_id→sequence mappings.
701
702        Returns:
703            (chain_sequences, chain_to_entity, entity_sequences)
704        """
705        chain_sequences: dict[str, list[str]] = {}
706        for token_idx in range(len(self.token_to_atoms)):
707            chain_id_numeric = self.chain_id[token_idx]
708            chain_id_str = self.metadata.chain_lookup.get(
709                int(chain_id_numeric), chr(65 + int(chain_id_numeric))
710            )
711            if chain_id_str not in chain_sequences:
712                chain_sequences[chain_id_str] = []
713            chain_sequences[chain_id_str].append(self.sequence[token_idx])
714
715        sequence_to_entity: dict[tuple[str, ...], int] = {}
716        chain_to_entity: dict[str, int] = {}
717        entity_sequences: dict[int, tuple[str, ...]] = {}
718        entity_id_counter = 1
719        for chain_id_str, sequence in chain_sequences.items():
720            seq_tuple = tuple(sequence)
721            if seq_tuple not in sequence_to_entity:
722                sequence_to_entity[seq_tuple] = entity_id_counter
723                entity_sequences[entity_id_counter] = seq_tuple
724                entity_id_counter += 1
725            chain_to_entity[chain_id_str] = sequence_to_entity[seq_tuple]
726
727        return chain_sequences, chain_to_entity, entity_sequences
728
729    def _add_entity_information(
730        self, cif_file: CIFFile, entity_sequences: dict[int, tuple[str, ...]]
731    ) -> None:
732        """Add _entity category to CIF file so OST can identify ligands vs polymers."""
733
734        entity_ids: list[str] = []
735        entity_types: list[str] = []
736        entity_descriptions: list[str] = []
737        for eid in sorted(entity_sequences.keys()):
738            seq = entity_sequences[eid]
739            entity_ids.append(str(eid))
740            has_protein = any(t in residue_constants.restype_3to1 for t in seq)
741            has_na = any(
742                t in ("A", "T", "G", "C", "U", "DA", "DT", "DG", "DC") for t in seq
743            )
744            if has_protein or has_na:
745                entity_types.append("polymer")
746                if has_protein:
747                    entity_descriptions.append(f"Polymer entity {eid} (protein)")
748                else:
749                    entity_descriptions.append(f"Polymer entity {eid} (nucleic acid)")
750            else:
751                entity_types.append("non-polymer")
752                entity_descriptions.append(f"Non-polymer entity {eid}")
753
754        if entity_ids:
755            cif_file.block["entity"] = CIFCategory(
756                name="entity",
757                columns={
758                    "id": CIFColumn(
759                        data=CIFData(array=np.array(entity_ids), dtype=np.str_)
760                    ),
761                    "type": CIFColumn(
762                        data=CIFData(array=np.array(entity_types), dtype=np.str_)
763                    ),
764                    "pdbx_description": CIFColumn(
765                        data=CIFData(array=np.array(entity_descriptions), dtype=np.str_)
766                    ),
767                },
768            )
769
770        # Add _struct_asym to map chain IDs to entity IDs
771        _, chain_to_entity, _ = self._get_entity_mapping()
772        if chain_to_entity:
773            asym_ids = sorted(chain_to_entity.keys())
774            asym_entity_ids = [str(chain_to_entity[c]) for c in asym_ids]
775            cif_file.block["struct_asym"] = CIFCategory(
776                name="struct_asym",
777                columns={
778                    "id": CIFColumn(
779                        data=CIFData(array=np.array(asym_ids), dtype=np.str_)
780                    ),
781                    "entity_id": CIFColumn(
782                        data=CIFData(array=np.array(asym_entity_ids), dtype=np.str_)
783                    ),
784                },
785            )
786
787    def to_mmcif(self) -> str:
788        """Write MolecularComplex to mmcif string using biotite.
789
790        Returns:
791            String representation of the complex in mmCIF format
792        """
793        # Pre-allocate AtomArray
794        n_atoms = len(self.atom_positions)
795        atom_array = bs.AtomArray(length=n_atoms)
796
797        # Set coordinates directly (already vectorized)
798        atom_array.coord = self.atom_positions
799
800        # Pre-allocate per-atom arrays
801        atom_res_ids = np.zeros(n_atoms, dtype=np.int32)
802        atom_chain_ids = np.empty(n_atoms, dtype=object)
803        atom_res_names = np.empty(n_atoms, dtype=object)
804        atom_hetero = np.zeros(n_atoms, dtype=bool)
805        atom_bfactors = np.zeros(n_atoms, dtype=np.float32)
806        atom_names = np.empty(n_atoms, dtype=object)
807
808        # Build entity mappings: chains with identical sequences share entity ID
809        _, chain_to_entity, entity_sequences = self._get_entity_mapping()
810
811        atom_entity_ids = np.zeros(n_atoms, dtype=np.int32)
812
813        # Track residue IDs per chain
814        chain_res_counters: dict[int, int] = {}
815
816        # Vectorized expansion of token-level to atom-level annotations
817        for token_idx, (start, end) in enumerate(self.token_to_atoms):
818            token = self.sequence[token_idx]
819            chain_id_numeric = self.chain_id[token_idx]
820            chain_id_str = self.metadata.chain_lookup.get(
821                int(chain_id_numeric), chr(65 + int(chain_id_numeric))
822            )
823
824            # Track residue numbering per chain
825            if chain_id_numeric not in chain_res_counters:
826                chain_res_counters[chain_id_numeric] = 1
827            res_id = chain_res_counters[chain_id_numeric]
828            chain_res_counters[chain_id_numeric] += 1
829
830            # Determine if protein
831            is_protein = token in residue_constants.restype_3to1
832
833            # Get atom names for this residue
834            if self.atom_names is not None:
835                # Use stored atom names (preserves original names from mmCIF)
836                names = list(self.atom_names[start:end])
837            elif is_protein:
838                # Fallback: use standard protein atom names
839                standard_names = residue_constants.residue_atoms.get(
840                    token, ["N", "CA", "C", "O"]
841                )
842                names = standard_names[: end - start]
843                # Pad if needed
844                while len(names) < (end - start):
845                    names.append(f"X{len(names)+1}")
846            else:
847                # Fallback: generate names for ligands/nucleic acids
848                names = [f"C{i+1}" for i in range(end - start)]
849
850            # Vectorized assignment for this token's atoms
851            atom_res_ids[start:end] = res_id
852            atom_chain_ids[start:end] = chain_id_str
853            atom_res_names[start:end] = token
854            # Use stored hetero flags if available, otherwise guess based on protein status
855            if self.atom_hetero is not None:
856                atom_hetero[start:end] = self.atom_hetero[start:end]
857            else:
858                atom_hetero[start:end] = not is_protein
859            atom_bfactors[start:end] = self.plddt[token_idx] * 100.0
860            atom_names[start:end] = names
861            atom_entity_ids[start:end] = chain_to_entity.get(chain_id_str, 1)
862
863        # Set all AtomArray attributes at once (convert object arrays to proper string arrays)
864        # res_name uses U8 to accommodate CCD codes up to 5 characters (e.g., A1AZ2);
865        # chain_id uses U16 because chain names like ``ligand_1`` / ``ligand_2`` /
866        # auth-asym ids of arbitrary length are possible.
867        atom_array.res_id = atom_res_ids
868        atom_array.chain_id = np.array(atom_chain_ids, dtype="U16")
869        atom_array.res_name = np.array(atom_res_names, dtype="U8")
870        atom_array.hetero = atom_hetero
871        atom_array.atom_name = np.array(atom_names, dtype="U4")
872        atom_array.add_annotation("b_factor", dtype=float)
873        atom_array.b_factor = atom_bfactors
874        atom_array.add_annotation("entity_id", dtype=int)
875        atom_array.entity_id = atom_entity_ids
876
877        # Use existing elements or infer them from atom names
878        if self.atom_elements is not None and len(self.atom_elements) == n_atoms:
879            # Convert object array to proper string array for biotite
880            atom_array.element = np.array(self.atom_elements, dtype="U4")
881        else:
882            # Use biotite's built-in element inference
883            atom_array.element = bs.infer_elements(atom_array)
884
885        # Create CIF file and set structure
886        cif_file = CIFFile()
887        set_structure(cif_file, atom_array, data_block=self.id)
888
889        # Manually fix label_entity_id (biotite doesn't use entity_id annotation correctly)
890        if "atom_site" in cif_file.block:
891            atom_site = cif_file.block["atom_site"]
892            if "label_asym_id" in atom_site and "label_entity_id" in atom_site:
893                label_asym_ids = atom_site["label_asym_id"]
894                if hasattr(label_asym_ids, "as_array"):
895                    chain_ids_list = label_asym_ids.as_array(str).tolist()
896                elif hasattr(label_asym_ids, "__iter__"):
897                    chain_ids_list = list(label_asym_ids)  # type: ignore[arg-type]
898                else:
899                    chain_ids_list = []
900                updated_entity_ids = [
901                    str(chain_to_entity.get(cid, 1)) for cid in chain_ids_list
902                ]
903                if updated_entity_ids:
904                    atom_site["label_entity_id"] = CIFColumn(
905                        data=CIFData(array=np.array(updated_entity_ids), dtype=np.str_)
906                    )
907
908        # Add _entity category for OST compatibility
909        self._add_entity_information(cif_file, entity_sequences)
910
911        # Convert to string
912        output = io.StringIO()
913        cif_file.write(output)
914        return output.getvalue()
915
916    def dockq(self, native: "MolecularComplex") -> Any:
917        """Compute DockQ score against native structure.
918
919        Args:
920            native: Native MolecularComplex to compute DockQ against
921
922        Returns:
923            DockQ result containing score and alignment information
924        """
925        # Imports moved to top of file
926
927        # Convert both complexes to ProteinComplex format for DockQ computation
928        # This extracts only the protein portion and converts to PDB format
929        try:
930            self_pc = self.to_protein_complex()
931            native_pc = native.to_protein_complex()
932        except ValueError as e:
933            raise ValueError(
934                f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {e}"
935            )
936
937        # Normalize chain IDs for PDB compatibility
938        self_pc = self_pc.normalize_chain_ids_for_pdb()
939        native_pc = native_pc.normalize_chain_ids_for_pdb()
940
941        # Use the existing ProteinComplex.dockq() method
942        try:
943            dockq_result = self_pc.dockq(native_pc)
944            return dockq_result
945        except Exception:
946            # Fallback to manual DockQ computation if ProteinComplex.dockq() fails
947            return self._compute_dockq_manual(native)
948
949    def _compute_dockq_manual(self, native: "MolecularComplex") -> Any:
950        """Manual DockQ computation fallback."""
951        # Imports moved to top of file
952
953        # Convert both complexes to ProteinComplex format
954        try:
955            self_pc = self.to_protein_complex()
956            native_pc = native.to_protein_complex()
957        except ValueError as e:
958            raise ValueError(
959                f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {e}"
960            )
961
962        # Normalize chain IDs for PDB compatibility
963        self_pc = self_pc.normalize_chain_ids_for_pdb()
964        native_pc = native_pc.normalize_chain_ids_for_pdb()
965
966        # Write temporary PDB files and run DockQ
967        with TemporaryDirectory() as tdir:
968            dir_path = Path(tdir)
969            self_pdb = dir_path / "self.pdb"
970            native_pdb = dir_path / "native.pdb"
971
972            # Write PDB files
973            self_pc.to_pdb(self_pdb)
974            native_pc.to_pdb(native_pdb)
975
976            # Run DockQ
977            try:
978                output = check_output(["DockQ", str(self_pdb), str(native_pdb)])
979                output_text = output.decode()
980
981                # Parse DockQ output
982                lines = output_text.split("\n")
983
984                # Find the total DockQ score
985                dockq_score = None
986                for line in lines:
987                    if "Total DockQ" in line:
988                        match = re.search(r"Total DockQ.*: ([\d.]+)", line)
989                        if match:
990                            dockq_score = float(match.group(1))
991                            break
992
993                if dockq_score is None:
994                    # Try to find individual DockQ scores
995                    for line in lines:
996                        if line.startswith("DockQ") and ":" in line:
997                            try:
998                                dockq_score = float(line.split(":")[1].strip())
999                                break
1000                            except (ValueError, IndexError):
1001                                continue
1002
1003                if dockq_score is None:
1004                    raise ValueError("Could not parse DockQ score from output")
1005
1006                # Return a simple result structure
1007                return {
1008                    "total_dockq": dockq_score,
1009                    "raw_output": output_text,
1010                    "aligned": self,  # Return self as aligned structure
1011                }
1012
1013            except FileNotFoundError:
1014                raise RuntimeError(
1015                    "DockQ is not installed. Please install DockQ to use this method."
1016                )
1017            except Exception as e:
1018                raise RuntimeError(f"DockQ computation failed: {e}")
1019
1020    def rmsd(self, target: "MolecularComplex", **kwargs) -> float:
1021        """Compute RMSD against target structure.
1022
1023        Args:
1024            target: Target MolecularComplex to compute RMSD against
1025            **kwargs: Additional arguments passed to compute_rmsd
1026
1027        Returns:
1028            float: RMSD value between the two structures
1029        """
1030        # Imports moved to top of file
1031
1032        # Ensure both complexes have the same number of tokens
1033        if len(self) != len(target):
1034            raise ValueError(
1035                f"Complexes must have the same number of tokens: {len(self)} vs {len(target)}"
1036            )
1037
1038        # Extract center positions for each token (using centroid of atoms)
1039        mobile_coords = []
1040        target_coords = []
1041        atom_mask = []
1042
1043        for i in range(len(self)):
1044            # Get atom positions for this token
1045            mobile_start, mobile_end = self.token_to_atoms[i]
1046            target_start, target_end = target.token_to_atoms[i]
1047
1048            # Extract atom positions
1049            mobile_atoms = self.atom_positions[mobile_start:mobile_end]
1050            target_atoms = target.atom_positions[target_start:target_end]
1051
1052            # Check if both tokens have atoms
1053            if len(mobile_atoms) == 0 or len(target_atoms) == 0:
1054                # Skip tokens with no atoms
1055                continue
1056
1057            # For simplicity, use the centroid of atoms as the representative position
1058            mobile_center = mobile_atoms.mean(axis=0)
1059            target_center = target_atoms.mean(axis=0)
1060
1061            mobile_coords.append(mobile_center)
1062            target_coords.append(target_center)
1063            atom_mask.append(True)
1064
1065        if len(mobile_coords) == 0:
1066            raise ValueError("No valid atoms found for RMSD computation")
1067
1068        # Convert to tensors
1069        mobile_tensor = torch.from_numpy(np.stack(mobile_coords, axis=0)).unsqueeze(
1070            0
1071        )  # [1, N, 3]
1072        target_tensor = torch.from_numpy(np.stack(target_coords, axis=0)).unsqueeze(
1073            0
1074        )  # [1, N, 3]
1075        mask_tensor = torch.tensor(atom_mask, dtype=torch.bool).unsqueeze(0)  # [1, N]
1076
1077        # Compute RMSD using existing infrastructure
1078        rmsd_value = compute_rmsd(
1079            mobile=mobile_tensor,
1080            target=target_tensor,
1081            atom_exists_mask=mask_tensor,
1082            reduction="batch",
1083            **kwargs,
1084        )
1085
1086        return float(rmsd_value)
1087
1088    def lddt_ca(self, target: "MolecularComplex", **kwargs) -> float:
1089        """Compute LDDT score against target structure.
1090
1091        Args:
1092            target: Target MolecularComplex to compute LDDT against
1093            **kwargs: Additional arguments passed to compute_lddt
1094
1095        Returns:
1096            float: LDDT value between the two structures
1097        """
1098        # Imports moved to top of file
1099
1100        # Ensure both complexes have the same number of tokens
1101        if len(self) != len(target):
1102            raise ValueError(
1103                f"Complexes must have the same number of tokens: {len(self)} vs {len(target)}"
1104            )
1105
1106        # Extract center positions for each token (using centroid of atoms)
1107        mobile_coords = []
1108        target_coords = []
1109        atom_mask = []
1110
1111        for i in range(len(self)):
1112            # Get atom positions for this token
1113            mobile_start, mobile_end = self.token_to_atoms[i]
1114            target_start, target_end = target.token_to_atoms[i]
1115
1116            # Extract atom positions
1117            mobile_atoms = self.atom_positions[mobile_start:mobile_end]
1118            target_atoms = target.atom_positions[target_start:target_end]
1119
1120            # Check if both tokens have atoms
1121            if len(mobile_atoms) == 0 or len(target_atoms) == 0:
1122                # Skip tokens with no atoms
1123                mobile_coords.append(np.full(3, np.nan))
1124                target_coords.append(np.full(3, np.nan))
1125                atom_mask.append(False)
1126                continue
1127
1128            # For simplicity, use the centroid of atoms as the representative position
1129            mobile_center = mobile_atoms.mean(axis=0)
1130            target_center = target_atoms.mean(axis=0)
1131
1132            mobile_coords.append(mobile_center)
1133            target_coords.append(target_center)
1134            atom_mask.append(True)
1135
1136        if not any(atom_mask):
1137            raise ValueError("No valid atoms found for LDDT computation")
1138
1139        # Convert to tensors
1140        mobile_tensor = torch.from_numpy(np.stack(mobile_coords, axis=0)).unsqueeze(
1141            0
1142        )  # [1, N, 3]
1143        target_tensor = torch.from_numpy(np.stack(target_coords, axis=0)).unsqueeze(
1144            0
1145        )  # [1, N, 3]
1146        mask_tensor = torch.tensor(atom_mask, dtype=torch.bool).unsqueeze(0)  # [1, N]
1147
1148        # Compute LDDT using existing infrastructure
1149        lddt_value = compute_lddt(
1150            all_atom_pred_pos=mobile_tensor,
1151            all_atom_positions=target_tensor,
1152            all_atom_mask=mask_tensor,
1153            per_residue=False,  # Return overall LDDT score
1154            **kwargs,
1155        )
1156
1157        return float(lddt_value)
1158
1159    def state_dict(self):
1160        """This state dict is optimized for storage, so it turns things to fp16 whenever
1161        possible and converts numpy arrays to lists for JSON serialization.
1162        """
1163        dct = {k: v for k, v in vars(self).items()}
1164        for k, v in dct.items():
1165            if isinstance(v, np.ndarray):
1166                match v.dtype:
1167                    case np.int64:
1168                        dct[k] = v.astype(np.int32).tolist()
1169                    case np.float64 | np.float32:
1170                        dct[k] = v.astype(np.float16).tolist()
1171                    case _:
1172                        dct[k] = v.tolist()
1173            elif isinstance(v, MolecularComplexMetadata):
1174                dct[k] = asdict(v)
1175
1176        return dct
1177
1178    def to_blob(self) -> bytes:
1179        return brotli.compress(msgpack.dumps(self.state_dict()), quality=5)
1180
1181    @classmethod
1182    def from_state_dict(cls, dct):
1183        for k, v in dct.items():
1184            if isinstance(v, list) and k in [
1185                "atom_positions",
1186                "atom_elements",
1187                "atom_names",
1188                "atom_hetero",
1189                "token_to_atoms",
1190                "chain_id",
1191                "plddt",
1192            ]:
1193                dct[k] = np.array(v)
1194
1195        for k, v in dct.items():
1196            if isinstance(v, np.ndarray):
1197                if k in ["atom_positions", "plddt"]:
1198                    dct[k] = v.astype(np.float32)
1199                elif k in ["token_to_atoms", "chain_id"]:
1200                    dct[k] = (

Showing the first 1,200 of 1227 lines. Download the file for the rest.