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,863 @@
|
|
|
1
|
+
"""
|
|
2
|
+
==========================================================
|
|
3
|
+
:mod:`polyphys.probe.histograms`
|
|
4
|
+
==========================================================
|
|
5
|
+
|
|
6
|
+
The :mod:`polyphys.probe.histograms` module bins the positions of every
|
|
7
|
+
species of a study in every frame of a trajectory: one-dimensional
|
|
8
|
+
histograms along the directions of the geometry, kept per frame, and
|
|
9
|
+
two-dimensional maps in the three Cartesian planes, kept as the mean map
|
|
10
|
+
of each block of consecutive frames. :class:`SpatialHistograms` is an
|
|
11
|
+
MDAnalysis :class:`~MDAnalysis.analysis.base.AnalysisBase`, so it runs over
|
|
12
|
+
a slice of frames in serial or, split across workers, in parallel;
|
|
13
|
+
:func:`histogram_edges` builds the bin edges of one whole from its probe
|
|
14
|
+
spec and parser, with the research code's bin sizes and the studies'
|
|
15
|
+
geometry.
|
|
16
|
+
|
|
17
|
+
The research code binned the same species in one loop over the frames of
|
|
18
|
+
each study's probe function, after shifting every position by half the
|
|
19
|
+
parser's box, and summed the counts over the frames. Here the one-dimensional
|
|
20
|
+
counts are kept per frame, so that the time dependence and the block
|
|
21
|
+
statistics of a profile remain available, and the two-dimensional maps,
|
|
22
|
+
which would not fit in memory per frame, are averaged over blocks of
|
|
23
|
+
``block_frames`` frames. The sum over frames of either is what the research
|
|
24
|
+
code saved.
|
|
25
|
+
|
|
26
|
+
Contents
|
|
27
|
+
========
|
|
28
|
+
|
|
29
|
+
- :class:`SpatialHistograms`, the per-frame and per-block binning.
|
|
30
|
+
- :func:`histogram_edges`, the bin edges of a whole.
|
|
31
|
+
|
|
32
|
+
Dependencies
|
|
33
|
+
============
|
|
34
|
+
- Python standard library: `math`, `pathlib`, `typing`.
|
|
35
|
+
- `NumPy <https://numpy.org/>`_.
|
|
36
|
+
- `MDAnalysis <https://docs.mdanalysis.org/>`_ 2.8 or later, through the
|
|
37
|
+
``mda`` extra: :class:`~MDAnalysis.analysis.base.AnalysisBase` and
|
|
38
|
+
:class:`~MDAnalysis.analysis.results.ResultsGroup`.
|
|
39
|
+
- :mod:`polyphys.analyze.measurer`: the histogram helpers
|
|
40
|
+
:func:`~polyphys.analyze.measurer.fixedsize_bins`,
|
|
41
|
+
:func:`~polyphys.analyze.measurer.radial_histogram`,
|
|
42
|
+
:func:`~polyphys.analyze.measurer.radial_cyl_histogram`,
|
|
43
|
+
:func:`~polyphys.analyze.measurer.axial_histogram`,
|
|
44
|
+
:func:`~polyphys.analyze.measurer.azimuth_cyl_histogram`, and
|
|
45
|
+
:func:`~polyphys.analyze.measurer.planar_cartesian_histogram`.
|
|
46
|
+
- :mod:`polyphys.spec`:
|
|
47
|
+
:class:`~polyphys.spec.ProbeSpec` and
|
|
48
|
+
:data:`~polyphys.spec.RecenterT`.
|
|
49
|
+
- :mod:`polyphys.manage.parser`:
|
|
50
|
+
:class:`~polyphys.manage.parser.ParserBase`.
|
|
51
|
+
- :mod:`polyphys.probe.core`: ``_ProbeAnalysis``, the base whose ``run``
|
|
52
|
+
closes the pool of the ``multiprocessing`` backend, and
|
|
53
|
+
``_compute_part``, which computes each part of a split run on its own
|
|
54
|
+
copy of the analysis.
|
|
55
|
+
|
|
56
|
+
Notation
|
|
57
|
+
========
|
|
58
|
+
:math:`F`, :math:`b`, :math:`K = \\lceil F / b \\rceil`
|
|
59
|
+
The frames of a run, the block length, and the number of blocks; block
|
|
60
|
+
:math:`k` holds the frames :math:`kb, \\ldots, \\min((k + 1) b, F) - 1`
|
|
61
|
+
in the order the run visits them.
|
|
62
|
+
:math:`\\mathbf{r}_i = (x_i, y_i, z_i)`
|
|
63
|
+
The position of particle :math:`i` after recentering, in the dump's
|
|
64
|
+
length unit, the monomer diameter in the studies.
|
|
65
|
+
:math:`r`, :math:`\\theta`, :math:`z`
|
|
66
|
+
In a cylinder whose axis is :math:`z`: :math:`\\sqrt{x^2 + y^2}`,
|
|
67
|
+
:math:`\\operatorname{atan2}(y, x)` in :math:`[-\\pi, \\pi]`, and the
|
|
68
|
+
axial coordinate. In a cube, :math:`r = |\\mathbf{r}|` from the box
|
|
69
|
+
center.
|
|
70
|
+
:math:`D`, :math:`Z`, :math:`L`, :math:`a_w`
|
|
71
|
+
The parser's ``dcyl``, ``lcyl``, and ``lcube``, and the wall-bead size
|
|
72
|
+
:math:`a_w = 1` of the cylindrical studies, so that the LAMMPS box is
|
|
73
|
+
:math:`(D + a_w) \\times (D + a_w) \\times Z` or :math:`L^3`.
|
|
74
|
+
|
|
75
|
+
Examples
|
|
76
|
+
========
|
|
77
|
+
Two particles in a periodic cube of side 20, three frames, with the dump
|
|
78
|
+
reader's frame already translated to the origin, so that ``'half'`` moves
|
|
79
|
+
the box center to the origin:
|
|
80
|
+
|
|
81
|
+
>>> import MDAnalysis as mda
|
|
82
|
+
>>> import numpy as np
|
|
83
|
+
>>> frames = np.array([
|
|
84
|
+
... [[10.0, 10.0, 10.0], [12.5, 10.0, 10.0]],
|
|
85
|
+
... [[10.0, 10.0, 10.0], [10.0, 10.0, 11.0]],
|
|
86
|
+
... [[10.0, 10.0, 10.0], [10.0, 13.5, 10.0]]])
|
|
87
|
+
>>> universe = mda.Universe.empty(2, trajectory=True)
|
|
88
|
+
>>> universe.load_new(frames, format='MEMORY', dimensions=np.tile(
|
|
89
|
+
... [20.0, 20.0, 20.0, 90.0, 90.0, 90.0], (3, 1)))
|
|
90
|
+
<Universe with 2 atoms>
|
|
91
|
+
>>> edges = {'r': np.arange(0.0, 17.5, 1.0)}
|
|
92
|
+
>>> for key in 'xyz':
|
|
93
|
+
... edges[key] = np.linspace(-10.0, 10.0, 5)
|
|
94
|
+
>>> analysis = SpatialHistograms(
|
|
95
|
+
... {'Mon': universe.atoms}, edges, geometry='cubic', block_frames=2)
|
|
96
|
+
>>> analysis = analysis.run()
|
|
97
|
+
>>> analysis.results['rHistTMon'][:, :4]
|
|
98
|
+
array([[1, 0, 1, 0],
|
|
99
|
+
[1, 1, 0, 0],
|
|
100
|
+
[1, 0, 0, 1]])
|
|
101
|
+
>>> analysis.results['blockFrames']
|
|
102
|
+
array([2, 1])
|
|
103
|
+
|
|
104
|
+
.. versionadded:: 0.7.0
|
|
105
|
+
"""
|
|
106
|
+
import math
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
from typing import Any, Callable, Mapping, Self
|
|
109
|
+
|
|
110
|
+
import numpy as np
|
|
111
|
+
from MDAnalysis.analysis.results import ResultsGroup
|
|
112
|
+
from MDAnalysis.core.groups import AtomGroup
|
|
113
|
+
from MDAnalysis.core.universe import Universe
|
|
114
|
+
|
|
115
|
+
from ..analyze import measurer
|
|
116
|
+
from ..manage.parser import ParserBase
|
|
117
|
+
from ..spec import ProbeSpec, RecenterT
|
|
118
|
+
from ..manage.types import (
|
|
119
|
+
AxisT,
|
|
120
|
+
BinT,
|
|
121
|
+
DirectionT,
|
|
122
|
+
EntityT,
|
|
123
|
+
GeometryT,
|
|
124
|
+
Pathish,
|
|
125
|
+
)
|
|
126
|
+
from .core import _ProbeAnalysis, _compute_part
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
'SpatialHistograms',
|
|
130
|
+
'histogram_edges',
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# The size of the beads whose centers form the cylindrical wall, which the
|
|
134
|
+
# parsers document: the LAMMPS box spans the wall-bead centers, so its
|
|
135
|
+
# transverse length is dcyl + _WALL_BEAD.
|
|
136
|
+
_WALL_BEAD = 1.0
|
|
137
|
+
|
|
138
|
+
# The directions of the one-dimensional histograms in each geometry, in the
|
|
139
|
+
# research code's order, and the edges each geometry's two-dimensional maps
|
|
140
|
+
# need. 'z' edges serve both the axial histogram and the maps of a cylinder.
|
|
141
|
+
_DIRECTIONS: Mapping[GeometryT, tuple[DirectionT, ...]] = {
|
|
142
|
+
'cubic': ('r',),
|
|
143
|
+
'cylindrical': ('r', 'z', 'theta'),
|
|
144
|
+
}
|
|
145
|
+
_CARTESIAN: tuple[DirectionT, ...] = ('x', 'y', 'z')
|
|
146
|
+
_PLANES: tuple[str, ...] = ('xy', 'xz', 'yz')
|
|
147
|
+
_AXIS: AxisT = 2 # the cylinder axis, z, in every study
|
|
148
|
+
|
|
149
|
+
# The directions each geometry makes periodic, along which a position is
|
|
150
|
+
# wrapped into the box before binning: every one in a periodic cube, the
|
|
151
|
+
# axis only in a cylinder (LAMMPS boundary ``ff ff pp``).
|
|
152
|
+
_PERIODIC: Mapping[GeometryT, tuple[int, ...]] = {
|
|
153
|
+
'cubic': (0, 1, 2),
|
|
154
|
+
'cylindrical': (_AXIS,),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# One frame's one-dimensional histogram along a direction.
|
|
158
|
+
_Histogram = Callable[[np.ndarray, np.ndarray], np.ndarray]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _radial_cyl(positions: np.ndarray, edges: np.ndarray) -> np.ndarray:
|
|
162
|
+
return measurer.radial_cyl_histogram(positions, edges, _AXIS)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _axial(positions: np.ndarray, edges: np.ndarray) -> np.ndarray:
|
|
166
|
+
return measurer.axial_histogram(positions, edges, _AXIS)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _azimuth(positions: np.ndarray, edges: np.ndarray) -> np.ndarray:
|
|
170
|
+
return measurer.azimuth_cyl_histogram(positions, edges, _AXIS)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_HISTOGRAMS: Mapping[GeometryT, Mapping[DirectionT, _Histogram]] = {
|
|
174
|
+
'cubic': {'r': measurer.radial_histogram},
|
|
175
|
+
'cylindrical': {'r': _radial_cyl, 'z': _axial, 'theta': _azimuth},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_BACKENDS = ('serial', 'multiprocessing', 'dask')
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _required_edges(geometry: GeometryT) -> tuple[DirectionT, ...]:
|
|
182
|
+
"""The edge keys a geometry's histograms and maps need, in order."""
|
|
183
|
+
return tuple(dict.fromkeys((*_DIRECTIONS[geometry], *_CARTESIAN)))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_recenter(recenter: Any) -> RecenterT:
|
|
187
|
+
"""Validate a recentering and return it with a triple as floats."""
|
|
188
|
+
if isinstance(recenter, str) and recenter == 'none':
|
|
189
|
+
return 'none'
|
|
190
|
+
if isinstance(recenter, str) and recenter == 'half':
|
|
191
|
+
return 'half'
|
|
192
|
+
if (isinstance(recenter, (tuple, list)) and len(recenter) == 3
|
|
193
|
+
and all(isinstance(value, (int, float))
|
|
194
|
+
and not isinstance(value, bool)
|
|
195
|
+
and math.isfinite(value) for value in recenter)):
|
|
196
|
+
return (float(recenter[0]), float(recenter[1]), float(recenter[2]))
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"'recenter' must be 'none', 'half', or a triple of finite numbers, "
|
|
199
|
+
f"not {recenter!r}.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _planar(
|
|
203
|
+
positions: np.ndarray, plane: str, edges: Mapping[str, np.ndarray]
|
|
204
|
+
) -> np.ndarray:
|
|
205
|
+
"""One frame's map in `plane`, rows along its first axis."""
|
|
206
|
+
if plane == 'xy':
|
|
207
|
+
return measurer.planar_cartesian_histogram(
|
|
208
|
+
positions, [edges['x'], edges['y']], 2)
|
|
209
|
+
if plane == 'yz':
|
|
210
|
+
return measurer.planar_cartesian_histogram(
|
|
211
|
+
positions, [edges['y'], edges['z']], 0)
|
|
212
|
+
# dim=1 bins (z, x); transpose to the research code's (x, z).
|
|
213
|
+
return measurer.planar_cartesian_histogram(
|
|
214
|
+
positions, [edges['z'], edges['x']], 1).T
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def histogram_edges(
|
|
218
|
+
spec: ProbeSpec, parser: ParserBase
|
|
219
|
+
) -> dict[str, np.ndarray]:
|
|
220
|
+
"""
|
|
221
|
+
The bin edges of one whole's histograms and maps.
|
|
222
|
+
|
|
223
|
+
The bin sizes are the spec's, resolved for this whole by
|
|
224
|
+
:meth:`~polyphys.spec.ProbeSpec.resolve`; the ranges follow
|
|
225
|
+
the geometry, so that every position inside the confinement falls
|
|
226
|
+
between the edges:
|
|
227
|
+
|
|
228
|
+
- Cylinder: ``'r'`` over :math:`[0, (D + a_w) / 2]`, the wall surface,
|
|
229
|
+
with 'nonnegative' bins; ``'z'`` over :math:`[-Z / 2, Z / 2]` and
|
|
230
|
+
``'x'``, ``'y'`` over :math:`[-(D + a_w) / 2, (D + a_w) / 2]` with
|
|
231
|
+
'ordinary' bins; ``'theta'`` over :math:`[-\\pi, \\pi]` with
|
|
232
|
+
'periodic' bins.
|
|
233
|
+
- Cube: ``'r'`` over :math:`[0, \\sqrt{3} L / 2]`, the corner, with
|
|
234
|
+
'nonnegative' bins; ``'x'``, ``'y'``, ``'z'`` over
|
|
235
|
+
:math:`[-L / 2, L / 2]` with 'ordinary' bins.
|
|
236
|
+
|
|
237
|
+
The research code ranged ``'r'``, ``'x'``, and ``'y'`` of a cylinder
|
|
238
|
+
over :math:`D / 2` and ``'r'`` of a cube over :math:`L / 2`, and
|
|
239
|
+
silently dropped the positions beyond them; the maintainer extended the
|
|
240
|
+
ranges on 2026-11-04, since :class:`SpatialHistograms` raises on such a
|
|
241
|
+
position instead.
|
|
242
|
+
|
|
243
|
+
.. versionadded:: 0.7.0
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
spec : :class:`~polyphys.spec.ProbeSpec`
|
|
248
|
+
The study's probe spec, whose ``bin_sizes`` holds every key that
|
|
249
|
+
``geometry`` needs: ``'r'``, ``'z'``, ``'theta'``, ``'x'``, ``'y'``
|
|
250
|
+
for a cylinder; ``'r'``, ``'x'``, ``'y'``, ``'z'`` for a cube.
|
|
251
|
+
parser : :class:`~polyphys.manage.parser.ParserBase`
|
|
252
|
+
The whole's parser, with ``dcyl`` and ``lcyl`` or ``lcube``.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
dict of (str -> numpy.ndarray)
|
|
257
|
+
The edges keyed by direction, each float64 and increasing, with
|
|
258
|
+
``n_bins + 1`` entries, in the order the geometry needs them.
|
|
259
|
+
|
|
260
|
+
Raises
|
|
261
|
+
------
|
|
262
|
+
ValueError
|
|
263
|
+
If ``spec.bin_sizes`` lacks a key the geometry needs, or if a
|
|
264
|
+
resolved bin size is not a finite positive number.
|
|
265
|
+
|
|
266
|
+
Notes
|
|
267
|
+
-----
|
|
268
|
+
:math:`O(B)` time and space for :math:`B` edges in all.
|
|
269
|
+
|
|
270
|
+
Examples
|
|
271
|
+
--------
|
|
272
|
+
>>> from polyphys.tests.synthetic import ChainCyl
|
|
273
|
+
>>> from polyphys.spec import ProbeSpec, ProjectSpec
|
|
274
|
+
>>> project = ProjectSpec(
|
|
275
|
+
... parser=ChainCyl, group='bug', divisor=0.025,
|
|
276
|
+
... crowder_attr='phi_bulk_c',
|
|
277
|
+
... physical_attrs=('space', 'nmon', 'dcyl', 'dcrowd'),
|
|
278
|
+
... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
|
|
279
|
+
... tseries_properties=('gyrTMon',))
|
|
280
|
+
>>> spec = ProbeSpec(
|
|
281
|
+
... project, {'Mon': 'resid 1', 'Crd': 'resid 0'}, 'Mon', ('gyrT',),
|
|
282
|
+
... unwrap=False, histogram_species=('Mon', 'Crd'), block_frames=100,
|
|
283
|
+
... bin_sizes={'r': 0.5, 'z': 1.0, 'theta': np.pi / 2, 'x': 0.5,
|
|
284
|
+
... 'y': 0.5})
|
|
285
|
+
>>> whole = ChainCyl(
|
|
286
|
+
... 'N20r2.5lz5ac1nc0dt0.005bdump1adump1ens1', 'whole',
|
|
287
|
+
... 'all')
|
|
288
|
+
>>> edges = histogram_edges(spec, whole)
|
|
289
|
+
>>> list(edges)
|
|
290
|
+
['r', 'z', 'theta', 'x', 'y']
|
|
291
|
+
>>> edges['r']
|
|
292
|
+
array([0. , 0.5, 1. , 1.5, 2. , 2.5])
|
|
293
|
+
>>> edges['z'][[0, -1]], edges['theta'].size
|
|
294
|
+
(array([-5., 5.]), 5)
|
|
295
|
+
"""
|
|
296
|
+
geometry = spec.geometry
|
|
297
|
+
missing = [key for key in _required_edges(geometry)
|
|
298
|
+
if key not in spec.bin_sizes]
|
|
299
|
+
if missing:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"{spec.name}: 'bin_sizes' lacks {missing}, which the "
|
|
302
|
+
f"{geometry} histograms need.")
|
|
303
|
+
sizes = {key: ProbeSpec.resolve(spec.bin_sizes[key], parser)
|
|
304
|
+
for key in _required_edges(geometry)}
|
|
305
|
+
ranges: dict[DirectionT, tuple[float, float, BinT]]
|
|
306
|
+
if geometry == 'cylindrical':
|
|
307
|
+
half_box = 0.5 * (float(getattr(parser, 'dcyl')) + _WALL_BEAD)
|
|
308
|
+
half_length = 0.5 * float(getattr(parser, 'lcyl'))
|
|
309
|
+
ranges = {
|
|
310
|
+
'r': (0.0, half_box, 'nonnegative'),
|
|
311
|
+
'z': (-half_length, half_length, 'ordinary'),
|
|
312
|
+
'theta': (-np.pi, np.pi, 'periodic'),
|
|
313
|
+
'x': (-half_box, half_box, 'ordinary'),
|
|
314
|
+
'y': (-half_box, half_box, 'ordinary'),
|
|
315
|
+
}
|
|
316
|
+
else:
|
|
317
|
+
half_side = 0.5 * float(getattr(parser, 'lcube'))
|
|
318
|
+
ranges = {
|
|
319
|
+
'r': (0.0, math.sqrt(3.0) * half_side, 'nonnegative'),
|
|
320
|
+
'x': (-half_side, half_side, 'ordinary'),
|
|
321
|
+
'y': (-half_side, half_side, 'ordinary'),
|
|
322
|
+
'z': (-half_side, half_side, 'ordinary'),
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
key: np.asarray(
|
|
326
|
+
measurer.fixedsize_bins(sizes[key], lmin, lmax,
|
|
327
|
+
bin_type)['bin_edges'],
|
|
328
|
+
dtype=np.float64)
|
|
329
|
+
for key, (lmin, lmax, bin_type) in ranges.items()}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SpatialHistograms(_ProbeAnalysis):
|
|
333
|
+
"""
|
|
334
|
+
The positions of each species binned in every frame: one-dimensional
|
|
335
|
+
histograms per frame and two-dimensional maps per block of frames.
|
|
336
|
+
|
|
337
|
+
.. versionadded:: 0.7.0
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
groups : mapping of :py:obj:`~.EntityT` to
|
|
342
|
+
~MDAnalysis.core.groups.AtomGroup
|
|
343
|
+
Each species label mapped to its atoms, all in one universe, in the
|
|
344
|
+
order ``results`` and :meth:`save` list the species. A group may be
|
|
345
|
+
empty, for a species a whole lacks; its histograms are then zero.
|
|
346
|
+
edges : mapping of str to numpy.ndarray
|
|
347
|
+
The bin edges, increasing, keyed as :func:`histogram_edges` returns
|
|
348
|
+
them: the directions of `geometry` and ``'x'``, ``'y'``, ``'z'``.
|
|
349
|
+
geometry : :py:obj:`~.GeometryT`
|
|
350
|
+
``'cylindrical'``, with the histograms along ``'r'``, ``'z'``, and
|
|
351
|
+
``'theta'`` about the ``z`` axis, or ``'cubic'``, with the
|
|
352
|
+
histogram along ``'r'`` from the box center.
|
|
353
|
+
block_frames : int
|
|
354
|
+
The block length :math:`b`, positive: consecutive frames of the
|
|
355
|
+
run whose maps are averaged into one stored map.
|
|
356
|
+
recenter : :py:data:`~polyphys.spec.RecenterT`, default 'half'
|
|
357
|
+
How a frame's positions are moved before binning: ``'half'``
|
|
358
|
+
subtracts half the frame's box lengths, ``'none'`` nothing, a
|
|
359
|
+
triple its values. The positions in the trajectory are untouched.
|
|
360
|
+
verbose : bool, default False
|
|
361
|
+
Whether to show a progress bar during
|
|
362
|
+
:meth:`~MDAnalysis.analysis.base.AnalysisBase.run`.
|
|
363
|
+
|
|
364
|
+
Attributes
|
|
365
|
+
----------
|
|
366
|
+
groups : mapping of :py:obj:`~.EntityT` to
|
|
367
|
+
~MDAnalysis.core.groups.AtomGroup
|
|
368
|
+
The species, a copy of the mapping given.
|
|
369
|
+
edges : mapping of str to numpy.ndarray
|
|
370
|
+
The edges, a copy of the mapping given, each float64.
|
|
371
|
+
geometry : :py:obj:`~.GeometryT`
|
|
372
|
+
The geometry.
|
|
373
|
+
block_frames : int
|
|
374
|
+
The block length.
|
|
375
|
+
recenter : :py:data:`~polyphys.spec.RecenterT`
|
|
376
|
+
The recentering, a triple as floats.
|
|
377
|
+
results : :class:`~MDAnalysis.analysis.results.Results`
|
|
378
|
+
After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, for each
|
|
379
|
+
species ``S`` and direction ``d`` of the geometry, ``dHistTS``, int64
|
|
380
|
+
``(n_frames, n_bins)``, the counts of frame ``i`` in row ``i``, and
|
|
381
|
+
``dHistS``, int64 ``(n_bins,)``, their sum over the frames; for each
|
|
382
|
+
plane ``p`` in ``xy``, ``xz``, ``yz``, ``pHistBlockS``, float64
|
|
383
|
+
``(n_blocks, n_a, n_b)``, the mean map of each block, rows along the
|
|
384
|
+
plane's first axis, and ``pHistS``, float64 ``(n_a, n_b)``, the counts
|
|
385
|
+
summed over the frames; and ``blockFrames``, int64 ``(n_blocks,)``, the
|
|
386
|
+
frames in each block.
|
|
387
|
+
n_frames : int
|
|
388
|
+
After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the number of
|
|
389
|
+
frames analyzed, from :class:`~MDAnalysis.analysis.base.AnalysisBase`.
|
|
390
|
+
frames : numpy.ndarray
|
|
391
|
+
After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the indices
|
|
392
|
+
of the frames analyzed, shape ``(n_frames,)``.
|
|
393
|
+
times : numpy.ndarray
|
|
394
|
+
After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the times of
|
|
395
|
+
the frames analyzed, shape ``(n_frames,)``, in the trajectory's time
|
|
396
|
+
unit.
|
|
397
|
+
|
|
398
|
+
Raises
|
|
399
|
+
------
|
|
400
|
+
ValueError
|
|
401
|
+
If `groups` is empty, has an empty label, or spans two universes; if
|
|
402
|
+
`edges` lacks a key the geometry needs or has edges that are not a
|
|
403
|
+
one-dimensional increasing array of at least two values; if `geometry`
|
|
404
|
+
is unknown; if `block_frames` is not a positive integer; if `recenter`
|
|
405
|
+
is neither ``'none'``, ``'half'``, nor a triple of finite numbers; or,
|
|
406
|
+
during :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, if a frame
|
|
407
|
+
carries no box, or if a wrapped and recentered position falls outside
|
|
408
|
+
the edges of a direction or a Cartesian axis.
|
|
409
|
+
|
|
410
|
+
Notes
|
|
411
|
+
-----
|
|
412
|
+
Per frame and species, the positions are copied, wrapped, recentered,
|
|
413
|
+
and binned. Wrapping maps each position into the box of the reader's
|
|
414
|
+
frame, :math:`[0, L)`, along the directions the geometry makes
|
|
415
|
+
periodic, every one in a cube and ``z`` in a cylinder: LAMMPS enforces
|
|
416
|
+
the periodic boundaries only when it rebuilds the neighbor lists, so a
|
|
417
|
+
dump can place an atom slightly outside the box [1]_, as the HnsCub
|
|
418
|
+
dumps do by up to :math:`0.04` monomer diameters. The box of the
|
|
419
|
+
reader's frame is MDAnalysis's primary unit cell, :math:`[0, L)`, where
|
|
420
|
+
its LAMMPS dump reader puts the box by subtracting the lower bounds; a
|
|
421
|
+
universe built in memory must place its positions there too. A position
|
|
422
|
+
past a wall is not wrapped. The positions are binned with
|
|
423
|
+
:func:`~polyphys.analyze.measurer.radial_cyl_histogram`,
|
|
424
|
+
:func:`~polyphys.analyze.measurer.axial_histogram`, and
|
|
425
|
+
:func:`~polyphys.analyze.measurer.azimuth_cyl_histogram` in a cylinder,
|
|
426
|
+
:func:`~polyphys.analyze.measurer.radial_histogram` in a cube, and
|
|
427
|
+
:func:`~polyphys.analyze.measurer.planar_cartesian_histogram` in each
|
|
428
|
+
plane, with the ``xz`` map transposed to rows along ``x`` as the
|
|
429
|
+
research code stored it. A position outside the edges after wrapping
|
|
430
|
+
raises, so every frame's counts sum to the species' particle number:
|
|
431
|
+
the research code's ``np.histogram`` dropped such a position silently,
|
|
432
|
+
and did not wrap.
|
|
433
|
+
|
|
434
|
+
Blocks are numbered over the frames of the whole run in the order
|
|
435
|
+
:meth:`~MDAnalysis.analysis.base.AnalysisBase.run` visits them, whatever
|
|
436
|
+
``start``, ``stop``, ``step``, or ``frames`` select, so that a run split
|
|
437
|
+
across workers gives the same blocks as a serial one: ``_setup_frames``
|
|
438
|
+
records the run's frame count before the split and ``_compute`` each
|
|
439
|
+
worker's offset into it. Each worker accumulates the block sums and frame
|
|
440
|
+
counts of its own frames, which ``_get_aggregator`` adds
|
|
441
|
+
(``ResultsGroup.ndarray_sum``), and its per-frame rows, which it
|
|
442
|
+
concatenates (``ResultsGroup.ndarray_vstack``); ``_conclude`` then divides
|
|
443
|
+
each block's sum by its frame count and sums the counts over the frames.
|
|
444
|
+
|
|
445
|
+
Cost per frame: :math:`O(S N \\log B)` time for :math:`S` species of
|
|
446
|
+
:math:`N` particles and :math:`B` bins along a direction, from the
|
|
447
|
+
sorted-edge search of ``np.histogram`` and ``np.histogram2d``, and
|
|
448
|
+
:math:`O(N)` extra space for the recentered positions. Over a run, each
|
|
449
|
+
one-dimensional histogram holds :math:`8 F B` bytes and each map
|
|
450
|
+
:math:`8 K B_a B_b` bytes, for :math:`F` frames, :math:`K = \\lceil F /
|
|
451
|
+
b \\rceil` blocks, and :math:`B_a \\times B_b` cells: with
|
|
452
|
+
:math:`b = 100` and :math:`200 \\times 200` cells, about 1 MB per
|
|
453
|
+
block per species for the three planes. Wrapping adds :math:`O(N)`
|
|
454
|
+
time per frame and species.
|
|
455
|
+
|
|
456
|
+
References
|
|
457
|
+
----------
|
|
458
|
+
.. [1] LAMMPS Developers, "dump command", LAMMPS documentation, note
|
|
459
|
+
on periodic boundaries: "the coordinates of an atom written to a dump
|
|
460
|
+
file may be slightly outside the simulation box".
|
|
461
|
+
https://docs.lammps.org/dump.html
|
|
462
|
+
|
|
463
|
+
Examples
|
|
464
|
+
--------
|
|
465
|
+
One particle in a cylinder of radius 2 and length 4, two frames in the
|
|
466
|
+
reader's frame, the box from 0 to 4, recentered by a given shift to
|
|
467
|
+
(1.5, 0, 1) and (0, 0.5, -1); the frame's counts sum to its one
|
|
468
|
+
particle along every direction:
|
|
469
|
+
|
|
470
|
+
>>> import MDAnalysis as mda
|
|
471
|
+
>>> import numpy as np
|
|
472
|
+
>>> frames = np.array([[[3.5, 2.0, 3.0]], [[2.0, 2.5, 1.0]]])
|
|
473
|
+
>>> universe = mda.Universe.empty(1, trajectory=True)
|
|
474
|
+
>>> universe.load_new(frames, format='MEMORY', dimensions=np.tile(
|
|
475
|
+
... [4.0, 4.0, 4.0, 90.0, 90.0, 90.0], (2, 1)))
|
|
476
|
+
<Universe with 1 atoms>
|
|
477
|
+
>>> edges = {'r': np.array([0.0, 1.0, 2.0]),
|
|
478
|
+
... 'z': np.array([-2.0, 0.0, 2.0]),
|
|
479
|
+
... 'theta': np.linspace(-np.pi, np.pi, 5),
|
|
480
|
+
... 'x': np.array([-2.0, 0.0, 2.0]),
|
|
481
|
+
... 'y': np.array([-2.0, 0.0, 2.0])}
|
|
482
|
+
>>> analysis = SpatialHistograms(
|
|
483
|
+
... {'Crd': universe.atoms}, edges, geometry='cylindrical',
|
|
484
|
+
... block_frames=2, recenter=(2.0, 2.0, 2.0)).run()
|
|
485
|
+
>>> analysis.results['rHistTCrd'], analysis.results['zHistTCrd']
|
|
486
|
+
(array([[0, 1],
|
|
487
|
+
[1, 0]]), array([[0, 1],
|
|
488
|
+
[1, 0]]))
|
|
489
|
+
>>> analysis.results['xyHistBlockCrd']
|
|
490
|
+
array([[[0., 0.],
|
|
491
|
+
[0., 1.]]])
|
|
492
|
+
>>> float(analysis.results['xyHistCrd'].sum())
|
|
493
|
+
2.0
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
_analysis_algorithm_is_parallelizable = True
|
|
497
|
+
|
|
498
|
+
def __init__(
|
|
499
|
+
self,
|
|
500
|
+
groups: Mapping[EntityT, AtomGroup],
|
|
501
|
+
edges: Mapping[str, np.ndarray],
|
|
502
|
+
*,
|
|
503
|
+
geometry: GeometryT,
|
|
504
|
+
block_frames: int,
|
|
505
|
+
recenter: RecenterT = 'half',
|
|
506
|
+
verbose: bool = False,
|
|
507
|
+
) -> None:
|
|
508
|
+
groups = dict(groups)
|
|
509
|
+
if not groups or any(not (isinstance(label, str) and label)
|
|
510
|
+
for label in groups):
|
|
511
|
+
raise ValueError(
|
|
512
|
+
f"'groups' must map each non-empty species label to its "
|
|
513
|
+
f"atoms, not {list(groups)!r}.")
|
|
514
|
+
universes = {id(group.universe) for group in groups.values()}
|
|
515
|
+
if len(universes) != 1:
|
|
516
|
+
raise ValueError("'groups' must all belong to one universe.")
|
|
517
|
+
if geometry not in _DIRECTIONS:
|
|
518
|
+
raise ValueError(
|
|
519
|
+
f"'geometry' must be one of {list(_DIRECTIONS)}, not "
|
|
520
|
+
f"{geometry!r}.")
|
|
521
|
+
required = _required_edges(geometry)
|
|
522
|
+
missing = [key for key in required if key not in edges]
|
|
523
|
+
if missing:
|
|
524
|
+
raise ValueError(
|
|
525
|
+
f"'edges' lacks {missing}, which the {geometry} histograms "
|
|
526
|
+
f"need.")
|
|
527
|
+
checked: dict[str, np.ndarray] = {}
|
|
528
|
+
for key in required:
|
|
529
|
+
array = np.asarray(edges[key], dtype=np.float64)
|
|
530
|
+
if array.ndim != 1 or array.size < 2 or np.any(
|
|
531
|
+
np.diff(array) <= 0):
|
|
532
|
+
raise ValueError(
|
|
533
|
+
f"The edges along {key!r} must be a one-dimensional "
|
|
534
|
+
f"increasing array of at least two values.")
|
|
535
|
+
checked[key] = array
|
|
536
|
+
if not (isinstance(block_frames, int)
|
|
537
|
+
and not isinstance(block_frames, bool) and block_frames > 0):
|
|
538
|
+
raise ValueError(
|
|
539
|
+
f"'block_frames' must be a positive integer, not "
|
|
540
|
+
f"{block_frames!r}.")
|
|
541
|
+
universe = next(iter(groups.values())).universe
|
|
542
|
+
super().__init__(universe.trajectory, verbose=verbose)
|
|
543
|
+
# Plain dicts, not read-only proxies: the multiprocessing backend
|
|
544
|
+
# pickles the analysis, and a mappingproxy cannot be pickled.
|
|
545
|
+
self.groups: Mapping[EntityT, AtomGroup] = groups
|
|
546
|
+
self.edges: Mapping[str, np.ndarray] = checked
|
|
547
|
+
self.geometry: GeometryT = geometry
|
|
548
|
+
self.block_frames = block_frames
|
|
549
|
+
self.recenter = _check_recenter(recenter)
|
|
550
|
+
self._directions = _DIRECTIONS[geometry]
|
|
551
|
+
self._histograms = _HISTOGRAMS[geometry]
|
|
552
|
+
self._run_n_frames = 0
|
|
553
|
+
self._run_offset = 0
|
|
554
|
+
|
|
555
|
+
@classmethod
|
|
556
|
+
def from_spec(
|
|
557
|
+
cls,
|
|
558
|
+
universe: Universe,
|
|
559
|
+
spec: ProbeSpec,
|
|
560
|
+
parser: ParserBase,
|
|
561
|
+
*,
|
|
562
|
+
verbose: bool = False,
|
|
563
|
+
) -> Self:
|
|
564
|
+
"""
|
|
565
|
+
Build the analysis of a whole from its study's probe spec.
|
|
566
|
+
|
|
567
|
+
The species are ``spec.histogram_species``, each selected with its
|
|
568
|
+
string in ``spec.selections``; the edges are
|
|
569
|
+
:func:`histogram_edges`'s; the geometry, block length, and
|
|
570
|
+
recentering are the spec's.
|
|
571
|
+
|
|
572
|
+
.. versionadded:: 0.7.0
|
|
573
|
+
|
|
574
|
+
Parameters
|
|
575
|
+
----------
|
|
576
|
+
universe : :class:`~MDAnalysis.core.universe.Universe`
|
|
577
|
+
The trajectory, as :func:`~polyphys.probe.core.make_universe`
|
|
578
|
+
opens it.
|
|
579
|
+
spec : :class:`~polyphys.spec.ProbeSpec`
|
|
580
|
+
The study's probe spec, with ``histogram_species``,
|
|
581
|
+
``bin_sizes``, and ``block_frames`` set.
|
|
582
|
+
parser : :class:`~polyphys.manage.parser.ParserBase`
|
|
583
|
+
The whole's parser, for the edges.
|
|
584
|
+
verbose : bool, default False
|
|
585
|
+
Whether to show a progress bar during
|
|
586
|
+
:meth:`~MDAnalysis.analysis.base.AnalysisBase.run`.
|
|
587
|
+
|
|
588
|
+
Returns
|
|
589
|
+
-------
|
|
590
|
+
SpatialHistograms
|
|
591
|
+
The analysis, not yet run.
|
|
592
|
+
|
|
593
|
+
Raises
|
|
594
|
+
------
|
|
595
|
+
ValueError
|
|
596
|
+
If the spec names no histogram species, or from
|
|
597
|
+
:func:`histogram_edges`.
|
|
598
|
+
|
|
599
|
+
Examples
|
|
600
|
+
--------
|
|
601
|
+
>>> import MDAnalysis as mda
|
|
602
|
+
>>> import numpy as np
|
|
603
|
+
>>> from polyphys.tests.synthetic import ChainCyl
|
|
604
|
+
>>> from polyphys.spec import ProbeSpec, ProjectSpec
|
|
605
|
+
>>> project = ProjectSpec(
|
|
606
|
+
... parser=ChainCyl, group='bug', divisor=0.025,
|
|
607
|
+
... crowder_attr='phi_bulk_c',
|
|
608
|
+
... physical_attrs=('space', 'nmon', 'dcyl', 'dcrowd'),
|
|
609
|
+
... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
|
|
610
|
+
... tseries_properties=('gyrTMon',))
|
|
611
|
+
>>> spec = ProbeSpec(
|
|
612
|
+
... project, {'Mon': 'resid 1', 'Crd': 'resid 0'}, 'Mon',
|
|
613
|
+
... ('gyrT',), unwrap=False, histogram_species=('Mon', 'Crd'),
|
|
614
|
+
... block_frames=100, bin_sizes={'r': 0.5, 'z': 1.0,
|
|
615
|
+
... 'theta': np.pi / 2, 'x': 0.5, 'y': 0.5})
|
|
616
|
+
>>> whole = ChainCyl(
|
|
617
|
+
... 'N2r2.5lz5ac1nc1dt0.005bdump1adump1ens1', 'whole',
|
|
618
|
+
... 'all')
|
|
619
|
+
>>> universe = mda.Universe.empty(
|
|
620
|
+
... 3, n_residues=2, atom_resindex=[0, 1, 1], trajectory=True)
|
|
621
|
+
>>> universe.add_TopologyAttr('resid', [0, 1])
|
|
622
|
+
>>> universe.load_new(np.array([[[1.0, 2.0, 3.0], [2.0, 2.0, 5.0],
|
|
623
|
+
... [3.0, 2.0, 5.0]]]), format='MEMORY', dimensions=np.array(
|
|
624
|
+
... [[5.0, 5.0, 10.0, 90.0, 90.0, 90.0]]))
|
|
625
|
+
<Universe with 3 atoms>
|
|
626
|
+
>>> analysis = SpatialHistograms.from_spec(universe, spec, whole)
|
|
627
|
+
>>> [group.n_atoms for group in analysis.groups.values()]
|
|
628
|
+
[2, 1]
|
|
629
|
+
>>> analysis.run().results['rHistCrd']
|
|
630
|
+
array([0, 0, 0, 1, 0])
|
|
631
|
+
"""
|
|
632
|
+
if not spec.histogram_species or spec.block_frames is None:
|
|
633
|
+
raise ValueError(
|
|
634
|
+
f"{spec.name}: the probe spec names no histogram species.")
|
|
635
|
+
groups = {species: universe.select_atoms(spec.selections[species])
|
|
636
|
+
for species in spec.histogram_species}
|
|
637
|
+
return cls(groups, histogram_edges(spec, parser),
|
|
638
|
+
geometry=spec.geometry, block_frames=spec.block_frames,
|
|
639
|
+
recenter=spec.recenter, verbose=verbose)
|
|
640
|
+
|
|
641
|
+
@classmethod
|
|
642
|
+
def get_supported_backends(cls) -> tuple[str, ...]:
|
|
643
|
+
"""
|
|
644
|
+
The backends :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`
|
|
645
|
+
accepts: ``'serial'``, ``'multiprocessing'``, and ``'dask'``.
|
|
646
|
+
|
|
647
|
+
Examples
|
|
648
|
+
--------
|
|
649
|
+
>>> SpatialHistograms.get_supported_backends()
|
|
650
|
+
('serial', 'multiprocessing', 'dask')
|
|
651
|
+
"""
|
|
652
|
+
return _BACKENDS
|
|
653
|
+
|
|
654
|
+
def _setup_frames(
|
|
655
|
+
self, trajectory: Any, start: Any = None, stop: Any = None,
|
|
656
|
+
step: Any = None, frames: Any = None,
|
|
657
|
+
) -> None:
|
|
658
|
+
"""
|
|
659
|
+
Record the frame count of the whole run before it is split across
|
|
660
|
+
workers, which fixes the number of blocks.
|
|
661
|
+
"""
|
|
662
|
+
super()._setup_frames(trajectory, start=start, stop=stop, step=step,
|
|
663
|
+
frames=frames)
|
|
664
|
+
self._run_n_frames = int(self.n_frames)
|
|
665
|
+
self._run_offset = 0
|
|
666
|
+
|
|
667
|
+
def _compute(
|
|
668
|
+
self, indexed_frames: np.ndarray, verbose: Any = None, *,
|
|
669
|
+
progressbar_kwargs: Any = None,
|
|
670
|
+
) -> Any:
|
|
671
|
+
"""
|
|
672
|
+
Record where this part's frames start in the run, then compute the
|
|
673
|
+
part on a copy of this analysis, so that serial parts do not
|
|
674
|
+
overwrite each other.
|
|
675
|
+
"""
|
|
676
|
+
self._run_offset = (int(indexed_frames[0, 0])
|
|
677
|
+
if len(indexed_frames) else 0)
|
|
678
|
+
return _compute_part(self, indexed_frames, verbose,
|
|
679
|
+
progressbar_kwargs=progressbar_kwargs)
|
|
680
|
+
|
|
681
|
+
def _get_aggregator(self) -> ResultsGroup:
|
|
682
|
+
"""
|
|
683
|
+
Concatenate each worker's per-frame rows and add its block sums and
|
|
684
|
+
frame counts.
|
|
685
|
+
"""
|
|
686
|
+
lookup: dict[str, Callable[..., Any]] = {
|
|
687
|
+
'blockFrames': ResultsGroup.ndarray_sum}
|
|
688
|
+
for species in self.groups:
|
|
689
|
+
for direction in self._directions:
|
|
690
|
+
lookup[f"{direction}HistT{species}"] = (
|
|
691
|
+
ResultsGroup.ndarray_vstack)
|
|
692
|
+
for plane in _PLANES:
|
|
693
|
+
lookup[f"{plane}HistBlock{species}"] = (
|
|
694
|
+
ResultsGroup.ndarray_sum)
|
|
695
|
+
return ResultsGroup(lookup=lookup)
|
|
696
|
+
|
|
697
|
+
def _prepare(self) -> None:
|
|
698
|
+
"""
|
|
699
|
+
Allocate the per-frame rows of this run or worker and the block sums
|
|
700
|
+
and frame counts of the whole run.
|
|
701
|
+
"""
|
|
702
|
+
n_blocks = -(-self._run_n_frames // self.block_frames)
|
|
703
|
+
self.results['blockFrames'] = np.zeros(n_blocks, dtype=np.int64)
|
|
704
|
+
for species in self.groups:
|
|
705
|
+
for direction in self._directions:
|
|
706
|
+
self.results[f"{direction}HistT{species}"] = np.zeros(
|
|
707
|
+
(self.n_frames, self.edges[direction].size - 1),
|
|
708
|
+
dtype=np.int64)
|
|
709
|
+
for plane in _PLANES:
|
|
710
|
+
self.results[f"{plane}HistBlock{species}"] = np.zeros(
|
|
711
|
+
(n_blocks, self.edges[plane[0]].size - 1,
|
|
712
|
+
self.edges[plane[1]].size - 1), dtype=np.float64)
|
|
713
|
+
|
|
714
|
+
def _shift(self) -> np.ndarray:
|
|
715
|
+
"""The vector subtracted from this frame's positions."""
|
|
716
|
+
if self.recenter == 'none':
|
|
717
|
+
return np.zeros(3)
|
|
718
|
+
if self.recenter == 'half':
|
|
719
|
+
return 0.5 * np.asarray(self._ts.dimensions[:3],
|
|
720
|
+
dtype=np.float64)
|
|
721
|
+
return np.asarray(self.recenter, dtype=np.float64)
|
|
722
|
+
|
|
723
|
+
def _wrap(self, positions: np.ndarray) -> np.ndarray:
|
|
724
|
+
"""Wrap `positions` into the frame's box, :math:`[0, L)`, along the
|
|
725
|
+
geometry's periodic directions, in place."""
|
|
726
|
+
if self._ts.dimensions is None:
|
|
727
|
+
raise ValueError(
|
|
728
|
+
f"Frame {self._ts.frame} carries no box, which wrapping "
|
|
729
|
+
f"along the periodic directions needs.")
|
|
730
|
+
axes = list(_PERIODIC[self.geometry])
|
|
731
|
+
lengths = np.asarray(self._ts.dimensions[:3],
|
|
732
|
+
dtype=np.float64)[axes]
|
|
733
|
+
positions[:, axes] -= lengths * np.floor(positions[:, axes] / lengths)
|
|
734
|
+
return positions
|
|
735
|
+
|
|
736
|
+
def _single_frame(self) -> None:
|
|
737
|
+
"""
|
|
738
|
+
Wrap, recenter, and bin every species on the current frame: the
|
|
739
|
+
histograms into this frame's rows, the maps into this frame's block.
|
|
740
|
+
"""
|
|
741
|
+
block = (self._run_offset + self._frame_index) // self.block_frames
|
|
742
|
+
self.results['blockFrames'][block] += 1
|
|
743
|
+
shift = self._shift()
|
|
744
|
+
for species, group in self.groups.items():
|
|
745
|
+
positions = self._wrap(
|
|
746
|
+
group.positions.astype(np.float64)) - shift
|
|
747
|
+
for direction in self._directions:
|
|
748
|
+
self.results[f"{direction}HistT{species}"][
|
|
749
|
+
self._frame_index] = self._histograms[direction](
|
|
750
|
+
positions, self.edges[direction])
|
|
751
|
+
for axis, key in enumerate(_CARTESIAN):
|
|
752
|
+
edges = self.edges[key]
|
|
753
|
+
outside = int(np.count_nonzero(
|
|
754
|
+
(positions[:, axis] < edges[0])
|
|
755
|
+
| (positions[:, axis] > edges[-1])))
|
|
756
|
+
if outside:
|
|
757
|
+
raise ValueError(
|
|
758
|
+
f"{outside} of {len(positions)} {species} positions "
|
|
759
|
+
f"fall outside the {key} edges "
|
|
760
|
+
f"[{edges[0]}, {edges[-1]}] in frame "
|
|
761
|
+
f"{self._ts.frame}.")
|
|
762
|
+
for plane in _PLANES:
|
|
763
|
+
self.results[f"{plane}HistBlock{species}"][block] += _planar(
|
|
764
|
+
positions, plane, self.edges)
|
|
765
|
+
|
|
766
|
+
def _conclude(self) -> None:
|
|
767
|
+
"""
|
|
768
|
+
Turn each block's summed map into its mean and sum the counts over
|
|
769
|
+
the frames.
|
|
770
|
+
"""
|
|
771
|
+
counts = self.results['blockFrames']
|
|
772
|
+
filled = counts > 0
|
|
773
|
+
for species in self.groups:
|
|
774
|
+
for direction in self._directions:
|
|
775
|
+
self.results[f"{direction}Hist{species}"] = self.results[
|
|
776
|
+
f"{direction}HistT{species}"].sum(axis=0)
|
|
777
|
+
for plane in _PLANES:
|
|
778
|
+
key = f"{plane}HistBlock{species}"
|
|
779
|
+
sums = self.results[key]
|
|
780
|
+
self.results[f"{plane}Hist{species}"] = sums.sum(axis=0)
|
|
781
|
+
means = np.zeros_like(sums)
|
|
782
|
+
means[filled] = sums[filled] / counts[filled, None, None]
|
|
783
|
+
self.results[key] = means
|
|
784
|
+
|
|
785
|
+
def save(self, save_to: Pathish, name: str) -> list[Path]:
|
|
786
|
+
"""
|
|
787
|
+
Save the edges, histograms, and maps under the research code's names.
|
|
788
|
+
|
|
789
|
+
For each species ``S`` and direction ``d``: ``<name>-dEdgeS.npy``,
|
|
790
|
+
``<name>-dHistS.npy`` (summed over the frames), and
|
|
791
|
+
``<name>-dHistTS.npy`` (per frame); for each Cartesian axis ``c``:
|
|
792
|
+
``<name>-cEdge.npy``; for each plane ``p`` and species:
|
|
793
|
+
``<name>-pHistS.npy`` (summed) and ``<name>-pHistBlockS.npy`` (per
|
|
794
|
+
block); and ``<name>-blockFrames.npy``.
|
|
795
|
+
|
|
796
|
+
.. versionadded:: 0.7.0
|
|
797
|
+
|
|
798
|
+
Parameters
|
|
799
|
+
----------
|
|
800
|
+
save_to : str or pathlib.Path
|
|
801
|
+
An existing directory.
|
|
802
|
+
name : str
|
|
803
|
+
The artifact's name, ``<parser.name>-<parser.group>`` in the
|
|
804
|
+
studies, such as ``'N200...ens1-all'``.
|
|
805
|
+
|
|
806
|
+
Returns
|
|
807
|
+
-------
|
|
808
|
+
list of pathlib.Path
|
|
809
|
+
The files written, in the order above.
|
|
810
|
+
|
|
811
|
+
Raises
|
|
812
|
+
------
|
|
813
|
+
RuntimeError
|
|
814
|
+
If :meth:`~MDAnalysis.analysis.base.AnalysisBase.run` has not been
|
|
815
|
+
called.
|
|
816
|
+
|
|
817
|
+
Examples
|
|
818
|
+
--------
|
|
819
|
+
>>> import tempfile
|
|
820
|
+
>>> import MDAnalysis as mda
|
|
821
|
+
>>> import numpy as np
|
|
822
|
+
>>> universe = mda.Universe.empty(1, trajectory=True)
|
|
823
|
+
>>> universe.load_new(np.array([[[1.0, 1.0, 1.0]]]), format='MEMORY',
|
|
824
|
+
... dimensions=np.array([[4.0, 4.0, 4.0, 90.0,
|
|
825
|
+
... 90.0, 90.0]]))
|
|
826
|
+
<Universe with 1 atoms>
|
|
827
|
+
>>> edges = {key: np.array([-2.0, 0.0, 2.0]) for key in 'xyz'}
|
|
828
|
+
>>> edges['r'] = np.array([0.0, 2.0, 4.0])
|
|
829
|
+
>>> analysis = SpatialHistograms(
|
|
830
|
+
... {'Mon': universe.atoms}, edges, geometry='cubic',
|
|
831
|
+
... block_frames=1).run()
|
|
832
|
+
>>> with tempfile.TemporaryDirectory() as tmp:
|
|
833
|
+
... paths = analysis.save(tmp, 'N1ens1-all')
|
|
834
|
+
... len(paths), paths[0].name, paths[-1].name
|
|
835
|
+
(13, 'N1ens1-all-rEdgeMon.npy', 'N1ens1-all-blockFrames.npy')
|
|
836
|
+
"""
|
|
837
|
+
if 'blockFrames' not in self.results:
|
|
838
|
+
raise RuntimeError("Call run() before save().")
|
|
839
|
+
folder = Path(save_to)
|
|
840
|
+
items: list[tuple[str, np.ndarray]] = []
|
|
841
|
+
for species in self.groups:
|
|
842
|
+
for direction in self._directions:
|
|
843
|
+
items.append((f"{direction}Edge{species}",
|
|
844
|
+
self.edges[direction]))
|
|
845
|
+
items.append((f"{direction}Hist{species}",
|
|
846
|
+
self.results[f"{direction}Hist{species}"]))
|
|
847
|
+
items.append((f"{direction}HistT{species}",
|
|
848
|
+
self.results[f"{direction}HistT{species}"]))
|
|
849
|
+
for key in _CARTESIAN:
|
|
850
|
+
items.append((f"{key}Edge", self.edges[key]))
|
|
851
|
+
for species in self.groups:
|
|
852
|
+
for plane in _PLANES:
|
|
853
|
+
items.append((f"{plane}Hist{species}",
|
|
854
|
+
self.results[f"{plane}Hist{species}"]))
|
|
855
|
+
items.append((f"{plane}HistBlock{species}",
|
|
856
|
+
self.results[f"{plane}HistBlock{species}"]))
|
|
857
|
+
items.append(('blockFrames', self.results['blockFrames']))
|
|
858
|
+
paths = []
|
|
859
|
+
for suffix, array in items:
|
|
860
|
+
path = folder / f"{name}-{suffix}.npy"
|
|
861
|
+
np.save(path, array)
|
|
862
|
+
paths.append(path)
|
|
863
|
+
return paths
|