polyphys 1.0.0__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 (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,977 @@
1
+ """\
2
+ =====================================================================
3
+ LAMMPS data files --- :mod:`polyphys.simulate.data`
4
+ =====================================================================
5
+
6
+ The :mod:`~polyphys.simulate.data` module reads and writes LAMMPS data
7
+ files, the input of the ``read_data`` command, for molecular systems of
8
+ atoms, bonds, and angles. A data file built here serves two ends: a start
9
+ configuration that LAMMPS reads and runs, and a topology that analysis tools
10
+ such as MDAnalysis read with a dump.
11
+
12
+ .. versionadded:: 0.6.0
13
+
14
+ Contents
15
+ ========
16
+
17
+ :class:`DataFile` holds the contents of one data file as NumPy arrays, its
18
+ box as a :class:`~polyphys.simulate.core.Box`, and checks them.
19
+ :func:`read_data` reads a file into it, raising :class:`BrokenDataError`, a
20
+ :class:`~polyphys.simulate.core.LammpsFormatError`, for a file that does not
21
+ follow the format; :func:`write_data` writes one out. :func:`with_frame`
22
+ takes the positions and the box of a dump frame
23
+ (:class:`~polyphys.simulate.dump.DumpFrame`) and the rest from a template, to
24
+ give a dump its matching topology.
25
+
26
+ Format
27
+ ======
28
+
29
+ A data file is a header and a body. The first line is a title and is
30
+ skipped. The header lines give counts and the box, in any order::
31
+
32
+ 8 atoms
33
+ 1 atom types
34
+ 8 bonds
35
+ 1 bond types
36
+ 0.0 6.0 xlo xhi
37
+ 0.0 6.0 ylo yhi
38
+ 0.0 6.0 zlo zhi
39
+ 1.0 0.5 -0.5 xy xz yz
40
+
41
+ The body is a sequence of sections, each a keyword line followed by one line
42
+ per entry: ``Masses`` (``type mass``), ``Atoms`` (columns by atom style, see
43
+ :class:`DataFile`), ``Velocities`` (``atom-ID vx vy vz``), ``Bonds``
44
+ (``ID type atom1 atom2``), and ``Angles`` (``ID type atom1 atom2 atom3``).
45
+ ``#`` starts a comment and blank lines are skipped. ``Atoms # bond`` names the
46
+ atom style in a comment. A file whose name ends in ``.gz`` is read through
47
+ gzip. Atom lines may end with the three image flags, on
48
+ all lines or on none; LAMMPS sets missing image flags and velocities to zero.
49
+
50
+ Supported atom styles: ``atomic``, ``bond``, ``angle``, ``molecular``, and
51
+ ``full``. Sections with names ending in ``Coeffs`` (force-field coefficients)
52
+ are skipped when read and never written; the input script sets them, as
53
+ ``write_data nocoeff`` assumes. Dihedrals, impropers, type labels, and
54
+ general triclinic boxes are not supported.
55
+
56
+ The module converts no units; values are in the ``units`` of the run.
57
+
58
+ Complexity
59
+ ==========
60
+
61
+ :func:`read_data` and :func:`write_data` take :math:`O(n_a + n_b + n_t)`
62
+ time and memory for :math:`n_a` atoms, :math:`n_b` bonds, and :math:`n_t`
63
+ angles; :func:`with_frame` takes :math:`O(n_a \\log n_a)` time to match atom
64
+ IDs.
65
+
66
+ References
67
+ ==========
68
+
69
+ The format follows the LAMMPS documentation of `read_data
70
+ <https://docs.lammps.org/read_data.html>`_ and `write_data
71
+ <https://docs.lammps.org/write_data.html>`_.
72
+
73
+ Dependencies
74
+ ============
75
+ - Python standard library: `dataclasses`, `logging`, `pathlib`
76
+ - Third-party: `numpy`
77
+ - PolyPhys: :mod:`polyphys.simulate.core`, :mod:`polyphys.simulate.dump`
78
+ """
79
+
80
+ import dataclasses
81
+ import logging
82
+ from dataclasses import dataclass, field
83
+ from pathlib import Path
84
+ from typing import Any, NamedTuple
85
+
86
+ import numpy as np
87
+ import numpy.typing as npt
88
+
89
+ from polyphys.simulate.core import (
90
+ Box,
91
+ FloatArray,
92
+ IntArray,
93
+ LammpsFormatError,
94
+ _frozen,
95
+ _open_text,
96
+ )
97
+ from polyphys.simulate.dump import DumpFrame
98
+
99
+ __all__ = [
100
+ "BrokenDataError",
101
+ "DataFile",
102
+ "read_data",
103
+ "with_frame",
104
+ "write_data",
105
+ ]
106
+
107
+ logger = logging.getLogger(__name__)
108
+
109
+
110
+ class _Style(NamedTuple):
111
+ """The per-atom columns and the topology an atom style allows."""
112
+
113
+ molecule: bool
114
+ charge: bool
115
+ bonds: bool
116
+ angles: bool
117
+
118
+
119
+ # Atoms-section columns per atom style (LAMMPS read_data, "Atoms section").
120
+ _STYLES = {
121
+ "atomic": _Style(molecule=False, charge=False, bonds=False, angles=False),
122
+ "bond": _Style(molecule=True, charge=False, bonds=True, angles=False),
123
+ "angle": _Style(molecule=True, charge=False, bonds=True, angles=True),
124
+ "molecular": _Style(molecule=True, charge=False, bonds=True, angles=True),
125
+ "full": _Style(molecule=True, charge=True, bonds=True, angles=True),
126
+ }
127
+ _COUNTS = {
128
+ "atoms": "atoms", "bonds": "bonds", "angles": "angles",
129
+ "atom types": "atom_types", "bond types": "bond_types",
130
+ "angle types": "angle_types",
131
+ }
132
+ _UNSUPPORTED_COUNTS = (
133
+ "dihedrals", "impropers", "dihedral types", "improper types",
134
+ "ellipsoids", "lines", "triangles", "bodies",
135
+ )
136
+ _AXES = (("xlo", "xhi"), ("ylo", "yhi"), ("zlo", "zhi"))
137
+ _DEFAULT_TITLE = "LAMMPS data file written by polyphys.simulate.data"
138
+
139
+
140
+ class BrokenDataError(LammpsFormatError):
141
+ """A LAMMPS data file does not follow the data-file format.
142
+
143
+ .. versionadded:: 0.6.0
144
+
145
+ Examples
146
+ --------
147
+ >>> import tempfile
148
+ >>> with tempfile.TemporaryDirectory() as tmp:
149
+ ... path = Path(tmp) / "empty.data"
150
+ ... _ = path.write_text("title\\n\\n2 atoms\\n")
151
+ ... try:
152
+ ... read_data(path)
153
+ ... except BrokenDataError as err:
154
+ ... print(str(err).split(": ", 1)[1])
155
+ the file has a header but no body
156
+ """
157
+
158
+
159
+ def _empty(width: int) -> IntArray:
160
+ """Return an empty ``(0, width)`` int64 array."""
161
+ return np.empty((0, width), dtype=np.int64)
162
+
163
+
164
+ @dataclass(frozen=True, eq=False)
165
+ class DataFile:
166
+ """The contents of one LAMMPS data file.
167
+
168
+ The arrays are copied, made read-only, and checked on construction; use
169
+ :func:`dataclasses.replace` to change a field.
170
+
171
+ .. versionadded:: 0.6.0
172
+
173
+ Attributes
174
+ ----------
175
+ atom_style : {'atomic', 'bond', 'angle', 'molecular', 'full'}
176
+ The LAMMPS atom style. It sets the Atoms columns: ``atom-ID
177
+ atom-type x y z`` for ``atomic``; ``atom-ID molecule-ID atom-type x y
178
+ z`` for ``bond``, ``angle``, and ``molecular``; and ``atom-ID
179
+ molecule-ID atom-type q x y z`` for ``full``. ``atomic`` has no bonds,
180
+ ``bond`` no angles.
181
+ box : :class:`~polyphys.simulate.core.Box`
182
+ The simulation box. A triclinic box writes its ``xy xz yz`` line;
183
+ the boundary flags are not part of a data file.
184
+ ids : numpy.ndarray
185
+ Shape ``(n_atoms,)``, int64: unique atom IDs, from 1.
186
+ types : numpy.ndarray
187
+ Shape ``(n_atoms,)``, int64: atom types, from 1 to `n_atom_types`.
188
+ positions : numpy.ndarray
189
+ Shape ``(n_atoms, 3)``, float64: coordinates in the length unit of
190
+ the run. The writer does not check that they lie in the box: LAMMPS
191
+ remaps an atom outside it across a periodic boundary and loses one
192
+ outside a non-periodic boundary.
193
+ n_atom_types : int
194
+ Number of atom types, 1 or more; a type may be unused.
195
+ masses : numpy.ndarray or None
196
+ Shape ``(n_atom_types,)``: the mass of type :math:`k` at index
197
+ :math:`k - 1`, or None to write no ``Masses`` section and leave the
198
+ masses to the input script.
199
+ mol_ids : numpy.ndarray or None
200
+ Shape ``(n_atoms,)``, int64: molecule IDs, required by every style
201
+ but ``atomic`` and None for it.
202
+ charges : numpy.ndarray or None
203
+ Shape ``(n_atoms,)``: charges, required by ``full`` and None for the
204
+ others.
205
+ image_flags : numpy.ndarray or None
206
+ Shape ``(n_atoms, 3)``, int64: image flags, or None to write none
207
+ (LAMMPS then sets them to zero).
208
+ velocities : numpy.ndarray or None
209
+ Shape ``(n_atoms, 3)``: velocities, or None to write no
210
+ ``Velocities`` section (LAMMPS then sets them to zero).
211
+ bonds : numpy.ndarray
212
+ Shape ``(n_bonds, 3)``, int64: ``type, atom1, atom2`` per bond, the
213
+ atoms by ID. Bond IDs are not kept: the writer numbers bonds from 1
214
+ in row order. A ring is a chain with one more bond.
215
+ angles : numpy.ndarray
216
+ Shape ``(n_angles, 4)``, int64: ``type, atom1, atom2, atom3`` per
217
+ angle, with ``atom2`` the vertex.
218
+ n_bond_types, n_angle_types : int
219
+ Numbers of bond and angle types; 1 or more when there are bonds or
220
+ angles.
221
+
222
+ Raises
223
+ ------
224
+ TypeError
225
+ If `box` is not a :class:`~polyphys.simulate.core.Box`.
226
+ ValueError
227
+ If the atom style is unknown, an array has the wrong shape or
228
+ non-integer values where integers are needed, an atom ID repeats or
229
+ is below 1, a type is out of range, a bond or angle names an atom ID
230
+ that does not exist, or a field is given that the style does not
231
+ allow or omitted where it needs one.
232
+
233
+ Examples
234
+ --------
235
+ A ring of three beads with a closing bond:
236
+
237
+ >>> ring = DataFile(
238
+ ... atom_style="bond", box=Box(lo=np.zeros(3), hi=np.full(3, 10.0)),
239
+ ... ids=np.arange(1, 4), types=np.ones(3, dtype=int),
240
+ ... positions=np.array([[5.0, 5.0, 5.0], [6.0, 5.0, 5.0],
241
+ ... [5.5, 5.9, 5.0]]),
242
+ ... n_atom_types=1, mol_ids=np.ones(3, dtype=int),
243
+ ... bonds=np.array([[1, 1, 2], [1, 2, 3], [1, 3, 1]]), n_bond_types=1)
244
+ >>> ring.n_atoms, ring.n_bonds, ring.n_angles
245
+ (3, 3, 0)
246
+ """
247
+
248
+ atom_style: str
249
+ box: Box
250
+ ids: IntArray
251
+ types: IntArray
252
+ positions: FloatArray
253
+ n_atom_types: int
254
+ masses: FloatArray | None = None
255
+ mol_ids: IntArray | None = None
256
+ charges: FloatArray | None = None
257
+ image_flags: IntArray | None = None
258
+ velocities: FloatArray | None = None
259
+ bonds: IntArray = field(default_factory=lambda: _empty(3))
260
+ angles: IntArray = field(default_factory=lambda: _empty(4))
261
+ n_bond_types: int = 0
262
+ n_angle_types: int = 0
263
+
264
+ def __post_init__(self) -> None:
265
+ if self.atom_style not in _STYLES:
266
+ raise ValueError(
267
+ f"'atom_style' must be one of {tuple(_STYLES)}, "
268
+ f"got {self.atom_style!r}"
269
+ )
270
+ if not isinstance(self.box, Box):
271
+ raise TypeError(f"'box' must be a Box, got {type(self.box)}")
272
+ style = _STYLES[self.atom_style]
273
+ ids = self._set_int("ids", (-1,))
274
+ n_atoms = ids.shape[0]
275
+ self._set_float("positions", (n_atoms, 3))
276
+ types = self._set_int("types", (n_atoms,))
277
+ self._set_optional_float("velocities", (n_atoms, 3))
278
+ self._set_optional_int("image_flags", (n_atoms, 3))
279
+ self._set_needed("mol_ids", style.molecule, (n_atoms,), integer=True)
280
+ self._set_needed("charges", style.charge, (n_atoms,), integer=False)
281
+ bonds = self._set_int("bonds", (-1, 3))
282
+ angles = self._set_int("angles", (-1, 4))
283
+ if np.unique(ids).size != n_atoms or np.any(ids < 1):
284
+ raise ValueError("atom IDs must be unique and 1 or more")
285
+ _check_types("atom", types, self.n_atom_types, minimum=1)
286
+ self._set_optional_float("masses", (self.n_atom_types,))
287
+ if bonds.shape[0] and not style.bonds:
288
+ raise ValueError(f"atom style {self.atom_style!r} has no bonds")
289
+ if angles.shape[0] and not style.angles:
290
+ raise ValueError(f"atom style {self.atom_style!r} has no angles")
291
+ _check_types("bond", bonds[:, 0], self.n_bond_types,
292
+ minimum=1 if bonds.shape[0] else 0)
293
+ _check_types("angle", angles[:, 0], self.n_angle_types,
294
+ minimum=1 if angles.shape[0] else 0)
295
+ for name, members in (("bond", bonds), ("angle", angles)):
296
+ missing = np.setdiff1d(members[:, 1:], ids)
297
+ if missing.size:
298
+ raise ValueError(
299
+ f"a {name} names atom IDs that do not exist: "
300
+ f"{missing.tolist()}"
301
+ )
302
+
303
+ @property
304
+ def n_atoms(self) -> int:
305
+ """The number of atoms."""
306
+ return int(self.ids.shape[0])
307
+
308
+ @property
309
+ def n_bonds(self) -> int:
310
+ """The number of bonds."""
311
+ return int(self.bonds.shape[0])
312
+
313
+ @property
314
+ def n_angles(self) -> int:
315
+ """The number of angles."""
316
+ return int(self.angles.shape[0])
317
+
318
+ def unwrapped_positions(self) -> FloatArray:
319
+ """Return the positions unwrapped by the image flags.
320
+
321
+ Returns
322
+ -------
323
+ numpy.ndarray
324
+ Shape ``(n_atoms, 3)``, float64: the positions moved by the image
325
+ flags along the box edge vectors, by
326
+ :meth:`~polyphys.simulate.core.Box.unwrap`; a copy of the
327
+ positions when there are no image flags, which LAMMPS reads as
328
+ zero. Time and memory are :math:`O(n_a)`.
329
+
330
+ Examples
331
+ --------
332
+ >>> pair = DataFile(
333
+ ... atom_style="atomic",
334
+ ... box=Box(lo=np.zeros(3), hi=np.full(3, 10.0)),
335
+ ... ids=np.array([1, 2]), types=np.array([1, 1]),
336
+ ... positions=np.array([[9.5, 5.0, 5.0], [0.5, 5.0, 5.0]]),
337
+ ... n_atom_types=1, image_flags=np.array([[-1, 0, 0], [0, 0, 0]]))
338
+ >>> pair.unwrapped_positions()
339
+ array([[-0.5, 5. , 5. ],
340
+ [ 0.5, 5. , 5. ]])
341
+ """
342
+ if self.image_flags is None:
343
+ return self.positions.copy()
344
+ return self.box.unwrap(self.positions, self.image_flags)
345
+
346
+ def _set_float(self, name: str, shape: tuple[int, ...]) -> FloatArray:
347
+ value = _frozen(getattr(self, name), np.float64)
348
+ _check_shape(name, value, shape)
349
+ object.__setattr__(self, name, value)
350
+ return value
351
+
352
+ def _set_int(self, name: str, shape: tuple[int, ...]) -> IntArray:
353
+ value = _integers(name, getattr(self, name))
354
+ if value.size == 0 and len(shape) == 2 and shape[1] > 0:
355
+ value = value.reshape(0, shape[1]) # no bonds or no angles
356
+ _check_shape(name, value, shape)
357
+ value = _frozen(value, np.int64)
358
+ object.__setattr__(self, name, value)
359
+ return value
360
+
361
+ def _set_optional_float(self, name: str, shape: tuple[int, ...]) -> None:
362
+ if getattr(self, name) is not None:
363
+ self._set_float(name, shape)
364
+
365
+ def _set_optional_int(self, name: str, shape: tuple[int, ...]) -> None:
366
+ if getattr(self, name) is not None:
367
+ self._set_int(name, shape)
368
+
369
+ def _set_needed(
370
+ self, name: str, needed: bool, shape: tuple[int, ...], integer: bool
371
+ ) -> None:
372
+ given = getattr(self, name) is not None
373
+ if needed and not given:
374
+ raise ValueError(f"atom style {self.atom_style!r} needs '{name}'")
375
+ if given and not needed:
376
+ raise ValueError(
377
+ f"atom style {self.atom_style!r} has no '{name}'"
378
+ )
379
+ if given and integer:
380
+ self._set_int(name, shape)
381
+ elif given:
382
+ self._set_float(name, shape)
383
+
384
+
385
+ def _integers(name: str, value: Any) -> IntArray:
386
+ """Return `value` as an int64 array, refusing non-integer values."""
387
+ array = np.array(value)
388
+ if array.size and not np.issubdtype(array.dtype, np.integer):
389
+ if not np.issubdtype(array.dtype, np.number) or not np.all(
390
+ array == np.round(array)
391
+ ):
392
+ raise ValueError(f"'{name}' must hold integers")
393
+ return array.astype(np.int64)
394
+
395
+
396
+ def _check_shape(
397
+ name: str, value: npt.NDArray[Any], shape: tuple[int, ...]
398
+ ) -> None:
399
+ """Check `value` against `shape`, where -1 matches any length."""
400
+ if value.ndim != len(shape) or any(
401
+ want not in (-1, got) for want, got in zip(shape, value.shape)
402
+ ):
403
+ raise ValueError(
404
+ f"'{name}' must have shape {shape}, got {value.shape}"
405
+ )
406
+
407
+
408
+ def _check_types(
409
+ kind: str, types: IntArray, n_types: int, minimum: int
410
+ ) -> None:
411
+ """Check that every type lies from 1 to `n_types`."""
412
+ if n_types < minimum:
413
+ raise ValueError(f"'n_{kind}_types' must be {minimum} or more, "
414
+ f"got {n_types}")
415
+ if types.size and (types.min() < 1 or types.max() > n_types):
416
+ raise ValueError(f"{kind} types must be from 1 to {n_types}")
417
+
418
+
419
+ def write_data(
420
+ data: DataFile, path: str | Path, *, title: str | None = None
421
+ ) -> Path:
422
+ """Write a LAMMPS data file.
423
+
424
+ .. versionadded:: 0.6.0
425
+
426
+ Parameters
427
+ ----------
428
+ data : DataFile
429
+ The contents to write.
430
+ path : str or pathlib.Path
431
+ The file to write; an existing file is overwritten.
432
+ title : str, optional
433
+ The first line, which LAMMPS skips; one line, defaulting to a line
434
+ that names this module.
435
+
436
+ Returns
437
+ -------
438
+ pathlib.Path
439
+ `path`.
440
+
441
+ Raises
442
+ ------
443
+ ValueError
444
+ If `title` has a line break.
445
+
446
+ Notes
447
+ -----
448
+ The header gives ``atoms`` and ``atom types``; ``bonds`` and ``bond
449
+ types`` for every style with bonds; ``angles`` and ``angle types`` for
450
+ every style with angles; the box; and ``xy xz yz`` when its box is
451
+ triclinic. Then come ``Masses`` if given, ``Atoms # <style>`` with
452
+ image flags if given, ``Velocities`` if given, and ``Bonds`` and
453
+ ``Angles`` if there are any, numbered from 1. Floats are written as the
454
+ shortest decimal that reads back to the same float64, so
455
+ ``read_data(write_data(data, path))`` returns `data` unchanged. No
456
+ coefficient section is written. Time and memory are :math:`O(n_a + n_b +
457
+ n_t)`.
458
+
459
+ Examples
460
+ --------
461
+ >>> import tempfile
462
+ >>> pair = DataFile(
463
+ ... atom_style="atomic", box=Box(lo=np.zeros(3), hi=np.full(3, 5.0)),
464
+ ... ids=np.array([1, 2]), types=np.array([1, 2]),
465
+ ... positions=np.array([[2.0, 2.5, 2.5], [3.1, 2.5, 2.5]]),
466
+ ... n_atom_types=2)
467
+ >>> with tempfile.TemporaryDirectory() as tmp:
468
+ ... path = write_data(pair, Path(tmp) / "pair.data", title="Two beads")
469
+ ... print(path.read_text())
470
+ Two beads
471
+ <BLANKLINE>
472
+ 2 atoms
473
+ 2 atom types
474
+ <BLANKLINE>
475
+ 0.0 5.0 xlo xhi
476
+ 0.0 5.0 ylo yhi
477
+ 0.0 5.0 zlo zhi
478
+ <BLANKLINE>
479
+ Atoms # atomic
480
+ <BLANKLINE>
481
+ 1 1 2.0 2.5 2.5
482
+ 2 2 3.1 2.5 2.5
483
+ <BLANKLINE>
484
+ """
485
+ title = _DEFAULT_TITLE if title is None else title
486
+ if "\n" in title or "\r" in title:
487
+ raise ValueError("'title' must be one line")
488
+ style = _STYLES[data.atom_style]
489
+ lines = [title, "", f"{data.n_atoms} atoms",
490
+ f"{data.n_atom_types} atom types"]
491
+ if style.bonds:
492
+ lines += [f"{data.n_bonds} bonds", f"{data.n_bond_types} bond types"]
493
+ if style.angles:
494
+ lines += [f"{data.n_angles} angles",
495
+ f"{data.n_angle_types} angle types"]
496
+ lines.append("")
497
+ for (lo_name, hi_name), lo, hi in zip(_AXES, data.box.lo.tolist(),
498
+ data.box.hi.tolist()):
499
+ lines.append(f"{lo!r} {hi!r} {lo_name} {hi_name}")
500
+ if data.box.triclinic:
501
+ lines.append(" ".join(map(repr, data.box.tilt.tolist()))
502
+ + " xy xz yz")
503
+ if data.masses is not None:
504
+ lines += ["", "Masses", ""]
505
+ lines += [f"{k} {mass!r}"
506
+ for k, mass in enumerate(data.masses.tolist(), start=1)]
507
+ lines += ["", f"Atoms # {data.atom_style}", ""]
508
+ lines += _atom_lines(data)
509
+ if data.velocities is not None:
510
+ lines += ["", "Velocities", ""]
511
+ lines += [f"{atom} " + " ".join(map(repr, v))
512
+ for atom, v in zip(data.ids.tolist(),
513
+ data.velocities.tolist())]
514
+ for name, rows in (("Bonds", data.bonds), ("Angles", data.angles)):
515
+ if rows.shape[0]:
516
+ lines += ["", name, ""]
517
+ lines += [f"{k} " + " ".join(map(str, row))
518
+ for k, row in enumerate(rows.tolist(), start=1)]
519
+ path = Path(path)
520
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
521
+ return path
522
+
523
+
524
+ def _atom_lines(data: DataFile) -> list[str]:
525
+ """Return the Atoms-section lines in the column order of the style."""
526
+ columns: list[list[str]] = [[str(atom)] for atom in data.ids.tolist()]
527
+ if data.mol_ids is not None:
528
+ for row, mol in zip(columns, data.mol_ids.tolist()):
529
+ row.append(str(mol))
530
+ for row, atom_type in zip(columns, data.types.tolist()):
531
+ row.append(str(atom_type))
532
+ if data.charges is not None:
533
+ for row, charge in zip(columns, data.charges.tolist()):
534
+ row.append(repr(charge))
535
+ for row, xyz in zip(columns, data.positions.tolist()):
536
+ row.extend(map(repr, xyz))
537
+ if data.image_flags is not None:
538
+ for row, image in zip(columns, data.image_flags.tolist()):
539
+ row.extend(map(str, image))
540
+ return [" ".join(row) for row in columns]
541
+
542
+
543
+ class _Section(NamedTuple):
544
+ """One body section: its name, its comment, and its numbered rows."""
545
+
546
+ name: str
547
+ comment: str
548
+ line: int
549
+ rows: list[tuple[int, list[str]]]
550
+
551
+
552
+ def read_data(path: str | Path, atom_style: str | None = None) -> DataFile:
553
+ """Read a LAMMPS data file.
554
+
555
+ .. versionadded:: 0.6.0
556
+
557
+ Parameters
558
+ ----------
559
+ path : str or pathlib.Path
560
+ The data file, read as UTF-8; a name ending in ``.gz`` is read
561
+ through gzip.
562
+ atom_style : str, optional
563
+ The atom style, one of those of :class:`DataFile`. Needed when the
564
+ ``Atoms`` line has no style comment such as ``Atoms # bond``; when
565
+ both are given they must agree.
566
+
567
+ Returns
568
+ -------
569
+ DataFile
570
+ Atoms sorted by ID; bonds and angles sorted by their IDs, which are
571
+ then dropped.
572
+
573
+ Raises
574
+ ------
575
+ BrokenDataError
576
+ If the file does not follow the format, uses a feature this module
577
+ does not support (see the module documentation), or gives no atom
578
+ style, or two that disagree. The message names the file and, where
579
+ it can, the line, from 1.
580
+
581
+ Notes
582
+ -----
583
+ Sections whose names end in ``Coeffs`` are skipped and logged at level
584
+ DEBUG. Time and memory are :math:`O(L)` in the length :math:`L` of the
585
+ file.
586
+
587
+ Examples
588
+ --------
589
+ >>> import tempfile
590
+ >>> text = '''Two beads and one bond
591
+ ...
592
+ ... 2 atoms
593
+ ... 1 atom types
594
+ ... 1 bonds
595
+ ... 1 bond types
596
+ ... 0 5 xlo xhi
597
+ ... 0 5 ylo yhi
598
+ ... 0 5 zlo zhi
599
+ ...
600
+ ... Bond Coeffs # fene
601
+ ...
602
+ ... 1 30.0 1.5 1.0 1.0
603
+ ...
604
+ ... Atoms # bond
605
+ ...
606
+ ... 2 1 1 3.0 2.5 2.5 0 0 0
607
+ ... 1 1 1 2.0 2.5 2.5 0 0 0
608
+ ...
609
+ ... Bonds
610
+ ...
611
+ ... 1 1 1 2
612
+ ... '''
613
+ >>> with tempfile.TemporaryDirectory() as tmp:
614
+ ... path = Path(tmp) / "pair.data"
615
+ ... _ = path.write_text(text)
616
+ ... data = read_data(path)
617
+ >>> data.atom_style, data.ids, data.bonds
618
+ ('bond', array([1, 2]), array([[1, 1, 2]]))
619
+ >>> data.positions[:, 0]
620
+ array([2., 3.])
621
+ """
622
+ path = Path(path)
623
+ with _open_text(path) as stream:
624
+ lines = stream.read().splitlines()
625
+ header, body = _read_header(lines, path)
626
+ sections = _read_sections(lines, body, path)
627
+ atoms = next((s for s in sections if s.name == "Atoms"), None)
628
+ if atoms is None:
629
+ raise _broken(path, None, "no Atoms section")
630
+ style = _atom_style(atoms, atom_style, path)
631
+ try:
632
+ fields = _read_body(sections, style, header, path)
633
+ return DataFile(
634
+ box=header["box"], n_atom_types=header["atom_types"],
635
+ n_bond_types=header["bond_types"],
636
+ n_angle_types=header["angle_types"], **fields,
637
+ )
638
+ except BrokenDataError:
639
+ raise
640
+ except ValueError as err:
641
+ raise _broken(path, None, str(err)) from None
642
+
643
+
644
+ def _broken(path: Path, line: int | None, message: str) -> BrokenDataError:
645
+ """Return an error naming the file and, when known, the line."""
646
+ where = f"{path}" if line is None else f"{path}, line {line}"
647
+ return BrokenDataError(f"{where}: {message}")
648
+
649
+
650
+ def _read_body(
651
+ sections: list["_Section"], style: str, header: dict[str, Any],
652
+ path: Path,
653
+ ) -> dict[str, Any]:
654
+ """Read the sections into the fields of :class:`DataFile`."""
655
+ fields: dict[str, Any] = {"atom_style": style}
656
+ for section in sections:
657
+ if section.name == "Atoms":
658
+ fields.update(_read_atoms(section, style, header, path))
659
+ elif section.name == "Velocities":
660
+ fields["velocities"] = _read_velocities(section, header, path)
661
+ elif section.name == "Masses":
662
+ fields["masses"] = _read_masses(section, header, path)
663
+ elif section.name in ("Bonds", "Angles"):
664
+ width = 4 if section.name == "Bonds" else 5
665
+ count = header["bonds" if width == 4 else "angles"]
666
+ rows = _table(section, width, count, path)
667
+ order = np.argsort(rows[:, 0], kind="stable")
668
+ fields[section.name.lower()] = _integers(
669
+ section.name, rows[order, 1:]
670
+ )
671
+ elif section.name.endswith("Coeffs"):
672
+ logger.debug("%s: skipped section %r (line %d)",
673
+ path, section.name, section.line)
674
+ else:
675
+ raise _broken(path, section.line,
676
+ f"section {section.name!r} is not supported")
677
+ if "velocities" in fields:
678
+ fields["velocities"] = _by_atom(fields.pop("velocities"),
679
+ fields["ids"], "Velocities", path)
680
+ if header["bonds"] and "bonds" not in fields:
681
+ raise _broken(path, None, f"{header['bonds']} bonds but no Bonds")
682
+ if header["angles"] and "angles" not in fields:
683
+ raise _broken(path, None, f"{header['angles']} angles but no Angles")
684
+ return fields
685
+
686
+
687
+ def _content(line: str) -> tuple[str, str]:
688
+ """Split a line into its content and its comment, both stripped."""
689
+ content, _, comment = line.partition("#")
690
+ return content.strip(), comment.strip()
691
+
692
+
693
+ def _read_header(lines: list[str], path: Path) -> tuple[dict[str, Any], int]:
694
+ """Read the header; return its values and the index of the body."""
695
+ header: dict[str, Any] = {key: 0 for key in _COUNTS.values()}
696
+ lo, hi = np.zeros(3), np.zeros(3)
697
+ tilt: FloatArray | None = None
698
+ seen: set[str] = set()
699
+ for index in range(1, len(lines)):
700
+ content, _ = _content(lines[index])
701
+ if not content:
702
+ continue
703
+ words = content.split()
704
+ keyword = " ".join(words[1:])
705
+ try:
706
+ if words[-3:] == ["xy", "xz", "yz"] and len(words) == 6:
707
+ tilt = np.array(words[:3], dtype=np.float64)
708
+ elif len(words) == 4 and tuple(words[2:]) in _AXES:
709
+ axis = _AXES.index((words[2], words[3]))
710
+ lo[axis] = float(words[0])
711
+ hi[axis] = float(words[1])
712
+ seen.add(words[2])
713
+ elif keyword in _COUNTS:
714
+ header[_COUNTS[keyword]] = int(words[0])
715
+ elif keyword in _UNSUPPORTED_COUNTS:
716
+ if int(words[0]):
717
+ raise ValueError(f"{keyword} are not supported")
718
+ elif len(words) > 2 and words[1] == "extra":
719
+ logger.debug("%s: skipped header line %r", path, content)
720
+ elif words[-2:] == ["abc", "origin"] or words[-1] in (
721
+ "avec", "bvec", "cvec"
722
+ ):
723
+ raise ValueError("general triclinic boxes are not supported")
724
+ else:
725
+ break
726
+ except ValueError as err:
727
+ raise _broken(path, index + 1, str(err)) from None
728
+ else:
729
+ raise _broken(path, None, "the file has a header but no body")
730
+ missing = [name for name, _ in _AXES if name not in seen]
731
+ if missing:
732
+ raise _broken(path, None, f"the header gives no {missing} bounds")
733
+ try:
734
+ header["box"] = Box(lo=lo, hi=hi,
735
+ tilt=np.zeros(3) if tilt is None else tilt,
736
+ triclinic=tilt is not None)
737
+ except ValueError as err:
738
+ raise _broken(path, None, str(err)) from None
739
+ return header, index
740
+
741
+
742
+ def _is_number(word: str) -> bool:
743
+ """Return True if `word` reads as a number."""
744
+ try:
745
+ float(word)
746
+ except ValueError:
747
+ return False
748
+ return True
749
+
750
+
751
+ def _read_sections(
752
+ lines: list[str], start: int, path: Path
753
+ ) -> list[_Section]:
754
+ """Split the body into sections; a line that starts with a number is an
755
+ entry of the section above it."""
756
+ sections: list[_Section] = []
757
+ names: set[str] = set()
758
+ for index in range(start, len(lines)):
759
+ content, comment = _content(lines[index])
760
+ if not content:
761
+ continue
762
+ words = content.split()
763
+ if _is_number(words[0]):
764
+ if not sections:
765
+ raise _broken(path, index + 1,
766
+ "an entry before any section keyword")
767
+ sections[-1].rows.append((index + 1, words))
768
+ continue
769
+ if content in names:
770
+ raise _broken(path, index + 1, f"section {content!r} repeats")
771
+ names.add(content)
772
+ sections.append(_Section(content, comment, index + 1, []))
773
+ return sections
774
+
775
+
776
+ def _atom_style(atoms: _Section, given: str | None, path: Path) -> str:
777
+ """Return the atom style from the argument and the Atoms comment."""
778
+ named = atoms.comment.split()[0] if atoms.comment else None
779
+ if given is not None:
780
+ if named is not None and given != named:
781
+ raise _broken(path, atoms.line, f"'atom_style' is {given!r} but "
782
+ f"the file says {named!r}")
783
+ style = given
784
+ elif named is not None:
785
+ style = named
786
+ else:
787
+ raise _broken(path, atoms.line,
788
+ "the Atoms line names no atom style; pass 'atom_style'")
789
+ if style not in _STYLES:
790
+ raise _broken(path, atoms.line, f"atom style {style!r} is not "
791
+ f"supported; use one of {tuple(_STYLES)}")
792
+ return style
793
+
794
+
795
+ def _table(
796
+ section: _Section, width: int | tuple[int, ...], count: int, path: Path
797
+ ) -> FloatArray:
798
+ """Return a section's rows as a float64 array, checking their number and
799
+ width."""
800
+ widths = (width,) if isinstance(width, int) else width
801
+ if len(section.rows) != count:
802
+ raise _broken(path, section.line, f"{section.name} has "
803
+ f"{len(section.rows)} entries, the header says {count}")
804
+ if not section.rows:
805
+ return np.empty((0, widths[0]))
806
+ found = len(section.rows[0][1])
807
+ for line, words in section.rows:
808
+ if len(words) != found or found not in widths:
809
+ raise _broken(path, line, f"a {section.name} entry needs "
810
+ f"{' or '.join(map(str, widths))} fields, the same "
811
+ f"on every line")
812
+ try:
813
+ return np.array([words for _, words in section.rows],
814
+ dtype=np.float64)
815
+ except ValueError:
816
+ raise _broken(path, section.line, f"a {section.name} entry holds a "
817
+ f"value that is not a number") from None
818
+
819
+
820
+ def _read_atoms(
821
+ section: _Section, style: str, header: dict[str, Any], path: Path
822
+ ) -> dict[str, Any]:
823
+ """Read the Atoms section into the fields of :class:`DataFile`."""
824
+ spec = _STYLES[style]
825
+ names = ["ids"] + (["mol_ids"] if spec.molecule else []) + ["types"]
826
+ names += (["charges"] if spec.charge else []) + ["x", "y", "z"]
827
+ rows = _table(section, (len(names), len(names) + 3), header["atoms"],
828
+ path)
829
+ rows = rows[np.argsort(rows[:, 0], kind="stable")]
830
+ fields: dict[str, Any] = {}
831
+ for column, name in enumerate(names[:-3]):
832
+ values = rows[:, column]
833
+ fields[name] = values if name == "charges" else _integers(
834
+ name, values)
835
+ fields["positions"] = rows[:, len(names) - 3:len(names)]
836
+ if rows.shape[1] > len(names):
837
+ fields["image_flags"] = _integers("image flags",
838
+ rows[:, len(names):])
839
+ return fields
840
+
841
+
842
+ def _read_velocities(
843
+ section: _Section, header: dict[str, Any], path: Path
844
+ ) -> FloatArray:
845
+ """Read the Velocities section as ``atom-ID vx vy vz`` rows."""
846
+ return _table(section, 4, header["atoms"], path)
847
+
848
+
849
+ def _by_atom(
850
+ rows: FloatArray, ids: IntArray, name: str, path: Path
851
+ ) -> FloatArray:
852
+ """Order ``atom-ID value...`` rows like `ids`, which are sorted."""
853
+ keys = _integers(name, rows[:, 0])
854
+ order = np.argsort(keys, kind="stable")
855
+ if not np.array_equal(keys[order], ids):
856
+ raise _broken(path, None,
857
+ f"the {name} atom IDs differ from the Atoms atom IDs")
858
+ ordered: FloatArray = rows[order, 1:]
859
+ return ordered
860
+
861
+
862
+ def _read_masses(
863
+ section: _Section, header: dict[str, Any], path: Path
864
+ ) -> FloatArray:
865
+ """Read the Masses section, one ``type mass`` line per atom type."""
866
+ rows = _table(section, 2, header["atom_types"], path)
867
+ types = _integers("Masses", rows[:, 0])
868
+ order = np.argsort(types, kind="stable")
869
+ expected = np.arange(1, header["atom_types"] + 1)
870
+ if not np.array_equal(types[order], expected):
871
+ raise _broken(path, section.line, f"Masses must give each atom type "
872
+ f"from 1 to {header['atom_types']} once")
873
+ masses: FloatArray = rows[order, 1]
874
+ return masses
875
+
876
+
877
+ def with_frame(template: DataFile, frame: DumpFrame) -> DataFile:
878
+ """Return `template` with the positions and the box of a dump frame.
879
+
880
+ Use it to write the topology that goes with a dump, for analysis: the
881
+ atoms, types, masses, bonds, and angles come from `template`, and the
882
+ positions and box from `frame`.
883
+
884
+ .. versionadded:: 0.6.0
885
+
886
+ Parameters
887
+ ----------
888
+ template : DataFile
889
+ A data file of the same system, such as the one the run started
890
+ from.
891
+ frame : DumpFrame
892
+ A frame with an ``id`` column and wrapped coordinate columns,
893
+ ``x y z`` or the scaled ``xs ys zs``. Unwrapped columns alone, ``xu
894
+ yu zu`` or ``xsu ysu zsu``, are not accepted.
895
+
896
+ Returns
897
+ -------
898
+ DataFile
899
+ `template` with, matched by atom ID: the frame's wrapped positions;
900
+ its image flags if it has ``ix iy iz``, else none; its velocities if
901
+ it has ``vx vy vz``, else none; and its box.
902
+
903
+ Raises
904
+ ------
905
+ ValueError
906
+ If the frame has no ``id`` column, its atom IDs differ from those of
907
+ `template`, or its ``type`` column, when present, disagrees with the
908
+ template's types.
909
+ KeyError
910
+ If the frame has no wrapped coordinate columns, as in a dump of
911
+ unwrapped coordinates only.
912
+
913
+ Notes
914
+ -----
915
+ The template's image flags and velocities belong to its own positions,
916
+ so they are never carried over. Time is :math:`O(n_a \\log n_a)`, memory
917
+ :math:`O(n_a)`.
918
+
919
+ Examples
920
+ --------
921
+ >>> template = DataFile(
922
+ ... atom_style="bond", box=Box(lo=np.zeros(3), hi=np.full(3, 10.0)),
923
+ ... ids=np.array([1, 2]), types=np.array([1, 1]),
924
+ ... positions=np.array([[1.0, 5.0, 5.0], [2.0, 5.0, 5.0]]),
925
+ ... n_atom_types=1, mol_ids=np.array([1, 1]),
926
+ ... bonds=np.array([[1, 1, 2]]), n_bond_types=1)
927
+ >>> frame = DumpFrame(timestep=1000,
928
+ ... box=Box(lo=np.zeros(3), hi=np.full(3, 8.0)),
929
+ ... columns=("id", "type", "xs", "ys", "zs"),
930
+ ... data=np.array([[2, 1, 0.5, 0.5, 0.5],
931
+ ... [1, 1, 0.375, 0.5, 0.5]]))
932
+ >>> moved = with_frame(template, frame)
933
+ >>> moved.positions
934
+ array([[3., 4., 4.],
935
+ [4., 4., 4.]])
936
+ >>> moved.box.hi, moved.bonds
937
+ (array([8., 8., 8.]), array([[1, 1, 2]]))
938
+ """
939
+ if "id" not in frame.columns:
940
+ raise ValueError("the frame has no 'id' column")
941
+ frame_ids = frame.column("id")
942
+ frame_order = np.argsort(frame_ids, kind="stable")
943
+ template_order = np.argsort(template.ids, kind="stable")
944
+ if not np.array_equal(frame_ids[frame_order],
945
+ template.ids[template_order]):
946
+ raise ValueError("the frame's atom IDs differ from the template's")
947
+ # Row k of the template takes the frame row with the same atom ID.
948
+ rows = np.empty_like(frame_order)
949
+ rows[template_order] = frame_order
950
+ if "type" in frame.columns and not np.array_equal(
951
+ frame.column("type")[rows], template.types
952
+ ):
953
+ raise ValueError("the frame's atom types differ from the template's")
954
+ try:
955
+ positions = frame.positions()
956
+ except KeyError:
957
+ raise KeyError(
958
+ "with_frame needs wrapped coordinates, x y z or xs ys zs; a frame "
959
+ "with unwrapped coordinates only, or none, has no positions "
960
+ f"inside the box: {frame.columns}"
961
+ ) from None
962
+ columns = set(frame.columns)
963
+ image: IntArray | None = None
964
+ if {"ix", "iy", "iz"} <= columns:
965
+ image = np.column_stack(
966
+ [frame.column(n) for n in ("ix", "iy", "iz")]
967
+ ).astype(np.int64)[rows]
968
+ velocities: FloatArray | None = None
969
+ if {"vx", "vy", "vz"} <= columns:
970
+ velocities = np.column_stack(
971
+ [frame.column(n) for n in ("vx", "vy", "vz")]
972
+ ).astype(np.float64)[rows]
973
+ return dataclasses.replace(
974
+ template,
975
+ positions=positions[rows], box=frame.box,
976
+ image_flags=image, velocities=velocities,
977
+ )