Skip to content

Refactor RMSD analyses into MDAnalysis AnaysisBase classes#90

Open
hannahbaumann wants to merge 16 commits into
mainfrom
rmsd_refactor_analysisbase
Open

Refactor RMSD analyses into MDAnalysis AnaysisBase classes#90
hannahbaumann wants to merge 16 commits into
mainfrom
rmsd_refactor_analysisbase

Conversation

@hannahbaumann
Copy link
Copy Markdown
Contributor

@hannahbaumann hannahbaumann commented Feb 20, 2026

Refactor the rmsd analysis using this example: https://docs.mdanalysis.org/2.7.0/documentation_pages/analysis/base.html

  • Longer term we may not want to have the gather_rms_data function but access the individual analysis classes directly in the openfe Protocol

@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.32%. Comparing base (12122bd) to head (fdf4531).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #90      +/-   ##
==========================================
+ Coverage   96.09%   96.32%   +0.23%     
==========================================
  Files           6        6              
  Lines         333      354      +21     
==========================================
+ Hits          320      341      +21     
  Misses         13       13              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hannahbaumann
Copy link
Copy Markdown
Contributor Author

@talagayev and @jthorton : This is a first go at the refactor into the individual RMSD classes, based on the MDAnalysis AnalysisBase. Please let me know what you think!

@hannahbaumann hannahbaumann changed the title Refactor RMSD analyses into MDAnalysis AnaysisBase classes Refactor RMSD analyses into MDAnalysis AnaysisBase classes Feb 20, 2026
Comment thread src/openfe_analysis/rmsd.py Outdated
Comment on lines +177 to +203
class LigandRMSD(AnalysisBase):
"""
1D RMSD time series for a ligand AtomGroup.
"""

def __init__(self, atomgroup, **kwargs):
super(LigandRMSD, self).__init__(atomgroup.universe.trajectory, **kwargs)

self._ag = atomgroup

def _prepare(self):
self.results.rmsd = []
self._reference = self._ag.positions
self._weights = self._ag.masses / np.mean(self._ag.masses)

def _single_frame(self):
rmsd = rms.rmsd(
self._ag.positions,
self._reference,
self._weights,
center=False,
superposition=False,
)
self.results.rmsd.append(rmsd)

def _conclude(self):
self.results.rmsd = np.asarray(self.results.rmsd)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 initial thoughts:

  • Can we make a more general RMSD class that can be reused for protein and ligand analysis, basically it should work on any atom group and does this not already exist in MDAnalysis?
  • Do we not want to switch to use the symmetry RMSD (spyrmsd I think its called)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jthorton : There's a difference between the RMSD class in MDAnalysis in what we've been doing, and I'm not sure yet what we want. In MDAnalysis, by default they are doing a superposition (rotational and translational), while we didn't do that. Now I'm not sure, what are we actually interested in, esp. for the ligand RMSD. Should this be the RMSD of the internal conformation of the ligand, or the RMSD of the ligand in the binding pocket/ligand pose? I think in the solvent we would definitely be more interested in the internal conformation of the ligand. In the binding pocket, I'm not sure. We also calculate the ligand COM displacement as a measure of stability in the pocket, but I'm not sure what we would want for the RMSD. What do you think?

Copy link
Copy Markdown
Collaborator

@talagayev talagayev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hannahbaumann I like it, looks very good to me :) also the structure is like for the MDAnalysis AnalysisBase classes, which is I think @IAlibay wanted to have and also adressing it that it takes any atom group adressing @jthorton comment is good.

Overall Looks good to me :)

Comment thread src/openfe_analysis/rmsd.py Outdated
prot_rmsd = RMSDAnalysis(prot).run(step=skip)
output["protein_RMSD"].append(prot_rmsd.results.rmsd)
# # Using the MDAnalysis RMSD class instead
# gs = ["protein and name CA", "protein"]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is an example of how we could do the same analysis using the MDAnalysis RMSD class.

Comment thread src/openfe_analysis/rmsd.py Outdated
self, atomgroup, reference=None, mass_weighted=False, superposition=False, **kwargs
):
super(RMSDAnalysis, self).__init__(atomgroup.universe.trajectory, **kwargs)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_analysis_algorithm_is_parallelizable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remember to add.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this!

@hannahbaumann
Copy link
Copy Markdown
Contributor Author

hannahbaumann commented Mar 19, 2026

  • Add tests for mass weighting and superposition. Potentially import test data from MDAnalysis.

Comment thread src/openfe_analysis/rmsd.py Outdated
prot_rmsd2d = Protein2DRMSD(prot).run(step=skip)
output["protein_2D_RMSD"].append(prot_rmsd2d.results.rmsd2d)
# # Using the MDAnalysis DistanceMatrix class
# prot_rmsd2d = diffusionmap.DistanceMatrix(u, select="protein and name CA")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This MDA code is much slower, on the test data 10s vs. 0.4s.

Comment thread src/openfe_analysis/rmsd.py Outdated
output["protein_RMSD"].append(prot_rmsd.results.rmsd)
# # Using the MDAnalysis RMSD class instead
# gs = ["protein and name CA"]
# prot_rmsd = rms.RMSD(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two RMSD classes are approximately equal in timing (on the test data)

# output["protein_2D_RMSD"].append(flattened)

if ligand.n_atoms > 0:
lig_rmsd = RMSDAnalysis(ligand, mass_weighted=True).run(step=skip)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ligand RMSD is currently calculated on the hybrid topology, which may not be what we want long term.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a separate PR - the atom selection (or atomgroup) should really be user defined rather than defaulting to UNK.

This might be a good argument for letting Protocols deal with this rather than making it uniform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened an issue here: #103

@hannahbaumann hannahbaumann requested review from IAlibay and jthorton May 21, 2026 07:48
For all unique frame pairs ``(i, j)`` with ``i < j``, this function
computes the RMSD between atomic coordinates after optimal alignment.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explicitly define _analysis_algorithm_is_parallelizable = False (it's inherited by default, but it would be good to have it explicitly defined here) in these classes and then raise an issue about looking into parallism?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this and also opened an issue.

Comment thread src/openfe_analysis/rmsd.py Outdated
self.results.rmsd2d = []

def _single_frame(self):
self._coords.append(self._ag.positions.copy())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is effectively the same as putting the whole trajectory into memory. I'm saying this as an FYI that it will be a possible failure mode when someone runs a really long simulation and then tries to use this class.

Might be good to document in the docstring notes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note in the doc string

Comment thread src/openfe_analysis/rmsd.py Outdated
self.results.rmsd2d = []

def _single_frame(self):
self._coords.append(self._ag.positions.copy())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the copy necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since now we're doing the pre-allocation, the copy is not necessary any more (not fully sure if it was before, but I wanted to be safe =)

Flattened 2D RMSD matrix

For all unique frame pairs ``(i, j)`` with ``i < j``, this function
computes the RMSD between atomic coordinates after optimal alignment.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you maybe expand this to mention you're doing a center of geometry fit as well as a rotational and translational superposition usingg QCP?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this!

self, atomgroup, reference=None, mass_weighted=False, superposition=False, **kwargs
):
super(RMSDAnalysis, self).__init__(atomgroup.universe.trajectory, **kwargs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remember to add.

Comment thread src/openfe_analysis/rmsd.py Outdated

def _single_frame(self):
# distance between start and current ligand position
# ignores PBC, but we've already centered the traj
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ignore PBC? Could you not just pass the box kwarg argument along? Or is the box distorted because of the transformation?

Please document this in the docstring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a note to the doc string, I think that if, e.g. the ligand drifted in the simulation away by more than half the box size, but stayed in the same box, applying the minimum image convention (by passing through the box) would actually make the drift look smaller than it really was. Or would it in that case still identify that the ligand stayed in the same box and not apply the minimum image convention?

Comment thread src/openfe_analysis/rmsd.py Outdated
self.results.com_drift.append(drift)

def _conclude(self):
self.results.com_drift = np.asarray(self.results.com_drift)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: it may be more ever so slightly efficient to just pre-allocate the array ahead of time in _prepare by defining a numpy array of length self.n_frames. This also has the nice side effect of not needing a _conclude definition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Comment thread src/openfe_analysis/rmsd.py Outdated
output.append(rmsd)
prot_rmsd = RMSDAnalysis(prot).run(step=skip)
output["protein_RMSD"].append(prot_rmsd.results.rmsd)
# # Using the MDAnalysis RMSD class instead
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remember to remove the commented out regions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this!

# output["protein_2D_RMSD"].append(flattened)

if ligand.n_atoms > 0:
lig_rmsd = RMSDAnalysis(ligand, mass_weighted=True).run(step=skip)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a separate PR - the atom selection (or atomgroup) should really be user defined rather than defaulting to UNK.

This might be a good argument for letting Protocols deal with this rather than making it uniform.

# lig_rmsd.run(step=skip)
# output["ligand_RMSD"].append(lig_rmsd.results.rmsd.T[3])
lig_com_drift = LigandCOMDrift(ligand).run(step=skip)
output["ligand_wander"].append(lig_com_drift.results.com_drift)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is historical, so it doesn't have to be here, but can we please renamed this to ligand_com_drift or anything else? wander is such an unspecific name 😅

Copy link
Copy Markdown
Contributor Author

@hannahbaumann hannahbaumann May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would do this in a separate PR, since it would require an update in openfe? Raised an issue here #104

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests for Protein2DRMSD or LigandCOMDrift?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original tests in test_rmsd.py are already covering Protein2DRMSD and LigandCOMDrift. I had added the MDA tests since in one of our meetings you had mentioned that it could make sense to just use those test data, however, for now I still kept all the original tests, also for RMSD, so some things are double tested right now. Should I try to move as much of the testing to use the MDA data, and only have a regression test on our own trajectories or what would you suggest?

Comment thread src/openfe_analysis/rmsd.py Outdated
self._ag = atomgroup

def _prepare(self):
self._coords = []
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you pre-allocate numpy arrays here instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

def _prepare(self):
self.results.rmsd = []

self._reference_pos = self._reference.positions.copy()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that if you call .run(start=10), this will mean your reference is frame 10 not frame 0. This should probably be documented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the doc string and also inline comment

Comment thread src/openfe_analysis/rmsd.py Outdated
@hannahbaumann hannahbaumann requested a review from IAlibay May 22, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor current analysis in an MDAnalysis like way API work - RMSD

4 participants