Skip to content

Commit c213877

Browse files
Add widget for MSD analysis (#35)
- Add a new `SlidingWindowMSD` class that is O(n) for each window - Support to compute and display particle MSDs - Support for configuring selection, dimension type and log scale
1 parent 02c03b2 commit c213877

6 files changed

Lines changed: 300 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ The rules for this file:
2929

3030
<!-- New added features -->
3131

32+
- Added widget for MSD Analysis (PR #35)
33+
3234
### Fixed
3335

3436
<!-- Bug fixes -->

mdadash/backend/analyses/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Module that has all the analyses widgets
33
"""
44

5-
from . import com_distance, dssp, energies, janin, ramachandran, rog
5+
from . import com_distance, dssp, energies, janin, msd, ramachandran, rog
66

77
__all__ = [
88
"energies",
@@ -11,4 +11,5 @@
1111
"dssp",
1212
"ramachandran",
1313
"janin",
14+
"msd",
1415
]

mdadash/backend/analyses/msd.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import logging
2+
3+
import matplotlib.pyplot as plt
4+
import MDAnalysis as mda
5+
import numpy as np
6+
from IPython.display import display
7+
from joblib import delayed
8+
from matplotlib.collections import LineCollection
9+
10+
from mdadash.backend.widgets.base import WidgetBase
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class MSDAnalysis(WidgetBase):
16+
name = "MSD Analysis"
17+
description = "Mean squared displacement analysis"
18+
19+
_inputs = [
20+
{
21+
"attribute": "_run_mode",
22+
"name": "Run mode",
23+
"description": "The mode in which the widget is run",
24+
"type": "select",
25+
"items": [
26+
"serial",
27+
"parallel",
28+
],
29+
},
30+
{
31+
"attribute": "selection",
32+
"name": "Selection",
33+
"description": "MDAnalysis selection phrase",
34+
"type": "str",
35+
},
36+
{
37+
"attribute": "msd_type",
38+
"name": "MSD type",
39+
"description": "Desired dimensions to be included in the MSD",
40+
"type": "select",
41+
"items": [
42+
"xyz",
43+
"xy",
44+
"yz",
45+
"xz",
46+
"x",
47+
"y",
48+
"z",
49+
],
50+
},
51+
{
52+
"attribute": "show_particle_msds",
53+
"name": "Show particle MSDs",
54+
"description": "Show MSDs for individual particles of the selection",
55+
"type": "bool",
56+
},
57+
{
58+
"attribute": "log_scale",
59+
"name": "Log scale",
60+
"description": "Use a log scale for the axes",
61+
"type": "bool",
62+
},
63+
{
64+
"attribute": "custom_title",
65+
"name": "Custom title",
66+
"description": "Custom title for the plot",
67+
"type": "str",
68+
},
69+
]
70+
71+
def __init__(self):
72+
super().__init__()
73+
self.msd = None
74+
self.selection = "all"
75+
self.msd_type = "xyz"
76+
self.log_scale = False
77+
self.show_particle_msds = False
78+
self.title = "MSD"
79+
self.custom_title = None
80+
self._setup_plot()
81+
82+
def _setup_plot(self):
83+
"""Setup matplotlib plot"""
84+
self.fig, self.ax = plt.subplots()
85+
# use non-empty values to prevent initial exception
86+
# if widget is configured to use log scale
87+
(self.plot,) = self.ax.plot([1], [1], color="red", zorder=2)
88+
self.lc = LineCollection([], colors="gray", alpha=0.2, lw=0.5, zorder=1)
89+
self.ax.add_collection(self.lc)
90+
self.ax.set_xlabel(r"Lag time $\Delta$t (ps)")
91+
self.ax.set_ylabel(r"MSD ($\AA^2$)")
92+
self.ax.grid(True, linestyle="--", alpha=0.6)
93+
self._set_title()
94+
self._set_axes_scale()
95+
96+
def _set_title(self):
97+
"""Set plot title"""
98+
self.ax.set_title(self.custom_title if self.custom_title else self.title)
99+
100+
def _set_axes_scale(self):
101+
"""Set axes scale"""
102+
self.ax.set_xscale("log" if self.log_scale else "linear")
103+
self.ax.set_yscale("log" if self.log_scale else "linear")
104+
105+
def _create_msd(self):
106+
"""Create msd instance"""
107+
self.msd = SlidingWindowMSD(
108+
self.u,
109+
select=self.selection,
110+
msd_type=self.msd_type,
111+
show_particle_msds=self.show_particle_msds,
112+
)
113+
self.title = f"MSD of '{self.selection}'"
114+
self._set_title()
115+
116+
def on_post_create(self):
117+
"""on_post_create handler"""
118+
self._set_title()
119+
self._set_axes_scale()
120+
121+
def on_post_connect(self):
122+
"""on_post_connect handler"""
123+
self._create_msd()
124+
125+
def on_input_change(self, attribute, _old_value, new_value):
126+
"""on_input_change handler"""
127+
if attribute == "custom_title":
128+
self._set_title()
129+
elif attribute == "log_scale":
130+
self._set_axes_scale()
131+
elif attribute == "_run_mode":
132+
pass
133+
else:
134+
self._create_msd()
135+
136+
def _compute(self, parallel: bool = False):
137+
"""Run MSD for the current timesteps window"""
138+
return self.msd.run(parallel=parallel)
139+
140+
def _update_plot(self, x, y1, y2):
141+
"""Update plot with computed values"""
142+
self.plot.set_data(x, y1)
143+
self.lc.set_segments(y2 if self.show_particle_msds else [])
144+
self.ax.relim()
145+
self.ax.autoscale_view()
146+
self.fig.canvas.draw()
147+
display(self.fig)
148+
149+
def run_every_frame(self):
150+
"""every-frame run handler"""
151+
x, y1, y2, _ = self._compute()
152+
self._update_plot(x, y1, y2)
153+
154+
def get_parallel_job(self):
155+
"""get parallel job handler"""
156+
return delayed(self._compute)(parallel=True)
157+
158+
def apply_parallel_results(self, values):
159+
"""apply parallel results handler"""
160+
x, y1, y2, (v1, v2, v3, v4) = values
161+
self._update_plot(x, y1, y2)
162+
# update msd state
163+
self.msd.msd_sums = v1
164+
self.msd.msd_counts = v2
165+
if self.show_particle_msds:
166+
self.msd.particle_msd_sums = v3
167+
self.msd.particle_msd_counts = v4
168+
169+
170+
class SlidingWindowMSD:
171+
"""Sliding Window MSD
172+
173+
Calculate MSD for a sliding window of frames
174+
175+
"""
176+
177+
def __init__(
178+
self,
179+
u: mda.Universe,
180+
select: str = "all",
181+
msd_type: str = "xyz",
182+
show_particle_msds: bool = False,
183+
):
184+
self.u = u
185+
self.select = select
186+
self.msd_type = msd_type
187+
self.show_particle_msds = show_particle_msds
188+
self._parse_msd_type()
189+
self.ag = u.select_atoms(self.select)
190+
self.n_atoms = self.ag.atoms.n_atoms
191+
self.n_lags = u.trajectory.buffer_size
192+
self.msd_sums = np.zeros(self.n_lags)
193+
self.msd_counts = np.zeros(self.n_lags, dtype=int)
194+
self.msd_counts[0] = 1
195+
if self.show_particle_msds:
196+
self.particle_msd_sums = np.zeros((self.n_lags, self.n_atoms))
197+
self.particle_msd_counts = np.zeros((self.n_lags, self.n_atoms), dtype=int)
198+
self.particle_msd_counts[0, :] = 1
199+
200+
def _parse_msd_type(self):
201+
"""Sets up the desired dimensionality of the MSD."""
202+
keys = {
203+
"x": [0],
204+
"y": [1],
205+
"z": [2],
206+
"xy": [0, 1],
207+
"xz": [0, 2],
208+
"yz": [1, 2],
209+
"xyz": [0, 1, 2],
210+
}
211+
self._dim = keys[self.msd_type.lower()]
212+
213+
def run(self, parallel: bool = False) -> tuple:
214+
"""Run MSD for the current window"""
215+
216+
n = len(self.u.trajectory) # buffer / window might not be full yet
217+
positions_current = self.ag.positions[:, self._dim]
218+
for i in range(n - 1):
219+
lag = n - 1 - i
220+
_ = self.u.trajectory[i] # set the buffered trajectory frame
221+
disp = positions_current - self.ag.positions[:, self._dim]
222+
squared_disp = np.sum(disp**2, axis=1)
223+
msd = np.mean(squared_disp)
224+
self.msd_sums[lag] += msd
225+
self.msd_counts[lag] += 1
226+
if self.show_particle_msds:
227+
self.particle_msd_sums[lag, :] += squared_disp
228+
self.particle_msd_counts[lag, :] += 1
229+
230+
# We will have at least 2 frames by the time we are here.
231+
# frame_dt will ensure the delta_t is correct even if we have step
232+
# value (other than 1) configured in the universe configuration
233+
frame_dt = round(self.u.trajectory[1].time - self.u.trajectory[0].time, 2)
234+
delta_t_values = np.arange(n) * frame_dt
235+
avg_msds = self.msd_sums[:n] / self.msd_counts[:n]
236+
msds_by_particle_lines = None
237+
if self.show_particle_msds:
238+
msds_by_particle_array = (
239+
self.particle_msd_sums[:n, :] / self.particle_msd_counts[:n, :]
240+
)
241+
msds_by_particle_lines = np.empty((self.n_atoms, n, 2))
242+
msds_by_particle_lines[:, :, 0] = delta_t_values
243+
msds_by_particle_lines[:, :, 1] = msds_by_particle_array.T
244+
245+
return (
246+
delta_t_values,
247+
avg_msds,
248+
msds_by_particle_lines,
249+
(
250+
self.msd_sums,
251+
self.msd_counts,
252+
self.particle_msd_sums if self.show_particle_msds else None,
253+
self.particle_msd_counts if self.show_particle_msds else None,
254+
)
255+
if parallel
256+
else (None,) * 4,
257+
)

mdadash/backend/tests/test_server.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,37 @@ async def test_widget_run_janin(_client, imd_server):
604604
await disconnect_from_simulation()
605605

606606

607+
async def test_widget_run_msd_serial(_client, imd_server):
608+
uuid = await add_widget("MSD Analysis")
609+
await connect_to_simulation(imd_server, step=1, batch_size=2)
610+
inputs = [
611+
("selection", "resid 1"),
612+
("custom_title", ""),
613+
("show_particle_msds", True),
614+
("log_scale", False),
615+
]
616+
await check_input_changes(uuid, inputs)
617+
await resume_simulation(imd_server)
618+
assert await sio_event_emitted(sio, "widgets:output", n=1)
619+
await remove_widget(uuid)
620+
await disconnect_from_simulation()
621+
622+
623+
async def test_widget_run_msd_parallel(_client, imd_server):
624+
uuid = await add_widget("MSD Analysis")
625+
await connect_to_simulation(imd_server, step=1, batch_size=2)
626+
inputs = [
627+
("selection", "resid 1"),
628+
("show_particle_msds", True),
629+
("_run_mode", "parallel"),
630+
]
631+
await check_input_changes(uuid, inputs)
632+
await resume_simulation(imd_server)
633+
assert await sio_event_emitted(sio, "widgets:output", n=1)
634+
await remove_widget(uuid)
635+
await disconnect_from_simulation()
636+
637+
607638
def test_state_load(tmp_path):
608639
# test with no state file
609640
sm = StateManager("")

mdadash/backend/tests/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ async def check_input_changes(uuid, inputs, status="ok"):
5252
assert response["status"] == status
5353

5454

55-
async def connect_to_simulation(imd_server):
55+
async def connect_to_simulation(imd_server, step=2, batch_size=1):
5656
main.mdadash.sm.universe_configs[0].update(
5757
{
5858
"topology": str(TPR),
5959
"trajectory": f"imd://localhost:{imd_server.port}",
60-
"step": 2,
61-
"batch_size": 1,
60+
"step": step,
61+
"batch_size": batch_size,
6262
}
6363
)
6464
handler = sio.handlers["/"]["connect_to_simulations"]

mdadash/backend/widgets/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,8 +556,12 @@ def _run_parallel_jobs(self, parallel_widgets, parallel_results):
556556
func, args, kwargs = widget.get_parallel_job()
557557
parallel_jobs.append((self.with_reset_frame, (func,) + args, kwargs))
558558
try:
559+
# without max_nbytes=None, np arrays passed / returned
560+
# are marked read-only in subsequent calls (eg: msd case)
559561
results = Parallel(
560-
n_jobs=self.n_jobs, initializer=WidgetManager._patch_IMDReader
562+
n_jobs=self.n_jobs,
563+
max_nbytes=None,
564+
initializer=WidgetManager._patch_IMDReader,
561565
)(parallel_jobs)
562566
parallel_results.extend(results)
563567
# pylint: disable=broad-exception-caught

0 commit comments

Comments
 (0)