Coverage for dynasor/trajectory/trajectory.py: 100%
164 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 20:02 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 20:02 +0000
1__all__ = ['Trajectory', 'WindowIterator']
3from collections import deque
4from itertools import islice, chain
5from os.path import isfile
6from typing import Callable, Optional, Union
8import numpy as np
9from numpy.typing import NDArray
11from dynasor.trajectory.atomic_indices import parse_gromacs_index_file
12from dynasor.trajectory.ase_trajectory_reader import ASETrajectoryReader
13from dynasor.trajectory.extxyz_trajectory_reader import ExtxyzTrajectoryReader
14from dynasor.trajectory.lammps_trajectory_reader import LammpsTrajectoryReader
15from dynasor.trajectory.mdanalysis_trajectory_reader import MDAnalysisTrajectoryReader
16from dynasor.trajectory.trajectory_frame import TrajectoryFrame
17from dynasor.logging_tools import logger
20class Trajectory:
21 """Instances of this class hold trajectories in a format suitable for
22 the computation of correlation functions. They behave as
23 iterators, where each step returns the next frame as a
24 :class:`TrajectoryFrame` object. The latter hold information
25 regarding atomic positions, types, and velocities.
27 Parameters
28 ----------
29 filename
30 Name of input file.
31 trajectory_format
32 Type of trajectory. Possible values are:
33 ``'lammps_internal'``, ``'extxyz'``, ``'ase'`` or one of the formats supported by
34 `MDAnalysis <https://www.mdanalysis.org/>`_ (except for ``'lammpsdump'``,
35 which can be called by specifying ``'lammps_mdanalysis'`` to avoid ambiguity)
36 atomic_indices
37 Specify which indices belong to which atom type. Can be
38 (1) a dictionary where the keys specify the species and the values
39 are a list of atomic indices,
40 (2) ``'read_from_trajectory'``, in which case the species are read from the trajectory or
41 (3) the path to a gromacs index file.
42 length_unit
43 Length unit of trajectory (``'Angstrom'``, ``'nm'``, ``'pm'``, ``'fm'``). Necessary for
44 correct conversion to internal dynasor units if the trajectory file does not contain unit
45 information.
46 If no length unit is specified and the reader cannot read units from the trajectory,
47 Angstrom is assumed.
48 time_unit
49 Time unit of trajectory (``'fs'``, ``'ps'``, ``'ns'``). Necessary for correct conversion to
50 internal dynasor units if the trajectory file does not contain unit information.
51 If no time unit is specified and the reader cannot read units from the trajectory, fs is
52 assumed.
53 frame_start
54 First frame to read; must be larger or equal ``0``.
55 frame_stop
56 Last frame to read. By default (``None``) the entire trajectory is read.
57 frame_step
58 Read every :attr:`frame_step`-th step of the input trajectory.
59 By default (``1``) every frame is read. Must be larger than ``0``.
61 """
62 def __init__(
63 self,
64 filename: str,
65 trajectory_format: str,
66 atomic_indices: Optional[Union[str, dict[str, list[int]]]] = None,
67 length_unit: Optional[str] = None,
68 time_unit: Optional[str] = None,
69 frame_start: Optional[int] = 0,
70 frame_stop: Optional[int] = None,
71 frame_step: Optional[int] = 1
72 ):
74 if frame_start < 0:
75 raise ValueError('frame_start should be positive')
76 if frame_step < 0:
77 raise ValueError('frame_step should be positive')
79 self._frame_start = frame_start
80 self._frame_step = frame_step
81 self._frame_stop = frame_stop
83 # setup trajectory reader
84 if not isfile(filename):
85 raise IOError(f'File {filename} does not exist')
86 self._filename = filename
88 if trajectory_format == 'lammps_internal':
89 reader = LammpsTrajectoryReader
90 elif trajectory_format == 'extxyz':
91 reader = ExtxyzTrajectoryReader
92 elif trajectory_format == 'lammps_mdanalysis':
93 reader = MDAnalysisTrajectoryReader
94 trajectory_format = 'lammpsdump'
95 elif trajectory_format == 'ase':
96 reader = ASETrajectoryReader
97 elif trajectory_format == 'lammps':
98 raise IOError('Ambiguous trajectory format, '
99 'did you mean lammps_internal or lammps_mdanalysis?')
100 else:
101 reader = MDAnalysisTrajectoryReader
103 logger.debug(f'Using trajectory reader: {reader.__name__}')
104 if reader == MDAnalysisTrajectoryReader:
105 self._reader_obj = reader(self._filename, trajectory_format,
106 length_unit=length_unit, time_unit=time_unit)
107 else:
108 self._reader_obj = reader(self._filename, length_unit=length_unit, time_unit=time_unit)
110 # Get two frames to set cell etc.
111 frame0 = next(self._reader_obj)
112 frame1 = next(self._reader_obj)
113 self._cell = frame0.cell
114 self._n_atoms = frame0.n_atoms
115 self._has_velocities = frame0.velocities is not None
117 # Make sure cell is not changed during consecutive frames
118 if not np.allclose(frame0.cell, frame1.cell):
119 raise ValueError('The cell changes between the first and second frame. '
120 'The concept of q-points becomes muddy if the simulation cell is '
121 'changing, such as during NPT MD simulations, so trajectories where '
122 'the cell changes are not supported by dynasor.')
124 # setup iterator slice (reuse frame0 and frame1 via chain)
125 self.number_of_frames_read = 0
126 self.current_frame_index = 0
127 self._reader_iter = islice(chain([frame0, frame1], self._reader_obj),
128 self._frame_start, self._frame_stop, self._frame_step)
130 # setup atomic indices
131 if atomic_indices is None: # Default behaviour
132 atomic_indices = {'X': np.arange(0, self.n_atoms)}
133 elif isinstance(atomic_indices, str): # Str input
134 if atomic_indices == 'read_from_trajectory':
135 if frame0.atom_types is None:
136 raise ValueError('Could not read atomic indices from the trajectory.')
137 else:
138 uniques = np.unique(frame0.atom_types)
139 atomic_indices = {str(uniques[i]):
140 (frame0.atom_types == uniques[i]).nonzero()[0]
141 for i in range(len(uniques))}
142 else:
143 atomic_indices = parse_gromacs_index_file(atomic_indices)
144 elif isinstance(atomic_indices, dict): # dict input
145 pass
146 else:
147 raise ValueError('Could not understand atomic_indices.')
148 self._atomic_indices = atomic_indices
150 # sanity checks for atomic_indices
151 for key, indices in self._atomic_indices.items():
152 if np.max(indices) >= self.n_atoms:
153 raise ValueError('Maximum index in atomic_indices exceeds number of atoms.')
154 if np.min(indices) < 0:
155 raise ValueError('Minimum index in atomic_indices is negative.')
156 if '_' in key:
157 # Since '_' is what we use to distinguish atom types in the results, e.g. Sqw_Cs_Pb
158 raise ValueError('The char "_" is not allowed in atomic_indices.')
160 # log info on trajectory and atom types etc
161 logger.info(f'Trajectory file: {self.filename}')
162 logger.info(f'Total number of particles: {self.n_atoms}')
163 logger.info(f'Number of atom types: {len(self.atom_types)}')
164 for atom_type, indices in self._atomic_indices.items():
165 logger.info(f'Number of atoms of type {atom_type}: {len(indices)}')
166 logger.info(f'Simulation cell (in Angstrom):\n{str(self._cell)}')
168 def __iter__(self):
169 return self
171 def __next__(self):
172 frame = next(self._reader_iter)
173 logger.debug(f'Read frame #{frame.frame_index}')
174 new_frame = TrajectoryFrame(
175 self.atomic_indices, frame.frame_index, frame.positions, frame.velocities)
176 self.number_of_frames_read += 1
177 self.current_frame_index = frame.frame_index
178 return new_frame
180 def __str__(self) -> str:
181 s = ['Trajectory']
182 s += ['{:12} : {}'.format('filename', self.filename)]
183 s += ['{:12} : {}'.format('natoms', self.n_atoms)]
184 s += ['{:12} : {}'.format('frame_start', self._frame_start)]
185 s += ['{:12} : {}'.format('frame_stop', self._frame_stop)]
186 s += ['{:12} : {}'.format('frame_step', self.frame_step)]
187 s += ['{:12} : {}'.format('frame_index', self.current_frame_index)]
188 s += ['{:12} : [{}\n {}\n {}]'
189 .format('cell', self.cell[0], self.cell[1], self.cell[2])]
190 return '\n'.join(s)
192 def __repr__(self) -> str:
193 return str(self)
195 def _repr_html_(self) -> str:
196 s = [f'<h3>{self.__class__.__name__}</h3>']
197 s += ['<table border="1" class="dataframe">']
198 s += ['<thead><tr><th style="text-align: left;">Field</th><th>Value</th></tr></thead>']
199 s += ['<tbody>']
200 s += [f'<tr"><td style="text-align: left;">File name</td><td>{self.filename}</td></tr>']
201 s += [f'<tr><td style="text-align: left;">Number of atoms</td><td>{self.n_atoms}</td></tr>']
202 s += [f'<tr><td style="text-align: left;">Cell metric</td><td>{self.cell}</td></tr>']
203 s += [f'<tr><td style="text-align: left;">Frame step</td><td>{self.frame_step}</td></tr>']
204 s += [f'<tr><td style="text-align: left;">Atom types</td><td>{self.atom_types}</td></tr>']
205 s += ['</tbody>']
206 s += ['</table>']
207 return '\n'.join(s)
209 @property
210 def cell(self) -> NDArray[float]:
211 """ Simulation cell """
212 return self._cell
214 @property
215 def n_atoms(self) -> int:
216 """ Number of atoms """
217 return self._n_atoms
219 @property
220 def has_velocities(self) -> bool:
221 """ Whether the trajectory provides velocities """
222 return self._has_velocities
224 @property
225 def filename(self) -> str:
226 """ The trajectory filename """
227 return self._filename
229 @property
230 def atomic_indices(self) -> dict[str, list[int]]:
231 """ Return copy of index arrays """
232 atomic_indices = dict()
233 for name, inds in self._atomic_indices.items():
234 atomic_indices[name] = inds.copy()
235 return atomic_indices
237 @property
238 def atom_types(self) -> list[str]:
239 return sorted(self._atomic_indices.keys())
241 @property
242 def frame_step(self) -> int:
243 """ Frame to access, trajectory will return every :attr:`frame_step`-th snapshot. """
244 return self._frame_step
247def consume(iterator, n):
248 """ Advance the iterator by :attr:`n` steps. If :attr:`n` is ``None``, consume entirely. """
249 # From python.org
250 if n is None:
251 deque(iterator, maxlen=0)
252 else:
253 next(islice(iterator, n, n), None)
256class WindowIterator:
257 """Sliding window iterator.
259 Returns consecutive windows (a window is represented as a list
260 of objects), created from an input iterator.
262 Parameters
263 ----------
264 itraj
265 Trajectory object.
266 width
267 Length of window (``window_size`` + 1).
268 window_step
269 Distance between the start of two consecutive window frames.
270 element_processor
271 Optional function applied to each frame before it is stored in the window.
272 Useful for pre-computing per-frame quantities (e.g., reciprocal-space densities)
273 so that the work is not repeated for frames shared between consecutive windows.
274 """
275 def __init__(self,
276 itraj: Trajectory,
277 width: int,
278 window_step: Optional[int] = 1,
279 element_processor: Optional[Callable] = None):
281 self._raw_it = itraj
282 if element_processor:
283 self._it = map(element_processor, self._raw_it)
284 else:
285 self._it = self._raw_it
286 assert window_step >= 1
287 assert width >= 1
288 self.width = width
289 self.window_step = window_step
290 self._window = None
292 def __iter__(self):
293 return self
295 def __next__(self):
296 """ Returns next element in sequence. """
297 if self._window is None:
298 self._window = deque(islice(self._it, self.width), self.width)
299 else:
300 if self.window_step >= self.width:
301 self._window.clear()
302 consume(self._raw_it, self.window_step - self.width)
303 else:
304 for _ in range(min((self.window_step, len(self._window)))):
305 self._window.popleft()
306 for f in islice(self._it, min((self.window_step, self.width))):
307 self._window.append(f)
309 if len(self._window) == 0:
310 raise StopIteration
312 return list(self._window)