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.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- 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
|
+
)
|