Skip to content

Add widget for MSD analysis#35

Merged
PardhavMaradani merged 10 commits into
MDAnalysis:mainfrom
PardhavMaradani:add-msd-widget
Jul 16, 2026
Merged

Add widget for MSD analysis#35
PardhavMaradani merged 10 commits into
MDAnalysis:mainfrom
PardhavMaradani:add-msd-widget

Conversation

@PardhavMaradani

Copy link
Copy Markdown
Collaborator

Changes made in this Pull Request:

  • Added widget for MSD analysis

PR Checklist

  • Tests?
  • Docs?
  • CHANGELOG updated?
  • Issue raised/referenced?

Hi @orbeckst, @jeremyleung521, @amruthesht, @HeydenLabASU,

This PR adds a new 'MSD Analysis' widget based on issue #34 raised by @amruthesht that uses MDAnalysis.analysis.msd.EinsteinMSD.

The widget runs every-frame and uses the timestep values from the sliding window buffer to create the plot. In the screenshots shown below, the 'Batch size' / 'Buffer size' is configured as 100 in 'Settings > Universe configuration' and hence the lag time shows up to 2 ps (each step is 0.02 ps in this example simulation).

Here is an example plot with all default values (select='all', msd_type='xyz', fft=False, non_linear=False):

msd-all-defaults

Here is the same plot with a log scale (configurable from the GUI):

msd-all-defaults-loglog

Here is the plot with fft=True:

msd-all-fft

Here is the plot with non_linear=True:

msd-all-non-linear

Here are the configurable inputs for this widget:

msd-inputs

I added parallel support for this as it seemed to take a bit of time.

Here is a plot with a different selection phrase:

msd-protein-defaults

Thanks

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (02c03b2) to head (284226a).

Additional details and impacted files
Components Coverage Δ
frontend 100.00% <ø> (ø)
backend 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@amruthesht amruthesht left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@PardhavMaradani, the PR looks like a good start for other lag-time based analyses. There are some issues with the current implementation that I have pointed out. Once resolved, it should be good to go.
Let me know if you need any help or clarification with any points raised or want to discuss possible solutions.

Comment thread mdadash/backend/analyses/msd.py Outdated
Comment thread mdadash/backend/analyses/msd.py Outdated
Comment thread mdadash/backend/analyses/msd.py Outdated
- Add a new SlidingWindowMSD class that is O(n) for each window
@PardhavMaradani

Copy link
Copy Markdown
Collaborator Author

Hi @amruthesht , updated the code based on your example in #34 .

I had to make a few minor changes to get this working correctly (verified that the plots were identical to the MDA's MSD ones):

  • The indexing was comparing frame 0 with the rest - changed it to compare the most recent one (last in the list) to all the previous ones
  • changed msd_dict to use a deque instead of a list to keep this from not growing forever
  • had to round the time delta to prevent jagged edges (similar to the non-linear case in MDA's MSD graph above)
  • added support for selection phrase and msd_type

Could you please review and let me know if there are any changes needed. Thanks

@PardhavMaradani

PardhavMaradani commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @amruthesht ,

Few additional thoughts to the previous comment:
The code in issue #34 was using a collections.defaultdict(list) to keep track of all the msd values for a given delta. With this, we'll get a global average, but in the current code in this PR (see previous comment), since we use a deque, we only get the local window averages, right? I wasn't sure which one you wanted. If it is just the global averages, we probably don't need to keep track of all values. Since I wasn't sure, I tried out a few things, but didn't commit the new changes as I wanted to hear your thoughts on which way to proceed.

I tried the following changes shown in the 'Show code' section below:

Show code
class SlidingWindowMSD:
    """Sliding Window MSD

    Calculate MSD for a sliding window of frames

    """

    def __init__(self, u: mda.Universe, select: str = "all", msd_type: str = "xyz"):
        self.u = u
        self.select = select
        self.msd_type = msd_type
        self._parse_msd_type()
        self.ag = u.select_atoms(self.select)
        self.window_size = self.u.trajectory.buffer_size
        # msd_values, w_sum, g_sum, g_count
        self.msd_data = defaultdict(
            lambda: [deque(maxlen=self.window_size), 0.0, 0.0, 0]
        )
        self.msd_data[0] = [deque([0]), 0.0, 0.0, 1]

    def _parse_msd_type(self):
        """Sets up the desired dimensionality of the MSD."""
        keys = {
            "x": [0],
            "y": [1],
            "z": [2],
            "xy": [0, 1],
            "xz": [0, 2],
            "yz": [1, 2],
            "xyz": [0, 1, 2],
        }
        self._dim = keys[self.msd_type.lower()]

    # pylint: disable=too-many-locals
    def run(self, parallel: bool = False) -> tuple:
        """Run MSD for the current window"""

        time_current = self.u.trajectory.ts.time
        positions_current = self.ag.positions[:, self._dim]

        for i in range(len(self.u.trajectory) - 1):
            ts = self.u.trajectory[i]  # set the buffered trajectory frame
            delta_t = round(time_current - ts.time, 2)
            disp = positions_current - self.ag.positions[:, self._dim]
            msd = np.mean(np.sum(disp**2, axis=1))

            msd_values, w_sum, g_sum, g_count = self.msd_data[delta_t]
            if len(msd_values) == self.window_size:
                w_sum -= msd_values[0]
            msd_values.append(msd)
            w_sum += msd
            self.msd_data[delta_t] = [msd_values, w_sum, g_sum + msd, g_count + 1]

        delta_t_values = sorted(self.msd_data.keys())
        # v[0] = msd_values, v[1] = w_sum, v[2] = g_sum, v[3] = g_count
        window_avgs, global_avgs = zip(
            *[
                (v[1] / len(v[0]), v[2] / v[3])
                for dt in delta_t_values
                for v in [self.msd_data[dt]]
            ]
        )

        return (
            delta_t_values,
            window_avgs,
            global_avgs,
            self.msd_data if parallel else None,
        )

With the above, we can plot both the global averages and the current window averages as shown in an example below:

msd-window-global

The mean calculation is also optimized like how you suggested in the comments in issue #34 code.

Could you please share your thoughts on this. Thanks

Update: I was able to get VACF for a sliding window (using this reference from MDAnalysis/transport-analysis) using the same approach as above - keep a running sum, delete the oldest when it gets removed from buffer, add the sum for the new entity, return the normalized avg. If this approach looks ok, I can create a PR for that after this one. Thanks

@amruthesht

amruthesht commented Jul 13, 2026

Copy link
Copy Markdown

@PardhavMaradani, thank you for responding promptly and taking into account the example code.

I would let you choose the data structure that best suits how you envision the code, but the current implementation looks good to me. As for the averages, a windowed average doesn't make much sense here. The statistics for such a windowed average would be a little confusing. You might want to just stick with the updating global average as you have it. This way, the user can see a live-updating plot that updates the average based on new data as it is read by MDA.

As for your point here

The indexing was comparing frame 0 with the rest - changed it to compare the most recent one (last in the list) to all the previous ones

I was unsure how the final indexing in BufferedTrajectory was structured in the current implementation. However, I think your current implementation in the loop here, is looping through timesteps before the current one in the buffer, as required by the analysis.

Overall, I would say the implementation looks good to me, great job!
I would just add the ability to store and output msd_per_particle as a calculated value as well.

As an aside, since you have made this code contribution here, I would suggest opening up an issue on MDA, asking to support a FixedReferenceMSD apart from the EinsteinMSD currently implemented. And if such a function gets added to MDA, you can swicth your current for loop with such a MDA function in the future (making your current frame the reference)

- Remove windowed averages
- Use pure numpy arrays instead of dicts and loops
@PardhavMaradani

PardhavMaradani commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @amruthesht, thanks for reviewing the changes.

I removed the windowed averages. I've added support for msd_per_particle. I believe you wanted to plot these as well (?) if we are adding computation for it here. The original implementation with dicts and loops was taking quite a bit of time and memory for computing and plotting these (even when using running sums and counts for the particles and the optimized matplotlib's LineCollection for plotting these). I had to change them to pure numpy arrays to get reasonable performance and low memory usage. I added an option (turned off by default) whether or not to show the msd's per particle in the plots.

Here is an example with different combinations for large selections:

msd-large-selections

Here is one for smaller selections:

msd-small-selections

If the intention was not to display them, please let me know, I can remove it.

I would suggest opening up an issue on MDA, asking to support a FixedReferenceMSD ...

Sure. I'll make a note of this and open this along with other issues that come out of this project.

Thanks

Edit: I updated the plots. The previous ones seemed a bit off. I tried with both (IMD-unwrap = Yes and IMD-unwrap = No) and got similar plots. I had to add a no jump transform (u.trajectory.add_transformations(trans.nojump.NoJump())) and the plot for 'all' doesn't seem that off...

@PardhavMaradani
PardhavMaradani merged commit c213877 into MDAnalysis:main Jul 16, 2026
20 checks passed
@PardhavMaradani
PardhavMaradani deleted the add-msd-widget branch July 16, 2026 00:51
@PardhavMaradani PardhavMaradani mentioned this pull request Jul 16, 2026
4 tasks
@amruthesht

Copy link
Copy Markdown

@PardhavMaradani, sorry I missed your comment and changes, but this looks good to me. As for the individual atom MSDs, you can keep a toggleable option to have them or not. The idea is to access those values in a data structure as the results of the MSD routine, which the user can use for other analyses if needed.

As for the analysis, you are right that the user would be expected to provide or transform trajectories to remove any jumps. But that is something the user should be expected to do. But since you are providing MSD as a pre-defined widget, it makes sense to hardcode it as a part of the widget. The 'hook' in the MSD observed at long lag times is mostly an artifact of lower stats, which should ideally improve as the simulation runs longer and more data arrives (more averaging).

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.

2 participants