|
|
|
|
| """Utilities for manipulating chemical components data.""" |
|
|
| from collections.abc import Iterable, Mapping, Sequence |
| import dataclasses |
| import functools |
| from typing import Self |
|
|
| from flax_model.alphafold3.constants import chemical_components |
| from flax_model.alphafold3.constants import residue_names |
| from flax_model.alphafold3.structure import mmcif |
| import rdkit.Chem as rd_chem |
|
|
|
|
| @dataclasses.dataclass(frozen=True) |
| class ChemCompEntry: |
| """Items of _chem_comp category. |
| |
| For the full list of items and their semantics see |
| http://mmcif.rcsb.org/dictionaries/mmcif_pdbx_v50.dic/Categories/chem_comp.html |
| """ |
|
|
| type: str |
| name: str = '?' |
| pdbx_synonyms: str = '?' |
| formula: str = '?' |
| formula_weight: str = '?' |
| mon_nstd_flag: str = '?' |
| pdbx_smiles: str | None = None |
|
|
| def __post_init__(self): |
| for field, value in vars(self).items(): |
| if not value and value is not None: |
| raise ValueError(f"{field} value can't be an empty string.") |
|
|
| def extends(self, other: Self) -> bool: |
| """Checks whether this ChemCompEntry extends another one.""" |
| for field, value in vars(self).items(): |
| other_value = getattr(other, field) |
| if _value_is_missing(other_value): |
| continue |
| if value != other_value: |
| return False |
| return True |
|
|
| @property |
| def rdkit_mol(self) -> rd_chem.Mol: |
| """Returns an RDKit Mol, created via RDKit from entry SMILES string.""" |
| if not self.pdbx_smiles: |
| raise ValueError('Cannot construct RDKit Mol with empty pdbx_smiles') |
| return rd_chem.MolFromSmiles(self.pdbx_smiles) |
|
|
|
|
| _REQUIRED_MMCIF_COLUMNS = ('_chem_comp.id', '_chem_comp.type') |
|
|
|
|
| class MissingChemicalComponentsDataError(Exception): |
| """Raised when chemical components data is missing from an mmCIF.""" |
|
|
|
|
| @dataclasses.dataclass(frozen=True) |
| class ChemicalComponentsData: |
| """Extra information for chemical components occurring in mmCIF. |
| |
| Fields: |
| chem_comp: A mapping from _chem_comp.id to associated items in the |
| chem_comp category. |
| """ |
|
|
| chem_comp: Mapping[str, ChemCompEntry] |
|
|
| @classmethod |
| def from_mmcif( |
| cls, cif: mmcif.Mmcif, fix_mse: bool, fix_unknown_dna: bool |
| ) -> Self: |
| """Constructs an instance of ChemicalComponentsData from an Mmcif object.""" |
| for col in _REQUIRED_MMCIF_COLUMNS: |
| if col not in cif: |
| raise MissingChemicalComponentsDataError(col) |
|
|
| id_ = cif['_chem_comp.id'] |
| type_ = cif['_chem_comp.type'] |
| name = cif.get('_chem_comp.name', ['?'] * len(id_)) |
| synonyms = cif.get('_chem_comp.pdbx_synonyms', ['?'] * len(id_)) |
| formula = cif.get('_chem_comp.formula', ['?'] * len(id_)) |
| weight = cif.get('_chem_comp.formula_weight', ['?'] * len(id_)) |
| mon_nstd_flag = cif.get('_chem_comp.mon_nstd_flag', ['?'] * len(id_)) |
| smiles = cif.get('_chem_comp.pdbx_smiles', ['?'] * len(id_)) |
| smiles = [None if s == '?' else s for s in smiles] |
|
|
| chem_comp = { |
| component_name: ChemCompEntry(*entry) |
| for component_name, *entry in zip( |
| id_, type_, name, synonyms, formula, weight, mon_nstd_flag, smiles |
| ) |
| } |
|
|
| if fix_mse and 'MSE' in chem_comp: |
| if 'MET' not in chem_comp: |
| chem_comp['MET'] = ChemCompEntry( |
| type='L-PEPTIDE LINKING', |
| name='METHIONINE', |
| pdbx_synonyms='?', |
| formula='C5 H11 N O2 S', |
| formula_weight='149.211', |
| mon_nstd_flag='y', |
| pdbx_smiles=None, |
| ) |
|
|
| if fix_unknown_dna and 'N' in chem_comp: |
| |
| if 'DN' not in chem_comp: |
| chem_comp['DN'] = ChemCompEntry( |
| type='DNA LINKING', |
| name="UNKNOWN 2'-DEOXYNUCLEOTIDE", |
| pdbx_synonyms='?', |
| formula='C5 H11 O6 P', |
| formula_weight='198.111', |
| mon_nstd_flag='y', |
| pdbx_smiles=None, |
| ) |
|
|
| return ChemicalComponentsData(chem_comp) |
|
|
| def to_mmcif_dict(self) -> Mapping[str, Sequence[str]]: |
| """Returns chemical components data as a dict suitable for `mmcif.Mmcif`.""" |
| mmcif_dict = {} |
|
|
| mmcif_fields = set() |
| for entry in self.chem_comp.values(): |
| for field, value in vars(entry).items(): |
| if value: |
| mmcif_fields.add(field) |
| chem_comp_ids = [] |
| for component_id in sorted(self.chem_comp): |
| entry = self.chem_comp[component_id] |
| chem_comp_ids.append(component_id) |
| for field in mmcif_fields: |
| mmcif_dict.setdefault(f'_chem_comp.{field}', []).append( |
| getattr(entry, field) or '?' |
| ) |
| if chem_comp_ids: |
| mmcif_dict['_chem_comp.id'] = chem_comp_ids |
| return mmcif_dict |
|
|
|
|
| def _value_is_missing(value: str) -> bool: |
| return not value or value in ('.', '?') |
|
|
|
|
| def get_data_for_ccd_components( |
| ccd: chemical_components.Ccd, |
| chemical_component_ids: Iterable[str], |
| populate_pdbx_smiles: bool = False, |
| ) -> ChemicalComponentsData: |
| """Returns `ChemicalComponentsData` for chemical components known by PDB.""" |
| chem_comp = {} |
| for chemical_component_id in chemical_component_ids: |
| chem_data = chemical_components.component_name_to_info( |
| ccd=ccd, res_name=chemical_component_id |
| ) |
| if not chem_data: |
| continue |
| chem_comp[chemical_component_id] = ChemCompEntry( |
| type=chem_data.type, |
| name=chem_data.name, |
| pdbx_synonyms=chem_data.pdbx_synonyms, |
| formula=chem_data.formula, |
| formula_weight=chem_data.formula_weight, |
| mon_nstd_flag=chem_data.mon_nstd_flag, |
| pdbx_smiles=( |
| chem_data.pdbx_smiles or None if populate_pdbx_smiles else None |
| ), |
| ) |
| return ChemicalComponentsData(chem_comp=chem_comp) |
|
|
|
|
| def populate_missing_ccd_data( |
| ccd: chemical_components.Ccd, |
| chemical_components_data: ChemicalComponentsData, |
| chemical_component_ids: Iterable[str] | None = None, |
| populate_pdbx_smiles: bool = False, |
| ) -> ChemicalComponentsData: |
| """Populates missing data for the chemical components from CCD. |
| |
| Args: |
| ccd: The chemical components database. |
| chemical_components_data: ChemicalComponentsData to populate missing values |
| for. This function doesn't modify the object, extended version is provided |
| as a return value. |
| chemical_component_ids: chemical components to populate missing values for. |
| If not specified, the function will consider all chemical components which |
| are already present in `chemical_components_data`. |
| populate_pdbx_smiles: whether to populate `pdbx_smiles` field using SMILES |
| descriptors from _pdbx_chem_comp_descriptor CCD table. If CCD provides |
| multiple SMILES strings, any of them could be used. |
| |
| Returns: |
| New instance of ChemicalComponentsData without missing values for CCD |
| entries. |
| """ |
| if chemical_component_ids is None: |
| chemical_component_ids = chemical_components_data.chem_comp.keys() |
|
|
| ccd_data = get_data_for_ccd_components( |
| ccd, chemical_component_ids, populate_pdbx_smiles |
| ) |
| chem_comp = dict(chemical_components_data.chem_comp) |
| for component_id, ccd_entry in ccd_data.chem_comp.items(): |
| if component_id not in chem_comp: |
| chem_comp[component_id] = ccd_entry |
| else: |
| already_specified_fields = { |
| field: value |
| for field, value in vars(chem_comp[component_id]).items() |
| if not _value_is_missing(value) |
| } |
| chem_comp[component_id] = ChemCompEntry( |
| **{**vars(ccd_entry), **already_specified_fields} |
| ) |
| return ChemicalComponentsData(chem_comp=chem_comp) |
|
|
|
|
| def get_all_atoms_in_entry( |
| ccd: chemical_components.Ccd, res_name: str |
| ) -> Mapping[str, Sequence[str]]: |
| """Get all possible atoms and bonds for this residue in a standard order. |
| |
| Args: |
| ccd: The chemical components dictionary. |
| res_name: Full CCD name. |
| |
| Returns: |
| A dictionary table of the atoms and bonds for this residue in this residue |
| type. |
| """ |
| |
| |
| if res_name == 'UNK': |
| res_name = 'GLY' |
| ccd_data = ccd.get(res_name) |
| if not ccd_data: |
| raise ValueError(f'Unknown residue type {res_name}') |
|
|
| keys = ( |
| '_chem_comp_atom.atom_id', |
| '_chem_comp_atom.type_symbol', |
| '_chem_comp_bond.atom_id_1', |
| '_chem_comp_bond.atom_id_2', |
| ) |
|
|
| |
| if res_name == 'PRO': |
| res_atoms = {key: [*ccd_data.get(key, [])] for key in keys} |
| res_atoms['_chem_comp_atom.atom_id'].extend(['H2', 'H3']) |
| res_atoms['_chem_comp_atom.type_symbol'].extend(['H', 'H']) |
| res_atoms['_chem_comp_bond.atom_id_1'].extend(['N', 'N']) |
| res_atoms['_chem_comp_bond.atom_id_2'].extend(['H2', 'H3']) |
| elif res_name in residue_names.PROTEIN_TYPES_WITH_UNKNOWN: |
| res_atoms = {key: [*ccd_data.get(key, [])] for key in keys} |
| res_atoms['_chem_comp_atom.atom_id'].append('H3') |
| res_atoms['_chem_comp_atom.type_symbol'].append('H') |
| res_atoms['_chem_comp_bond.atom_id_1'].append('N') |
| res_atoms['_chem_comp_bond.atom_id_2'].append('H3') |
| else: |
| res_atoms = {key: ccd_data.get(key, []) for key in keys} |
|
|
| return res_atoms |
|
|
|
|
| @functools.lru_cache(maxsize=128) |
| def get_res_atom_names(ccd: chemical_components.Ccd, res_name: str) -> set[str]: |
| """Gets the names of the atoms in a given CCD residue.""" |
| atoms = get_all_atoms_in_entry(ccd, res_name)['_chem_comp_atom.atom_id'] |
| return set(atoms) |
|
|