Skip to content

Commit a1022a2

Browse files
committed
Add utils for state atom extraction and fixing elements in a state
1 parent 2b4f9f5 commit a1022a2

3 files changed

Lines changed: 363 additions & 67 deletions

File tree

src/openfe_analysis/rmsd.py

Lines changed: 30 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,12 @@
88
import spyrmsd.rmsd as srmsd
99
from MDAnalysis.analysis import rms
1010
from MDAnalysis.analysis.base import AnalysisBase
11-
from MDAnalysis.guesser.tables import vdwradii as MDA_VDWRADII
1211
from MDAnalysis.transformations import unwrap
13-
from rdkit.Chem import rdmolops
12+
from rdkit import Chem
1413

1514
from .reader import FEReader
1615
from .transformations import Aligner, ClosestImageShift, NoJump
17-
18-
# B-factor values used to identify atoms present at a given lambda state.
19-
# 0.25 marks atoms unique to one end state, 0.5 marks atoms shared by both.
20-
_BFACTOR_STATE_VALUES = (0.25, 0.5)
16+
from .utils.universe_utils import guess_ligand_bonds, select_state_atoms
2117

2218

2319
def make_Universe(top: pathlib.Path, trj: nc.Dataset, state: int) -> mda.Universe:
@@ -225,46 +221,42 @@ def _single_frame(self) -> None:
225221

226222
class SymmetryCorrectedLigandRMSD(AnalysisBase):
227223
"""
228-
1D RMSD time series for an AtomGroup.
224+
Symmetry-corrected 1D RMSD time series for a ligand AtomGroup.
229225
230226
Parameters
231227
----------
232-
atomgroup : MDAnalysis.AtomGroup
233-
Atoms to compute RMSD for.
234-
mass_weighted : bool, optional
235-
If True, compute mass-weighted RMSD.
228+
atomgroup : mda.AtomGroup
229+
Ligand atoms to compute RMSD for. If ``rdmol`` is not provided,
230+
bonds must be guessed on the atomgroup before instantiating this
231+
class; use :func:`guess_ligand_bonds` for this purpose.
232+
rdmol : Chem.Mol, optional
233+
RDKit molecule corresponding to ``atomgroup``. If provided, it is
234+
used directly and ``guess_ligand_bonds`` does not need to be called.
235+
If ``None``, the RDKit molecule is derived from ``atomgroup`` via
236+
``convert_to("RDKIT")``.
236237
"""
237238

238-
def __init__(self, atomgroup, mass_weighted=False, **kwargs):
239+
_analysis_algorithm_is_parallelizable = False
240+
241+
def __init__(
242+
self,
243+
atomgroup: mda.AtomGroup,
244+
rdmol: Optional[Chem.Mol] = None,
245+
**kwargs,
246+
):
239247
super().__init__(atomgroup.universe.trajectory, **kwargs)
240248
self._ag = atomgroup
241-
self._mass_weighted = mass_weighted
242-
self._isomorphisms = None
243-
244-
vdwradii = dict(MDA_VDWRADII)
245-
vdwradii.update(
246-
{
247-
"Cl": vdwradii["CL"],
248-
"Br": vdwradii["BR"],
249-
"Na": vdwradii["NA"],
250-
}
251-
)
252-
253-
atomgroup.guess_bonds(vdwradii)
254-
self._mol = atomgroup.convert_to("RDKIT")
249+
self._mol = rdmol if rdmol is not None else atomgroup.convert_to("RDKIT")
255250
self._aprops = np.array([atom.GetAtomicNum() for atom in self._mol.GetAtoms()])
256-
self._am = rdmolops.GetAdjacencyMatrix(self._mol)
251+
self._am = Chem.rdmolops.GetAdjacencyMatrix(self._mol)
257252

258253
def _prepare(self):
259-
self.results.rmsd = []
254+
self.results.rmsd = np.zeros(self.n_frames, dtype=np.float64)
255+
# reference is taken from the first analyzed frame, not necessarily frame 0
260256
self._reference = self._ag.positions.copy()
257+
self._isomorphisms: list | None = None
261258

262-
if self._mass_weighted:
263-
self._weights = self._ag.masses / np.mean(self._ag.masses)
264-
else:
265-
self._weights = None
266-
267-
def _single_frame(self):
259+
def _single_frame(self) -> None:
268260
frame_rmsd, isomorphisms, _ = srmsd._rmsd_isomorphic_core(
269261
coords1=self._ag.positions.copy(),
270262
coords2=self._reference,
@@ -276,13 +268,11 @@ def _single_frame(self):
276268
minimize=False,
277269
isomorphisms=self._isomorphisms,
278270
)
279-
self.results.rmsd.append(frame_rmsd)
271+
self.results.rmsd[self._frame_index] = frame_rmsd
272+
# cache isomorphisms after first frame to avoid redundant graph matching
280273
if self._isomorphisms is None:
281274
self._isomorphisms = isomorphisms
282275

283-
def _conclude(self):
284-
self.results.rmsd = np.asarray(self.results.rmsd)
285-
286276

287277
class LigandCOMDrift(AnalysisBase):
288278
"""
@@ -325,27 +315,6 @@ def _single_frame(self) -> None:
325315
)
326316

327317

328-
def _select_state_ligand(u: mda.Universe) -> mda.AtomGroup:
329-
"""
330-
Select ligand atoms that are present at the current lambda state.
331-
332-
Atoms are identified by their b-factor values: ``0.25`` marks atoms
333-
unique to one end state and ``0.5`` marks atoms shared by both end
334-
states. Only atoms with these b-factor values and residue name "UNK"
335-
are included.
336-
337-
Parameters
338-
----------
339-
u : mda.Universe
340-
341-
Returns
342-
-------
343-
MDAnalysis.AtomGroup
344-
"""
345-
state_indices = np.array([atom.ix for atom in u.atoms if atom.bfactor in _BFACTOR_STATE_VALUES])
346-
return u.atoms[state_indices].select_atoms("resname UNK")
347-
348-
349318
def gather_rms_data(
350319
pdb_topology: pathlib.Path,
351320
dataset: pathlib.Path,
@@ -417,24 +386,18 @@ def gather_rms_data(
417386
u = make_Universe(u_top._topology, ds, state=state_idx)
418387
prot = u.select_atoms("protein and name CA")
419388
ligand = u.select_atoms("resname UNK")
420-
state_lig = _select_state_ligand(u)
389+
state_lig = select_state_atoms(u, end_state="A").select_atoms("resname UNK")
421390

422391
if prot:
423392
prot_rmsd = RMSDAnalysis(prot).run(step=skip)
424393
output["protein_RMSD"].append(prot_rmsd.results.rmsd)
425394

426395
prot_rmsd2d = Protein2DRMSD(prot).run(step=skip)
427396
output["protein_2D_RMSD"].append(prot_rmsd2d.results.rmsd2d)
428-
# # Using the MDAnalysis DistanceMatrix class
429-
# prot_rmsd2d = diffusionmap.DistanceMatrix(u, select="protein and name CA")
430-
# prot_rmsd2d.run(step=skip)
431-
# dist_mat = prot_rmsd2d.results.dist_matrix
432-
# i, j = np.triu_indices_from(dist_mat, k=1)
433-
# flattened = dist_mat[i, j]
434-
# output["protein_2D_RMSD"].append(flattened)
435397

436398
if ligand:
437399
# lig_rmsd = RMSDAnalysis(ligand, mass_weighted=True).run(step=skip)
400+
guess_ligand_bonds(state_lig, delete_existing=True)
438401
lig_rmsd = SymmetryCorrectedLigandRMSD(state_lig, mass_weighted=True).run(step=skip)
439402
output["ligand_RMSD"].append(lig_rmsd.results.rmsd)
440403

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import MDAnalysis as mda
2+
import numpy as np
3+
import pytest
4+
from rdkit import Chem
5+
6+
from openfe_analysis.rmsd import make_Universe
7+
from openfe_analysis.utils.universe_utils import (
8+
correct_elements,
9+
guess_ligand_bonds,
10+
select_state_atoms,
11+
)
12+
13+
14+
@pytest.fixture
15+
def universe(hybrid_system_skipped_pdb, simulation_skipped_nc):
16+
u = make_Universe(hybrid_system_skipped_pdb, simulation_skipped_nc, state=0)
17+
yield u
18+
u.trajectory.close()
19+
20+
21+
@pytest.fixture
22+
def ligand_ag(universe):
23+
return select_state_atoms(universe, end_state="A").select_atoms("resname UNK")
24+
25+
26+
def test_guess_ligand_bonds_adds_bonds(ligand_ag):
27+
"""Bonds should be present on the atomgroup after guess_ligand_bonds."""
28+
original_count = len(ligand_ag.bonds)
29+
# This also has stateB bond
30+
assert original_count == 49
31+
guess_ligand_bonds(ligand_ag, delete_existing=True)
32+
# Now only 48 stateA bonds
33+
assert len(ligand_ag.bonds) == 48
34+
35+
36+
def test_guess_ligand_bonds_modifies_universe_inplace(ligand_ag):
37+
"""Bond topology should be reflected on the parent universe after guessing."""
38+
guess_ligand_bonds(ligand_ag)
39+
universe_bonds = ligand_ag.universe.select_atoms("resname UNK").bonds
40+
assert len(universe_bonds) > 0
41+
42+
43+
@pytest.mark.parametrize(
44+
"end_state, expected_bfactors",
45+
[
46+
("A", (0.25, 0.5)),
47+
("B", (0.75, 0.5)),
48+
],
49+
)
50+
def test_select_state_atoms(universe, end_state, expected_bfactors):
51+
"""State selection should include state-unique and shared atoms."""
52+
state = select_state_atoms(universe, end_state=end_state)
53+
assert len(state) > 0
54+
assert all(atom.bfactor in expected_bfactors for atom in state)
55+
56+
57+
def test_select_state_atoms_invalid_state(universe):
58+
"""Invalid end_state should raise a ValueError."""
59+
with pytest.raises(ValueError, match="end_state must be 'A' or 'B'"):
60+
select_state_atoms(universe, end_state="C")
61+
62+
63+
def test_select_state_atoms_shared_atoms(universe):
64+
"""Shared atoms (bfactor 0.5) should appear in both state A and B selections."""
65+
state_a = select_state_atoms(universe, end_state="A")
66+
state_b = select_state_atoms(universe, end_state="B")
67+
shared_a = set(atom.ix for atom in state_a if atom.bfactor == 0.5)
68+
shared_b = set(atom.ix for atom in state_b if atom.bfactor == 0.5)
69+
assert shared_a == shared_b
70+
71+
72+
def test_correct_elements_fixes_element():
73+
"""correct_elements should update element where rdmol differs."""
74+
75+
# Build a minimal universe with a C atom
76+
u = mda.Universe.empty(2, n_residues=1, trajectory=True)
77+
u.add_TopologyAttr("elements", ["C", "C"]) # second atom is wrong
78+
u.add_TopologyAttr("names", ["C1", "C2"])
79+
u.add_TopologyAttr("resnames", ["UNK"])
80+
u.add_TopologyAttr("resids", [1])
81+
u.load_new(
82+
np.array([[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]]]),
83+
order="fac",
84+
)
85+
ag = u.select_atoms("all")
86+
87+
mol = Chem.RWMol()
88+
mol.AddAtom(Chem.Atom(6)) # C
89+
mol.AddAtom(Chem.Atom(7)) # N
90+
rdmol = mol.GetMol()
91+
92+
with pytest.warns(UserWarning, match="No atom_mapping provided"):
93+
correct_elements(ag, rdmol)
94+
95+
assert ag[0].element == "C"
96+
assert ag[1].element == "N"
97+
assert ag[1].name == "N"
98+
99+
100+
def test_correct_elements_no_change_when_correct():
101+
"""correct_elements should not modify atoms that already have correct elements."""
102+
103+
u = mda.Universe.empty(2, n_residues=1, trajectory=True)
104+
u.add_TopologyAttr("elements", ["C", "N"])
105+
u.add_TopologyAttr("names", ["C1", "N1"])
106+
u.add_TopologyAttr("resnames", ["UNK"])
107+
u.add_TopologyAttr("resids", [1])
108+
u.load_new(
109+
np.array([[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]]]),
110+
order="fac",
111+
)
112+
ag = u.select_atoms("all")
113+
114+
mol = Chem.RWMol()
115+
mol.AddAtom(Chem.Atom(6)) # C
116+
mol.AddAtom(Chem.Atom(7)) # N
117+
rdmol = mol.GetMol()
118+
119+
with pytest.warns(UserWarning, match="No atom_mapping provided"):
120+
correct_elements(ag, rdmol)
121+
122+
assert ag[0].element == "C"
123+
assert ag[0].name == "C1" # name unchanged
124+
assert ag[1].element == "N"
125+
assert ag[1].name == "N1" # name unchanged
126+
127+
128+
def test_correct_elements_with_atom_mapping():
129+
"""correct_elements with atom_mapping should use mapping without warning."""
130+
131+
u = mda.Universe.empty(2, n_residues=1, trajectory=True)
132+
u.add_TopologyAttr("elements", ["C", "C"]) # second atom is wrong
133+
u.add_TopologyAttr("names", ["C1", "C2"])
134+
u.add_TopologyAttr("resnames", ["UNK"])
135+
u.add_TopologyAttr("resids", [1])
136+
u.load_new(
137+
np.array([[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]]]),
138+
order="fac",
139+
)
140+
ag = u.select_atoms("all")
141+
142+
# rdmol has atoms in reverse order: N, C
143+
mol = Chem.RWMol()
144+
mol.AddAtom(Chem.Atom(7)) # N at rdmol index 0
145+
mol.AddAtom(Chem.Atom(6)) # C at rdmol index 1
146+
rdmol = mol.GetMol()
147+
148+
# explicitly map ag index 0 -> rdmol index 1 (C), ag index 1 -> rdmol index 0 (N)
149+
correct_elements(ag, rdmol, atom_mapping={0: 1, 1: 0})
150+
151+
assert ag[0].element == "C" # mapped to rdmol index 1 (C)
152+
assert ag[1].element == "N" # mapped to rdmol index 0 (N)
153+
assert ag[1].name == "N"
154+
155+
156+
def test_correct_elements_raises_size_error():
157+
"""correct_elements should raise ValueError if atom counts don't match."""
158+
159+
u = mda.Universe.empty(2, n_residues=1, trajectory=True)
160+
u.add_TopologyAttr("elements", ["C", "N"])
161+
u.add_TopologyAttr("names", ["C1", "N1"])
162+
u.add_TopologyAttr("resnames", ["UNK"])
163+
u.add_TopologyAttr("resids", [1])
164+
u.load_new(np.array([[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]]]), order="fac")
165+
ag = u.select_atoms("all")
166+
167+
mol = Chem.RWMol()
168+
mol.AddAtom(Chem.Atom(6)) # only 1 atom
169+
rdmol = mol.GetMol()
170+
171+
with pytest.raises(ValueError, match="atomgroup has 2 atoms but rdmol has 1"):
172+
correct_elements(ag, rdmol)

0 commit comments

Comments
 (0)