File size: 6,230 Bytes
b7b760f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183


"""Chirality detection and comparison."""

from collections.abc import Mapping

from absl import logging
from flax_model.alphafold3 import structure
from flax_model.alphafold3.constants import chemical_components
from flax_model.alphafold3.data.tools import rdkit_utils
import rdkit.Chem as rd_chem

_CHIRAL_ELEMENTS = frozenset({'C', 'S'})


def _find_chiral_centres(mol: rd_chem.Mol) -> dict[str, str]:
  """Find chiral centres and detect their chirality.

  Only elements listed in _CHIRAL_ELEMENTS are considered as centres.

  Args:
    mol: The molecule for which to detect chirality.

  Returns:
    Map from chiral centre atom names to identified chirality.
  """
  chiral_centres = rd_chem.FindMolChiralCenters(
      mol, force=True, includeUnassigned=False, useLegacyImplementation=True
  )
  atom_name_by_idx = {
      atom.GetIdx(): atom.GetProp('atom_name') for atom in mol.GetAtoms()
  }
  atom_chirality_by_name = {atom_name_by_idx[k]: v for k, v in chiral_centres}
  return {
      k: v
      for k, v in atom_chirality_by_name.items()
      if any(k[: len(el)].upper() == el for el in _CHIRAL_ELEMENTS)
  }


def _chiral_match(mol1: rd_chem.Mol, mol2: rd_chem.Mol) -> bool:
  """Compares chirality of two Mols. Mol1 can match a subset of mol2."""

  mol1_atom_names = {a.GetProp('atom_name') for a in mol1.GetAtoms()}
  mol2_atom_names = {a.GetProp('atom_name') for a in mol2.GetAtoms()}
  if mol1_atom_names != mol2_atom_names:
    if not mol1_atom_names.issubset(mol2_atom_names):
      raise ValueError('Mol1 atoms are not a subset of mol2 atoms.')

  mol1_chiral_centres = _find_chiral_centres(mol1)
  mol2_chiral_centres = _find_chiral_centres(mol2)
  if set(mol1_chiral_centres) != set(mol2_chiral_centres):
    if not set(mol1_chiral_centres).issubset(mol2_chiral_centres):
      return False
  chirality_matches = {
      centre_atom: chirality1 == mol2_chiral_centres[centre_atom]
      for centre_atom, chirality1 in mol1_chiral_centres.items()
      if '?' != mol2_chiral_centres[centre_atom]
  }
  return all(chirality_matches.values())


def _mol_from_ligand_struc(
    ligand_struc: structure.Structure,
    ref_mol: rd_chem.Mol,
) -> rd_chem.Mol | None:
  """Creates a Mol object from a ligand structure and reference mol."""

  if ligand_struc.num_residues(count_unresolved=True) > 1:
    raise ValueError('ligand_struc %s has more than one residue.')
  coords_by_atom_name = dict(zip(ligand_struc.atom_name, ligand_struc.coords))

  ref_mol = rdkit_utils.sanitize_mol(
      ref_mol,
      sort_alphabetically=False,
      remove_hydrogens=True,
  )

  mol = rd_chem.Mol(ref_mol)
  mol.RemoveAllConformers()

  atom_indices_to_remove = [
      a.GetIdx()
      for a in mol.GetAtoms()
      if a.GetProp('atom_name') not in coords_by_atom_name
  ]
  editable_mol = rd_chem.EditableMol(mol)
  # Remove indices from the largest to smallest, to avoid invalidating.
  for atom_idx in atom_indices_to_remove[::-1]:
    editable_mol.RemoveAtom(atom_idx)
  mol = editable_mol.GetMol()

  conformer = rd_chem.Conformer(mol.GetNumAtoms())
  for atom_idx, atom in enumerate(mol.GetAtoms()):
    atom_name = atom.GetProp('atom_name')
    coords = coords_by_atom_name[atom_name]
    conformer.SetAtomPosition(atom_idx, coords.tolist())
  mol.AddConformer(conformer)
  try:
    rd_chem.AssignStereochemistryFrom3D(mol)
  except RuntimeError as e:
    # Catch only this specific rdkit error.
    if 'Cannot normalize a zero length vector' in str(e):
      return None
    else:
      raise
  return mol


def _maybe_mol_from_ccd(res_name: str) -> rd_chem.Mol | None:
  """Creates a Mol object from CCD information if res_name is in the CCD."""
  ccd = chemical_components.Ccd()
  ccd_cif = ccd.get(res_name)
  if not ccd_cif:
    logging.warning('No ccd information for residue %s.', res_name)
    return None
  try:
    mol = rdkit_utils.mol_from_ccd_cif(ccd_cif, force_parse=False)
  except rdkit_utils.MolFromMmcifError as e:
    logging.warning('Failed to create mol from ccd for %s: %s', res_name, e)
    return None
  if mol is None:
    raise ValueError('Failed to create mol from ccd for %s.' % res_name)
  mol = rdkit_utils.sanitize_mol(
      mol,
      sort_alphabetically=False,
      remove_hydrogens=True,
  )
  return mol


def compare_chirality(
    test_struc: structure.Structure,
    ref_mol_by_chain: Mapping[str, rd_chem.Mol] | None = None,
) -> dict[str, bool]:
  """Compares chirality of ligands in a structure with reference molecules.

  We do not enforce that ligand atoms exactly match, only that the ligand atoms
  and chiral centres are a subset of those in ref mol.

  Args:
    test_struc: The structure for whose ligands to match chirality.
    ref_mol_by_chain: Optional dictionary mapping chain IDs to mol objects with
      conformers to compare against. If this is not provided, the comparison is
      to the corresponding ligands in the CCD if the ligand residue name is in
      the CCD.

  Returns:
    Dictionary mapping chain id to whether chirality mismatches the ref mol.
    Only single residue ligands where reference molecules are available are
    compared.
  """
  ref_mol_by_chain = ref_mol_by_chain or {}
  test_struc = test_struc.filter_to_entity_type(ligand=True)
  name = test_struc.name
  chiral_match_by_chain_id = {}
  for chain_id in test_struc.chains:
    chain_struc = test_struc.filter(chain_id=chain_id)
    # Only compare single-residue ligands.
    if chain_struc.num_residues(count_unresolved=True) > 1:
      logging.warning('%s: Chain %s has >1 residues. Skipping.', name, chain_id)
      continue
    if chain_id not in ref_mol_by_chain:
      ref_mol = _maybe_mol_from_ccd(chain_struc.res_name[0])
    else:
      ref_mol = ref_mol_by_chain[chain_id]
    if ref_mol is None:
      logging.warning(
          '%s: Ref mol is None for chain %s. Skipping.', name, chain_id
      )
      continue
    mol = _mol_from_ligand_struc(
        ligand_struc=chain_struc,
        ref_mol=ref_mol,
    )
    if mol is None:
      logging.warning(
          '%s: Failed to create mol for chain %s. Skipping.', name, chain_id
      )
      continue
    chiral_match_by_chain_id[chain_id] = _chiral_match(mol, ref_mol)
  return chiral_match_by_chain_id