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,753 @@
|
|
|
1
|
+
"""Shared fixtures for the PolyPhys test suite.
|
|
2
|
+
|
|
3
|
+
Every fixture here writes tier A data: small synthetic files generated in the
|
|
4
|
+
test's own ``tmp_path``, carrying no simulation data.
|
|
5
|
+
``docs/source/testing.md`` describes the three test-data tiers and when each
|
|
6
|
+
is used.
|
|
7
|
+
|
|
8
|
+
Fixtures
|
|
9
|
+
--------
|
|
10
|
+
make_temp_file
|
|
11
|
+
An empty file with a given name.
|
|
12
|
+
make_lineage_tree
|
|
13
|
+
A synthetic lineage tree of ``segment`` or ``whole`` files named by a
|
|
14
|
+
chosen parser class.
|
|
15
|
+
make_lammps_data, make_lammps_dump
|
|
16
|
+
A tiny LAMMPS data file in the ``atomic``, ``bond``, or ``angle`` atom
|
|
17
|
+
style, and a tiny LAMMPS dump file in the ``atom`` style with unscaled
|
|
18
|
+
coordinates.
|
|
19
|
+
make_chain_files
|
|
20
|
+
A ``bond``-style LAMMPS data file and a dump of one linear chain, from
|
|
21
|
+
an array of per-frame positions, for the probe tests and the pipeline
|
|
22
|
+
test that reduces a probe's output.
|
|
23
|
+
private_data_dir
|
|
24
|
+
The directory named by ``POLYPHYS_DATA_DIR``, for tests marked
|
|
25
|
+
``private_data``.
|
|
26
|
+
lammps_executable
|
|
27
|
+
The LAMMPS executable named by ``LMP``, or ``lmp_serial`` on ``PATH``,
|
|
28
|
+
for tests marked ``lammps``.
|
|
29
|
+
|
|
30
|
+
Markers
|
|
31
|
+
-------
|
|
32
|
+
private_data
|
|
33
|
+
The test reads the maintainer's local full-size data (tier C). It is
|
|
34
|
+
skipped when ``POLYPHYS_DATA_DIR`` is unset, which is always the case in
|
|
35
|
+
CI, and it errors when the variable names something that is not a
|
|
36
|
+
directory.
|
|
37
|
+
lammps
|
|
38
|
+
The test runs LAMMPS. It is skipped when neither ``LMP`` names an
|
|
39
|
+
executable nor ``lmp_serial`` is on ``PATH``, which is the case in CI,
|
|
40
|
+
and it errors when ``LMP`` names something that is not an executable.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from types import MappingProxyType
|
|
48
|
+
from typing import Any, Callable, Mapping, Optional, Sequence
|
|
49
|
+
|
|
50
|
+
import numpy as np
|
|
51
|
+
import pandas as pd
|
|
52
|
+
import pytest
|
|
53
|
+
|
|
54
|
+
from polyphys.simulate.core import Box
|
|
55
|
+
from polyphys.simulate.data import DataFile, write_data
|
|
56
|
+
|
|
57
|
+
DATA_DIR_ENV = "POLYPHYS_DATA_DIR"
|
|
58
|
+
LAMMPS_ENV = "LMP"
|
|
59
|
+
LAMMPS_DEFAULT = "lmp_serial"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lammps_executable() -> Optional[str]:
|
|
63
|
+
"""Return the LAMMPS executable: ``$LMP`` when set, else ``lmp_serial``
|
|
64
|
+
on ``PATH``, else None."""
|
|
65
|
+
named = os.environ.get(LAMMPS_ENV, "")
|
|
66
|
+
if named:
|
|
67
|
+
return named
|
|
68
|
+
return shutil.which(LAMMPS_DEFAULT)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
72
|
+
"""Register the ``private_data`` and ``lammps`` markers.
|
|
73
|
+
|
|
74
|
+
The markers are registered here rather than in ``pyproject.toml`` so that
|
|
75
|
+
a nested pytest run (``pytester``) that loads this file also knows them.
|
|
76
|
+
"""
|
|
77
|
+
config.addinivalue_line(
|
|
78
|
+
"markers",
|
|
79
|
+
"private_data: reads the maintainer's local data under "
|
|
80
|
+
f"{DATA_DIR_ENV}; skipped when the variable is unset",
|
|
81
|
+
)
|
|
82
|
+
config.addinivalue_line(
|
|
83
|
+
"markers",
|
|
84
|
+
f"lammps: runs the LAMMPS executable named by {LAMMPS_ENV}, or "
|
|
85
|
+
f"{LAMMPS_DEFAULT} on PATH; skipped when neither exists",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pytest_runtest_setup(item: pytest.Item) -> None:
|
|
90
|
+
"""Skip or fail a ``private_data`` or ``lammps`` test by its environment.
|
|
91
|
+
|
|
92
|
+
``private_data``: ``POLYPHYS_DATA_DIR`` unset or empty skips the test;
|
|
93
|
+
set to a path that is not a directory, the test errors in setup, so a
|
|
94
|
+
misconfigured machine is not mistaken for one without data. ``lammps``:
|
|
95
|
+
no executable skips the test; ``LMP`` naming something that is not an
|
|
96
|
+
executable errors.
|
|
97
|
+
"""
|
|
98
|
+
if item.get_closest_marker("private_data") is not None:
|
|
99
|
+
data_dir = os.environ.get(DATA_DIR_ENV, "")
|
|
100
|
+
if data_dir == "":
|
|
101
|
+
pytest.skip(f"{DATA_DIR_ENV} is not set")
|
|
102
|
+
if not Path(data_dir).is_dir():
|
|
103
|
+
pytest.fail(
|
|
104
|
+
f"{DATA_DIR_ENV}={data_dir!r} is not a directory",
|
|
105
|
+
pytrace=False,
|
|
106
|
+
)
|
|
107
|
+
if item.get_closest_marker("lammps") is not None:
|
|
108
|
+
executable = _lammps_executable()
|
|
109
|
+
if executable is None:
|
|
110
|
+
pytest.skip(f"no LAMMPS: set {LAMMPS_ENV} or put "
|
|
111
|
+
f"{LAMMPS_DEFAULT} on PATH")
|
|
112
|
+
if shutil.which(executable) is None:
|
|
113
|
+
pytest.fail(
|
|
114
|
+
f"{LAMMPS_ENV}={executable!r} is not an executable",
|
|
115
|
+
pytrace=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.fixture
|
|
120
|
+
def lammps_executable() -> str:
|
|
121
|
+
"""Return the LAMMPS executable for a test marked ``lammps``.
|
|
122
|
+
|
|
123
|
+
The marker skips the test before this fixture runs when there is none.
|
|
124
|
+
|
|
125
|
+
Examples
|
|
126
|
+
--------
|
|
127
|
+
.. code-block:: python
|
|
128
|
+
|
|
129
|
+
@pytest.mark.lammps
|
|
130
|
+
def test_run_starts(lammps_executable, tmp_path):
|
|
131
|
+
subprocess.run([lammps_executable, "-in", "in.kg", ...])
|
|
132
|
+
"""
|
|
133
|
+
executable = _lammps_executable()
|
|
134
|
+
if executable is None:
|
|
135
|
+
pytest.skip(f"no LAMMPS: set {LAMMPS_ENV} or put "
|
|
136
|
+
f"{LAMMPS_DEFAULT} on PATH")
|
|
137
|
+
return executable
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.fixture
|
|
141
|
+
def private_data_dir() -> Path:
|
|
142
|
+
"""Return the directory named by ``POLYPHYS_DATA_DIR``.
|
|
143
|
+
|
|
144
|
+
Use it only in a test marked ``@pytest.mark.private_data``; the marker
|
|
145
|
+
skips the test before this fixture runs when the variable is unset.
|
|
146
|
+
|
|
147
|
+
Examples
|
|
148
|
+
--------
|
|
149
|
+
.. code-block:: python
|
|
150
|
+
|
|
151
|
+
@pytest.mark.private_data
|
|
152
|
+
def test_profiles_match_reference(private_data_dir):
|
|
153
|
+
folder = private_data_dir / "TransFociCyl" / "golden" / "density"
|
|
154
|
+
"""
|
|
155
|
+
return Path(os.environ[DATA_DIR_ENV]) # pragma: no cover - needs data
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.fixture
|
|
159
|
+
def make_temp_file(tmp_path):
|
|
160
|
+
"""
|
|
161
|
+
Create an empty temporary file with a given filename and return its full
|
|
162
|
+
path. Useful for testing parsers that expect a file path as input.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def _make_temp_file(filename: str):
|
|
166
|
+
f = tmp_path / filename
|
|
167
|
+
f.touch()
|
|
168
|
+
return str(f)
|
|
169
|
+
|
|
170
|
+
return _make_temp_file
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Synthetic lineage tree
|
|
174
|
+
|
|
175
|
+
# A value for every filename keyword of the parser classes in
|
|
176
|
+
# polyphys.manage.parser. They are small, so every derived volume fraction
|
|
177
|
+
# stays well below 1, and they carry no meaning beyond being parseable.
|
|
178
|
+
_KEYWORD_DEFAULTS: dict[str, float] = {
|
|
179
|
+
"N": 20,
|
|
180
|
+
"nm": 2,
|
|
181
|
+
"ns": 20,
|
|
182
|
+
"nl": 2,
|
|
183
|
+
"nh": 2,
|
|
184
|
+
"nc": 100,
|
|
185
|
+
"nb": 10,
|
|
186
|
+
"am": 1.0,
|
|
187
|
+
"ac": 1.0,
|
|
188
|
+
"ab": 2.0,
|
|
189
|
+
"al": 2.0,
|
|
190
|
+
"ml": 8.0,
|
|
191
|
+
"sig": 1.0,
|
|
192
|
+
"hl": 10.0,
|
|
193
|
+
"l": 10.0,
|
|
194
|
+
"r": 5.5,
|
|
195
|
+
"lz": 25.0,
|
|
196
|
+
"sd": 1.0,
|
|
197
|
+
"epsilon": 5.0,
|
|
198
|
+
"epss": 1.0,
|
|
199
|
+
"epsl": 1.0,
|
|
200
|
+
"kbmm": 1.0,
|
|
201
|
+
"epshc": 1.0,
|
|
202
|
+
"dt": 0.005,
|
|
203
|
+
"bdump": 1000,
|
|
204
|
+
"adump": 5000,
|
|
205
|
+
"tdump": 1000,
|
|
206
|
+
}
|
|
207
|
+
# Keywords the tree sets itself, one per lineage level.
|
|
208
|
+
_INDEX_KEYWORDS = ("ens", "j")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class LineageTree:
|
|
213
|
+
"""The files written by :func:`make_lineage_tree`.
|
|
214
|
+
|
|
215
|
+
Attributes
|
|
216
|
+
----------
|
|
217
|
+
root : Path
|
|
218
|
+
Directory holding every file. Its name contains hyphens, as a lineage
|
|
219
|
+
directory name does.
|
|
220
|
+
lineage : {'segment', 'whole'}
|
|
221
|
+
The lineage level of every file.
|
|
222
|
+
group : str
|
|
223
|
+
The particle group in every filename.
|
|
224
|
+
prop : str
|
|
225
|
+
The property in every filename.
|
|
226
|
+
paths : tuple of Path
|
|
227
|
+
Every file, ordered by ensemble, then whole, then segment.
|
|
228
|
+
index : mapping of Path to tuple of int
|
|
229
|
+
For each file, its ``(ensemble, whole, segment)`` indices, all
|
|
230
|
+
starting at 1. ``whole`` equals the ``ens`` value in the name and
|
|
231
|
+
``segment`` the ``j`` value; ``segment`` is 0 for a whole file.
|
|
232
|
+
Read-only, like the rest of the record.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
root: Path
|
|
236
|
+
lineage: str
|
|
237
|
+
group: str
|
|
238
|
+
prop: str
|
|
239
|
+
paths: tuple[Path, ...]
|
|
240
|
+
index: Mapping[Path, tuple[int, int, int]]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def lineage_tree_value(
|
|
244
|
+
ensemble: int, whole: int, segment: int, n_rows: int
|
|
245
|
+
) -> np.ndarray:
|
|
246
|
+
"""Return the contents of one lineage-tree file.
|
|
247
|
+
|
|
248
|
+
Row ``k`` holds ``1000 * ensemble + 100 * whole + 10 * segment + k``, so
|
|
249
|
+
each value names its file and row, and sums and concatenations over a
|
|
250
|
+
tree can be written down by hand.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
ensemble, whole, segment : int
|
|
255
|
+
The file's indices, as in :attr:`LineageTree.index`; each from 0 to 9.
|
|
256
|
+
n_rows : int
|
|
257
|
+
Number of rows, from 1 to 10.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
numpy.ndarray
|
|
262
|
+
Float64 array of shape ``(n_rows,)``.
|
|
263
|
+
|
|
264
|
+
Examples
|
|
265
|
+
--------
|
|
266
|
+
>>> lineage_tree_value(2, 3, 1, 3)
|
|
267
|
+
array([2310., 2311., 2312.])
|
|
268
|
+
"""
|
|
269
|
+
base = 1000 * ensemble + 100 * whole + 10 * segment
|
|
270
|
+
return base + np.arange(n_rows, dtype=np.float64)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def write_lineage_tree(
|
|
274
|
+
root: Path,
|
|
275
|
+
parser: type,
|
|
276
|
+
*,
|
|
277
|
+
group: Optional[str] = None,
|
|
278
|
+
n_ensembles: int = 2,
|
|
279
|
+
n_wholes: int = 2,
|
|
280
|
+
n_segments: int = 2,
|
|
281
|
+
lineage: str = "segment",
|
|
282
|
+
ext: str = ".npy",
|
|
283
|
+
prop: str = "gyrTMon",
|
|
284
|
+
n_rows: int = 3,
|
|
285
|
+
attrs: Optional[Mapping[str, float]] = None,
|
|
286
|
+
vary: str = "nc",
|
|
287
|
+
vary_step: float = 100,
|
|
288
|
+
) -> LineageTree:
|
|
289
|
+
"""Write a synthetic lineage tree of tiny files named for `parser`.
|
|
290
|
+
|
|
291
|
+
The tree has ``n_ensembles`` state points that differ only in the
|
|
292
|
+
keyword `vary`, ``n_wholes`` replicates (``ens1``, ``ens2``, ...) per
|
|
293
|
+
state point, and, at the ``segment`` level, ``n_segments`` segments
|
|
294
|
+
(``.j01``, ``.j02``, ...) per whole. Names follow the parser's ``whole``
|
|
295
|
+
keyword order, with the values written in the file as given:
|
|
296
|
+
|
|
297
|
+
- segment: ``<whole>.j<NN>.<group>.<prop><ext>``, as the probe phase
|
|
298
|
+
writes them;
|
|
299
|
+
- whole: ``<whole>-<group>-<prop><ext>``, as
|
|
300
|
+
:func:`polyphys.manage.organizer.save_artifact` writes them.
|
|
301
|
+
|
|
302
|
+
File contents follow :func:`lineage_tree_value`. A ``.npy`` file holds
|
|
303
|
+
the array; a ``.csv`` file holds one column named `prop`.
|
|
304
|
+
|
|
305
|
+
Parameters
|
|
306
|
+
----------
|
|
307
|
+
root : Path
|
|
308
|
+
Parent directory; the tree goes in a new subdirectory of it.
|
|
309
|
+
parser : type
|
|
310
|
+
A parser class from :mod:`polyphys.manage.parser`.
|
|
311
|
+
group : str, optional
|
|
312
|
+
Particle group; defaults to the parser's first group.
|
|
313
|
+
n_ensembles, n_wholes, n_segments : int
|
|
314
|
+
Tree sizes, each from 1 to 9.
|
|
315
|
+
lineage : {'segment', 'whole'}
|
|
316
|
+
Lineage level of the files.
|
|
317
|
+
ext : {'.npy', '.csv'}
|
|
318
|
+
File format.
|
|
319
|
+
prop : str
|
|
320
|
+
Property name in the filenames and the ``.csv`` column.
|
|
321
|
+
n_rows : int
|
|
322
|
+
Rows per file, from 1 to 10.
|
|
323
|
+
attrs : mapping of str to float, optional
|
|
324
|
+
Keyword values that override the defaults, keyed by the short
|
|
325
|
+
keyword (``'r'``, ``'nc'``, ...). ``'ens'`` and ``'j'`` are set by
|
|
326
|
+
the tree and cannot be given.
|
|
327
|
+
vary : str
|
|
328
|
+
The keyword that differs between ensembles.
|
|
329
|
+
vary_step : float
|
|
330
|
+
Ensemble ``e`` (from 1) gets ``vary`` equal to its base value plus
|
|
331
|
+
``(e - 1) * vary_step``.
|
|
332
|
+
|
|
333
|
+
Returns
|
|
334
|
+
-------
|
|
335
|
+
LineageTree
|
|
336
|
+
|
|
337
|
+
Raises
|
|
338
|
+
------
|
|
339
|
+
ValueError
|
|
340
|
+
If a size is out of range, `lineage` or `ext` is unknown, `attrs`
|
|
341
|
+
sets ``'ens'`` or ``'j'``, or `vary` or a key of `attrs` is not a
|
|
342
|
+
keyword of the parser's ``whole`` names.
|
|
343
|
+
|
|
344
|
+
Notes
|
|
345
|
+
-----
|
|
346
|
+
Writes ``n_ensembles * n_wholes * n_segments`` files (``n_segments`` is
|
|
347
|
+
1 at the ``whole`` level) of ``n_rows`` values each: time and disk space
|
|
348
|
+
are O(n_ensembles * n_wholes * n_segments * n_rows).
|
|
349
|
+
|
|
350
|
+
The parser's ``_genealogy_attributes`` table gives the keyword order.
|
|
351
|
+
Take expected lineage names in a test from the parser applied to a
|
|
352
|
+
written filename, not from the strings built here, so the test checks
|
|
353
|
+
the code under test against the parser's own lineage rules.
|
|
354
|
+
|
|
355
|
+
Examples
|
|
356
|
+
--------
|
|
357
|
+
.. code-block:: python
|
|
358
|
+
|
|
359
|
+
def test_example(make_lineage_tree):
|
|
360
|
+
tree = make_lineage_tree(ChainCyl, n_ensembles=1)
|
|
361
|
+
assert len(tree.paths) == 4
|
|
362
|
+
"""
|
|
363
|
+
sizes = {
|
|
364
|
+
"n_ensembles": n_ensembles,
|
|
365
|
+
"n_wholes": n_wholes,
|
|
366
|
+
"n_segments": n_segments,
|
|
367
|
+
}
|
|
368
|
+
for name, size in sizes.items():
|
|
369
|
+
if not 1 <= size <= 9:
|
|
370
|
+
raise ValueError(f"'{name}' must be from 1 to 9, got {size}")
|
|
371
|
+
if not 1 <= n_rows <= 10:
|
|
372
|
+
raise ValueError(f"'n_rows' must be from 1 to 10, got {n_rows}")
|
|
373
|
+
if lineage not in ("segment", "whole"):
|
|
374
|
+
raise ValueError(
|
|
375
|
+
f"'lineage' must be 'segment' or 'whole', " f"got {lineage!r}"
|
|
376
|
+
)
|
|
377
|
+
if ext not in (".npy", ".csv"):
|
|
378
|
+
raise ValueError(f"'ext' must be '.npy' or '.csv', got {ext!r}")
|
|
379
|
+
attrs = dict(attrs or {})
|
|
380
|
+
reserved = sorted(set(attrs) & set(_INDEX_KEYWORDS))
|
|
381
|
+
if reserved:
|
|
382
|
+
raise ValueError(f"'attrs' cannot set {reserved}")
|
|
383
|
+
|
|
384
|
+
keywords = [
|
|
385
|
+
kw
|
|
386
|
+
for kw in parser._genealogy_attributes["whole"].values()
|
|
387
|
+
if kw not in _INDEX_KEYWORDS
|
|
388
|
+
]
|
|
389
|
+
unknown = sorted((set(attrs) | {vary}) - set(keywords))
|
|
390
|
+
if unknown:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"{unknown} are not keywords of {parser.__name__} whole names"
|
|
393
|
+
)
|
|
394
|
+
values = {kw: attrs.get(kw, _KEYWORD_DEFAULTS[kw]) for kw in keywords}
|
|
395
|
+
group = group or parser._groups[0]
|
|
396
|
+
n_segs = n_segments if lineage == "segment" else 1
|
|
397
|
+
stage = "segment" if lineage == "segment" else "wholeSim"
|
|
398
|
+
tree_dir = root / f"{parser.__name__}-{group}-{stage}"
|
|
399
|
+
tree_dir.mkdir(parents=True)
|
|
400
|
+
|
|
401
|
+
paths: list[Path] = []
|
|
402
|
+
index: dict[Path, tuple[int, int, int]] = {}
|
|
403
|
+
for e in range(1, n_ensembles + 1):
|
|
404
|
+
state = dict(values, **{vary: values[vary] + (e - 1) * vary_step})
|
|
405
|
+
ensemble_long = "".join(f"{kw}{state[kw]}" for kw in keywords)
|
|
406
|
+
for w in range(1, n_wholes + 1):
|
|
407
|
+
whole = f"{ensemble_long}ens{w}"
|
|
408
|
+
for s in range(1, n_segs + 1):
|
|
409
|
+
if lineage == "segment":
|
|
410
|
+
filename = f"{whole}.j{s:02d}.{group}.{prop}{ext}"
|
|
411
|
+
seg = s
|
|
412
|
+
else:
|
|
413
|
+
filename = f"{whole}-{group}-{prop}{ext}"
|
|
414
|
+
seg = 0
|
|
415
|
+
path = tree_dir / filename
|
|
416
|
+
data = lineage_tree_value(e, w, seg, n_rows)
|
|
417
|
+
if ext == ".npy":
|
|
418
|
+
np.save(path, data)
|
|
419
|
+
else:
|
|
420
|
+
pd.DataFrame({prop: data}).to_csv(path, index=False)
|
|
421
|
+
paths.append(path)
|
|
422
|
+
index[path] = (e, w, seg)
|
|
423
|
+
return LineageTree(tree_dir, lineage, group, prop, tuple(paths),
|
|
424
|
+
MappingProxyType(index))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@pytest.fixture
|
|
428
|
+
def make_lineage_tree(tmp_path: Path) -> Callable[..., LineageTree]:
|
|
429
|
+
"""Factory: write a lineage tree under ``tmp_path``.
|
|
430
|
+
|
|
431
|
+
Takes the arguments of :func:`write_lineage_tree` after `root`. Each call
|
|
432
|
+
writes a new tree, so give trees in one test different `group`, `lineage`,
|
|
433
|
+
or parser classes.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def _make(parser: type, **kwargs: Any) -> LineageTree:
|
|
437
|
+
return write_lineage_tree(tmp_path, parser, **kwargs)
|
|
438
|
+
|
|
439
|
+
return _make
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# Tiny LAMMPS files
|
|
443
|
+
#
|
|
444
|
+
# Formats from the LAMMPS manual, read_data command (Atoms, Bonds, Angles,
|
|
445
|
+
# and Masses sections; header keywords) and dump command (dump style atom,
|
|
446
|
+
# BOX BOUNDS boundary flags): https://docs.lammps.org/read_data.html and
|
|
447
|
+
# https://docs.lammps.org/dump.html. The order of the dump's ITEM lines
|
|
448
|
+
# matches what MDAnalysis 2.9.0's LAMMPSDUMP reader parses
|
|
449
|
+
# (MDAnalysis.coordinates.LAMMPS.DumpReader).
|
|
450
|
+
|
|
451
|
+
_ATOM_STYLES = ("atomic", "bond", "angle")
|
|
452
|
+
_BOX = ((0.0, 10.0), (0.0, 10.0), (0.0, 10.0))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _default_chain(n_atoms: int) -> np.ndarray:
|
|
456
|
+
"""Return a straight chain along x, one unit between neighbors."""
|
|
457
|
+
positions = np.full((n_atoms, 3), 5.0)
|
|
458
|
+
positions[:, 0] = 1.0 + np.arange(n_atoms)
|
|
459
|
+
return positions
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _box_lines(box: Sequence[Sequence[float]]) -> list[str]:
|
|
463
|
+
"""Return the three ``lo hi`` lines of an orthogonal box."""
|
|
464
|
+
return [f"{lo:.6f} {hi:.6f}" for lo, hi in box]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def write_lammps_data(
|
|
468
|
+
path: Path,
|
|
469
|
+
atom_style: str = "bond",
|
|
470
|
+
positions: Optional[np.ndarray] = None,
|
|
471
|
+
atom_types: Optional[Sequence[int]] = None,
|
|
472
|
+
mol_ids: Optional[Sequence[int]] = None,
|
|
473
|
+
box: Sequence[Sequence[float]] = _BOX,
|
|
474
|
+
) -> Path:
|
|
475
|
+
"""Write a tiny LAMMPS data file of one linear chain.
|
|
476
|
+
|
|
477
|
+
The ``Atoms`` lines are ``atom-ID atom-type x y z`` for ``atomic`` and
|
|
478
|
+
``atom-ID molecule-ID atom-type x y z`` for ``bond`` and ``angle``. A
|
|
479
|
+
``bond`` or ``angle`` file bonds atom ``i`` to ``i + 1``; an ``angle``
|
|
480
|
+
file also has the angle ``i, i + 1, i + 2``. Bond and angle types are 1.
|
|
481
|
+
Every atom type gets mass 1.0. Coordinates are written with six decimals.
|
|
482
|
+
|
|
483
|
+
Parameters
|
|
484
|
+
----------
|
|
485
|
+
path : Path
|
|
486
|
+
File to write.
|
|
487
|
+
atom_style : {'atomic', 'bond', 'angle'}
|
|
488
|
+
LAMMPS atom style; it is also written after ``Atoms #``.
|
|
489
|
+
positions : numpy.ndarray, optional
|
|
490
|
+
Shape ``(n_atoms, 3)``; defaults to 4 atoms one unit apart along x.
|
|
491
|
+
atom_types : sequence of int, optional
|
|
492
|
+
Per-atom types from 1; defaults to all 1.
|
|
493
|
+
mol_ids : sequence of int, optional
|
|
494
|
+
Per-atom molecule IDs, ignored for ``atomic``; defaults to all 1.
|
|
495
|
+
box : sequence of (float, float)
|
|
496
|
+
``(lo, hi)`` for x, y, and z.
|
|
497
|
+
|
|
498
|
+
Returns
|
|
499
|
+
-------
|
|
500
|
+
Path
|
|
501
|
+
`path`.
|
|
502
|
+
|
|
503
|
+
Raises
|
|
504
|
+
------
|
|
505
|
+
ValueError
|
|
506
|
+
If `atom_style` is unknown or `positions` is not ``(n_atoms, 3)``.
|
|
507
|
+
|
|
508
|
+
Notes
|
|
509
|
+
-----
|
|
510
|
+
Time and space are O(n_atoms).
|
|
511
|
+
|
|
512
|
+
Examples
|
|
513
|
+
--------
|
|
514
|
+
.. code-block:: python
|
|
515
|
+
|
|
516
|
+
def test_example(make_lammps_data):
|
|
517
|
+
path = make_lammps_data(atom_style="angle")
|
|
518
|
+
"""
|
|
519
|
+
if atom_style not in _ATOM_STYLES:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
f"'atom_style' must be one of {_ATOM_STYLES}, "
|
|
522
|
+
f"got {atom_style!r}"
|
|
523
|
+
)
|
|
524
|
+
pos = _default_chain(4) if positions is None else np.asarray(positions)
|
|
525
|
+
if pos.ndim != 2 or pos.shape[1] != 3:
|
|
526
|
+
raise ValueError(
|
|
527
|
+
f"'positions' must have shape (n_atoms, 3), " f"got {pos.shape}"
|
|
528
|
+
)
|
|
529
|
+
n_atoms = pos.shape[0]
|
|
530
|
+
types = list(atom_types) if atom_types is not None else [1] * n_atoms
|
|
531
|
+
mols = list(mol_ids) if mol_ids is not None else [1] * n_atoms
|
|
532
|
+
bonds = (
|
|
533
|
+
[(i, i + 1) for i in range(1, n_atoms)]
|
|
534
|
+
if atom_style in ("bond", "angle")
|
|
535
|
+
else []
|
|
536
|
+
)
|
|
537
|
+
angles = (
|
|
538
|
+
[(i, i + 1, i + 2) for i in range(1, n_atoms - 1)]
|
|
539
|
+
if atom_style == "angle"
|
|
540
|
+
else []
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
header = [f"{n_atoms} atoms", f"{max(types)} atom types"]
|
|
544
|
+
if atom_style in ("bond", "angle"):
|
|
545
|
+
header += [f"{len(bonds)} bonds", "1 bond types"]
|
|
546
|
+
if atom_style == "angle":
|
|
547
|
+
header += [f"{len(angles)} angles", "1 angle types"]
|
|
548
|
+
box_lines = [
|
|
549
|
+
f"{line} {ax}lo {ax}hi" for line, ax in zip(_box_lines(box), "xyz")
|
|
550
|
+
]
|
|
551
|
+
masses = [f"{t} 1.0" for t in range(1, max(types) + 1)]
|
|
552
|
+
atoms = []
|
|
553
|
+
for i, (xyz, atype, mol) in enumerate(zip(pos, types, mols), start=1):
|
|
554
|
+
coords = " ".join(f"{c:.6f}" for c in xyz)
|
|
555
|
+
mol_col = "" if atom_style == "atomic" else f"{mol} "
|
|
556
|
+
atoms.append(f"{i} {mol_col}{atype} {coords}")
|
|
557
|
+
|
|
558
|
+
lines = [
|
|
559
|
+
f"LAMMPS data file, PolyPhys synthetic fixture, "
|
|
560
|
+
f"atom_style {atom_style}",
|
|
561
|
+
"",
|
|
562
|
+
]
|
|
563
|
+
lines += header + [""] + box_lines
|
|
564
|
+
lines += ["", "Masses", ""] + masses
|
|
565
|
+
lines += ["", f"Atoms # {atom_style}", ""] + atoms
|
|
566
|
+
if bonds:
|
|
567
|
+
lines += ["", "Bonds", ""]
|
|
568
|
+
lines += [f"{k} 1 {i} {j}" for k, (i, j) in enumerate(bonds, 1)]
|
|
569
|
+
if angles:
|
|
570
|
+
lines += ["", "Angles", ""]
|
|
571
|
+
lines += [
|
|
572
|
+
f"{k} 1 {i} {j} {m}" for k, (i, j, m) in enumerate(angles, 1)
|
|
573
|
+
]
|
|
574
|
+
path.write_text("\n".join(lines) + "\n")
|
|
575
|
+
return path
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def write_lammps_dump(
|
|
579
|
+
path: Path,
|
|
580
|
+
positions: Optional[np.ndarray] = None,
|
|
581
|
+
atom_types: Optional[Sequence[int]] = None,
|
|
582
|
+
timesteps: Optional[Sequence[int]] = None,
|
|
583
|
+
box: Sequence[Sequence[float]] = _BOX,
|
|
584
|
+
boundary: str = "pp pp pp",
|
|
585
|
+
) -> Path:
|
|
586
|
+
"""Write a tiny LAMMPS dump file in the ``atom`` style.
|
|
587
|
+
|
|
588
|
+
Each frame is ``ITEM: TIMESTEP``, ``ITEM: NUMBER OF ATOMS``,
|
|
589
|
+
``ITEM: BOX BOUNDS <boundary>`` with three ``lo hi`` lines, and
|
|
590
|
+
``ITEM: ATOMS id type x y z`` with one line per atom: the ``atom`` style
|
|
591
|
+
with ``dump_modify scale no`` (unscaled, wrapped coordinates).
|
|
592
|
+
Coordinates are written with six decimals.
|
|
593
|
+
|
|
594
|
+
Parameters
|
|
595
|
+
----------
|
|
596
|
+
path : Path
|
|
597
|
+
File to write.
|
|
598
|
+
positions : numpy.ndarray, optional
|
|
599
|
+
Shape ``(n_frames, n_atoms, 3)``; defaults to 3 frames of a 4-atom
|
|
600
|
+
chain along x that moves 0.1 along z per frame.
|
|
601
|
+
atom_types : sequence of int, optional
|
|
602
|
+
Per-atom types from 1; defaults to all 1.
|
|
603
|
+
timesteps : sequence of int, optional
|
|
604
|
+
One per frame; defaults to ``0, 1000, 2000, ...``.
|
|
605
|
+
box : sequence of (float, float)
|
|
606
|
+
``(lo, hi)`` for x, y, and z, the same in every frame.
|
|
607
|
+
boundary : str
|
|
608
|
+
Three two-letter boundary flags, each letter one of ``p``, ``f``,
|
|
609
|
+
``s``, or ``m``, such as ``'ff ff pp'`` for a cylinder periodic
|
|
610
|
+
along z.
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
Path
|
|
615
|
+
`path`.
|
|
616
|
+
|
|
617
|
+
Raises
|
|
618
|
+
------
|
|
619
|
+
ValueError
|
|
620
|
+
If `positions` is not ``(n_frames, n_atoms, 3)``, `timesteps` does
|
|
621
|
+
not have one entry per frame, or `boundary` is malformed.
|
|
622
|
+
|
|
623
|
+
Notes
|
|
624
|
+
-----
|
|
625
|
+
Time and space are O(n_frames * n_atoms).
|
|
626
|
+
|
|
627
|
+
Examples
|
|
628
|
+
--------
|
|
629
|
+
.. code-block:: python
|
|
630
|
+
|
|
631
|
+
def test_example(make_lammps_dump):
|
|
632
|
+
path = make_lammps_dump(boundary="ff ff pp")
|
|
633
|
+
"""
|
|
634
|
+
if positions is None:
|
|
635
|
+
shift = np.zeros((3, 1, 3))
|
|
636
|
+
shift[:, 0, 2] = 0.1 * np.arange(3)
|
|
637
|
+
pos = _default_chain(4)[np.newaxis] + shift
|
|
638
|
+
else:
|
|
639
|
+
pos = np.asarray(positions)
|
|
640
|
+
if pos.ndim != 3 or pos.shape[2] != 3:
|
|
641
|
+
raise ValueError(
|
|
642
|
+
f"'positions' must have shape "
|
|
643
|
+
f"(n_frames, n_atoms, 3), got {pos.shape}"
|
|
644
|
+
)
|
|
645
|
+
n_frames, n_atoms = pos.shape[:2]
|
|
646
|
+
steps = (
|
|
647
|
+
list(timesteps)
|
|
648
|
+
if timesteps is not None
|
|
649
|
+
else [1000 * f for f in range(n_frames)]
|
|
650
|
+
)
|
|
651
|
+
if len(steps) != n_frames:
|
|
652
|
+
raise ValueError(
|
|
653
|
+
f"'timesteps' has {len(steps)} entries for " f"{n_frames} frames"
|
|
654
|
+
)
|
|
655
|
+
flags = boundary.split()
|
|
656
|
+
if len(flags) != 3 or any(
|
|
657
|
+
len(f) != 2 or set(f) - set("pfsm") for f in flags
|
|
658
|
+
):
|
|
659
|
+
raise ValueError(
|
|
660
|
+
f"'boundary' must be three flags such as "
|
|
661
|
+
f"'pp pp pp', got {boundary!r}"
|
|
662
|
+
)
|
|
663
|
+
types = list(atom_types) if atom_types is not None else [1] * n_atoms
|
|
664
|
+
|
|
665
|
+
lines: list[str] = []
|
|
666
|
+
for step, frame in zip(steps, pos):
|
|
667
|
+
lines += [
|
|
668
|
+
"ITEM: TIMESTEP",
|
|
669
|
+
str(step),
|
|
670
|
+
"ITEM: NUMBER OF ATOMS",
|
|
671
|
+
str(n_atoms),
|
|
672
|
+
f"ITEM: BOX BOUNDS {boundary}",
|
|
673
|
+
] + _box_lines(box)
|
|
674
|
+
lines.append("ITEM: ATOMS id type x y z")
|
|
675
|
+
lines += [
|
|
676
|
+
f"{i} {atype} " + " ".join(f"{c:.6f}" for c in xyz)
|
|
677
|
+
for i, (xyz, atype) in enumerate(zip(frame, types), 1)
|
|
678
|
+
]
|
|
679
|
+
path.write_text("\n".join(lines) + "\n")
|
|
680
|
+
return path
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@pytest.fixture
|
|
684
|
+
def make_lammps_data(tmp_path: Path) -> Callable[..., Path]:
|
|
685
|
+
"""Factory: write a tiny LAMMPS data file under ``tmp_path``.
|
|
686
|
+
|
|
687
|
+
Takes `name` (default ``'chain.data'``) and the keyword arguments of
|
|
688
|
+
:func:`write_lammps_data`.
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
def _make(name: str = "chain.data", **kwargs: Any) -> Path:
|
|
692
|
+
return write_lammps_data(tmp_path / name, **kwargs)
|
|
693
|
+
|
|
694
|
+
return _make
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@pytest.fixture
|
|
698
|
+
def make_lammps_dump(tmp_path: Path) -> Callable[..., Path]:
|
|
699
|
+
"""Factory: write a tiny LAMMPS dump file under ``tmp_path``.
|
|
700
|
+
|
|
701
|
+
Takes `name` (default ``'chain.lammpstrj'``) and the keyword arguments
|
|
702
|
+
of :func:`write_lammps_dump`.
|
|
703
|
+
"""
|
|
704
|
+
|
|
705
|
+
def _make(name: str = "chain.lammpstrj", **kwargs: Any) -> Path:
|
|
706
|
+
return write_lammps_dump(tmp_path / name, **kwargs)
|
|
707
|
+
|
|
708
|
+
return _make
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@pytest.fixture
|
|
712
|
+
def make_chain_files(
|
|
713
|
+
tmp_path: Path, make_lammps_dump: Callable[..., Path]
|
|
714
|
+
) -> Callable[..., tuple[Path, Path]]:
|
|
715
|
+
"""Factory: write a ``bond``-style data file of one linear chain, with
|
|
716
|
+
masses and a bond between neighbors, and a dump of its frames.
|
|
717
|
+
|
|
718
|
+
Takes `positions` of shape ``(n_frames, n_atoms, 3)``, `box` as three
|
|
719
|
+
``(lo, hi)`` pairs, `boundary` as the dump's three flags, `masses` per
|
|
720
|
+
atom type, `atom_types` per atom, and `name` for both files. Returns
|
|
721
|
+
the data and dump paths.
|
|
722
|
+
"""
|
|
723
|
+
|
|
724
|
+
def _make(
|
|
725
|
+
positions: Any,
|
|
726
|
+
*,
|
|
727
|
+
box: Sequence[Sequence[float]] = ((0.0, 20.0),) * 3,
|
|
728
|
+
boundary: str = "ff ff pp",
|
|
729
|
+
masses: Sequence[float] = (1.0,),
|
|
730
|
+
atom_types: Optional[Sequence[int]] = None,
|
|
731
|
+
name: str = "chain",
|
|
732
|
+
) -> tuple[Path, Path]:
|
|
733
|
+
frames = np.asarray(positions, dtype=float)
|
|
734
|
+
n_atoms = frames.shape[1]
|
|
735
|
+
types = np.array(atom_types if atom_types is not None
|
|
736
|
+
else [1] * n_atoms)
|
|
737
|
+
data = DataFile(
|
|
738
|
+
atom_style="bond",
|
|
739
|
+
box=Box(lo=np.array([lo for lo, _ in box], dtype=float),
|
|
740
|
+
hi=np.array([hi for _, hi in box], dtype=float)),
|
|
741
|
+
ids=np.arange(1, n_atoms + 1), types=types,
|
|
742
|
+
positions=frames[0], n_atom_types=len(masses),
|
|
743
|
+
masses=np.asarray(masses, dtype=float),
|
|
744
|
+
mol_ids=np.ones(n_atoms, dtype=int),
|
|
745
|
+
bonds=np.array([[1, i, i + 1] for i in range(1, n_atoms)]),
|
|
746
|
+
n_bond_types=1)
|
|
747
|
+
data_path = write_data(data, tmp_path / f"{name}.data")
|
|
748
|
+
dump_path = make_lammps_dump(
|
|
749
|
+
name=f"{name}.lammpstrj", positions=frames, box=box,
|
|
750
|
+
boundary=boundary, atom_types=types.tolist())
|
|
751
|
+
return data_path, dump_path
|
|
752
|
+
|
|
753
|
+
return _make
|