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,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