crisp-ase 1.1.2__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.
Files changed (42) hide show
  1. CRISP/__init__.py +99 -0
  2. CRISP/_version.py +1 -0
  3. CRISP/cli.py +41 -0
  4. CRISP/data_analysis/__init__.py +38 -0
  5. CRISP/data_analysis/clustering.py +838 -0
  6. CRISP/data_analysis/contact_coordination.py +915 -0
  7. CRISP/data_analysis/h_bond.py +772 -0
  8. CRISP/data_analysis/msd.py +1199 -0
  9. CRISP/data_analysis/prdf.py +404 -0
  10. CRISP/data_analysis/volumetric_atomic_density.py +527 -0
  11. CRISP/py.typed +1 -0
  12. CRISP/simulation_utility/__init__.py +31 -0
  13. CRISP/simulation_utility/atomic_indices.py +155 -0
  14. CRISP/simulation_utility/atomic_traj_linemap.py +278 -0
  15. CRISP/simulation_utility/error_analysis.py +254 -0
  16. CRISP/simulation_utility/interatomic_distances.py +200 -0
  17. CRISP/simulation_utility/subsampling.py +241 -0
  18. CRISP/tests/DataAnalysis/__init__.py +1 -0
  19. CRISP/tests/DataAnalysis/test_clustering_extended.py +212 -0
  20. CRISP/tests/DataAnalysis/test_contact_coordination.py +184 -0
  21. CRISP/tests/DataAnalysis/test_contact_coordination_extended.py +465 -0
  22. CRISP/tests/DataAnalysis/test_h_bond_complete.py +326 -0
  23. CRISP/tests/DataAnalysis/test_h_bond_extended.py +322 -0
  24. CRISP/tests/DataAnalysis/test_msd_complete.py +305 -0
  25. CRISP/tests/DataAnalysis/test_msd_extended.py +522 -0
  26. CRISP/tests/DataAnalysis/test_prdf.py +206 -0
  27. CRISP/tests/DataAnalysis/test_volumetric_atomic_density.py +463 -0
  28. CRISP/tests/SimulationUtility/__init__.py +1 -0
  29. CRISP/tests/SimulationUtility/test_atomic_traj_linemap.py +101 -0
  30. CRISP/tests/SimulationUtility/test_atomic_traj_linemap_extended.py +469 -0
  31. CRISP/tests/SimulationUtility/test_error_analysis_extended.py +151 -0
  32. CRISP/tests/SimulationUtility/test_interatomic_distances.py +223 -0
  33. CRISP/tests/SimulationUtility/test_subsampling.py +365 -0
  34. CRISP/tests/__init__.py +1 -0
  35. CRISP/tests/test_CRISP.py +28 -0
  36. CRISP/tests/test_cli.py +87 -0
  37. CRISP/tests/test_crisp_comprehensive.py +679 -0
  38. crisp_ase-1.1.2.dist-info/METADATA +141 -0
  39. crisp_ase-1.1.2.dist-info/RECORD +42 -0
  40. crisp_ase-1.1.2.dist-info/WHEEL +5 -0
  41. crisp_ase-1.1.2.dist-info/entry_points.txt +2 -0
  42. crisp_ase-1.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,522 @@
1
+ """Extended comprehensive tests for Mean Square Displacement (MSD) module."""
2
+ import pytest
3
+ import numpy as np
4
+ import pandas as pd
5
+ import os
6
+ import tempfile
7
+ import shutil
8
+ from ase import Atoms
9
+ from ase.io import write, read
10
+ from CRISP.data_analysis.msd import (
11
+ calculate_msd,
12
+ calculate_frame_msd,
13
+ read_trajectory_chunk,
14
+ save_msd_data,
15
+ plot_diffusion_time_series,
16
+ block_averaging_error,
17
+ calculate_msd_windowed,
18
+ calculate_frame_msd_windowed,
19
+ msd_analysis,
20
+ calculate_diffusion_coefficient,
21
+ )
22
+
23
+
24
+ class TestCalculateFrameMSD:
25
+ """Test single frame MSD calculation."""
26
+
27
+ def test_calculate_frame_msd_basic(self):
28
+ """Test basic frame MSD calculation."""
29
+ ref_frame = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
30
+ curr_frame = Atoms('H2O', positions=[[0.1, 0, 0], [1.1, 0, 0], [0.1, 1, 0]])
31
+ atom_indices = np.array([0, 1, 2])
32
+
33
+ frame_idx, msd = calculate_frame_msd(0, curr_frame, ref_frame, atom_indices, msd_direction=False)
34
+
35
+ assert frame_idx == 0
36
+ assert isinstance(msd, (float, np.floating))
37
+ assert msd > 0
38
+
39
+ def test_calculate_frame_msd_directional(self):
40
+ """Test directional MSD calculation."""
41
+ ref_frame = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
42
+ curr_frame = Atoms('H2O', positions=[[0.1, 0.2, 0.3], [1.1, 0, 0], [0.1, 1, 0]])
43
+ atom_indices = np.array([0, 1, 2])
44
+
45
+ result = calculate_frame_msd(1, curr_frame, ref_frame, atom_indices, msd_direction=True)
46
+
47
+ assert len(result) == 4 # frame_idx, msd_x, msd_y, msd_z
48
+ frame_idx, msd_x, msd_y, msd_z = result
49
+ assert frame_idx == 1
50
+ assert isinstance(msd_x, (float, np.floating))
51
+ assert isinstance(msd_y, (float, np.floating))
52
+ assert isinstance(msd_z, (float, np.floating))
53
+ assert all(v >= 0 for v in [msd_x, msd_y, msd_z])
54
+
55
+ def test_calculate_frame_msd_single_atom(self):
56
+ """Test MSD for single atom."""
57
+ ref_frame = Atoms('H', positions=[[0, 0, 0]])
58
+ curr_frame = Atoms('H', positions=[[1, 1, 1]])
59
+ atom_indices = np.array([0])
60
+
61
+ frame_idx, msd = calculate_frame_msd(0, curr_frame, ref_frame, atom_indices)
62
+
63
+ assert msd == pytest.approx(3.0) # (1^2 + 1^2 + 1^2) / 1
64
+
65
+ def test_calculate_frame_msd_no_displacement(self):
66
+ """Test MSD when atoms don't move."""
67
+ frame = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
68
+ atom_indices = np.array([0, 1, 2])
69
+
70
+ frame_idx, msd = calculate_frame_msd(0, frame, frame, atom_indices)
71
+
72
+ assert msd == pytest.approx(0.0)
73
+
74
+
75
+ class TestReadTrajectoryChunk:
76
+ """Test trajectory file reading."""
77
+
78
+ @pytest.fixture
79
+ def sample_trajectory(self):
80
+ """Create a sample trajectory file."""
81
+ temp_dir = tempfile.mkdtemp()
82
+ traj_path = os.path.join(temp_dir, 'test_traj.traj')
83
+
84
+ # Create multi-frame trajectory
85
+ frames = []
86
+ for i in range(5):
87
+ atoms = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
88
+ atoms.positions += np.random.rand(3, 3) * 0.1 * i
89
+ frames.append(atoms)
90
+
91
+ write(traj_path, frames)
92
+ yield traj_path
93
+ shutil.rmtree(temp_dir)
94
+
95
+ def test_read_trajectory_chunk_all_frames(self, sample_trajectory):
96
+ """Test reading all frames."""
97
+ frames = read_trajectory_chunk(sample_trajectory, ':')
98
+
99
+ assert isinstance(frames, list)
100
+ assert len(frames) == 5
101
+ assert all(isinstance(f, Atoms) for f in frames)
102
+
103
+ def test_read_trajectory_chunk_specific_slice(self, sample_trajectory):
104
+ """Test reading specific frame slice."""
105
+ frames = read_trajectory_chunk(sample_trajectory, '0:3')
106
+
107
+ assert len(frames) == 3
108
+
109
+ def test_read_trajectory_chunk_with_frame_skip(self, sample_trajectory):
110
+ """Test reading with frame skip."""
111
+ frames = read_trajectory_chunk(sample_trajectory, ':', frame_skip=2)
112
+
113
+ # 5 frames with skip=2 should give 3 frames (indices 0, 2, 4)
114
+ assert len(frames) == 3
115
+
116
+ def test_read_trajectory_chunk_invalid_file(self):
117
+ """Test reading non-existent file."""
118
+ result = read_trajectory_chunk('/nonexistent/path.traj', ':')
119
+
120
+ assert result == []
121
+
122
+
123
+ class TestCalculateMSD:
124
+ """Test MSD calculation for trajectories."""
125
+
126
+ @pytest.fixture
127
+ def trajectory(self):
128
+ """Create sample trajectory."""
129
+ frames = []
130
+ for i in range(10):
131
+ atoms = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
132
+ # Linear displacement
133
+ atoms.positions += np.array([0.01*i, 0.01*i, 0])
134
+ frames.append(atoms)
135
+ return frames
136
+
137
+ def test_calculate_msd_basic(self, trajectory):
138
+ """Test basic MSD calculation."""
139
+ timestep = 1.0
140
+ atom_indices = np.array([0, 1, 2])
141
+
142
+ result = calculate_msd(trajectory, timestep, atom_indices=atom_indices)
143
+
144
+ assert isinstance(result, tuple)
145
+ assert len(result) == 2 # (msd_values, msd_times)
146
+ msd_values, msd_times = result
147
+ assert len(msd_values) > 0
148
+ assert len(msd_times) > 0
149
+ assert msd_times[0] == 0.0
150
+
151
+ def test_calculate_msd_directional(self, trajectory):
152
+ """Test directional MSD calculation."""
153
+ timestep = 1.0
154
+ atom_indices = np.array([0, 1, 2])
155
+
156
+ result = calculate_msd(trajectory, timestep, atom_indices=atom_indices, msd_direction=True)
157
+
158
+ assert isinstance(result, tuple)
159
+ assert len(result) == 4 # (msd_times, msd_x, msd_y, msd_z)
160
+ msd_times, msd_x, msd_y, msd_z = result
161
+ assert all(len(arr) > 0 for arr in [msd_times, msd_x, msd_y, msd_z])
162
+
163
+ def test_calculate_msd_with_ignore_frames(self, trajectory):
164
+ """Test MSD calculation ignoring initial frames."""
165
+ timestep = 1.0
166
+ atom_indices = np.array([0, 1, 2])
167
+
168
+ result_full = calculate_msd(trajectory, timestep, atom_indices=atom_indices, ignore_n_images=0)
169
+ result_skip = calculate_msd(trajectory, timestep, atom_indices=atom_indices, ignore_n_images=3)
170
+
171
+ msd_full, time_full = result_full
172
+ msd_skip, time_skip = result_skip
173
+
174
+ assert len(time_skip) < len(time_full)
175
+
176
+ def test_calculate_msd_single_atom_index(self, trajectory):
177
+ """Test MSD for single atom."""
178
+ timestep = 1.0
179
+ atom_indices = np.array([0])
180
+
181
+ msd_values, msd_times = calculate_msd(trajectory, timestep, atom_indices=atom_indices)
182
+
183
+ assert isinstance(msd_values, np.ndarray)
184
+ assert len(msd_values) > 0
185
+ # MSD should increase with time for linearly moving particle
186
+ assert msd_values[-1] > msd_values[0]
187
+
188
+
189
+ class TestSaveMSDData:
190
+ """Test MSD data saving."""
191
+
192
+ def test_save_msd_data_basic(self):
193
+ """Test basic MSD data saving."""
194
+ temp_dir = tempfile.mkdtemp()
195
+
196
+ # Use tuple format: (msd_values, msd_times)
197
+ msd_values = np.array([0, 0.1, 0.2, 0.3, 0.4])
198
+ msd_times = np.array([0, 1, 2, 3, 4])
199
+ msd_data = (msd_values, msd_times)
200
+
201
+ csv_file = os.path.join(temp_dir, 'msd_test.csv')
202
+ save_msd_data(msd_data, csv_file, output_dir=temp_dir)
203
+
204
+ assert os.path.exists(csv_file)
205
+
206
+ # Read back and verify
207
+ df = pd.read_csv(csv_file)
208
+ assert len(df) > 0
209
+
210
+ shutil.rmtree(temp_dir)
211
+
212
+ def test_save_msd_data_creates_directory(self):
213
+ """Test that output directory is created."""
214
+ temp_dir = tempfile.mkdtemp()
215
+ output_subdir = os.path.join(temp_dir, 'new_output')
216
+
217
+ # Use tuple format: (msd_values, msd_times)
218
+ msd_values = np.array([0, 0.1, 0.2])
219
+ msd_times = np.array([0, 1, 2])
220
+ msd_data = (msd_values, msd_times)
221
+
222
+ csv_file = os.path.join(output_subdir, 'msd.csv')
223
+ save_msd_data(msd_data, csv_file, output_dir=output_subdir)
224
+
225
+ assert os.path.exists(output_subdir)
226
+
227
+ shutil.rmtree(temp_dir)
228
+
229
+
230
+ class TestPlotDiffusionTimeSeries:
231
+ """Test diffusion time series plotting."""
232
+
233
+ def test_plot_diffusion_time_series_basic(self):
234
+ """Test basic plotting functionality."""
235
+ temp_dir = tempfile.mkdtemp()
236
+
237
+ msd_times = np.array([0, 1, 2, 3, 4, 5])
238
+ msd_values = np.array([0, 0.1, 0.2, 0.3, 0.4, 0.5])
239
+
240
+ # Plot without saving (just test it doesn't crash)
241
+ plot_diffusion_time_series(msd_times, msd_values, min_window=2)
242
+
243
+ shutil.rmtree(temp_dir)
244
+
245
+ def test_plot_diffusion_with_intercept(self):
246
+ """Test plotting with intercept."""
247
+ msd_times = np.array([0, 1, 2, 3, 4, 5])
248
+ msd_values = np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6])
249
+
250
+ # Should not raise error
251
+ plot_diffusion_time_series(msd_times, msd_values, with_intercept=True, min_window=2)
252
+
253
+ def test_plot_diffusion_dimension_variations(self):
254
+ """Test plotting with different dimensions."""
255
+ msd_times = np.array([0, 1, 2, 3, 4])
256
+ msd_values = np.array([0, 0.1, 0.2, 0.3, 0.4])
257
+
258
+ for dim in [1, 2, 3]:
259
+ plot_diffusion_time_series(msd_times, msd_values, dimension=dim, min_window=2)
260
+
261
+
262
+ class TestBlockAveragingError:
263
+ """Test block averaging error analysis."""
264
+
265
+ def test_block_averaging_error_basic(self):
266
+ """Test basic block averaging."""
267
+ msd_times = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
268
+ msd_values = np.array([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])
269
+
270
+ result = block_averaging_error(msd_times, msd_values, n_blocks=5)
271
+
272
+ assert result is not None
273
+ # Should return tuple or similar structure
274
+ assert isinstance(result, (tuple, dict, type(None)))
275
+
276
+ def test_block_averaging_different_block_counts(self):
277
+ """Test with different block counts."""
278
+ msd_times = np.arange(0, 20, 1.0)
279
+ msd_values = msd_times * 0.1
280
+
281
+ for n_blocks in [2, 3, 5, 10]:
282
+ result = block_averaging_error(msd_times, msd_values, n_blocks=n_blocks)
283
+ assert result is not None
284
+
285
+ def test_block_averaging_dimension_variations(self):
286
+ """Test block averaging with different dimensions."""
287
+ msd_times = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
288
+ msd_values = np.array([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])
289
+
290
+ for dim in [1, 2, 3]:
291
+ result = block_averaging_error(msd_times, msd_values, dimension=dim, n_blocks=2)
292
+ assert result is not None
293
+
294
+
295
+ class TestCalculateFrameMSDWindowed:
296
+ """Test windowed frame MSD calculation."""
297
+
298
+ def test_calculate_frame_msd_windowed_basic(self):
299
+ """Test basic windowed MSD calculation."""
300
+ pos_i = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]])
301
+ pos_j = np.array([[0.1, 0, 0], [1.1, 0, 0], [0.1, 1, 0]])
302
+ atom_indices = np.array([0, 1, 2])
303
+
304
+ result = calculate_frame_msd_windowed(pos_i, pos_j, atom_indices, msd_direction=False)
305
+
306
+ assert isinstance(result, (float, np.floating))
307
+ assert result > 0
308
+
309
+ def test_calculate_frame_msd_windowed_directional(self):
310
+ """Test windowed directional MSD."""
311
+ pos_i = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]])
312
+ pos_j = np.array([[0.1, 0.2, 0.3], [1.1, 0, 0], [0.1, 1, 0]])
313
+ atom_indices = np.array([0, 1, 2])
314
+
315
+ result = calculate_frame_msd_windowed(pos_i, pos_j, atom_indices, msd_direction=True)
316
+
317
+ assert isinstance(result, tuple)
318
+ assert len(result) == 3 # msd_x, msd_y, msd_z
319
+
320
+
321
+ class TestCalculateMSDWindowed:
322
+ """Test windowed MSD calculation for trajectories."""
323
+
324
+ @pytest.fixture
325
+ def window_trajectory(self):
326
+ """Create trajectory for windowed tests."""
327
+ frames = []
328
+ for i in range(15):
329
+ atoms = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
330
+ atoms.positions += np.array([0.05*i, 0.05*i, 0])
331
+ frames.append(atoms)
332
+ return frames
333
+
334
+ def test_calculate_msd_windowed_basic(self, window_trajectory):
335
+ """Test basic windowed MSD calculation."""
336
+ timestep = 1.0
337
+ atom_indices = np.array([0, 1, 2])
338
+
339
+ # Test without window_size parameter (not supported)
340
+ result = calculate_msd_windowed(
341
+ window_trajectory,
342
+ timestep,
343
+ atom_indices=atom_indices
344
+ )
345
+
346
+ assert result is not None
347
+ assert len(result) == 2
348
+
349
+
350
+ class TestMSDAnalysis:
351
+ """Test complete MSD analysis workflow."""
352
+
353
+ @pytest.fixture
354
+ def test_trajectory_file(self):
355
+ """Create test trajectory file."""
356
+ temp_dir = tempfile.mkdtemp()
357
+ traj_path = os.path.join(temp_dir, 'test_analysis.traj')
358
+
359
+ frames = []
360
+ for i in range(10):
361
+ atoms = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
362
+ atoms.positions += np.random.rand(3, 3) * 0.05 * i
363
+ atoms.cell = [10, 10, 10]
364
+ atoms.pbc = True
365
+ frames.append(atoms)
366
+
367
+ write(traj_path, frames)
368
+ yield traj_path
369
+ shutil.rmtree(temp_dir)
370
+
371
+ def test_msd_analysis_basic(self, test_trajectory_file):
372
+ """Test basic MSD analysis."""
373
+ result = msd_analysis(
374
+ test_trajectory_file,
375
+ timestep_fs=1.0,
376
+ indices_path=None # Use correct parameter name
377
+ )
378
+
379
+ # Should complete without error
380
+ assert result is not None
381
+
382
+ def test_msd_analysis_with_indices_path(self, test_trajectory_file):
383
+ """Test analysis with indices_path parameter."""
384
+ # Create temp indices file
385
+ temp_dir = tempfile.mkdtemp()
386
+ indices_file = os.path.join(temp_dir, 'indices.npy')
387
+ np.save(indices_file, np.array([0, 1]))
388
+
389
+ result = msd_analysis(
390
+ test_trajectory_file,
391
+ timestep_fs=1.0,
392
+ indices_path=indices_file
393
+ )
394
+
395
+ assert result is not None
396
+ shutil.rmtree(temp_dir)
397
+
398
+
399
+ class TestMSDParameterVariations:
400
+ """Test MSD calculations with various parameter combinations."""
401
+
402
+ @pytest.fixture
403
+ def varied_trajectory(self):
404
+ """Create trajectory with varied properties."""
405
+ frames = []
406
+ for i in range(8):
407
+ atoms = Atoms('H4O2', positions=[
408
+ [0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0],
409
+ [0.5, 1, 0], [1.5, 1, 0]
410
+ ])
411
+ atoms.positions += np.random.rand(6, 3) * 0.02 * i
412
+ atoms.cell = [10, 10, 10]
413
+ frames.append(atoms)
414
+ return frames
415
+
416
+ def test_msd_with_atom_subset_variations(self, varied_trajectory):
417
+ """Test MSD with different atom subsets."""
418
+ timestep = 1.0
419
+
420
+ subsets = [
421
+ np.array([0]),
422
+ np.array([0, 1]),
423
+ np.array([0, 1, 2, 3]),
424
+ np.array([0, 1, 2, 3, 4, 5])
425
+ ]
426
+
427
+ for atom_indices in subsets:
428
+ result = calculate_msd(varied_trajectory, timestep, atom_indices=atom_indices)
429
+ assert result is not None
430
+ msd_vals, msd_times = result
431
+ assert len(msd_vals) > 0
432
+
433
+ def test_msd_with_different_timesteps(self, varied_trajectory):
434
+ """Test MSD with different timestep values."""
435
+ atom_indices = np.array([0, 1])
436
+
437
+ for timestep in [0.5, 1.0, 2.0, 5.0]:
438
+ result = calculate_msd(varied_trajectory, timestep, atom_indices=atom_indices)
439
+ msd_vals, msd_times = result
440
+
441
+ # Larger timestep should result in larger time values
442
+ assert msd_times[-1] > 0
443
+
444
+
445
+ class TestMSDIntegration:
446
+ """Integration tests combining multiple MSD functions."""
447
+
448
+ def test_full_msd_workflow(self):
449
+ """Test complete workflow from trajectory to diffusion coefficient."""
450
+ # Create trajectory
451
+ frames = []
452
+ for i in range(10):
453
+ atoms = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
454
+ atoms.positions += np.array([0.02*i, 0.02*i, 0])
455
+ frames.append(atoms)
456
+
457
+ # Calculate MSD
458
+ timestep = 1.0
459
+ atom_indices = np.array([0, 1, 2])
460
+ msd_values, msd_times = calculate_msd(frames, timestep, atom_indices=atom_indices)
461
+
462
+ # Just verify the MSD calculation worked
463
+ assert len(msd_values) > 0
464
+ assert len(msd_times) > 0
465
+ assert msd_values[0] >= 0
466
+
467
+ def test_msd_with_different_atom_types(self):
468
+ """Test MSD calculation distinguishing atom types."""
469
+ frames = []
470
+ for i in range(8):
471
+ atoms = Atoms('H2O2', positions=[
472
+ [0, 0, 0], [1, 0, 0], # H atoms
473
+ [0.5, 0.5, 0], [1.5, 0.5, 0] # O atoms
474
+ ])
475
+ atoms.positions += np.random.rand(4, 3) * 0.01 * i
476
+ frames.append(atoms)
477
+
478
+ timestep = 1.0
479
+
480
+ # MSD for H atoms only
481
+ h_indices = np.array([0, 1])
482
+ msd_h, time_h = calculate_msd(frames, timestep, atom_indices=h_indices)
483
+
484
+ # MSD for O atoms only
485
+ o_indices = np.array([2, 3])
486
+ msd_o, time_o = calculate_msd(frames, timestep, atom_indices=o_indices)
487
+
488
+ assert len(msd_h) > 0
489
+ assert len(msd_o) > 0
490
+ # Both should have same time array
491
+ assert len(time_h) == len(time_o)
492
+
493
+
494
+ class TestMSDEdgeCases:
495
+ """Test MSD with edge cases."""
496
+
497
+ def test_msd_two_frame_trajectory(self):
498
+ """Test MSD with minimal 2-frame trajectory."""
499
+ frames = [
500
+ Atoms('H2', positions=[[0, 0, 0], [1, 0, 0]]),
501
+ Atoms('H2', positions=[[0.1, 0, 0], [1.1, 0, 0]])
502
+ ]
503
+
504
+ result = calculate_msd(frames, 1.0, atom_indices=np.array([0, 1]))
505
+ assert result is not None
506
+
507
+ def test_msd_large_trajectory(self):
508
+ """Test MSD with larger trajectory."""
509
+ frames = []
510
+ for i in range(50):
511
+ atoms = Atoms('H2O', positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]])
512
+ atoms.positions += np.random.rand(3, 3) * 0.01 * i
513
+ frames.append(atoms)
514
+
515
+ result = calculate_msd(frames, 1.0, atom_indices=np.array([0, 1, 2]))
516
+ msd_vals, msd_times = result
517
+
518
+ # MSD calculation may return N or N+1 points
519
+ assert len(msd_times) >= 50
520
+ assert len(msd_times) <= 51
521
+ # MSD values should increase over time (random walk)
522
+ assert msd_vals[0] == 0 # Initial MSD is zero