import io
import logging
import os
from abc import ABC
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Literal
import biotite.structure as struc
import numpy as np
from biotite.structure import AtomArray
from biotite.structure.io import pdbx
from rdkit import Chem
from rdkit.Chem import AllChem
import atomworks.io.transforms.atom_array as ta
from atomworks.common import KeyToIntMapper, exists
from atomworks.constants import (
CCD_MIRROR_PATH,
STANDARD_AA_ONE_LETTER,
STANDARD_DNA_ONE_LETTER,
STANDARD_RNA,
UNKNOWN_LIGAND,
)
from atomworks.enums import ChainType, ChainTypeInfo
from atomworks.io import parse
from atomworks.io.config import ParseConfig
from atomworks.io.parser import STANDARD_PARSER_ARGS
from atomworks.io.template import add_missing_atoms_for_chain, infer_bonds_from_residue_names
from atomworks.io.tools.fasta import (
infer_chain_type_from_one_letter,
one_letter_to_ccd_code,
split_generalized_fasta_sequence,
)
from atomworks.io.tools.rdkit import atom_array_from_rdkit, inchi_to_rdkit
from atomworks.io.utils.annotator import ensure_annotations
from atomworks.io.utils.bonds import (
get_coarse_graph_as_nodes_and_edges,
get_connected_nodes,
hash_atom_array,
)
from atomworks.io.utils.ccd import (
atom_array_from_ccd_code,
check_ccd_codes_are_available,
get_chain_type_from_ccd_code,
get_chem_comp_type,
parse_ccd_cif,
)
from atomworks.io.utils.chain import create_chain_id_generator
from atomworks.io.utils.io_utils import CIF_LIKE_EXTENSIONS, read_any
from atomworks.io.utils.query import AtomSelection
logger = logging.getLogger("atomworks.io")
[docs]
class ChemicalComponent(ABC): # noqa: B024
[docs]
def as_dict(self) -> dict:
return asdict(self)
[docs]
@staticmethod
def from_dict(args_dict: dict) -> "ChemicalComponent":
if "seq" in args_dict:
args_dict = {k: v for k, v in args_dict.items() if k != "is_polymer"}
return SequenceComponent(**args_dict)
elif "smiles" in args_dict:
return SmilesComponent(**args_dict)
elif "inchi" in args_dict:
return InChIComponent(**args_dict)
elif "path" in args_dict and args_dict["path"].endswith(".sdf"):
return SDFComponent(**args_dict)
elif "path" in args_dict and any(extension in args_dict["path"] for extension in CIF_LIKE_EXTENSIONS):
return CIFOrPDBFileComponent(**args_dict)
elif "ccd_code" in args_dict:
return CCDComponent(**args_dict)
else:
raise ValueError(f"Unknown chemical component type: {args_dict=}")
[docs]
@dataclass
class SequenceComponent(ChemicalComponent):
seq: str | list[str]
chain_type: ChainType | None = None
chain_id: str | None = None
include_bonds: bool = True
msa_path: os.PathLike | None = None
[docs]
@staticmethod
def infer_chain_type(seq: str | list[str]) -> ChainType:
"""Infer chain type from sequence notation."""
return infer_chain_type_from_one_letter(seq)
[docs]
@staticmethod
def assert_valid_chain_type(seq: list[str], chain_type: ChainType, allow_other: bool = False) -> bool:
"""Asserts that all the CCD codes in the sequence are valid for the given chain type.
Args:
seq (list[str]): List of three-letter CCD codes.
chain_type (ChainType): The chain type to check against.
allow_other (bool): If True, allow non-CCD codes (e.g., custom NCAA) to be valid.
Ignore non-CCD codes (e.g., custom NCAA) which are presumed to be valid (and are mapped to "other")
"""
ccd_codes = set(seq)
chem_comp_types = {get_chem_comp_type(ccd_code) for ccd_code in ccd_codes}
if allow_other:
chem_comp_types.discard("OTHER")
valid_chem_comp_types = ChainTypeInfo.VALID_CHEM_COMP_TYPES.get(chain_type, chem_comp_types)
if not chem_comp_types.issubset(valid_chem_comp_types):
raise ValueError(f"Invalid {chain_type=} for {chem_comp_types=}. Valid are {valid_chem_comp_types=}")
[docs]
@staticmethod
def from_seq(
seq: str | list[str], *, chain_type: ChainType | str = None, is_polymer: bool | None = None
) -> "SequenceComponent":
chain_type = chain_type or SequenceComponent.infer_chain_type(seq)
if chain_type in ChainTypeInfo.PROTEINS:
return Protein(seq=seq, chain_type=chain_type)
elif chain_type == ChainType.RNA:
return RNA(seq=seq, chain_type=chain_type)
elif chain_type == ChainType.DNA:
return DNA(seq=seq, chain_type=chain_type)
else:
return SequenceComponent(seq=seq, chain_type=chain_type)
@property
def is_polymer(self) -> bool:
return self.chain_type.is_polymer()
def __post_init__(self):
# If the chain type is not provided, infer it from the sequence
self.chain_type = self.chain_type or SequenceComponent.infer_chain_type(self.seq)
self.chain_type = ChainType.as_enum(self.chain_type)
# If the sequence is a string, split it into a list of one-letter codes
if isinstance(self.seq, str):
self.seq = split_generalized_fasta_sequence(self.seq)
# Process sequence into CCD codes
if isinstance(self.seq, str):
self.seq = split_generalized_fasta_sequence(self.seq)
self.seq = one_letter_to_ccd_code(self.seq, self.chain_type, check_ccd_codes=False)
# Validate chain type
SequenceComponent.assert_valid_chain_type(self.seq, self.chain_type, allow_other=True)
[docs]
@dataclass
class LigandComponent(ChemicalComponent):
def __post_init__(self):
self.chain_type = ChainType.as_enum(self.chain_type)
if self.is_polymer:
raise ValueError(f"{self.__class__.__name__} must have 'is_polymer=False'")
if self.chain_type != ChainType.NON_POLYMER:
raise ValueError(f"{self.__class__.__name__} must have 'chain_type=ChainType.NON_POLYMER'")
[docs]
@dataclass
class CCDComponent(LigandComponent):
ccd_code: str
chain_type: ChainType | str = "non-polymer"
is_polymer: bool = False
chain_id: str | None = None
[docs]
@dataclass
class SmilesComponent(LigandComponent):
smiles: str
chain_type: ChainType | str = "non-polymer"
is_polymer: bool = False
chain_id: str | None = None
res_name: str = UNKNOWN_LIGAND
[docs]
@dataclass
class InChIComponent(LigandComponent):
inchi: str
chain_type: ChainType | str = "non-polymer"
is_polymer: bool = False
chain_id: str | None = None
res_name: str = UNKNOWN_LIGAND
[docs]
@dataclass
class SDFComponent(LigandComponent):
path: os.PathLike | io.StringIO
chain_type: ChainType | str = "non-polymer"
is_polymer: bool = False
chain_id: str | None = None
res_name: str = UNKNOWN_LIGAND
[docs]
@dataclass
class CIFOrPDBFileComponent(ChemicalComponent):
path: os.PathLike | io.StringIO
msa_paths: dict[str, os.PathLike] | None = None
custom_parse_kwargs: dict[str, Any] | None = None
chain_id: str | None = None
def __post_init__(self):
"""Initialize the component by parsing the structure file."""
if self._is_ccd_cif_file():
self._parse_ccd_style_cif()
else:
self._parse_standard_pdb_or_cif()
# Validate and apply chain_id if provided
self._validate_and_apply_chain_id()
def _is_ccd_cif_file(self) -> bool:
"""Check if we are given a CCD CIF file, which by convention includes the _chem_comp_atom field but not the atom_site field"""
# If not a CIF file, return False
cif = read_any(self.path)
if not isinstance(cif, pdbx.CIFFile | pdbx.BinaryCIFFile):
return False
keys = list(cif.block.keys())
has_atom_site = "atom_site" in keys
has_chem_comp_atom = "chem_comp_atom" in keys
return has_chem_comp_atom and not has_atom_site
def _parse_ccd_style_cif(self) -> None:
"""Parse a CCD-style CIF file."""
if self.custom_parse_kwargs is not None:
raise ValueError("Custom parse kwargs are not supported for CCD CIF files.")
logger.warning(
f"CCD CIF file detected: {self.path}. "
"This file will be parsed as a CCD CIF file rather than a regular CIF file "
"(e.g., with an `atom_site` category)."
)
self.atom_array = parse_ccd_cif(read_any(self.path))
self.atom_array.set_annotation("is_polymer", np.full(len(self.atom_array), False))
self.chain_ids = np.unique(self.atom_array.chain_id)
# Set occupancy to all 1s since we presumably want to predict everything
self.atom_array.occupancy = np.full(len(self.atom_array), 1.0)
def _parse_standard_pdb_or_cif(self) -> None:
"""Parse a standard PDB or CIF structure file."""
if self.custom_parse_kwargs is None:
self.custom_parse_kwargs = {}
# We add missing atoms later to the fully-concatenated inference AtomArray.
parse_kwargs = {
**STANDARD_PARSER_ARGS,
"add_missing_atoms": False,
"return_atom_array_plus": False,
} | self.custom_parse_kwargs
if parse_kwargs["add_missing_atoms"]:
logger.warning(
"Missing atoms will be added later to the fully-concatenated inference AtomArray. "
"It is recommended to set this argument to False in initial CIFOrPDBFileComponent parsing."
)
parsing_results = parse(self.path, config=ParseConfig(**parse_kwargs))
if "assemblies" in parsing_results:
assemblies = parsing_results["assemblies"]
# We will keep only the first assembly that was parsed
first_assembly_id = next(iter(assemblies.keys()))
if len(assemblies) > 1:
logger.warning(
f"Multiple biological assemblies found in {self.path} and none were specified. "
f"Only the first assembly (assembly_id={first_assembly_id}) will be used for inference. "
"If you would like to use a different assembly, please specify this in the `parse_kwargs`."
)
atom_array_stack = assemblies[first_assembly_id]
else:
atom_array_stack = parsing_results["asym_unit"]
if atom_array_stack.stack_depth() > 1:
logger.warning(
f"Multiple models found in {self.path}. Only the first model will be used for inference. "
"If you would like to use a different model, please specify this in the `parse_kwargs`."
)
structure_file_atom_array = atom_array_stack[0]
self.chain_ids = np.unique(structure_file_atom_array.chain_id)
self.atom_array = structure_file_atom_array
def _validate_and_apply_chain_id(self) -> None:
"""Validate chain_id can be applied and assign it if provided.
Raises:
ValueError: If chain_id is provided but structure has multiple molecules.
"""
if self.chain_id is None:
return
# Check if structure is a single connected molecule using bond connectivity
# Use chain_id level for the coarse graph
nodes, edges = get_coarse_graph_as_nodes_and_edges(self.atom_array, "chain_id")
connected_components = get_connected_nodes(nodes, edges)
if len(connected_components) > 1:
raise ValueError(
f"Cannot assign chain_id '{self.chain_id}' to CIF file '{self.path}': "
f"structure contains {len(connected_components)} disconnected molecules. "
f"chain_id can only be assigned when all atoms form a single connected molecule."
)
# Assign the chain_id to all atoms
self.atom_array.chain_id = np.full(len(self.atom_array), self.chain_id)
self.chain_ids = np.array([self.chain_id])
[docs]
@dataclass
class Polymer(SequenceComponent):
is_polymer: bool = True
[docs]
@dataclass
class Protein(SequenceComponent):
chain_type: ChainType = ChainType.POLYPEPTIDE_L
@staticmethod
def _valid_one_letter_codes() -> set[str]:
return set(STANDARD_AA_ONE_LETTER)
[docs]
@dataclass
class RNA(SequenceComponent):
chain_type: ChainType = ChainType.RNA
@staticmethod
def _valid_one_letter_codes() -> set[str]:
return set(STANDARD_RNA)
[docs]
@dataclass
class DNA(SequenceComponent):
chain_type: ChainType = ChainType.DNA
@staticmethod
def _valid_one_letter_codes() -> set[str]:
return set(STANDARD_DNA_ONE_LETTER)
[docs]
@dataclass
class Peptide(SequenceComponent):
chain_type: ChainType = ChainType.POLYPEPTIDE_L
is_polymer: bool = False
[docs]
def read_chai_fasta(fasta_path: Path) -> list[ChemicalComponent]:
from biotite.sequence.io.fasta import FastaFile
fasta = FastaFile.read(fasta_path)
components = []
for metadata, content in fasta.items():
metadata = metadata.lower()
if metadata.startswith("ligand"):
components.append(SmilesComponent(smiles=content))
elif metadata.endswith(".sdf"):
components.append(sdf_to_annotated_atom_array(path=content))
else:
if "protein" in metadata:
components.append(Protein(seq=content))
elif "rna" in metadata:
components.append(RNA(seq=content))
elif "dna" in metadata:
components.append(DNA(seq=content))
elif "peptide" in metadata:
components.append(Peptide(seq=content))
else:
components.append(SequenceComponent.from_seq(content))
return components
[docs]
def build_chain_atom_array(
seq: list[str],
chain_id: str,
chain_type: ChainType,
*,
include_bonds: bool = False,
ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH,
) -> AtomArray:
"""Build an AtomArray from a sequence of CCD codes for a single chain.
Calls :py:func:`add_missing_atoms_for_chain` directly to build complete chains
from CCD templates, then patches ``occupancy`` to ``1.0`` since every atom in
the result is conceptually present (not unresolved).
Args:
seq: Sequence of three-letter CCD codes.
chain_id: Chain ID for all residues.
chain_type: Chain type (determines ``is_polymer`` and other annotations).
include_bonds: If ``True``, infer bonds, remove leaving atoms, and fix charges/bond types
via :py:func:`infer_bonds_from_residue_names`. Defaults to ``False``.
ccd_mirror_path: Path to local CCD mirror.
Returns:
AtomArray with residue IDs ``1..len(seq)``, ``occupancy=1.0``, and ``b_factor=nan``.
"""
chain_type = ChainType.as_enum(chain_type)
templates, _ = add_missing_atoms_for_chain(
sequence=list(seq),
res_ids=list(range(1, len(seq) + 1)),
chain_id=chain_id,
chain_type=chain_type,
ccd_mirror_path=ccd_mirror_path,
)
atom_array = struc.concatenate(templates)
atom_array.set_annotation("occupancy", np.ones(len(atom_array)))
if include_bonds:
atom_array = infer_bonds_from_residue_names(
atom_array,
sanitize=True,
ccd_mirror_path=ccd_mirror_path,
)
return atom_array
[docs]
def sequence_to_annotated_atom_array(
seq: list[str],
chain_id: str,
*,
chain_type: ChainType | str = None,
include_bonds: bool = True,
ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH,
**kwargs,
) -> AtomArray:
if isinstance(seq, str):
seq = one_letter_to_ccd_code(
split_generalized_fasta_sequence(seq), chain_type=chain_type, check_ccd_codes=False
)
seq = np.asarray(seq)
chain_type = ChainType.as_enum(chain_type or SequenceComponent.infer_chain_type(seq))
# Ensure that the sequence is a valid combination of existing 3-letter CCD codes
ccd_codes_in_seq = set(seq)
if UNKNOWN_LIGAND in ccd_codes_in_seq:
raise ValueError(
f"Unknown ligand `{UNKNOWN_LIGAND}` found in sequence. If you want to pass a ligand, that "
f"is not in the CCD, use a SMILES string or SDF file instead."
)
check_ccd_codes_are_available(ccd_codes_in_seq, ccd_mirror_path=ccd_mirror_path, mode="raise")
return build_chain_atom_array(
seq,
chain_id,
chain_type,
include_bonds=include_bonds,
ccd_mirror_path=ccd_mirror_path,
)
[docs]
def smiles_to_annotated_atom_array(
smiles: str,
chain_id: str,
*,
chain_type: ChainType | str = "non-polymer",
is_polymer: bool = False,
backend: Literal["openbabel", "rdkit"] = "rdkit",
res_name: str = UNKNOWN_LIGAND,
) -> AtomArray:
if backend == "rdkit":
from atomworks.io.tools.rdkit import atom_array_from_rdkit, smiles_to_rdkit
mol = smiles_to_rdkit(smiles)
try:
# ... generate a conformer to keep the stereochemistry encoded in the SMILES
# NOTE: This may stall for 40ish seconds for some difficult molecules like HEM
# TODO: Migrate the timeout utils to atomworks.io so we can timeout here.
mol = Chem.AddHs(mol)
params = AllChem.ETKDGv3()
params.maxAttempts = 1
AllChem.EmbedMultipleConfs(mol, numConfs=1, params=params)
except Exception:
pass
array = atom_array_from_rdkit(mol)
elif backend == "openbabel":
raise NotImplementedError("Openbabel backend not yet implemented.")
else:
raise ValueError(f"Unknown backend: {backend=}")
# Update annotations
array.set_annotation("occupancy", np.ones(array.array_length()))
array.set_annotation("hetero", np.full(array.array_length(), True))
array.set_annotation("res_name", np.full(array.array_length(), res_name))
array.set_annotation("chain_id", np.full(array.array_length(), chain_id))
array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer))
array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8))
array.set_annotation("b_factor", np.full(array.array_length(), np.nan))
array.set_annotation("stereo", np.full(array.array_length(), "N"))
array.set_annotation("is_backbone_atom", np.full(array.array_length(), False))
return array
[docs]
def inchi_to_annotated_atom_array(
inchi: str,
chain_id: str,
*,
chain_type: ChainType | str = "non-polymer",
is_polymer: bool = False,
res_name: str = UNKNOWN_LIGAND,
) -> AtomArray:
"""Convert an InChI string to an annotated AtomArray.
Args:
inchi: The InChI string representing the molecule.
chain_id: The chain ID to assign.
chain_type: The chain type. Defaults to "non-polymer".
is_polymer: Whether the component is a polymer. Defaults to False.
res_name: The residue name. Defaults to UNKNOWN_LIGAND.
Returns:
AtomArray with standard ligand annotations.
"""
mol = inchi_to_rdkit(inchi)
try:
# ... generate a conformer
mol = Chem.AddHs(mol)
params = AllChem.ETKDGv3()
params.maxAttempts = 1
AllChem.EmbedMultipleConfs(mol, numConfs=1, params=params)
except Exception:
pass
array = atom_array_from_rdkit(mol)
# Update annotations
array.set_annotation("occupancy", np.ones(array.array_length()))
array.set_annotation("hetero", np.full(array.array_length(), True))
array.set_annotation("res_name", np.full(array.array_length(), res_name))
array.set_annotation("chain_id", np.full(array.array_length(), chain_id))
array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer))
array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8))
array.set_annotation("b_factor", np.full(array.array_length(), np.nan))
array.set_annotation("stereo", np.full(array.array_length(), "N"))
array.set_annotation("is_backbone_atom", np.full(array.array_length(), False))
return array
[docs]
def sdf_to_annotated_atom_array(
path: io.StringIO | os.PathLike,
chain_id: str,
*,
chain_type: ChainType | str = "non-polymer",
is_polymer: bool = False,
res_name: str = UNKNOWN_LIGAND,
backend: Literal["openbabel", "rdkit"] = "rdkit",
) -> AtomArray:
if backend == "rdkit":
from atomworks.io.tools.rdkit import atom_array_from_rdkit, sdf_to_rdkit
mol = sdf_to_rdkit(path)
array = atom_array_from_rdkit(mol)
elif backend == "openbabel":
raise NotImplementedError("Openbabel backend not yet implemented.")
else:
raise ValueError(f"Unknown backend: {backend=}")
# Update annotations
array.set_annotation("occupancy", np.ones(array.array_length()))
array.set_annotation("hetero", np.full(array.array_length(), True))
array.set_annotation("res_name", np.full(array.array_length(), res_name))
array.set_annotation("chain_id", np.full(array.array_length(), chain_id))
array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer))
array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8))
array.set_annotation("b_factor", np.full(array.array_length(), np.nan))
array.set_annotation("stereo", np.full(array.array_length(), "N"))
array.set_annotation("is_backbone_atom", np.full(array.array_length(), False))
return array
[docs]
def ccd_code_to_annotated_atom_array(
ccd_code: list[str],
chain_id: str,
*,
chain_type: ChainType | str = None,
is_polymer: bool | None = None,
ccd_mirror_path: os.PathLike = CCD_MIRROR_PATH,
) -> AtomArray:
check_ccd_codes_are_available([ccd_code], ccd_mirror_path=ccd_mirror_path, mode="raise")
# ... build the atom array
array = atom_array_from_ccd_code(ccd_code)
# ... set or infer chain type
chain_type = chain_type or get_chain_type_from_ccd_code(ccd_code)
is_polymer = is_polymer or chain_type.is_polymer()
# ... update annotations
array.set_annotation("occupancy", np.ones(array.array_length()))
array.set_annotation("hetero", np.full(array.array_length(), True))
array.set_annotation("res_name", np.full(array.array_length(), ccd_code))
array.set_annotation("chain_id", np.full(array.array_length(), chain_id))
array.set_annotation("is_polymer", np.full(array.array_length(), is_polymer))
array.set_annotation("chain_type", np.full(array.array_length(), ChainType.as_enum(chain_type), dtype=np.int8))
return array
[docs]
def assign_res_name_from_atom_array_hash(atom_array: AtomArray, hash_to_id: KeyToIntMapper) -> AtomArray:
"""Assigns a residue name to an array based on its hash.
The residue names will be assigned as `L:{id}` where `id` is a unique integer assigned to each hash.
Args:
ligand_array (AtomArray): The ligand array to assign a residue name to.
ligand_hash_to_id (KeyToIntMapper): A mapper from ligand hash to ligand ID.
"""
ligand_hash = hash_atom_array(atom_array, annotations=["element", "atom_name"], bond_order=True)
ligand_id = hash_to_id(ligand_hash)
atom_array.res_name = np.full(atom_array.array_length(), f"L:{ligand_id}")
return atom_array
[docs]
def standardize_component_keys(component_dict: dict) -> dict:
"""Standardize component dictionary keys for compatibility with AF3's inference API.
Maps:
- "sequence" -> "seq"
- "id" -> "chain_id"
"""
# Create a copy to avoid modifying the original
standardized = component_dict.copy()
# Handle sequence/seq mapping
if "sequence" in standardized and "seq" not in standardized:
standardized["seq"] = standardized.pop("sequence")
elif "sequence" in standardized and "seq" in standardized:
raise ValueError(f"Both 'sequence' and 'seq' are present in {standardized=}")
# Handle id/chain_id mapping
if "id" in standardized and "chain_id" not in standardized:
standardized["chain_id"] = standardized.pop("id")
return standardized
[docs]
def build_msa_paths_by_chain_id_from_component_list(components: list[ChemicalComponent]) -> dict[str, os.PathLike]:
"""Build a dictionary of MSA paths by chain ID from a list of ChemicalComponent objects.
The composed dictionary may be encoded as extra metadata in the CIF file, and ultimately loaded
into `chain_info` through `parse`.
"""
msa_paths_by_chain_id = {}
for component in components:
if hasattr(component, "msa_path") and component.msa_path is not None:
msa_paths_by_chain_id[component.chain_id] = component.msa_path
elif hasattr(component, "msa_paths") and component.msa_paths is not None:
for chain_id, msa_path in component.msa_paths.items():
msa_paths_by_chain_id[chain_id] = msa_path
return msa_paths_by_chain_id
def _finalize_inference_atom_array(atom_array: AtomArray) -> AtomArray:
"""Add standard post-concat annotations to a concatenated inference atom array."""
ensure_annotations(atom_array, "chem_comp_type", "atomic_number")
if "transformation_id" not in atom_array.get_annotation_categories():
atom_array.set_annotation("transformation_id", np.full(len(atom_array), "1"))
# Entity + ID annotations must come before IIDs (iid = id + "_" + transformation_id)
atom_array = ta.add_id_and_entity_annotations(atom_array)
atom_array = ta.add_iid_annotations(atom_array)
return atom_array
def _add_bonds_from_strings(
atom_array: AtomArray,
bond_strings: list[tuple[str, str]],
) -> AtomArray:
"""Add explicit covalent bonds from CHAIN/RESNAME/RESID/ATOMNAME string pairs.
Bonds are added as SINGLE bonds. Post-processing (leaving-atom removal, charge and
bond-type correction) is handled by a subsequent call to
:py:func:`~atomworks.io.template.infer_bonds_from_residue_names`.
Uses :py:class:`~atomworks.io.utils.query.AtomSelection` to parse bond strings,
supporting wildcards (``"*"``) and the same syntax as the rest of the selection API.
"""
if not bond_strings:
return atom_array
raw_bonds = []
for atom1_str, atom2_str in bond_strings:
idx1 = AtomSelection.from_selection_str(atom1_str).get_idxs(atom_array)
idx2 = AtomSelection.from_selection_str(atom2_str).get_idxs(atom_array)
if len(idx1) != 1 or len(idx2) != 1:
raise ValueError(
f"Bond specification must resolve to exactly one atom each: "
f"{atom1_str!r} → {len(idx1)} atoms, {atom2_str!r} → {len(idx2)} atoms"
)
raw_bonds.append((int(idx1[0]), int(idx2[0]), struc.BondType.SINGLE))
new_bonds = struc.BondList(atom_array.array_length(), np.array(raw_bonds, dtype=np.uint32))
atom_array.bonds = atom_array.bonds.merge(new_bonds) if atom_array.bonds is not None else new_bonds
return atom_array
[docs]
def components_to_atom_array(
components: list[ChemicalComponent | dict],
bonds: list[str] | None = None,
return_components: bool = False,
) -> AtomArray | list[ChemicalComponent]:
"""Build an AtomArray from a list of ChemicalComponent objects and supporting details (bonds).
Args:
components (list[ChemicalComponent | dict]): List of ChemicalComponent objects or dictionaries that can be
converted to ChemicalComponent objects using ChemicalComponent.from_dict().
bonds (list[str]): List of tuples of atom ids to be bonded. We will add them like spoof `struct_conn` entries,
ensuring that we remove leaving groups as appropriate. Bonds tuples must be in the format (1-indexed!):
```
(CHAIN_ID / RES_NAME / RES_ID / ATOM_NAME, CHAIN_ID / RES_NAME / RES_ID / ATOM_NAME)
```
e.g., [("A/THR/4/CG", "D/L:1/0/O13"), ("A/CYS/5/SG", "A/CYS/137/SG")]
return_components (bool): If True, return the components list as well as the AtomArray. Useful for e.g., mapping
components to generated chain IDs or inferred chain types.
NOTE: If manually specifying bonds, we recommend visualizing the bond graph with `matplotlib` to ensure that the bonds are correctly
NOTE: The res_id numbering follows the RCSB convention (1-indexed)
NOTE: Custom CCD entries can be registered using :py:func:`~atomworks.io.utils.ccd.register_custom_ccd_entry`
to override standard CCD definitions before calling this function.
Returns:
AtomArray: The assembled AtomArray, used for visualization or inference.
Raises:
ValueError: If there are duplicate chain_ids across input Components
ValueError: If there are duplicate chain_ids that correspond to non-identical molecular entities.
"""
standardized_components = []
for component in components:
if isinstance(component, dict):
# Standardize the keys
component = standardize_component_keys(component)
# If chain_id is a list, create copies for each chain_id
if "chain_id" in component and isinstance(component["chain_id"], list):
for single_chain_id in component["chain_id"]:
component_copy = component.copy()
component_copy["chain_id"] = single_chain_id
standardized_components.append(component_copy)
else:
standardized_components.append(component)
elif isinstance(component, ChemicalComponent):
standardized_components.append(component)
else:
raise ValueError(f"Unknown component type: {type(component)}")
# Ensure that all components are ChemicalComponent objects
components = [
ChemicalComponent.from_dict(component) if isinstance(component, dict) else component
for component in standardized_components
]
chain_ids = []
# Get existing chain ids
for component in components:
if hasattr(component, "chain_id") and exists(component.chain_id):
chain_ids.append(component.chain_id)
elif hasattr(component, "chain_ids") and exists(component.chain_ids):
chain_ids.extend(component.chain_ids)
# Raise an exception if there are duplicate chain_ids across input components
# Note that intra-component duplicates may still be present due to multiple transformations of the same asym_unit
if len(chain_ids) > len(set(chain_ids)):
duplicated_chain_ids = set()
for chain_id in chain_ids:
if chain_ids.count(chain_id) > 1:
duplicated_chain_ids.add(chain_id)
chain_counter = Counter(chain_ids)
duplicated_chain_ids = {chain_id for chain_id, count in chain_counter.items() if count > 1}
raise ValueError(
f"The following chain_ids were present in multiple input components: {duplicated_chain_ids}. "
f"Please rename chains to avoid this issue."
)
# Instantiate a chain id generator
chain_id_generator = create_chain_id_generator(chain_ids)
atom_arrays = []
ligand_hash_to_id = KeyToIntMapper() # ... to keep track of identical ligands
for component in components:
# CIFOrPDBFileComponents already have parsed AtomArrays
if isinstance(component, CIFOrPDBFileComponent):
atom_array = component.atom_array
if np.any(atom_array.chain_id == ""):
atom_array.chain_id = np.full(atom_array.array_length(), next(chain_id_generator))
logger.warning(
f"Chain ID was not set for {component.path}. "
f"The next available chain ID was assigned, assuming that this is a single-chain structure: {atom_array.chain_id[0]}"
)
atom_arrays.append(component.atom_array)
continue
component.chain_id = component.chain_id or next(chain_id_generator)
if isinstance(component, SequenceComponent):
# include_bonds=False: global bond inference runs after all chains are concatenated
atom_arrays.append(sequence_to_annotated_atom_array(**{**component.as_dict(), "include_bonds": False}))
elif isinstance(component, SmilesComponent):
ligand_array = smiles_to_annotated_atom_array(**component.as_dict())
if component.res_name == UNKNOWN_LIGAND:
ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id)
atom_arrays.append(ligand_array)
elif isinstance(component, InChIComponent):
ligand_array = inchi_to_annotated_atom_array(**component.as_dict())
if component.res_name == UNKNOWN_LIGAND:
ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id)
atom_arrays.append(ligand_array)
elif isinstance(component, CCDComponent):
atom_arrays.append(ccd_code_to_annotated_atom_array(**component.as_dict()))
elif isinstance(component, SDFComponent):
ligand_array = sdf_to_annotated_atom_array(**component.as_dict())
if component.res_name == UNKNOWN_LIGAND:
ligand_array = assign_res_name_from_atom_array_hash(ligand_array, ligand_hash_to_id)
atom_arrays.append(ligand_array)
else:
raise ValueError(f"Unknown chemical component type: {type(component)}")
# add required per-array annotations before concatenation so biotite does not fill
# missing annotations with defaults when arrays have heterogeneous annotation sets
for arr in atom_arrays:
if "b_factor" not in arr.get_annotation_categories():
arr.set_annotation("b_factor", np.full(arr.array_length(), np.nan))
if "transformation_id" not in arr.get_annotation_categories():
arr.set_annotation("transformation_id", np.full(arr.array_length(), "1"))
# ... concatenate all atom arrays into a single AtomArray
atom_array = struc.concatenate(atom_arrays)
# ... add explicit struct-conn bonds before infer_bonds_from_residue_names so they
# participate in leaving-atom removal and charge/bond-type correction
if bonds:
atom_array = _add_bonds_from_strings(atom_array, bonds)
# ... infer CCD bonds, remove leaving atoms, fix charges and bond types.
# Merges with existing bonds (including struct-conn bonds added above).
atom_array = infer_bonds_from_residue_names(
atom_array,
sanitize=True,
)
# ... remove hydrogens
atom_array = ta.remove_hydrogens(atom_array)
# ... add atomic_number, transformation_id, and all IID/entity/ID annotations.
# Must come AFTER bond inference so molecule_id reflects full bond connectivity.
atom_array = _finalize_inference_atom_array(atom_array)
# Raise an error if chain_ids with the same name correspond to different entities
for chain_id in np.unique(atom_array.chain_id):
subsetted_atom_array = atom_array[atom_array.chain_id == chain_id]
if len(np.unique(subsetted_atom_array.chain_entity)) > 1:
raise ValueError(
f"Chain ID {chain_id} corresponds to multiple non-identical molecular entities. "
f"Please ensure that each chain_id corresponds to only a single entity."
)
if return_components:
return atom_array, components
return atom_array