crisp-ase 1.0.0.post0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of crisp-ase might be problematic. Click here for more details.

@@ -0,0 +1,1179 @@
1
+ """
2
+ CRISP/data_analysis/msd.py
3
+
4
+ This module performs mean square displacement (MSD) analysis on molecular dynamics
5
+ trajectory data for diffusion coefficient calculations.
6
+ """
7
+
8
+ import ase.io
9
+ import numpy as np
10
+ import matplotlib.pyplot as plt
11
+ import csv
12
+ import sys
13
+ from ase.units import fs
14
+ from ase.units import fs as fs_conversion
15
+ from ase.data import chemical_symbols
16
+ from scipy.optimize import curve_fit
17
+ import pandas as pd
18
+ import os
19
+ import traceback
20
+ from joblib import Parallel, delayed, cpu_count
21
+
22
+
23
+ def read_trajectory_chunk(traj_path, index_slice, frame_skip=1):
24
+ """
25
+ Read a chunk of trajectory data in parallel.
26
+
27
+ Parameters
28
+ ----------
29
+ traj_path : str
30
+ Path to the trajectory file (supports any ASE-readable format)
31
+ index_slice : str
32
+ ASE index slice for reading a subset of frames
33
+ frame_skip : int, optional
34
+ Number of frames to skip (default: 1)
35
+
36
+ Returns
37
+ -------
38
+ list
39
+ List of ASE Atoms objects for the specified chunk
40
+ """
41
+ try:
42
+ frames = ase.io.read(traj_path, index=index_slice)
43
+ if not isinstance(frames, list):
44
+ frames = [frames]
45
+
46
+ frames = frames[::frame_skip]
47
+ return frames
48
+ except Exception as e:
49
+ print(f"Error reading trajectory chunk {index_slice}: {e}")
50
+ return []
51
+
52
+ def calculate_frame_msd(frame_idx, current_frame, reference_frame, atom_indices, msd_direction=False):
53
+ """
54
+ Calculate MSD for a single frame.
55
+
56
+ Parameters
57
+ ----------
58
+ frame_idx : int
59
+ Index of the current frame
60
+ current_frame : ase.Atoms
61
+ Current frame
62
+ reference_frame : ase.Atoms
63
+ Reference frame
64
+ atom_indices : list
65
+ List of atom indices to include in MSD calculation
66
+ msd_direction : bool, optional
67
+ Whether to calculate directional MSD (default: False)
68
+
69
+ Returns
70
+ -------
71
+ tuple
72
+ If msd_direction is False: (frame_idx, msd_value)
73
+ If msd_direction is True: (frame_idx, msd_x, msd_y, msd_z)
74
+ """
75
+ atom_positions_current = current_frame.positions[atom_indices]
76
+ atom_positions_reference = reference_frame.positions[atom_indices]
77
+ displacements = atom_positions_current - atom_positions_reference
78
+
79
+ if not msd_direction:
80
+ # Calculate total MSD
81
+ msd_value = np.sum(np.square(displacements)) / (len(atom_indices))
82
+ return frame_idx, msd_value
83
+ else:
84
+ # Directional MSDs
85
+ msd_x = np.sum(displacements[:, 0]**2) / len(atom_indices)
86
+ msd_y = np.sum(displacements[:, 1]**2) / len(atom_indices)
87
+ msd_z = np.sum(displacements[:, 2]**2) / len(atom_indices)
88
+
89
+ return frame_idx, msd_x, msd_y, msd_z
90
+
91
+ def calculate_msd(traj, timestep, atom_indices=None, ignore_n_images=0, n_jobs=-1,
92
+ msd_direction=False, msd_direction_atom=None):
93
+ """
94
+ Calculate Mean Square Displacement (MSD) vs time using parallel processing.
95
+
96
+ Parameters
97
+ ----------
98
+ traj : list of ase.Atoms
99
+ Trajectory data
100
+ timestep : float
101
+ Simulation timestep
102
+ atom_indices : numpy.ndarray, optional
103
+ Indices of atoms to analyze (default: all atoms)
104
+ ignore_n_images : int, optional
105
+ Number of initial images to ignore (default: 0)
106
+ n_jobs : int, optional
107
+ Number of parallel jobs to run (default: -1, use all available cores)
108
+ msd_direction : bool, optional
109
+ Whether to calculate directional MSD (default: False)
110
+ If True and atom_indices is provided, directional MSD is calculated for those indices
111
+ msd_direction_atom : str or int, optional
112
+ Atom symbol or atomic number to filter for directional MSD (default: None)
113
+ Only used when atom_indices is None
114
+
115
+ Returns
116
+ -------
117
+ tuple or dict
118
+ If atom_indices is provided: (msd_times, msd_x, msd_y, msd_z) if msd_direction=True
119
+ else (msd_values, msd_times)
120
+ If atom_indices is None: A dictionary with keys for each atom type
121
+
122
+ """
123
+ # Time values
124
+ total_images = len(traj) - ignore_n_images
125
+ timesteps = np.linspace(0, total_images * timestep, total_images+1)
126
+ msd_times = timesteps[:] / fs_conversion # Convert to femtoseconds
127
+
128
+ # Reference frame
129
+ reference_frame = traj[ignore_n_images]
130
+
131
+ if n_jobs == -1:
132
+ n_jobs = cpu_count()
133
+
134
+ direction_indices = None
135
+ if msd_direction and msd_direction_atom is not None:
136
+ atoms = traj[0]
137
+ if isinstance(msd_direction_atom, str):
138
+ # An atom symbol (e.g., 'O')
139
+ symbols = atoms.get_chemical_symbols()
140
+ direction_indices = [i for i, s in enumerate(symbols) if s == msd_direction_atom]
141
+ print(f"Calculating directional MSD for {len(direction_indices)} {msd_direction_atom} atoms")
142
+ elif isinstance(msd_direction_atom, int):
143
+ # An atomic number (e.g., 8 for oxygen)
144
+ atomic_numbers = atoms.get_atomic_numbers()
145
+ direction_indices = [i for i, z in enumerate(atomic_numbers) if z == msd_direction_atom]
146
+ symbol = chemical_symbols[msd_direction_atom]
147
+ print(f"Calculating directional MSD for {len(direction_indices)} {symbol} atoms (Z={msd_direction_atom})")
148
+
149
+ # MSD for those atoms
150
+ if atom_indices is not None:
151
+ do_direction = msd_direction
152
+
153
+ # Parallelize MSD calculation
154
+ results = Parallel(n_jobs=n_jobs)(
155
+ delayed(calculate_frame_msd)(
156
+ i - ignore_n_images,
157
+ traj[i],
158
+ reference_frame,
159
+ atom_indices,
160
+ do_direction
161
+ )
162
+ for i in range(ignore_n_images, len(traj))
163
+ )
164
+
165
+ # Sort results by frame index and extract MSD values
166
+ results.sort(key=lambda x: x[0])
167
+
168
+ if do_direction:
169
+ print(f"Calculating directional MSD for {len(atom_indices)} specified atoms")
170
+ msd_x = np.array([r[1] for r in results])
171
+ msd_y = np.array([r[2] for r in results])
172
+ msd_z = np.array([r[3] for r in results])
173
+ return msd_times, msd_x, msd_y, msd_z
174
+ else:
175
+ msd_values = np.array([r[1] for r in results])
176
+ return msd_values, msd_times[:]
177
+
178
+ # MSD per atom type
179
+ else:
180
+ atoms = traj[0]
181
+ symbols = atoms.get_chemical_symbols()
182
+ unique_symbols = set(symbols)
183
+
184
+ # A dictionary mapping symbols to their indices
185
+ symbol_indices = {symbol: [i for i, s in enumerate(symbols) if s == symbol]
186
+ for symbol in unique_symbols}
187
+
188
+ # Overall MSD using all atoms
189
+ all_indices = list(range(len(atoms)))
190
+
191
+ overall_results = Parallel(n_jobs=n_jobs)(
192
+ delayed(calculate_frame_msd)(
193
+ i - ignore_n_images,
194
+ traj[i],
195
+ reference_frame,
196
+ all_indices,
197
+ False
198
+ )
199
+ for i in range(ignore_n_images, len(traj))
200
+ )
201
+
202
+ # Sort results by frame index and extract MSD values
203
+ overall_results.sort(key=lambda x: x[0])
204
+ overall_msd = np.array([r[1] for r in overall_results])
205
+
206
+ # Dictionary to store MSD results
207
+ result = {'overall': (overall_msd, msd_times)}
208
+
209
+ # Calculate MSD for each atom type in parallel
210
+ for symbol, indices in symbol_indices.items():
211
+ print(f"Calculating MSD for {symbol} atoms...")
212
+ calc_direction = msd_direction and (
213
+ (isinstance(msd_direction_atom, str) and symbol == msd_direction_atom) or
214
+ (isinstance(msd_direction_atom, int) and
215
+ atoms.get_atomic_numbers()[indices[0]] == msd_direction_atom)
216
+ )
217
+
218
+ symbol_results = Parallel(n_jobs=n_jobs)(
219
+ delayed(calculate_frame_msd)(
220
+ i - ignore_n_images,
221
+ traj[i],
222
+ reference_frame,
223
+ indices,
224
+ calc_direction
225
+ )
226
+ for i in range(ignore_n_images, len(traj))
227
+ )
228
+
229
+ # Sort results by frame index
230
+ symbol_results.sort(key=lambda x: x[0])
231
+
232
+ if calc_direction:
233
+ msd_x = np.array([r[1] for r in symbol_results])
234
+ msd_y = np.array([r[2] for r in symbol_results])
235
+ msd_z = np.array([r[3] for r in symbol_results])
236
+
237
+ result[symbol] = (msd_times)
238
+ result[f'{symbol}_x'] = (msd_x, msd_times)
239
+ result[f'{symbol}_y'] = (msd_y, msd_times)
240
+ result[f'{symbol}_z'] = (msd_z, msd_times)
241
+
242
+ print(f"Saved directional MSD data for {symbol} atoms")
243
+ else:
244
+ msd_values = np.array([r[1] for r in symbol_results])
245
+ result[symbol] = (msd_values, msd_times)
246
+
247
+ return result
248
+
249
+ def save_msd_data(msd_data, csv_file_path, output_dir="traj_csv_detailed"):
250
+ """
251
+ Save MSD data to CSV files.
252
+
253
+ Parameters
254
+ ----------
255
+ msd_data : tuple or dict
256
+ MSD data to be saved
257
+ csv_file_path : str
258
+ Path to the CSV file
259
+ output_dir : str, optional
260
+ Directory to save CSV files (default: "traj_csv_detailed")
261
+
262
+ Returns
263
+ -------
264
+ list
265
+ List of saved file paths
266
+ """
267
+ saved_files = []
268
+
269
+ os.makedirs(output_dir, exist_ok=True)
270
+
271
+ base_filename = os.path.basename(csv_file_path)
272
+
273
+ if isinstance(msd_data, tuple):
274
+ if len(msd_data) == 2:
275
+ msd_values, msd_times = msd_data
276
+
277
+ csv_full_path = os.path.join(output_dir, base_filename)
278
+
279
+ with open(csv_full_path, 'w', newline='') as csvfile:
280
+ csv_writer = csv.writer(csvfile)
281
+ csv_writer.writerow(['Time (fs)', 'MSD'])
282
+ for time, msd in zip(msd_times, msd_values):
283
+ csv_writer.writerow([time, msd])
284
+
285
+ print(f"MSD data has been saved to {csv_full_path}")
286
+ saved_files.append(csv_full_path)
287
+
288
+ elif len(msd_data) == 4:
289
+ msd_times, msd_x, msd_y, msd_z = msd_data
290
+
291
+ base_path, ext = os.path.splitext(base_filename)
292
+
293
+ total_path = os.path.join(output_dir, base_filename)
294
+ with open(total_path, 'w', newline='') as csvfile:
295
+ csv_writer = csv.writer(csvfile)
296
+ csv_writer.writerow(['Time (fs)', 'MSD'])
297
+ for time, msd in zip(msd_times, msd_values):
298
+ csv_writer.writerow([time, msd])
299
+ print(f"Total MSD data has been saved to {total_path}")
300
+ saved_files.append(total_path)
301
+
302
+ x_path = os.path.join(output_dir, f"{base_path}_x{ext}")
303
+ with open(x_path, 'w', newline='') as csvfile:
304
+ csv_writer = csv.writer(csvfile)
305
+ csv_writer.writerow(['Time (fs)', 'MSD'])
306
+ for time, msd in zip(msd_times, msd_x):
307
+ csv_writer.writerow([time, msd])
308
+ print(f"X-direction MSD data has been saved to {x_path}")
309
+ saved_files.append(x_path)
310
+
311
+ y_path = os.path.join(output_dir, f"{base_path}_y{ext}")
312
+ with open(y_path, 'w', newline='') as csvfile:
313
+ csv_writer = csv.writer(csvfile)
314
+ csv_writer.writerow(['Time (fs)', 'MSD'])
315
+ for time, msd in zip(msd_times, msd_y):
316
+ csv_writer.writerow([time, msd])
317
+ print(f"Y-direction MSD data has been saved to {y_path}")
318
+ saved_files.append(y_path)
319
+
320
+ z_path = os.path.join(output_dir, f"{base_path}_z{ext}")
321
+ with open(z_path, 'w', newline='') as csvfile:
322
+ csv_writer = csv.writer(csvfile)
323
+ csv_writer.writerow(['Time (fs)', 'MSD'])
324
+ for time, msd in zip(msd_times, msd_z):
325
+ csv_writer.writerow([time, msd])
326
+ print(f"Z-direction MSD data has been saved to {z_path}")
327
+ saved_files.append(z_path)
328
+
329
+ elif isinstance(msd_data, dict):
330
+ base_name, ext = os.path.splitext(base_filename)
331
+
332
+ if 'overall' in msd_data:
333
+ overall_filename = f"{base_name}_overall{ext}"
334
+ overall_path = os.path.join(output_dir, overall_filename)
335
+ msd_values, msd_times = msd_data['overall']
336
+
337
+ with open(overall_path, 'w', newline='') as csvfile:
338
+ csv_writer = csv.writer(csvfile)
339
+ csv_writer.writerow(['Time (fs)', 'MSD'])
340
+ for time, msd in zip(msd_times, msd_values):
341
+ csv_writer.writerow([time, msd])
342
+
343
+ print(f"Overall MSD data has been saved to {overall_path}")
344
+ saved_files.append(overall_path)
345
+
346
+ for symbol, data in msd_data.items():
347
+ if symbol == 'overall':
348
+ continue
349
+
350
+ symbol_filename = f"{base_name}_{symbol}{ext}"
351
+ symbol_path = os.path.join(output_dir, symbol_filename)
352
+ msd_values, msd_times = data
353
+
354
+ with open(symbol_path, 'w', newline='') as csvfile:
355
+ csv_writer = csv.writer(csvfile)
356
+ csv_writer.writerow(['Time (fs)', 'MSD'])
357
+ for time, msd in zip(msd_times, msd_values):
358
+ csv_writer.writerow([time, msd])
359
+
360
+ print(f"MSD data for {symbol} atoms has been saved to {symbol_path}")
361
+ saved_files.append(symbol_path)
362
+
363
+ return saved_files
364
+
365
+ def calculate_diffusion_coefficient(msd_times, msd_values, start_index=None, end_index=None,
366
+ with_intercept=False, plot_msd=False, dimension=3):
367
+ """
368
+ Calculate diffusion coefficient from MSD data in a general way for 1D, 2D, or 3D.
369
+
370
+ Parameters
371
+ ----------
372
+ msd_times : numpy.ndarray
373
+ Time values in femtoseconds.
374
+ msd_values : numpy.ndarray
375
+ Mean square displacement values.
376
+ start_index : int, optional
377
+ Starting index for the fit (default: 1/3 of data length).
378
+ end_index : int, optional
379
+ Ending index for the fit (default: None).
380
+ with_intercept : bool, optional
381
+ Whether to fit with intercept (default: False).
382
+ plot_msd : bool, optional
383
+ Whether to plot the fit (default: False).
384
+ dimension : int, optional
385
+ Dimensionality of the system (default: 3). Use 1 for 1D, 2 for 2D, 3 for 3D.
386
+
387
+ Returns
388
+ -------
389
+ tuple
390
+ (D, error) where D is the diffusion coefficient in cm²/s and error is the statistical error.
391
+ """
392
+ if start_index is None:
393
+ start_index = len(msd_times) // 3
394
+ if end_index is None:
395
+ end_index = len(msd_times)
396
+ if start_index < 0 or end_index > len(msd_times):
397
+ raise ValueError("Indices are out of bounds.")
398
+ if start_index >= end_index:
399
+ raise ValueError("Start index must be less than end index.")
400
+
401
+ x_fit = msd_times[start_index:end_index]
402
+ y_fit = msd_values[start_index:end_index]
403
+
404
+ def linear_no_intercept(x, m):
405
+ return m * x
406
+
407
+ def linear_with_intercept(x, m, c):
408
+ return m * x + c
409
+
410
+ if with_intercept:
411
+ params, covariance = curve_fit(linear_with_intercept, x_fit, y_fit)
412
+ slope, intercept = params
413
+ fit_func = lambda x: linear_with_intercept(x, slope, intercept)
414
+ else:
415
+ params, covariance = curve_fit(linear_no_intercept, x_fit, y_fit)
416
+ slope = params[0]
417
+ intercept = 0
418
+ fit_func = lambda x: linear_no_intercept(x, slope)
419
+
420
+ std_err = np.sqrt(np.diag(covariance))[0]
421
+
422
+ # Calculate diffusion coefficient using D = slope / (2 * dimension)
423
+ # Correct conversion from Ų/fs to cm²/s:
424
+ # 1 Å = 10^-8 cm, 1 Ų = 10^-16 cm²
425
+ # 1 fs = 10^-15 s
426
+ # (Ų/fs) * (10^-16 cm²/Ų) / (10^-15 s/fs) = 10^-1 cm²/s
427
+ conversion_angstrom2_fs_to_cm2_s = 0.1
428
+ D = slope / (2 * dimension) * conversion_angstrom2_fs_to_cm2_s
429
+ error = std_err / (2 * dimension) * conversion_angstrom2_fs_to_cm2_s
430
+
431
+ # goodness‐of‐fit R²
432
+ y_model = fit_func(x_fit)
433
+ ss_res = np.sum((y_fit - y_model)**2)
434
+ ss_tot = np.sum((y_fit - np.mean(y_fit))**2)
435
+ r2 = 1 - ss_res/ss_tot
436
+
437
+ if plot_msd:
438
+ plt.figure(figsize=(10, 6))
439
+ # Convert time from fs to ps for plotting
440
+ plt.scatter(msd_times/1000, msd_values, s=10, alpha=0.5, label='MSD data')
441
+ plt.plot(x_fit/1000, fit_func(x_fit), 'r-', linewidth=2,
442
+ label=f'D = {D:.2e} cm²/s')
443
+ plt.xlabel('Time (ps)')
444
+ plt.ylabel('MSD (Ų)')
445
+ plt.title('Mean Square Displacement vs Time')
446
+ plt.grid(True, alpha=0.3)
447
+ plt.legend()
448
+ plt.tight_layout()
449
+ plt.savefig('msd_analysis.png', dpi=300, bbox_inches='tight')
450
+ plt.show()
451
+
452
+ print(f"R² = {r2:.4f}")
453
+
454
+ return D, error
455
+
456
+ def plot_diffusion_time_series(msd_times, msd_values, min_window=10, with_intercept=False, csv_file=None, dimension=3):
457
+ """
458
+ Plot diffusion coefficient as a time series by calculating it over different time windows.
459
+
460
+ Parameters
461
+ ----------
462
+ msd_times : numpy.ndarray
463
+ Time values in femtoseconds
464
+ msd_values : numpy.ndarray
465
+ Mean square displacement values in Ų
466
+ min_window : int, optional
467
+ Minimum window size for calculating diffusion (default: 10)
468
+ with_intercept : bool, optional
469
+ Whether to fit with intercept (default: False)
470
+ csv_file : str, optional
471
+ Path to the CSV file, used for output filename (default: None)
472
+ dimension : int, optional
473
+ Dimensionality of the system: 1 for 1D, 2 for 2D, 3 for 3D (default: 3)
474
+
475
+ Returns
476
+ -------
477
+ None
478
+ """
479
+
480
+ def linear_no_intercept(x, m):
481
+ return m * x
482
+
483
+ def linear_with_intercept(x, m, c):
484
+ return m * x + c
485
+
486
+ # Conversion from Ų/fs to Ų/ps
487
+ # 1 ps = 1000 fs, so multiply by 1000
488
+ conversion_fs_to_ps = 1000.0
489
+
490
+ diffusion_coeffs = []
491
+ window_ends = []
492
+
493
+ for end_idx in range(min_window + 1, len(msd_times)):
494
+ x_fit = msd_times[:end_idx]
495
+ y_fit = msd_values[:end_idx]
496
+
497
+ try:
498
+ if with_intercept:
499
+ params, covariance = curve_fit(linear_with_intercept, x_fit, y_fit)
500
+ slope = params[0]
501
+ else:
502
+ params, covariance = curve_fit(linear_no_intercept, x_fit, y_fit)
503
+ slope = params[0]
504
+
505
+ D = slope / (2 * dimension) * conversion_fs_to_ps
506
+
507
+ diffusion_coeffs.append(D)
508
+ window_ends.append(msd_times[end_idx-1])
509
+ except:
510
+ continue
511
+
512
+ plt.figure(figsize=(10, 6))
513
+
514
+ window_ends_ps = np.array(window_ends) / 1000.0
515
+
516
+ # Plot diffusion coefficient vs. time
517
+ plt.plot(window_ends_ps, diffusion_coeffs, 'b-', linewidth=2, label='Diffusion Coefficient')
518
+
519
+ if len(diffusion_coeffs) > 1:
520
+ avg_diffusion = np.mean(diffusion_coeffs)
521
+ std_diffusion = np.std(diffusion_coeffs, ddof=1)
522
+ plt.axhline(y=avg_diffusion, color='r', linestyle='--',
523
+ label=f'Average D = {avg_diffusion:.2e} ± {std_diffusion:.2e} Ų/ps')
524
+
525
+ plt.axhspan(avg_diffusion - std_diffusion, avg_diffusion + std_diffusion,
526
+ color='r', alpha=0.2)
527
+
528
+ plt.xlabel('Time Window End (ps)')
529
+ plt.ylabel('Diffusion Coefficient (Ų/ps)')
530
+ plt.title(f'{dimension}D Diffusion Coefficient Evolution Over Time')
531
+ plt.grid(True, alpha=0.3)
532
+ plt.legend()
533
+
534
+ output_file = 'diffusion_time_series.png'
535
+ if csv_file:
536
+ dir_name = os.path.dirname(csv_file)
537
+ base_name = os.path.splitext(os.path.basename(csv_file))[0]
538
+ output_file = os.path.join(dir_name, f"{base_name}_{dimension}D_diffusion_evolution.png")
539
+
540
+ plt.tight_layout()
541
+ plt.savefig(output_file, dpi=300, bbox_inches='tight')
542
+ plt.show()
543
+
544
+ print(f"Diffusion coefficient evolution plot saved to: {output_file}")
545
+
546
+ def calculate_save_msd(traj_path, timestep_fs, indices_path=None,
547
+ ignore_n_images=0, output_file="msd_results.csv",
548
+ frame_skip=1, n_jobs=-1, output_dir="traj_csv_detailed",
549
+ msd_direction=False, msd_direction_atom=None,
550
+ use_windowed=True, lag_times_fs=None):
551
+ """
552
+ Calculate MSD data and save to CSV file.
553
+
554
+ Parameters
555
+ ----------
556
+ traj_path : str
557
+ Path to the ASE trajectory file
558
+ timestep_fs : float
559
+ Simulation timestep in femtoseconds (fs)
560
+ indices_path : str, optional
561
+ Path to file containing atom indices (default: None)
562
+ ignore_n_images : int, optional
563
+ Number of initial images to ignore (default: 0)
564
+ output_file : str, optional
565
+ Output CSV file path (default: "msd_results.csv")
566
+ frame_skip : int, optional
567
+ Number of frames to skip between samples (default: 1)
568
+ n_jobs : int, optional
569
+ Number of parallel jobs to run (default: -1, use all available cores)
570
+ output_dir : str, optional
571
+ Directory to save CSV files (default: "traj_csv_detailed")
572
+ msd_direction : bool, optional
573
+ Whether to calculate directional MSD (default: False)
574
+ msd_direction_atom : str or int, optional
575
+ Atom symbol or atomic number for directional analysis (default: None)
576
+ use_windowed : bool, optional
577
+ Whether to use the windowed approach for more robust statistics (default: True)
578
+ lag_times_fs : list of float, optional
579
+ List of lag times (in fs) for which to compute MSD (default: None, use all possible lags)
580
+
581
+ Returns
582
+ -------
583
+ tuple or dict
584
+ MSD values and corresponding time values
585
+ """
586
+ if not os.path.exists(traj_path):
587
+ raise FileNotFoundError(f"Trajectory file not found: {traj_path}")
588
+
589
+ if indices_path is not None and not os.path.exists(indices_path):
590
+ raise FileNotFoundError(f"Indices file not found: {indices_path}")
591
+
592
+ try:
593
+ traj = ase.io.read(traj_path, index=f'::{frame_skip}')
594
+ if not isinstance(traj, list):
595
+ traj = [traj]
596
+
597
+ print(f"Loaded {len(traj)} frames after applying frame_skip={frame_skip}")
598
+
599
+ traj_nodrift = []
600
+ for frame in traj:
601
+ new_frame = frame.copy()
602
+ com = new_frame.get_center_of_mass()
603
+ new_frame.set_positions(new_frame.get_positions() - com)
604
+ traj_nodrift.append(new_frame)
605
+ traj = traj_nodrift
606
+
607
+
608
+ except Exception as e:
609
+ print(f"Error loading trajectory file: {e}")
610
+ return None, None
611
+
612
+ atom_indices = None
613
+ if indices_path:
614
+ try:
615
+ atom_indices = np.load(indices_path)
616
+ print(f"Loaded {len(atom_indices)} atom indices")
617
+ except Exception as e:
618
+ print(f"Error loading atom indices: {e}")
619
+ return None, None
620
+
621
+ timestep = timestep_fs * fs
622
+ print(f"Using timestep: {timestep_fs} fs")
623
+
624
+ print("Calculating MSD using parallel processing...")
625
+ if use_windowed:
626
+ print("Using windowed approach for MSD calculation (averaging over all time origins)")
627
+ if indices_path:
628
+ msd_data = calculate_msd_windowed(
629
+ traj=traj,
630
+ timestep=timestep,
631
+ atom_indices=atom_indices,
632
+ ignore_n_images=ignore_n_images,
633
+ n_jobs=n_jobs,
634
+ msd_direction=msd_direction,
635
+ lag_times_fs=lag_times_fs
636
+ )
637
+ else:
638
+ msd_data = calculate_msd_windowed(
639
+ traj=traj,
640
+ timestep=timestep,
641
+ atom_indices=atom_indices,
642
+ ignore_n_images=ignore_n_images,
643
+ n_jobs=n_jobs,
644
+ msd_direction=msd_direction,
645
+ msd_direction_atom=msd_direction_atom,
646
+ lag_times_fs=lag_times_fs
647
+ )
648
+ else:
649
+ print("Using single reference frame approach for MSD calculation")
650
+ if indices_path:
651
+ msd_data = calculate_msd(
652
+ traj=traj,
653
+ timestep=timestep,
654
+ atom_indices=atom_indices,
655
+ ignore_n_images=ignore_n_images,
656
+ n_jobs=n_jobs,
657
+ msd_direction=msd_direction
658
+ )
659
+ else:
660
+ msd_data = calculate_msd(
661
+ traj=traj,
662
+ timestep=timestep,
663
+ atom_indices=atom_indices,
664
+ ignore_n_images=ignore_n_images,
665
+ n_jobs=n_jobs,
666
+ msd_direction=msd_direction,
667
+ msd_direction_atom=msd_direction_atom
668
+ )
669
+
670
+ saved_files = save_msd_data(
671
+ msd_data=msd_data,
672
+ csv_file_path=output_file,
673
+ output_dir=output_dir
674
+ )
675
+
676
+ return msd_data
677
+
678
+ def analyze_from_csv(csv_file="msd_results.csv", fit_start=None, fit_end=None, dimension=3,
679
+ with_intercept=False, plot_msd=False, plot_diffusion=False,
680
+ use_block_averaging=False, n_blocks=10):
681
+ """
682
+ Analyze MSD data from a CSV file with block averaging by default.
683
+
684
+ Parameters
685
+ ----------
686
+ csv_file : str, optional
687
+ Path to the CSV file containing MSD data (default: "msd_results.csv")
688
+ fit_start : int, optional
689
+ Start index for fitting or visualization if using block averaging (default: None)
690
+ fit_end : int, optional
691
+ End index for fitting or visualization if using block averaging (default: None)
692
+ dimension : int, optional
693
+ Dimensionality of the system: 1 for 1D, 2 for 2D, 3 for 3D (default: 3)
694
+ with_intercept : bool, optional
695
+ Whether to fit with intercept (default: False)
696
+ plot_msd : bool, optional
697
+ Whether to plot MSD vs time (default: False)
698
+ plot_diffusion : bool, optional
699
+ Whether to plot diffusion coefficient as time series (default: False)
700
+ use_block_averaging : bool, optional
701
+ Whether to use block averaging for error estimation (default: True)
702
+ n_blocks : int, optional
703
+ Number of blocks for block averaging (default: 10)
704
+
705
+ Returns
706
+ -------
707
+ tuple
708
+ (D, error) where D is the diffusion coefficient in cm²/s and error is the statistical error
709
+ """
710
+ try:
711
+ df = pd.read_csv(csv_file)
712
+ print(f"Loaded MSD data from {csv_file}")
713
+
714
+ # Extract time and MSD values
715
+ msd_times = df['Time (fs)'].values
716
+ msd_values = df['MSD'].values
717
+
718
+ # Diffusion coefficient
719
+ if use_block_averaging:
720
+ # Use fit_start and fit_end to select the fit zone
721
+ fit_start_idx = fit_start if fit_start is not None else 0
722
+ fit_end_idx = fit_end if fit_end is not None else len(msd_times)
723
+
724
+ msd_times_fit = msd_times[fit_start_idx:fit_end_idx]
725
+ msd_values_fit = msd_values[fit_start_idx:fit_end_idx]
726
+
727
+ D, error = block_averaging_error(
728
+ msd_times=msd_times_fit,
729
+ msd_values=msd_values_fit,
730
+ n_blocks=n_blocks,
731
+ dimension=dimension,
732
+ with_intercept=with_intercept
733
+ )
734
+
735
+ print(f"Using block averaging method with {n_blocks} blocks")
736
+
737
+ # Plot MSD with the block averaging diffusion coefficient
738
+ if plot_msd:
739
+ visualization_start = fit_start if fit_start is not None else int(len(msd_times) * 0.3)
740
+ visualization_end = fit_end if fit_end is not None else int(len(msd_times) * 0.8)
741
+
742
+ plt.figure(figsize=(10, 6))
743
+ plt.scatter(msd_times/1000, msd_values, s=10, alpha=0.5, label='MSD data')
744
+
745
+ x_fit = msd_times[visualization_start:visualization_end]
746
+ if with_intercept:
747
+ slope = 2 * dimension * D / 0.1 # Convert from cm²/s to Ų/fs
748
+ y_fit = msd_values[visualization_start:visualization_end]
749
+ residuals = y_fit - (slope * x_fit)
750
+ intercept = np.mean(residuals)
751
+ fit_line = slope * x_fit + intercept
752
+ else:
753
+ slope = 2 * dimension * D / 0.1
754
+ fit_line = slope * x_fit
755
+
756
+ plt.plot(x_fit/1000, fit_line, 'r-', linewidth=2,
757
+ label=f'Block Avg: D = ({D:.2e} ± {error:.2e}) cm²/s')
758
+ plt.xlabel('Time (ps)')
759
+ plt.ylabel('MSD (Ų)')
760
+ plt.title('Mean Square Displacement vs Time')
761
+ plt.grid(True, alpha=0.3)
762
+ plt.legend()
763
+
764
+ dir_name = os.path.dirname(csv_file)
765
+ base_name = os.path.splitext(os.path.basename(csv_file))[0]
766
+ output_file = os.path.join(dir_name, f"{base_name}_msd_block_avg.png")
767
+ plt.tight_layout()
768
+ plt.savefig(output_file, dpi=300, bbox_inches='tight')
769
+ plt.show()
770
+ print(f"MSD plot saved to: {output_file}")
771
+ else:
772
+ D, error = calculate_diffusion_coefficient(
773
+ msd_times=msd_times,
774
+ msd_values=msd_values,
775
+ start_index=fit_start,
776
+ end_index=fit_end,
777
+ with_intercept=with_intercept,
778
+ plot_msd=plot_msd,
779
+ dimension=dimension
780
+ )
781
+
782
+ if plot_diffusion:
783
+ plot_diffusion_time_series(msd_times, msd_values, 10, with_intercept, csv_file, dimension)
784
+
785
+ method = "Block Averaging" if use_block_averaging else "Standard Fit"
786
+ print(f"\nMSD Analysis Results ({method}):")
787
+ if use_block_averaging:
788
+ print(f"Diffusion Coefficient: D = {D:.4e} ± {error:.4e} cm²/s ({100*error/D:.1f}%)")
789
+ else:
790
+ print(f"Diffusion Coefficient: D = {D:.4e} cm²/s")
791
+
792
+ return D, error
793
+
794
+ except Exception as e:
795
+ print(f"Error analyzing MSD data: {e}")
796
+ traceback.print_exc()
797
+ return None, None
798
+
799
+ def msd_analysis(traj_path, timestep_fs, indices_path=None, ignore_n_images=0,
800
+ output_dir=None, frame_skip=10, fit_start=None, fit_end=None,
801
+ with_intercept=False, plot_msd=False, save_csvs_in_subdir=False,
802
+ msd_direction=False, msd_direction_atom=None, dimension=3,
803
+ use_windowed=True, lag_times_fs=None):
804
+ """
805
+ Perform MSD analysis workflow: calculate MSD and save data.
806
+
807
+ Parameters
808
+ ----------
809
+ traj_path : str
810
+ Path to the ASE trajectory file
811
+ timestep_fs : float
812
+ Simulation timestep in femtoseconds (fs)
813
+ indices_path : str, optional
814
+ Path to file containing atom indices (default: None)
815
+ ignore_n_images : int, optional
816
+ Number of initial images to ignore (default: 0)
817
+ output_dir : str, optional
818
+ Directory to save output files (default: based on trajectory filename)
819
+ frame_skip : int, optional
820
+ Number of frames to skip between samples (default: 10)
821
+ fit_start : int, optional
822
+ Start index for fitting diffusion coefficient (default: None)
823
+ fit_end : int, optional
824
+ End index for fitting diffusion coefficient (default: None)
825
+ with_intercept : bool, optional
826
+ Whether to fit with intercept (default: False)
827
+ plot_msd : bool, optional
828
+ Whether to plot results (default: False)
829
+ save_csvs_in_subdir : bool, optional
830
+ Whether to save CSV files in a subdirectory (default: False)
831
+ msd_direction : bool, optional
832
+ Whether to calculate directional MSD (default: False)
833
+ msd_direction_atom : str or int, optional
834
+ Atom symbol or atomic number for directional analysis (default: None)
835
+ dimension : int, optional
836
+ Dimensionality of the system: 1 for 1D, 2 for 2D, 3 for 3D (default: 3)
837
+ use_windowed : bool, optional
838
+ Whether to use the windowed approach for more robust statistics (default: True)
839
+ lag_times_fs : list of float, optional
840
+ List of lag times (in fs) for which to compute MSD (default: None, use all possible lags)
841
+
842
+ Returns
843
+ -------
844
+ dict
845
+ Dictionary containing MSD values, times, output directory, and optionally diffusion coefficient
846
+ """
847
+ if output_dir is None:
848
+ traj_basename = os.path.splitext(os.path.basename(traj_path))[0]
849
+ output_dir = f"msd_{traj_basename}"
850
+ print(f"Using trajectory-based output directory: {output_dir}")
851
+
852
+ os.makedirs(output_dir, exist_ok=True)
853
+
854
+ csv_path = os.path.join(output_dir, "msd_data.csv")
855
+
856
+ csv_dir = os.path.join(output_dir, "csv_data") if save_csvs_in_subdir else output_dir
857
+
858
+ # Calculate and save MSD data (passing timestep directly in fs)
859
+ msd_data = calculate_save_msd(
860
+ traj_path=traj_path,
861
+ timestep_fs=timestep_fs,
862
+ indices_path=indices_path,
863
+ ignore_n_images=ignore_n_images,
864
+ output_file=csv_path,
865
+ frame_skip=frame_skip,
866
+ output_dir=csv_dir,
867
+ msd_direction=msd_direction,
868
+ msd_direction_atom=msd_direction_atom,
869
+ use_windowed=use_windowed,
870
+ lag_times_fs=lag_times_fs
871
+ )
872
+
873
+ if isinstance(msd_data, tuple) and msd_data[0] is None:
874
+ print("Error: Failed to calculate MSD data")
875
+ return {"error": "Failed to calculate MSD data"}
876
+
877
+ # Extract MSD values and times for return value
878
+ if isinstance(msd_data, dict):
879
+ if 'overall' in msd_data:
880
+ msd_values, msd_times = msd_data['overall']
881
+ else:
882
+ # Use the first atom type's data
883
+ first_symbol = next(iter(msd_data))
884
+ msd_values, msd_times = msd_data[first_symbol]
885
+ else:
886
+ if len(msd_data) == 4:
887
+ msd_times, msd_x, msd_y, msd_z = msd_data
888
+ else:
889
+ msd_values, msd_times = msd_data
890
+
891
+ result_dict = {
892
+ "msd_values": msd_values,
893
+ "msd_times": msd_times,
894
+ "output_dir": output_dir
895
+ }
896
+
897
+ # Diffusion coefficient analyzing the CSV file
898
+ if fit_start is not None or fit_end is not None or plot_msd:
899
+ try:
900
+ print("Calculating diffusion coefficient...")
901
+ D, error = calculate_diffusion_coefficient(
902
+ msd_times=msd_times,
903
+ msd_values=msd_values,
904
+ start_index=fit_start,
905
+ end_index=fit_end,
906
+ with_intercept=with_intercept,
907
+ plot_msd=plot_msd,
908
+ dimension=dimension
909
+ )
910
+
911
+ if D is not None:
912
+ result_dict["diffusion_coefficient"] = D
913
+ result_dict["error"] = error
914
+ print(f"Calculated diffusion coefficient: {D:.2e} cm²/s")
915
+ except Exception as e:
916
+ print(f"Error calculating diffusion coefficient: {e}")
917
+
918
+ return result_dict
919
+
920
+ def block_averaging_error(msd_times, msd_values, n_blocks=5, dimension=3, **kwargs):
921
+ """
922
+ Calculate diffusion coefficient error using block averaging.
923
+
924
+ Parameters
925
+ ----------
926
+ msd_times : numpy.ndarray
927
+ Time values in femtoseconds
928
+ msd_values : numpy.ndarray
929
+ MSD values
930
+ n_blocks : int
931
+ Number of blocks to divide the data into
932
+ dimension : int
933
+ Dimensionality of the system (default: 3)
934
+
935
+ Returns
936
+ -------
937
+ tuple
938
+ (mean_D, std_error_D) - mean diffusion coefficient and its standard error
939
+ """
940
+ # Block size
941
+ block_size = len(msd_times) // n_blocks
942
+ if block_size < 10:
943
+ print(f"Warning: Block size is small ({block_size} points). Consider using fewer blocks.")
944
+
945
+ # D for each block
946
+ D_values = []
947
+ for i in range(n_blocks):
948
+ start_idx = i * block_size
949
+ end_idx = (i + 1) * block_size if i < n_blocks - 1 else len(msd_times)
950
+
951
+ if end_idx - start_idx < 10:
952
+ continue
953
+
954
+ # Remove fit_start/end from kwargs for block fit
955
+ block_kwargs = {k: v for k, v in kwargs.items() if k not in ['start_index', 'end_index', 'fit_start', 'fit_end']}
956
+
957
+ try:
958
+ D, _ = calculate_diffusion_coefficient(
959
+ msd_times[start_idx:end_idx],
960
+ msd_values[start_idx:end_idx],
961
+ dimension=dimension,
962
+ **block_kwargs
963
+ )
964
+ D_values.append(D)
965
+ except Exception as e:
966
+ print(f"Warning: Failed to fit block {i}: {e}")
967
+
968
+ # Statistics
969
+ D_values = np.array(D_values)
970
+ mean_D = np.mean(D_values)
971
+ std_D = np.std(D_values, ddof=1)
972
+ std_error_D = std_D / np.sqrt(len(D_values))
973
+
974
+ return mean_D, std_error_D
975
+
976
+ def calculate_frame_msd_windowed(positions_i, positions_j, atom_indices, msd_direction=False):
977
+ """
978
+ Calculate MSD between two frames for the windowed approach.
979
+
980
+ Parameters
981
+ ----------
982
+ positions_i : numpy.ndarray
983
+ Positions at time i
984
+ positions_j : numpy.ndarray
985
+ Positions at time j
986
+ atom_indices : list
987
+ List of atom indices to include in MSD calculation
988
+ msd_direction : bool, optional
989
+ Whether to calculate directional MSD (default: False)
990
+
991
+ Returns
992
+ -------
993
+ float or tuple
994
+ If msd_direction is False: msd_value
995
+ If msd_direction is True: (msd_x, msd_y, msd_z)
996
+ """
997
+ atom_positions_i = positions_i[atom_indices]
998
+ atom_positions_j = positions_j[atom_indices]
999
+ displacements = atom_positions_j - atom_positions_i
1000
+
1001
+ if not msd_direction:
1002
+ # Calculate total MSD
1003
+ msd_value = np.sum(np.square(displacements)) / (len(atom_indices))
1004
+ return msd_value
1005
+ else:
1006
+ # Directional MSDs
1007
+ msd_x = np.sum(displacements[:, 0]**2) / len(atom_indices)
1008
+ msd_y = np.sum(displacements[:, 1]**2) / len(atom_indices)
1009
+ msd_z = np.sum(displacements[:, 2]**2) / len(atom_indices)
1010
+
1011
+ return msd_x, msd_y, msd_z
1012
+
1013
+ def calculate_msd_windowed(
1014
+ traj, timestep, atom_indices=None, ignore_n_images=0, n_jobs=-1,
1015
+ msd_direction=False, msd_direction_atom=None, lag_times_fs=None
1016
+ ):
1017
+ """
1018
+ Calculate Mean Square Displacement (MSD) vs time using the windowed approach,
1019
+ averaging over all possible time origins.
1020
+
1021
+ Parameters
1022
+ ----------
1023
+ traj : list of ase.Atoms
1024
+ Trajectory data
1025
+ timestep : float
1026
+ Simulation timestep
1027
+ atom_indices : numpy.ndarray, optional
1028
+ Indices of atoms to analyze (default: all atoms)
1029
+ ignore_n_images : int, optional
1030
+ Number of initial images to ignore (default: 0)
1031
+ n_jobs : int, optional
1032
+ Number of parallel jobs to run (default: -1, use all available cores)
1033
+ msd_direction : bool, optional
1034
+ Whether to calculate directional MSD (default: False)
1035
+ msd_direction_atom : str or int, optional
1036
+ Atom symbol or atomic number to filter for directional MSD (default: None)
1037
+ lag_times_fs : list of float, optional
1038
+ List of lag times (in fs) for which to compute MSD (default: None, use all possible lags)
1039
+
1040
+ Returns
1041
+ -------
1042
+ tuple or dict
1043
+ If atom_indices is provided: (msd_values, msd_times) or (msd_times, msd_x, msd_y, msd_z) if msd_direction=True
1044
+ If atom_indices is None: A dictionary with keys for each atom type
1045
+ """
1046
+
1047
+ # Time values
1048
+ total_images = len(traj) - ignore_n_images
1049
+ timestep_fs = timestep / fs # Convert timestep to fs
1050
+ n_frames = total_images
1051
+ positions = [traj[i].positions for i in range(ignore_n_images, len(traj))]
1052
+ positions = np.array(positions)
1053
+
1054
+ # Determine lag times (in frames)
1055
+ if lag_times_fs is not None:
1056
+ lag_frames = [int(round(lag_fs / timestep_fs)) for lag_fs in lag_times_fs if lag_fs > 0]
1057
+ lag_frames = [lf for lf in lag_frames if 1 <= lf < n_frames]
1058
+ else:
1059
+ lag_frames = list(range(1, n_frames))
1060
+
1061
+ msd_times = np.array(lag_frames) * timestep_fs # in fs
1062
+
1063
+ # MSD for specific atoms
1064
+ if atom_indices is not None:
1065
+ if msd_direction:
1066
+ msd_x = np.zeros(len(lag_frames))
1067
+ msd_y = np.zeros(len(lag_frames))
1068
+ msd_z = np.zeros(len(lag_frames))
1069
+ for idx, lag in enumerate(lag_frames):
1070
+ n_pairs = n_frames - lag
1071
+ results = Parallel(n_jobs=n_jobs)(
1072
+ delayed(calculate_frame_msd_windowed)(
1073
+ positions[i], positions[i + lag], atom_indices, True
1074
+ ) for i in range(n_pairs)
1075
+ )
1076
+ msd_x[idx] = np.mean([r[0] for r in results])
1077
+ msd_y[idx] = np.mean([r[1] for r in results])
1078
+ msd_z[idx] = np.mean([r[2] for r in results])
1079
+ return msd_times, msd_x, msd_y, msd_z
1080
+ else:
1081
+ msd_values = np.zeros(len(lag_frames))
1082
+ for idx, lag in enumerate(lag_frames):
1083
+ n_pairs = n_frames - lag
1084
+ results = Parallel(n_jobs=n_jobs)(
1085
+ delayed(calculate_frame_msd_windowed)(
1086
+ positions[i], positions[i + lag], atom_indices, False
1087
+ ) for i in range(n_pairs)
1088
+ )
1089
+ msd_values[idx] = np.mean(results)
1090
+ return msd_values, msd_times
1091
+
1092
+ # MSD per atom type
1093
+ else:
1094
+ atoms = traj[0]
1095
+ symbols = atoms.get_chemical_symbols()
1096
+ unique_symbols = set(symbols)
1097
+
1098
+ # A dictionary mapping symbols to their indices
1099
+ symbol_indices = {symbol: [i for i, s in enumerate(symbols) if s == symbol]
1100
+ for symbol in unique_symbols}
1101
+
1102
+ # Overall MSD using all atoms
1103
+ all_indices = list(range(len(atoms)))
1104
+
1105
+ # Initialize array for overall MSD
1106
+ overall_msd = np.zeros(len(lag_frames))
1107
+
1108
+ # For each lag time, calculate the average MSD over all possible time origins
1109
+ for idx, lag in enumerate(lag_frames):
1110
+ n_pairs = n_frames - lag
1111
+ results = Parallel(n_jobs=n_jobs)(
1112
+ delayed(calculate_frame_msd_windowed)(
1113
+ positions[i],
1114
+ positions[i + lag],
1115
+ all_indices,
1116
+ False
1117
+ )
1118
+ for i in range(n_pairs)
1119
+ )
1120
+ overall_msd[idx] = np.mean(results)
1121
+
1122
+ # Dictionary to store MSD results
1123
+ result = {'overall': (overall_msd[:], msd_times[:])}
1124
+
1125
+ # Calculate MSD for each atom type
1126
+ for symbol, indices in symbol_indices.items():
1127
+ print(f"Calculating MSD for {symbol} atoms...")
1128
+ calc_direction = msd_direction and (
1129
+ (isinstance(msd_direction_atom, str) and symbol == msd_direction_atom) or
1130
+ (isinstance(msd_direction_atom, int) and
1131
+ atoms.get_atomic_numbers()[indices[0]] == msd_direction_atom)
1132
+ )
1133
+
1134
+ if calc_direction:
1135
+ # Initialize arrays for directional MSD
1136
+ msd_x = np.zeros(len(lag_frames))
1137
+ msd_y = np.zeros(len(lag_frames))
1138
+ msd_z = np.zeros(len(lag_frames))
1139
+
1140
+ # For each lag time, calculate the average MSD over all possible time origins
1141
+ for idx2, lag in enumerate(lag_frames):
1142
+ n_pairs = n_frames - lag
1143
+ results = Parallel(n_jobs=n_jobs)(
1144
+ delayed(calculate_frame_msd_windowed)(
1145
+ positions[i],
1146
+ positions[i + lag],
1147
+ indices,
1148
+ True
1149
+ )
1150
+ for i in range(n_pairs)
1151
+ )
1152
+ msd_x[idx2] = np.mean([r[0] for r in results])
1153
+ msd_y[idx2] = np.mean([r[1] for r in results])
1154
+ msd_z[idx2] = np.mean([r[2] for r in results])
1155
+
1156
+ result[symbol] = (msd_times[:])
1157
+ result[f'{symbol}_x'] = (msd_x, msd_times[:])
1158
+ result[f'{symbol}_y'] = (msd_y, msd_times[:])
1159
+ result[f'{symbol}_z'] = (msd_z, msd_times[:])
1160
+
1161
+ print(f"Saved directional MSD data for {symbol} atoms")
1162
+ else:
1163
+ msd_values = np.zeros(len(lag_frames))
1164
+ for idx2, lag in enumerate(lag_frames):
1165
+ n_pairs = n_frames - lag
1166
+ results = Parallel(n_jobs=n_jobs)(
1167
+ delayed(calculate_frame_msd_windowed)(
1168
+ positions[i],
1169
+ positions[i + lag],
1170
+ indices,
1171
+ False
1172
+ )
1173
+ for i in range(n_pairs)
1174
+ )
1175
+ msd_values[idx2] = np.mean(results)
1176
+
1177
+ result[symbol] = (msd_values[:], msd_times[:])
1178
+
1179
+ return result