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,1338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
==========================================================
|
|
3
|
+
:mod:`polyphys.analyze.measurer`
|
|
4
|
+
==========================================================
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
The :mod:`polyphys.analyze.measurer` module measures one frame of positions:
|
|
8
|
+
distances, sizes, and spans of a group of particles, the statistics of
|
|
9
|
+
independent samples, the volumes of a bead cut by planes or by a sphere, and
|
|
10
|
+
fixed-width bins and histograms of one frame.
|
|
11
|
+
|
|
12
|
+
Contents
|
|
13
|
+
========
|
|
14
|
+
|
|
15
|
+
Core geometric and structural measurements:
|
|
16
|
+
:func:`apply_pbc_orthogonal`, :func:`pair_distance`, :func:`end_to_end`,
|
|
17
|
+
:func:`transverse_size`, :func:`max_distance`, and :func:`fsd`.
|
|
18
|
+
|
|
19
|
+
Summary statistics:
|
|
20
|
+
:func:`simple_stats` and :func:`sem`.
|
|
21
|
+
|
|
22
|
+
Advanced geometric calculations:
|
|
23
|
+
:func:`sphere_sphere_intersection` and :func:`spherical_segment`.
|
|
24
|
+
|
|
25
|
+
Binning and histogram processing:
|
|
26
|
+
:func:`create_bin_edge_and_hist`, :func:`fixedsize_bins`,
|
|
27
|
+
:func:`radial_histogram`, :func:`radial_cyl_histogram`,
|
|
28
|
+
:func:`axial_histogram`, :func:`azimuth_cyl_histogram`, and
|
|
29
|
+
:func:`planar_cartesian_histogram`.
|
|
30
|
+
|
|
31
|
+
Number-density and volume-fraction conversions live in
|
|
32
|
+
:mod:`polyphys.manage.utils`.
|
|
33
|
+
|
|
34
|
+
References
|
|
35
|
+
==========
|
|
36
|
+
For Feret's statistical diameter:
|
|
37
|
+
|
|
38
|
+
- Wang Y, Teraoka I, Hansen FY, Peters GH, Hassager O. "A Theoretical Study of
|
|
39
|
+
the Separation Principle in Size Exclusion Chromatography." Macromolecules
|
|
40
|
+
2010, 43, 3, 1651-1659. https://doi.org/10.1021/ma902377g
|
|
41
|
+
|
|
42
|
+
For spherical segment calculations:
|
|
43
|
+
|
|
44
|
+
- Weisstein, Eric W. "Spherical Segment." From MathWorld--A Wolfram Web
|
|
45
|
+
Resource. https://mathworld.wolfram.com/SphericalSegment.html
|
|
46
|
+
|
|
47
|
+
For sphere-sphere intersection:
|
|
48
|
+
|
|
49
|
+
- Weisstein, Eric W. "Sphere-Sphere Intersection." From MathWorld--A Wolfram
|
|
50
|
+
Web Resource. https://mathworld.wolfram.com/Sphere-SphereIntersection.html
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from typing import Dict, Tuple, Optional, Union, Any, List, cast
|
|
54
|
+
import numpy as np
|
|
55
|
+
|
|
56
|
+
from ..manage.types import AxisT, EntityT, PropertyT, BinT
|
|
57
|
+
from ..manage.utils import invalid_keyword
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
'apply_pbc_orthogonal',
|
|
61
|
+
'axial_histogram',
|
|
62
|
+
'azimuth_cyl_histogram',
|
|
63
|
+
'create_bin_edge_and_hist',
|
|
64
|
+
'end_to_end',
|
|
65
|
+
'fixedsize_bins',
|
|
66
|
+
'fsd',
|
|
67
|
+
'max_distance',
|
|
68
|
+
'pair_distance',
|
|
69
|
+
'planar_cartesian_histogram',
|
|
70
|
+
'radial_cyl_histogram',
|
|
71
|
+
'radial_histogram',
|
|
72
|
+
'sem',
|
|
73
|
+
'simple_stats',
|
|
74
|
+
'sphere_sphere_intersection',
|
|
75
|
+
'spherical_segment',
|
|
76
|
+
'transverse_size',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# Relative tolerance for deciding whether a range holds a whole number of
|
|
80
|
+
# bins, used by :func:`create_bin_edge_and_hist` and :func:`fixedsize_bins`.
|
|
81
|
+
# The ratio ``(lmax - lmin) / bin_size`` is computed in float64, so a range
|
|
82
|
+
# that is exactly a whole number of bins can come out a few ulps above or
|
|
83
|
+
# below that integer (``2.1 / 0.3`` is ``7.000000000000001``). A plain
|
|
84
|
+
# ``ceil`` then adds a bin, and a plain comparison can leave the edges a few
|
|
85
|
+
# 1e-15 inside the range. The tolerance has to sit between two scales:
|
|
86
|
+
#
|
|
87
|
+
# - the accumulated round-off in the ratio, a few times the float64 epsilon
|
|
88
|
+
# (2.2e-16), so below about 1e-15 relative;
|
|
89
|
+
# - the smallest deviation a user can intend, which is set by the precision of
|
|
90
|
+
# the inputs: box sizes and bead diameters are read from filenames with at
|
|
91
|
+
# most seven significant digits, so about 1e-7 relative.
|
|
92
|
+
#
|
|
93
|
+
# Any value between those classifies every realistic input correctly. 1e-9
|
|
94
|
+
# keeps six orders of magnitude from the round-off side and two from the
|
|
95
|
+
# intent side; the margins are unequal on purpose, because the two errors are
|
|
96
|
+
# not equally bad. Calling a true multiple a non-multiple adds a bin and, for
|
|
97
|
+
# 'ordinary' bins, shifts the whole grid by half a bin; calling an intended
|
|
98
|
+
# non-multiple a multiple moves the edges by less than
|
|
99
|
+
# ``1e-9 * (lmax - lmin)``.
|
|
100
|
+
_BIN_COUNT_RTOL = 1e-9
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _count_bins(length: float, bin_size: float) -> Tuple[int, float]:
|
|
104
|
+
"""
|
|
105
|
+
Return the number of bins of width `bin_size` that cover `length`, and
|
|
106
|
+
the excess length they add.
|
|
107
|
+
|
|
108
|
+
When ``length / bin_size`` is a whole number up to the relative tolerance
|
|
109
|
+
``_BIN_COUNT_RTOL``, the count is that whole number and the excess is
|
|
110
|
+
exactly ``0.0``; otherwise the count is the ceiling of the ratio and the
|
|
111
|
+
excess ``n_bins * bin_size - length`` is strictly positive, by a margin
|
|
112
|
+
far above float64 round-off.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
length : float
|
|
117
|
+
Length of the range to cover, positive.
|
|
118
|
+
bin_size : float
|
|
119
|
+
Width of each bin, positive.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
Tuple[int, float]
|
|
124
|
+
``(n_bins, excess)``.
|
|
125
|
+
|
|
126
|
+
Examples
|
|
127
|
+
--------
|
|
128
|
+
>>> _count_bins(2.1, 0.3)
|
|
129
|
+
(7, 0.0)
|
|
130
|
+
>>> n_bins, excess = _count_bins(2.0, 0.7)
|
|
131
|
+
>>> n_bins, round(excess, 12)
|
|
132
|
+
(3, 0.1)
|
|
133
|
+
"""
|
|
134
|
+
ratio = length / bin_size
|
|
135
|
+
n_bins = int(np.rint(ratio))
|
|
136
|
+
if n_bins > 0 and np.isclose(
|
|
137
|
+
ratio, n_bins, rtol=_BIN_COUNT_RTOL, atol=0.0
|
|
138
|
+
):
|
|
139
|
+
return n_bins, 0.0
|
|
140
|
+
n_bins = int(np.ceil(ratio))
|
|
141
|
+
return n_bins, n_bins * bin_size - length
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _histogram_or_raise(values: np.ndarray, edges: np.ndarray) -> np.ndarray:
|
|
145
|
+
"""
|
|
146
|
+
Histogram `values` over `edges`, raising if any value falls outside them.
|
|
147
|
+
|
|
148
|
+
`np.histogram` silently drops values below the first edge or above the
|
|
149
|
+
last one. For the spatial profiles this module serves, every position
|
|
150
|
+
belongs to the system, so a dropped position is a defect in the caller's
|
|
151
|
+
edges or an unwrapped coordinate, not data to ignore.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
values : np.ndarray
|
|
156
|
+
One-dimensional array of values to bin.
|
|
157
|
+
edges : np.ndarray
|
|
158
|
+
Monotonically increasing bin edges, including the rightmost edge.
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
np.ndarray
|
|
163
|
+
Counts per bin, shape ``(len(edges) - 1,)``; their sum is
|
|
164
|
+
``values.size``.
|
|
165
|
+
|
|
166
|
+
Raises
|
|
167
|
+
------
|
|
168
|
+
ValueError
|
|
169
|
+
If any value lies outside ``[edges[0], edges[-1]]``.
|
|
170
|
+
|
|
171
|
+
Examples
|
|
172
|
+
--------
|
|
173
|
+
>>> edges = np.array([0., 1., 2., 3.])
|
|
174
|
+
>>> _histogram_or_raise(np.array([0.5, 2.0, 3.0]), edges)
|
|
175
|
+
array([1, 0, 2])
|
|
176
|
+
>>> _histogram_or_raise(np.array([0.5, 9.0]), edges)
|
|
177
|
+
Traceback (most recent call last):
|
|
178
|
+
...
|
|
179
|
+
ValueError: 1 of 2 positions fall outside the edges [0.0, 3.0].
|
|
180
|
+
"""
|
|
181
|
+
# Declared, not cast: numpy 2.5 stubs type these counts as Any and
|
|
182
|
+
# numpy 2.3 stubs as an ndarray, where a cast is redundant.
|
|
183
|
+
hist: np.ndarray
|
|
184
|
+
hist, _ = np.histogram(values, bins=edges)
|
|
185
|
+
n_missed = values.size - int(hist.sum())
|
|
186
|
+
if n_missed:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"{n_missed} of {values.size} positions fall outside the edges "
|
|
189
|
+
f"[{edges[0]}, {edges[-1]}]."
|
|
190
|
+
)
|
|
191
|
+
return hist
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def apply_pbc_orthogonal(
|
|
195
|
+
pbc_lengths: np.ndarray,
|
|
196
|
+
pbc_lengths_inverse: np.ndarray,
|
|
197
|
+
pbc: Dict[AxisT, float],
|
|
198
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
199
|
+
"""
|
|
200
|
+
Write the box length and its inverse of each axis in `pbc` into the
|
|
201
|
+
given arrays.
|
|
202
|
+
|
|
203
|
+
The arrays are modified in place and also returned; an axis not in
|
|
204
|
+
`pbc` keeps its value.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
pbc_lengths : numpy.ndarray
|
|
209
|
+
The box length along each axis, shape ``(n_dim,)``, typically
|
|
210
|
+
``(3,)``, in the length unit of the positions; modified in place.
|
|
211
|
+
pbc_lengths_inverse : numpy.ndarray
|
|
212
|
+
The inverse box length along each axis, the same shape; modified in
|
|
213
|
+
place.
|
|
214
|
+
pbc : Dict[:py:obj:`~.AxisT`, float]
|
|
215
|
+
Axis indices (0=x, 1=y, 2=z) mapped to their box lengths, positive.
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
pbc_lengths : numpy.ndarray
|
|
220
|
+
`pbc_lengths`, updated.
|
|
221
|
+
pbc_lengths_inverse : numpy.ndarray
|
|
222
|
+
`pbc_lengths_inverse`, updated.
|
|
223
|
+
|
|
224
|
+
Raises
|
|
225
|
+
------
|
|
226
|
+
ZeroDivisionError
|
|
227
|
+
If any specified length is zero, which would result in division by
|
|
228
|
+
zero.
|
|
229
|
+
ValueError
|
|
230
|
+
If any specified length is negative, which is physically invalid for
|
|
231
|
+
box dimensions.
|
|
232
|
+
|
|
233
|
+
Examples
|
|
234
|
+
--------
|
|
235
|
+
>>> pbc_lengths = np.zeros(3)
|
|
236
|
+
>>> pbc_lengths_inverse = np.zeros(3)
|
|
237
|
+
>>> pbc = {0: 10.0, 2: 20.0}
|
|
238
|
+
>>> apply_pbc_orthogonal(pbc_lengths, pbc_lengths_inverse, pbc)
|
|
239
|
+
(array([10., 0., 20.]), array([0.1 , 0. , 0.05]))
|
|
240
|
+
"""
|
|
241
|
+
for ax, length in pbc.items():
|
|
242
|
+
if length == 0.0:
|
|
243
|
+
raise ZeroDivisionError(f"Length for axis {ax} cannot be zero.")
|
|
244
|
+
if length < 0.0:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"Length for axis {ax} must be positive; got {length}."
|
|
247
|
+
)
|
|
248
|
+
pbc_lengths[ax] = length
|
|
249
|
+
pbc_lengths_inverse[ax] = 1 / length
|
|
250
|
+
return pbc_lengths, pbc_lengths_inverse
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def pair_distance(
|
|
254
|
+
positions: np.ndarray, pbc: Optional[Dict[AxisT, float]] = None
|
|
255
|
+
) -> np.ndarray:
|
|
256
|
+
"""
|
|
257
|
+
Compute the displacement vector from the first particle to the second,
|
|
258
|
+
optionally under the minimum image convention.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
positions : numpy.ndarray
|
|
263
|
+
The positions of the two particles, shape ``(2, n_dim)``; the
|
|
264
|
+
order sets the sign of the result. Integer positions are converted
|
|
265
|
+
to ``float64``.
|
|
266
|
+
pbc : Optional[Dict[:py:obj:`~.AxisT`, float]], default None
|
|
267
|
+
Dictionary with axis indices (0=x, 1=y, 2=z) as keys and box lengths as
|
|
268
|
+
values. If provided, periodic boundary conditions (PBC) are applied
|
|
269
|
+
along each axis with a finite length; an ``inf`` length leaves its
|
|
270
|
+
axis non-periodic, as in
|
|
271
|
+
:func:`polyphys.analyze.clusters.pair_distances`; see
|
|
272
|
+
:mod:`polyphys.manage.types`.
|
|
273
|
+
|
|
274
|
+
Returns
|
|
275
|
+
-------
|
|
276
|
+
np.ndarray
|
|
277
|
+
The signed displacement ``positions[1] - positions[0]`` along each
|
|
278
|
+
axis, shape ``(n_dim,)``, ``float64``, in the length unit of
|
|
279
|
+
`positions`; with `pbc`, each periodic component is folded into
|
|
280
|
+
``[-L/2, L/2]``.
|
|
281
|
+
|
|
282
|
+
Raises
|
|
283
|
+
------
|
|
284
|
+
ValueError
|
|
285
|
+
If `positions` does not contain exactly two sets of coordinates.
|
|
286
|
+
|
|
287
|
+
Notes
|
|
288
|
+
-----
|
|
289
|
+
The result is the component-wise displacement, not its Euclidean norm.
|
|
290
|
+
|
|
291
|
+
Examples
|
|
292
|
+
--------
|
|
293
|
+
>>> positions = np.array([[1.0, 2.0, 3.0], [4.0, 2.0, 3.0]])
|
|
294
|
+
>>> pair_distance(positions)
|
|
295
|
+
array([3., 0., 0.])
|
|
296
|
+
|
|
297
|
+
>>> pbc = {0: 8.0}
|
|
298
|
+
>>> positions = np.array([[1.0, 2.0, 3.0], [9.5, 2.0, 3.0]])
|
|
299
|
+
>>> pair_distance(positions, pbc)
|
|
300
|
+
array([0.5, 0. , 0. ])
|
|
301
|
+
"""
|
|
302
|
+
positions = np.asarray(positions, dtype=np.float64)
|
|
303
|
+
n_atoms, n_dims = positions.shape
|
|
304
|
+
if n_atoms != 2:
|
|
305
|
+
raise ValueError("'pair_distance' only works for two atoms.")
|
|
306
|
+
# Calculation in the center of geometry (cog) of the atom group.
|
|
307
|
+
delta = positions[1] - positions[0]
|
|
308
|
+
|
|
309
|
+
if pbc is not None:
|
|
310
|
+
# Prepare length and inverse-length arrays
|
|
311
|
+
pbc_lengths, pbc_lengths_inverse = apply_pbc_orthogonal(
|
|
312
|
+
np.zeros(n_dims), np.zeros(n_dims), pbc
|
|
313
|
+
)
|
|
314
|
+
# Apply the minimum image convention along the finite lengths; an
|
|
315
|
+
# infinite length is not periodic, and inf * 0 would give NaN.
|
|
316
|
+
periodic = np.isfinite(pbc_lengths)
|
|
317
|
+
delta[periodic] -= pbc_lengths[periodic] * np.round(
|
|
318
|
+
delta[periodic] * pbc_lengths_inverse[periodic]
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# cast: numpy's stubs return Any; at runtime this is an ndarray.
|
|
322
|
+
return cast(np.ndarray, delta)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def end_to_end(positions: np.ndarray) -> Union[np.floating, np.ndarray]:
|
|
326
|
+
"""
|
|
327
|
+
Compute the end-to-end distance of a chain, the distance between its
|
|
328
|
+
first and its last bead.
|
|
329
|
+
|
|
330
|
+
Parameters
|
|
331
|
+
----------
|
|
332
|
+
positions : np.ndarray
|
|
333
|
+
Array of shape (n_atoms, n_dim) containing atomic positions, in
|
|
334
|
+
backbone order from the first monomer to the last.
|
|
335
|
+
|
|
336
|
+
Returns
|
|
337
|
+
-------
|
|
338
|
+
numpy.floating
|
|
339
|
+
The end-to-end distance, in the length unit of `positions`.
|
|
340
|
+
|
|
341
|
+
Raises
|
|
342
|
+
------
|
|
343
|
+
ValueError
|
|
344
|
+
If `positions` contains fewer than two atoms.
|
|
345
|
+
|
|
346
|
+
Notes
|
|
347
|
+
-----
|
|
348
|
+
The positions are centered on their center of geometry first; the
|
|
349
|
+
distance does not depend on that translation.
|
|
350
|
+
|
|
351
|
+
Examples
|
|
352
|
+
--------
|
|
353
|
+
>>> positions = np.array([[0.0, 0.0, 0.0],
|
|
354
|
+
... [1.0, 0.0, 0.0],
|
|
355
|
+
... [2.0, 0.0, 0.0]])
|
|
356
|
+
>>> end_to_end(positions)
|
|
357
|
+
np.float64(2.0)
|
|
358
|
+
|
|
359
|
+
>>> positions = np.array([[1.0, 1.0, 1.0],
|
|
360
|
+
... [2.0, 2.0, 2.0],
|
|
361
|
+
... [3.0, 3.0, 3.0]])
|
|
362
|
+
>>> end_to_end(positions)
|
|
363
|
+
np.float64(3.4641016151377544)
|
|
364
|
+
"""
|
|
365
|
+
# calculation in the center of geometry of the atom group.
|
|
366
|
+
if positions.shape[0] < 2:
|
|
367
|
+
raise ValueError("`positions` must contain at least two atoms.")
|
|
368
|
+
centered_positions = positions - np.mean(positions, axis=0)
|
|
369
|
+
# cast: numpy 2.5 stubs return Any; at runtime this is a NumPy floating
|
|
370
|
+
# scalar, so the cast restates the annotation and changes nothing.
|
|
371
|
+
return cast(
|
|
372
|
+
Union[np.floating, np.ndarray],
|
|
373
|
+
np.linalg.norm(centered_positions[-1] - centered_positions[0]),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def transverse_size(positions: np.ndarray, axis: AxisT) -> float:
|
|
378
|
+
"""
|
|
379
|
+
Compute the transverse size of a group of atoms, twice their mean
|
|
380
|
+
distance from their center in the plane perpendicular to `axis`.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
positions : np.ndarray
|
|
385
|
+
Array of shape (n_atoms, n_dim) containing atomic positions, in any
|
|
386
|
+
order.
|
|
387
|
+
axis : :py:obj:`~.AxisT`
|
|
388
|
+
The axis (0 for x, 1 for y, and 2 for z) perpendicular to the plane
|
|
389
|
+
in which the size is measured.
|
|
390
|
+
|
|
391
|
+
Returns
|
|
392
|
+
-------
|
|
393
|
+
float
|
|
394
|
+
Twice the mean distance of the atoms from their center of geometry
|
|
395
|
+
in the plane perpendicular to `axis`, a diameter, in the length unit
|
|
396
|
+
of `positions`.
|
|
397
|
+
|
|
398
|
+
Raises
|
|
399
|
+
------
|
|
400
|
+
IndexError
|
|
401
|
+
If `axis` is out of bounds for `positions`.
|
|
402
|
+
|
|
403
|
+
Notes
|
|
404
|
+
-----
|
|
405
|
+
The distance is calculated in a reference frame centered at the center of
|
|
406
|
+
geometry of atoms in the plane perpendicular to a given axis.
|
|
407
|
+
|
|
408
|
+
Examples
|
|
409
|
+
--------
|
|
410
|
+
>>> positions = np.array([[1, 0, 0], [1, 2, 2]])
|
|
411
|
+
>>> transverse_size(positions, axis=0)
|
|
412
|
+
np.float64(2.8284271247461903)
|
|
413
|
+
|
|
414
|
+
>>> transverse_size(positions, axis=1)
|
|
415
|
+
np.float64(2.0)
|
|
416
|
+
"""
|
|
417
|
+
trans_axes = {0: [1, 2], 1: [0, 2], 2: [0, 1]}
|
|
418
|
+
if axis >= positions.shape[1]:
|
|
419
|
+
raise IndexError(
|
|
420
|
+
f"Axis {axis} is out of bounds for positions array"
|
|
421
|
+
f" with shape {positions.shape}."
|
|
422
|
+
)
|
|
423
|
+
trans_pos = positions[:, trans_axes[axis]] - np.mean(
|
|
424
|
+
positions[:, trans_axes[axis]], axis=0
|
|
425
|
+
)
|
|
426
|
+
# Times by 2, so we have the diameter not radius:
|
|
427
|
+
# cast: numpy's stubs return Any. The value is a NumPy floating scalar
|
|
428
|
+
# (np.float32 for float32 input), not always a float; the cast only
|
|
429
|
+
# restates the existing annotation and changes nothing at runtime.
|
|
430
|
+
return cast(float, 2 * np.mean(np.linalg.norm(trans_pos, axis=1)))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def max_distance(positions: np.ndarray) -> np.ndarray:
|
|
434
|
+
"""
|
|
435
|
+
Compute the extent of the positions along each Cartesian axis.
|
|
436
|
+
|
|
437
|
+
Parameters
|
|
438
|
+
----------
|
|
439
|
+
positions : np.ndarray
|
|
440
|
+
Array of shape (n_atoms, n_dim) containing atom positions, in any
|
|
441
|
+
order.
|
|
442
|
+
|
|
443
|
+
Returns
|
|
444
|
+
-------
|
|
445
|
+
np.ndarray
|
|
446
|
+
The largest coordinate minus the smallest along each axis, shape
|
|
447
|
+
``(n_dim,)``, in the length unit of `positions`.
|
|
448
|
+
|
|
449
|
+
Raises
|
|
450
|
+
------
|
|
451
|
+
ValueError
|
|
452
|
+
If `positions` is empty.
|
|
453
|
+
|
|
454
|
+
Notes
|
|
455
|
+
-----
|
|
456
|
+
The positions are centered on their center of geometry first; the
|
|
457
|
+
extent does not depend on that translation.
|
|
458
|
+
|
|
459
|
+
Examples
|
|
460
|
+
--------
|
|
461
|
+
>>> positions = np.array([[0, 0, 0], [4, 2, 6], [2, 3, 0]])
|
|
462
|
+
>>> max_distance(positions)
|
|
463
|
+
array([4., 3., 6.])
|
|
464
|
+
"""
|
|
465
|
+
# calculation in the center of geometry of the atom group.
|
|
466
|
+
centered_positions = positions - np.mean(positions, axis=0)
|
|
467
|
+
# cast: numpy's stubs return Any; at runtime this is an ndarray.
|
|
468
|
+
return cast(np.ndarray, np.ptp(centered_positions, axis=0))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def fsd(positions: np.ndarray, axis: AxisT) -> float:
|
|
472
|
+
"""
|
|
473
|
+
Compute the span of the positions along the selected axis.
|
|
474
|
+
|
|
475
|
+
The span is the largest coordinate minus the smallest along `axis`, for
|
|
476
|
+
one configuration; nothing is averaged over frames or orientations. The
|
|
477
|
+
name ``fsd`` is kept from Feret's statistical diameter, the caliper
|
|
478
|
+
distance along a fixed direction.
|
|
479
|
+
|
|
480
|
+
Parameters
|
|
481
|
+
----------
|
|
482
|
+
positions : np.ndarray
|
|
483
|
+
Array of shape ``(n_atoms, n_dim)`` of atom positions, in any order;
|
|
484
|
+
the span does not depend on it.
|
|
485
|
+
axis : :py:obj:`~.AxisT`
|
|
486
|
+
Axis index (0=x, 1=y, 2=z) along which to compute the span; see
|
|
487
|
+
:mod:`polyphys.manage.types`.
|
|
488
|
+
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
float
|
|
492
|
+
The span along `axis`, in the units of `positions`.
|
|
493
|
+
|
|
494
|
+
Raises
|
|
495
|
+
------
|
|
496
|
+
IndexError
|
|
497
|
+
If `axis` is out of bounds for `positions`.
|
|
498
|
+
|
|
499
|
+
References
|
|
500
|
+
----------
|
|
501
|
+
Wang Y, Teraoka I, Hansen FY, Peters GH, Hassager O. "A Theoretical
|
|
502
|
+
Study of the Separation Principle in Size Exclusion Chromatography."
|
|
503
|
+
Macromolecules 2010, 43, 3, 1651-1659.
|
|
504
|
+
https://doi.org/10.1021/ma902377g
|
|
505
|
+
|
|
506
|
+
Examples
|
|
507
|
+
--------
|
|
508
|
+
>>> positions = np.array([[1, 2, 3], [4, 8, 6]])
|
|
509
|
+
>>> fsd(positions, axis=1)
|
|
510
|
+
np.float64(6.0)
|
|
511
|
+
"""
|
|
512
|
+
if axis >= positions.shape[1]:
|
|
513
|
+
raise IndexError(
|
|
514
|
+
f"Axis {axis} is out of bounds for positions array"
|
|
515
|
+
f" with shape {positions.shape}."
|
|
516
|
+
)
|
|
517
|
+
# calculation in the center of geometry of the atom group:
|
|
518
|
+
positions = positions - np.mean(positions, axis=0)
|
|
519
|
+
# cast: numpy's stubs return Any. The value is a NumPy floating scalar
|
|
520
|
+
# (np.float32 for float32 input), not always a float; the cast only
|
|
521
|
+
# restates the existing annotation and changes nothing at runtime.
|
|
522
|
+
return cast(float, np.ptp(positions[:, axis]))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def simple_stats(
|
|
526
|
+
entity: EntityT,
|
|
527
|
+
data: np.ndarray
|
|
528
|
+
) -> Dict[PropertyT, float | np.floating]:
|
|
529
|
+
"""
|
|
530
|
+
Compute basic statistics (mean, variance, SEM) for a physical `entity`.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
entity : EntityT
|
|
535
|
+
The name that prefixes the keys of the result.
|
|
536
|
+
data : np.ndarray
|
|
537
|
+
One-dimensional array of independent samples.
|
|
538
|
+
|
|
539
|
+
Returns
|
|
540
|
+
-------
|
|
541
|
+
Dict[PropertyT, float | numpy.floating]
|
|
542
|
+
The mean, the sample variance with ``ddof=1``, and the SEM, the
|
|
543
|
+
sample standard deviation with ``ddof=1`` over the square root of
|
|
544
|
+
the number of samples, keyed ``'<entity>_mean'``,
|
|
545
|
+
``'<entity>_var'``, and ``'<entity>_sem'``.
|
|
546
|
+
|
|
547
|
+
Raises
|
|
548
|
+
------
|
|
549
|
+
ValueError
|
|
550
|
+
If `data` is empty or holds one sample, whose unbiased variance
|
|
551
|
+
divides by ``n - 1 = 0``.
|
|
552
|
+
|
|
553
|
+
Examples
|
|
554
|
+
--------
|
|
555
|
+
>>> import numpy as np
|
|
556
|
+
>>> simple_stats('energy', np.array([1.0, 2.0, 3.0])) # noqa: E501
|
|
557
|
+
{'energy_mean': np.float64(2.0), 'energy_var': np.float64(1.0), 'energy_sem': np.float64(0.5773502691896258)}
|
|
558
|
+
"""
|
|
559
|
+
if len(data) == 0:
|
|
560
|
+
raise ValueError('Input array must not be empty.')
|
|
561
|
+
if len(data) < 2:
|
|
562
|
+
raise ValueError(
|
|
563
|
+
'Input array must hold at least two samples for an unbiased'
|
|
564
|
+
' variance.'
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Unbiased std, var, and sem.
|
|
568
|
+
return {
|
|
569
|
+
entity + '_mean': np.mean(data),
|
|
570
|
+
entity + '_var': np.var(data, ddof=1),
|
|
571
|
+
entity + '_sem': np.std(data, ddof=1) / np.sqrt(len(data)),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def sem(data: np.ndarray) -> float:
|
|
576
|
+
"""
|
|
577
|
+
Compute the standard error of the mean (SEM) of independent samples.
|
|
578
|
+
|
|
579
|
+
Parameters
|
|
580
|
+
----------
|
|
581
|
+
data : np.ndarray
|
|
582
|
+
One-dimensional array of sample data.
|
|
583
|
+
|
|
584
|
+
Returns
|
|
585
|
+
-------
|
|
586
|
+
float
|
|
587
|
+
The standard error of the mean, in the unit of `data`.
|
|
588
|
+
|
|
589
|
+
Raises
|
|
590
|
+
------
|
|
591
|
+
ValueError
|
|
592
|
+
If `data` is empty or holds one sample, whose sample standard
|
|
593
|
+
deviation with ``ddof=1`` divides by ``n - 1 = 0``.
|
|
594
|
+
|
|
595
|
+
Notes
|
|
596
|
+
-----
|
|
597
|
+
SEM is calculated using sample standard deviation (ddof=1) and sample size.
|
|
598
|
+
The estimate assumes independent samples, such as the means of independent
|
|
599
|
+
replicates. For a time series of correlated frames it understates the
|
|
600
|
+
error; use :func:`polyphys.analyze.stats.sem_blocked` instead.
|
|
601
|
+
|
|
602
|
+
Examples
|
|
603
|
+
--------
|
|
604
|
+
>>> import numpy as np
|
|
605
|
+
>>> sem(np.array([1.0, 2.0, 3.0]))
|
|
606
|
+
np.float64(0.5773502691896258)
|
|
607
|
+
"""
|
|
608
|
+
if len(data) == 0:
|
|
609
|
+
raise ValueError('Input data array must not be empty.')
|
|
610
|
+
if len(data) < 2:
|
|
611
|
+
raise ValueError(
|
|
612
|
+
'Input data array must hold at least two samples for an unbiased'
|
|
613
|
+
' standard deviation.'
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# cast: numpy's stubs return Any. The value is a NumPy floating scalar
|
|
617
|
+
# (np.float32 for float32 input), not always a float; the cast only
|
|
618
|
+
# restates the existing annotation and changes nothing at runtime.
|
|
619
|
+
return cast(float, np.std(data, ddof=1) / len(data) ** 0.5)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def spherical_segment(r: float, a: float, b: float) -> float:
|
|
623
|
+
"""
|
|
624
|
+
Compute the volume of a spherical segment defined by two parallel planes.
|
|
625
|
+
|
|
626
|
+
Parameters
|
|
627
|
+
----------
|
|
628
|
+
r : float
|
|
629
|
+
Radius of the sphere. Must be positive.
|
|
630
|
+
a : float
|
|
631
|
+
Distance of the first plane from the center of the sphere, along the
|
|
632
|
+
axis of symmetry.
|
|
633
|
+
b : float
|
|
634
|
+
Distance of the second plane from the center of the sphere, along the
|
|
635
|
+
axis of symmetry.
|
|
636
|
+
|
|
637
|
+
Returns
|
|
638
|
+
-------
|
|
639
|
+
vol : float
|
|
640
|
+
Volume of the spherical segment.
|
|
641
|
+
|
|
642
|
+
Raises
|
|
643
|
+
------
|
|
644
|
+
ValueError
|
|
645
|
+
If `r` is not a positive number.
|
|
646
|
+
|
|
647
|
+
Notes
|
|
648
|
+
-----
|
|
649
|
+
- `a` and `b` can be positive or negative values, as long as they fall
|
|
650
|
+
within the range `[-r, r]`.
|
|
651
|
+
- The function will adjust `a` and `b` to `-r` or `r` if they exceed these
|
|
652
|
+
bounds.
|
|
653
|
+
- If `a = r` or `b = r`, the spherical segment becomes a spherical cap.
|
|
654
|
+
- If both `a` and `b` lie outside the range `[-r, r]` and share the same
|
|
655
|
+
sign, the volume is zero.
|
|
656
|
+
|
|
657
|
+
References
|
|
658
|
+
----------
|
|
659
|
+
Weisstein, Eric W. "Spherical Segment." From MathWorld--A Wolfram Web
|
|
660
|
+
Resource. https://mathworld.wolfram.com/SphericalSegment.html
|
|
661
|
+
|
|
662
|
+
Examples
|
|
663
|
+
--------
|
|
664
|
+
>>> spherical_segment(3, 1, 2)
|
|
665
|
+
20.94395102393195
|
|
666
|
+
>>> spherical_segment(3, -3, 3)
|
|
667
|
+
113.09733552923255
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
if r <= 0:
|
|
671
|
+
raise ValueError(f"The radius 'r' must be positive. Got {r}.")
|
|
672
|
+
|
|
673
|
+
# Ensure the bounds are within [-r, r]
|
|
674
|
+
lower = max(min(a, b), -r)
|
|
675
|
+
upper = min(max(a, b), r)
|
|
676
|
+
|
|
677
|
+
# If both bounds are outside [-r, r] with the same sign, the volume is zero
|
|
678
|
+
if lower * upper >= r**2:
|
|
679
|
+
return 0.0
|
|
680
|
+
# Calculate the volume of the spherical segment
|
|
681
|
+
vol = np.pi * (r**2 * (upper - lower) - (upper**3 - lower**3) / 3)
|
|
682
|
+
return vol
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def sphere_sphere_intersection(r1: float, r2: float, d: float) -> float:
|
|
686
|
+
"""
|
|
687
|
+
Compute the volume of intersection between two spheres.
|
|
688
|
+
|
|
689
|
+
The sphere with radius `r1` is separated from the sphere with radius `r2`
|
|
690
|
+
by a distance `d` along the x-axis. Thus, the vector form of the distance
|
|
691
|
+
between their centers is `(d, 0, 0)` in Cartesian coordinates.
|
|
692
|
+
|
|
693
|
+
Parameters
|
|
694
|
+
----------
|
|
695
|
+
r1 : float
|
|
696
|
+
Radius of the first sphere.
|
|
697
|
+
r2 : float
|
|
698
|
+
Radius of the second sphere.
|
|
699
|
+
d : float
|
|
700
|
+
Distance between the centers of the two spheres along the x-axis.
|
|
701
|
+
|
|
702
|
+
Returns
|
|
703
|
+
-------
|
|
704
|
+
vol : float
|
|
705
|
+
Volume of the intersection between the two spheres.
|
|
706
|
+
|
|
707
|
+
References
|
|
708
|
+
----------
|
|
709
|
+
Weisstein, Eric W. "Sphere-Sphere Intersection." From MathWorld--A Wolfram
|
|
710
|
+
Web Resource. https://mathworld.wolfram.com/Sphere-SphereIntersection.html
|
|
711
|
+
|
|
712
|
+
Examples
|
|
713
|
+
--------
|
|
714
|
+
>>> sphere_sphere_intersection(3, 4, 2)
|
|
715
|
+
94.90227807719167
|
|
716
|
+
>>> sphere_sphere_intersection(3, 4, 10)
|
|
717
|
+
0.0
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
r_max = max(r1, r2)
|
|
721
|
+
r_min = min(r1, r2)
|
|
722
|
+
|
|
723
|
+
# Volume is zero if one sphere has a radius of zero or no intersection
|
|
724
|
+
# occurs:
|
|
725
|
+
if r1 == 0.0 or r2 == 0.0:
|
|
726
|
+
return 0.0
|
|
727
|
+
if d >= r_min + r_max:
|
|
728
|
+
return 0.0
|
|
729
|
+
if d <= r_max - r_min:
|
|
730
|
+
# The smaller sphere is entirely contained within the larger one
|
|
731
|
+
return 4 * np.pi * r_min**3 / 3
|
|
732
|
+
|
|
733
|
+
# Calculate the intersection volume for overlapping spheres
|
|
734
|
+
vol = (
|
|
735
|
+
(np.pi / (12 * d))
|
|
736
|
+
* (r_max + r_min - d) ** 2
|
|
737
|
+
* (
|
|
738
|
+
d**2
|
|
739
|
+
+ 2 * d * (r_max + r_min)
|
|
740
|
+
- 3 * (r_min**2 + r_max**2)
|
|
741
|
+
+ 6 * r_min * r_max
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
return vol
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def create_bin_edge_and_hist(
|
|
749
|
+
bin_size: float, lmin: float, lmax: float, output: Optional[str] = None
|
|
750
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
751
|
+
"""
|
|
752
|
+
Create bin edges of a fixed width and a zero-filled histogram.
|
|
753
|
+
|
|
754
|
+
The edges start at `lmin` and step by `bin_size` up to the first edge at
|
|
755
|
+
or beyond `lmax`, so there are ``n_bins = ceil((lmax - lmin) / bin_size)``
|
|
756
|
+
bins of width `bin_size`. When the range is a whole number of bins up to
|
|
757
|
+
the relative tolerance ``_BIN_COUNT_RTOL`` (1e-9), the last edge is
|
|
758
|
+
exactly `lmax`; otherwise it exceeds `lmax` by less than one bin.
|
|
759
|
+
|
|
760
|
+
Parameters
|
|
761
|
+
----------
|
|
762
|
+
bin_size : float
|
|
763
|
+
Size of each bin.
|
|
764
|
+
lmin : float
|
|
765
|
+
Lower bound of the system in the direction of interest.
|
|
766
|
+
lmax : float
|
|
767
|
+
Upper bound of the system in the direction of interest.
|
|
768
|
+
output : Optional[str], default None
|
|
769
|
+
Filename (including filepath) to which bin edges array is saved. A
|
|
770
|
+
`.npy` extension will be appended to the filename if it does not
|
|
771
|
+
already have one.
|
|
772
|
+
|
|
773
|
+
Returns
|
|
774
|
+
-------
|
|
775
|
+
Tuple[np.ndarray, np.ndarray]
|
|
776
|
+
- bin_edges : Array of bin edges, shape ``(n_bins + 1,)``, equal to
|
|
777
|
+
``lmin + bin_size * k`` for ``k = 0, ..., n_bins``.
|
|
778
|
+
- hist : Zero-initialized histogram of dtype ``np.int64`` and shape
|
|
779
|
+
``(n_bins,)``. The 64-bit integer type keeps per-frame counts
|
|
780
|
+
accumulated in place (``hist += np.histogram(...)[0]``) from
|
|
781
|
+
overflowing.
|
|
782
|
+
|
|
783
|
+
Raises
|
|
784
|
+
------
|
|
785
|
+
ValueError
|
|
786
|
+
If `lmin` is not less than `lmax`.
|
|
787
|
+
|
|
788
|
+
Notes
|
|
789
|
+
-----
|
|
790
|
+
The `.npy` extension is handled internally by `np.save`.
|
|
791
|
+
|
|
792
|
+
The edges come from `np.linspace`, so their number is exactly
|
|
793
|
+
``n_bins + 1``; a float-step `np.arange` can return one edge too many
|
|
794
|
+
when `bin_size` is not exactly representable (for example 0.3). The bin
|
|
795
|
+
count treats a ratio within 1e-9 of a whole number as that whole number;
|
|
796
|
+
see ``_BIN_COUNT_RTOL`` for why.
|
|
797
|
+
|
|
798
|
+
Time and space are O(`n_bins`).
|
|
799
|
+
|
|
800
|
+
Examples
|
|
801
|
+
--------
|
|
802
|
+
>>> edges, hist = \
|
|
803
|
+
create_bin_edge_and_hist(bin_size=1.0, lmin=0.0, lmax=5.0)
|
|
804
|
+
>>> edges
|
|
805
|
+
array([0., 1., 2., 3., 4., 5.])
|
|
806
|
+
>>> hist
|
|
807
|
+
array([0, 0, 0, 0, 0])
|
|
808
|
+
|
|
809
|
+
A step that is not exactly representable still gives one edge per bin
|
|
810
|
+
plus one, ending exactly at `lmax`:
|
|
811
|
+
|
|
812
|
+
>>> edges, hist = create_bin_edge_and_hist(0.3, -6.0, 6.0)
|
|
813
|
+
>>> print(len(edges), len(hist), edges[0], edges[-1])
|
|
814
|
+
41 40 -6.0 6.0
|
|
815
|
+
|
|
816
|
+
A range that is not a multiple of `bin_size` is covered by one more bin:
|
|
817
|
+
|
|
818
|
+
>>> create_bin_edge_and_hist(0.7, 0.0, 2.0)[0]
|
|
819
|
+
array([0. , 0.7, 1.4, 2.1])
|
|
820
|
+
"""
|
|
821
|
+
if lmin >= lmax:
|
|
822
|
+
raise ValueError("'lmin' must be less than 'lmax'.")
|
|
823
|
+
|
|
824
|
+
n_bins, excess = _count_bins(lmax - lmin, bin_size)
|
|
825
|
+
upper = lmax if excess == 0.0 else lmin + n_bins * bin_size
|
|
826
|
+
bin_edges = np.linspace(lmin, upper, n_bins + 1)
|
|
827
|
+
hist = np.zeros(n_bins, dtype=np.int64)
|
|
828
|
+
|
|
829
|
+
if output is not None:
|
|
830
|
+
np.save(output, bin_edges)
|
|
831
|
+
|
|
832
|
+
return bin_edges, hist
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def fixedsize_bins(
|
|
836
|
+
bin_size: float,
|
|
837
|
+
lmin: float,
|
|
838
|
+
lmax: float,
|
|
839
|
+
bin_type: BinT = 'ordinary',
|
|
840
|
+
save_bin_edges: Optional[str] = None,
|
|
841
|
+
) -> Dict[str, Any]:
|
|
842
|
+
"""
|
|
843
|
+
Generate bin edges and zero-filled histogram arrays with consistent
|
|
844
|
+
bin width.
|
|
845
|
+
|
|
846
|
+
Every bin type returns ``n_bins + 1`` equally spaced edges of width
|
|
847
|
+
`bin_size` that cover [`lmin`, `lmax`].
|
|
848
|
+
|
|
849
|
+
Parameters
|
|
850
|
+
----------
|
|
851
|
+
bin_size : float
|
|
852
|
+
Width of each bin.
|
|
853
|
+
lmin : float
|
|
854
|
+
Start of the range
|
|
855
|
+
lmax : float
|
|
856
|
+
End of the range
|
|
857
|
+
bin_type : :py:obj:`~.BinT`, default 'ordinary'
|
|
858
|
+
Type of binning. With ``n_bins = ceil((lmax - lmin) / bin_size)``
|
|
859
|
+
bins, the excess length ``n_bins * bin_size - (lmax - lmin)`` is
|
|
860
|
+
zero when the range is a whole number of bins and is otherwise
|
|
861
|
+
shared out as follows:
|
|
862
|
+
|
|
863
|
+
- 'ordinary': Extends `lmin` and `lmax` symmetrically, by half the
|
|
864
|
+
excess each. Examples are Cartesian coordinates and spherical polar
|
|
865
|
+
coordinate.
|
|
866
|
+
- 'nonnegative': Extends `lmin` and `lmax` symmetrically if the
|
|
867
|
+
extended `lmin` stays nonnegative; otherwise `lmin` is set to 0 and
|
|
868
|
+
`lmax` to ``n_bins * bin_size``. Examples are radial directions in
|
|
869
|
+
the polar, spherical, and cylindrical coordinate systems.
|
|
870
|
+
- 'periodic': Periodic binning over one period, ``lmax - lmin``,
|
|
871
|
+
which must be a whole number of bins; the edges run from `lmin` to
|
|
872
|
+
`lmax` exactly. Examples are azimuthal directions in cylindrical
|
|
873
|
+
and spherical coordinate systems.
|
|
874
|
+
|
|
875
|
+
See the Notes for how "a whole number of bins" is decided.
|
|
876
|
+
|
|
877
|
+
See :mod:`polyphys.manage.types`.
|
|
878
|
+
save_bin_edges : Optional[str], default None
|
|
879
|
+
Filename (including filepath) to which bin edges array is saved. A
|
|
880
|
+
`.npy` extension will be appended to the filename if it does not
|
|
881
|
+
already have one.
|
|
882
|
+
|
|
883
|
+
Returns
|
|
884
|
+
-------
|
|
885
|
+
Dict[str, Any]
|
|
886
|
+
Dictionary with keys:
|
|
887
|
+
|
|
888
|
+
- 'n_bins' : int
|
|
889
|
+
Number of bins.
|
|
890
|
+
- 'bin_edges' : np.ndarray
|
|
891
|
+
A monotonically increasing array of bin edges, including the
|
|
892
|
+
rightmost edge.
|
|
893
|
+
- 'collector' : np.ndarray
|
|
894
|
+
Zero-initialized array of dtype ``np.int64`` and shape
|
|
895
|
+
``(n_bins,)`` for histogram counts. The 64-bit integer type keeps
|
|
896
|
+
per-frame counts accumulated in place
|
|
897
|
+
(``collector += np.histogram(...)[0]``) from overflowing.
|
|
898
|
+
- 'collector_std' : np.ndarray
|
|
899
|
+
Zero-initialized array of dtype ``np.float64`` and shape
|
|
900
|
+
``(n_bins,)`` for standard deviation values or the squared counts
|
|
901
|
+
they are computed from.
|
|
902
|
+
- 'range' : Tuple[float, float]
|
|
903
|
+
Updated range of bins (`lmin`, `lmax`).
|
|
904
|
+
|
|
905
|
+
Raises
|
|
906
|
+
------
|
|
907
|
+
ValueError
|
|
908
|
+
If `lmin` is not less than `lmax`.
|
|
909
|
+
ValueError
|
|
910
|
+
If `bin_type` is 'nonnegative' and `lmin` is negative: clipping the
|
|
911
|
+
lower end to zero would leave the range below zero uncovered.
|
|
912
|
+
ValueError
|
|
913
|
+
If `bin_type` is 'periodic' and ``lmax - lmin`` is not a multiple of
|
|
914
|
+
`bin_size`. A last bin running past the period would cover values
|
|
915
|
+
that never occur, so a density normalized by its width would be
|
|
916
|
+
biased low.
|
|
917
|
+
|
|
918
|
+
Notes
|
|
919
|
+
-----
|
|
920
|
+
The `.npy` extension is handled internally by `np.save`.
|
|
921
|
+
|
|
922
|
+
The edges come from `np.linspace`, so their number is exactly
|
|
923
|
+
``n_bins + 1``, and the first and last edges enclose `lmin` and `lmax`
|
|
924
|
+
exactly: a position at either end of the range falls inside the bins,
|
|
925
|
+
and `np.histogram` counts a value equal to the last edge in the last bin.
|
|
926
|
+
|
|
927
|
+
**Deciding whether the range is a whole number of bins.** The ratio
|
|
928
|
+
``(lmax - lmin) / bin_size`` is computed in float64, so a range that is
|
|
929
|
+
exactly a whole number of bins can come out a few ulps above or below
|
|
930
|
+
that integer: ``2.1 / 0.3`` is ``7.000000000000001``, and
|
|
931
|
+
``530 * 0.03`` is a few 1e-15 below ``15.9``. Taking the ceiling of such
|
|
932
|
+
a ratio adds a bin and, for 'ordinary' bins, shifts every edge by half
|
|
933
|
+
a bin; comparing it directly can leave the end edges a few 1e-15 inside
|
|
934
|
+
the range, where a position at exactly `lmin` or `lmax` is lost. The ratio
|
|
935
|
+
is therefore compared with the nearest integer at the relative tolerance
|
|
936
|
+
``_BIN_COUNT_RTOL`` = 1e-9. That tolerance sits between the two
|
|
937
|
+
scales that matter: the accumulated round-off in the ratio, a few times
|
|
938
|
+
the float64 epsilon (about 1e-15), and the smallest deviation a user can
|
|
939
|
+
intend, which the seven significant digits of the box sizes and bead
|
|
940
|
+
diameters in the simulation filenames put at about 1e-7. It is closer to
|
|
941
|
+
the intent side on purpose, because the two misclassifications are not
|
|
942
|
+
equally bad: calling a true multiple a non-multiple adds a bin and shifts
|
|
943
|
+
the grid, while calling an intended non-multiple a multiple moves the
|
|
944
|
+
edges by less than ``1e-9 * (lmax - lmin)``.
|
|
945
|
+
|
|
946
|
+
The symmetric extension of the 'ordinary' bins follows MDAnalysis's
|
|
947
|
+
``fixedwidth_bins``; the nearest-integer bin count departs from it, for
|
|
948
|
+
the reasons above.
|
|
949
|
+
|
|
950
|
+
Time and space are O(`n_bins`).
|
|
951
|
+
|
|
952
|
+
References
|
|
953
|
+
----------
|
|
954
|
+
MDAnalysis ``lib.util.fixedwidth_bins``:
|
|
955
|
+
https://github.com/MDAnalysis/mdanalysis/blob/ae9ff4b738606ba1e7c18a8a9b99a8a87eb69f74/package/MDAnalysis/lib/util.py#L1350
|
|
956
|
+
|
|
957
|
+
Examples
|
|
958
|
+
--------
|
|
959
|
+
>>> result = fixedsize_bins(1.0, 0.0, 5.0)
|
|
960
|
+
>>> result['n_bins']
|
|
961
|
+
5
|
|
962
|
+
>>> result['bin_edges']
|
|
963
|
+
array([0., 1., 2., 3., 4., 5.])
|
|
964
|
+
|
|
965
|
+
A range that is a whole number of bins up to round-off is tiled exactly:
|
|
966
|
+
|
|
967
|
+
>>> result = fixedsize_bins(0.3, 0.0, 2.1)
|
|
968
|
+
>>> edges = result['bin_edges']
|
|
969
|
+
>>> print(result['n_bins'], edges[0], edges[-1])
|
|
970
|
+
7 0.0 2.1
|
|
971
|
+
|
|
972
|
+
A radial range whose lower end would fall below zero is clipped there,
|
|
973
|
+
and the bins keep their width:
|
|
974
|
+
|
|
975
|
+
>>> result = fixedsize_bins(1.0, 0.1, 2.5, bin_type='nonnegative')
|
|
976
|
+
>>> result['bin_edges']
|
|
977
|
+
array([0., 1., 2., 3.])
|
|
978
|
+
|
|
979
|
+
Azimuthal bins of 5 degrees span exactly one period:
|
|
980
|
+
|
|
981
|
+
>>> result = fixedsize_bins(np.pi / 36, -np.pi, np.pi, bin_type='periodic')
|
|
982
|
+
>>> print(result['n_bins'], result['bin_edges'][-1] == np.pi)
|
|
983
|
+
72 True
|
|
984
|
+
"""
|
|
985
|
+
if lmin >= lmax:
|
|
986
|
+
raise ValueError("'lmin' must be less than 'lmax'.")
|
|
987
|
+
|
|
988
|
+
_length = lmax - lmin
|
|
989
|
+
_delta = bin_size
|
|
990
|
+
n_bins, excess = _count_bins(_length, _delta)
|
|
991
|
+
dl = 0.5 * excess # the excess length to share between the two ends
|
|
992
|
+
|
|
993
|
+
if bin_type == 'ordinary':
|
|
994
|
+
# add half of the excess to each end:
|
|
995
|
+
lmin_adj = lmin - dl
|
|
996
|
+
lmax_adj = lmax + dl
|
|
997
|
+
elif bin_type == 'nonnegative':
|
|
998
|
+
if lmin < 0.0:
|
|
999
|
+
raise ValueError(
|
|
1000
|
+
"'lmin' must be nonnegative for the 'nonnegative' bin type."
|
|
1001
|
+
)
|
|
1002
|
+
if lmin - dl < 0.0:
|
|
1003
|
+
# clip the lower end at zero and span n_bins full widths from it
|
|
1004
|
+
lmin_adj = 0.0
|
|
1005
|
+
lmax_adj = n_bins * _delta
|
|
1006
|
+
else:
|
|
1007
|
+
lmin_adj = lmin - dl
|
|
1008
|
+
lmax_adj = lmax + dl
|
|
1009
|
+
elif bin_type == 'periodic':
|
|
1010
|
+
if excess != 0.0:
|
|
1011
|
+
raise ValueError(
|
|
1012
|
+
f"For the 'periodic' bin type, the period 'lmax - lmin' "
|
|
1013
|
+
f"({_length}) must be a multiple of 'bin_size' ({_delta})."
|
|
1014
|
+
)
|
|
1015
|
+
lmin_adj, lmax_adj = lmin, lmax
|
|
1016
|
+
else:
|
|
1017
|
+
invalid_keyword(bin_type, ['ordinary', 'nonnegative', 'periodic'])
|
|
1018
|
+
|
|
1019
|
+
bin_edges = np.linspace(lmin_adj, lmax_adj, n_bins + 1, endpoint=True)
|
|
1020
|
+
collectors = np.zeros(n_bins, dtype=np.int64)
|
|
1021
|
+
collectors_std = np.zeros(n_bins, dtype=np.float64)
|
|
1022
|
+
|
|
1023
|
+
if save_bin_edges is not None:
|
|
1024
|
+
np.save(save_bin_edges, bin_edges)
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
'n_bins': n_bins,
|
|
1028
|
+
'bin_edges': bin_edges,
|
|
1029
|
+
'collector': collectors,
|
|
1030
|
+
'collector_std': collectors_std,
|
|
1031
|
+
'range': (lmin_adj, lmax_adj),
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def radial_histogram(
|
|
1036
|
+
positions: np.ndarray,
|
|
1037
|
+
edges: np.ndarray,
|
|
1038
|
+
) -> np.ndarray:
|
|
1039
|
+
"""
|
|
1040
|
+
Bin the distances of the positions from the origin, the spherical
|
|
1041
|
+
radial coordinate.
|
|
1042
|
+
|
|
1043
|
+
Parameters
|
|
1044
|
+
----------
|
|
1045
|
+
positions : np.ndarray
|
|
1046
|
+
Array of shape (n_atoms, n_dim) containing atom positions, in any
|
|
1047
|
+
order.
|
|
1048
|
+
edges : np.ndarray
|
|
1049
|
+
A monotonically increasing array of bin edges, including the rightmost
|
|
1050
|
+
edge. The last bin includes its right edge. Every position must lie
|
|
1051
|
+
within the outermost edges: a coordinate along a periodic axis must
|
|
1052
|
+
already be wrapped into the box, since this function does not wrap.
|
|
1053
|
+
|
|
1054
|
+
Returns
|
|
1055
|
+
-------
|
|
1056
|
+
hist : np.ndarray
|
|
1057
|
+
The counts per bin, shape ``(len(edges) - 1,)``, integer.
|
|
1058
|
+
|
|
1059
|
+
Raises
|
|
1060
|
+
------
|
|
1061
|
+
ValueError
|
|
1062
|
+
If the positions array is not two-dimensional.
|
|
1063
|
+
ValueError
|
|
1064
|
+
If any position falls outside the outermost edges. `np.histogram`
|
|
1065
|
+
would drop it silently; here a dropped position means wrong edges or
|
|
1066
|
+
an unwrapped coordinate, so it is an error.
|
|
1067
|
+
|
|
1068
|
+
Examples
|
|
1069
|
+
--------
|
|
1070
|
+
>>> positions= np.array([[1, 0, 0], [0, 1, 0], [0, 0, 2]])
|
|
1071
|
+
>>> edges = np.array([0, 1, 2, 3])
|
|
1072
|
+
>>> radial_histogram(positions, edges)
|
|
1073
|
+
array([0, 2, 1])
|
|
1074
|
+
"""
|
|
1075
|
+
if positions.ndim != 2:
|
|
1076
|
+
raise ValueError(
|
|
1077
|
+
"'positions' must be a 2D array with shape (n_atoms, n_dim)."
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
rad_distances = np.linalg.norm(positions, axis=1)
|
|
1081
|
+
return _histogram_or_raise(rad_distances, edges)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def radial_cyl_histogram(
|
|
1085
|
+
positions: np.ndarray,
|
|
1086
|
+
edges: np.ndarray,
|
|
1087
|
+
dim: AxisT,
|
|
1088
|
+
) -> np.ndarray:
|
|
1089
|
+
"""
|
|
1090
|
+
Bin the distances of the positions from the axis along `dim`, the
|
|
1091
|
+
cylindrical radial coordinate.
|
|
1092
|
+
|
|
1093
|
+
Parameters
|
|
1094
|
+
----------
|
|
1095
|
+
positions : np.ndarray
|
|
1096
|
+
Array of shape (n_atoms, n_dim) containing atom positions, in any
|
|
1097
|
+
order.
|
|
1098
|
+
edges : np.ndarray
|
|
1099
|
+
A monotonically increasing array of bin edges, including the rightmost
|
|
1100
|
+
edge. The last bin includes its right edge. Every position must lie
|
|
1101
|
+
within the outermost edges: a coordinate along a periodic axis must
|
|
1102
|
+
already be wrapped into the box, since this function does not wrap.
|
|
1103
|
+
dim : :py:obj:`~.AxisT`
|
|
1104
|
+
Axis direction (0=x, 1=y, 2=z); see :mod:`polyphys.manage.types`.
|
|
1105
|
+
|
|
1106
|
+
Returns
|
|
1107
|
+
-------
|
|
1108
|
+
hist : np.ndarray
|
|
1109
|
+
The counts per bin, shape ``(len(edges) - 1,)``, integer.
|
|
1110
|
+
|
|
1111
|
+
Raises
|
|
1112
|
+
------
|
|
1113
|
+
ValueError
|
|
1114
|
+
If `dim` is not one of {0, 1, 2}.
|
|
1115
|
+
ValueError
|
|
1116
|
+
If the positions array is not two-dimensional.
|
|
1117
|
+
ValueError
|
|
1118
|
+
If any position falls outside the outermost edges. `np.histogram`
|
|
1119
|
+
would drop it silently; here a dropped position means wrong edges or
|
|
1120
|
+
an unwrapped coordinate, so it is an error.
|
|
1121
|
+
|
|
1122
|
+
Examples
|
|
1123
|
+
--------
|
|
1124
|
+
>>> import numpy as np
|
|
1125
|
+
>>> positions = np.array([[1, 0, 0], [0, 2, 0], [0, 0, 3], [0, 3, 4]])
|
|
1126
|
+
>>> edges = np.array([0, 1, 2, 3, 4])
|
|
1127
|
+
>>> radial_cyl_histogram(positions, edges, dim=1)
|
|
1128
|
+
array([1, 1, 0, 2])
|
|
1129
|
+
"""
|
|
1130
|
+
if dim not in {0, 1, 2}:
|
|
1131
|
+
raise ValueError("'dim' must be one of {0, 1, 2}.")
|
|
1132
|
+
if positions.ndim != 2:
|
|
1133
|
+
raise ValueError(
|
|
1134
|
+
"'positions' must be a 2D array with shape (n_atoms, n_dim)."
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
trans_axes = np.roll(np.arange(3), -dim)[1:] # selecting transverse axes
|
|
1138
|
+
trans_distances = np.linalg.norm(positions[:, trans_axes], axis=1)
|
|
1139
|
+
return _histogram_or_raise(trans_distances, edges)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def axial_histogram(
|
|
1143
|
+
positions: np.ndarray,
|
|
1144
|
+
edges: np.ndarray,
|
|
1145
|
+
dim: AxisT,
|
|
1146
|
+
) -> np.ndarray:
|
|
1147
|
+
"""
|
|
1148
|
+
Bin signed coordinates along the selected axis `dim`.
|
|
1149
|
+
|
|
1150
|
+
Parameters
|
|
1151
|
+
----------
|
|
1152
|
+
positions : np.ndarray
|
|
1153
|
+
Array of shape (n_atoms, n_dim) containing atom positions, in any
|
|
1154
|
+
order.
|
|
1155
|
+
edges : np.ndarray
|
|
1156
|
+
A monotonically increasing array of bin edges, including the rightmost
|
|
1157
|
+
edge. The last bin includes its right edge. Every position must lie
|
|
1158
|
+
within the outermost edges: a coordinate along a periodic axis must
|
|
1159
|
+
already be wrapped into the box, since this function does not wrap.
|
|
1160
|
+
dim : :py:obj:`~.AxisT`
|
|
1161
|
+
The longitudinal axis (0=x, 1=y, 2=z); see
|
|
1162
|
+
:mod:`polyphys.manage.types`.
|
|
1163
|
+
|
|
1164
|
+
Returns
|
|
1165
|
+
-------
|
|
1166
|
+
hist : np.ndarray
|
|
1167
|
+
The counts per bin, shape ``(len(edges) - 1,)``, integer.
|
|
1168
|
+
|
|
1169
|
+
Raises
|
|
1170
|
+
------
|
|
1171
|
+
ValueError
|
|
1172
|
+
If `dim` is not one of {0, 1, 2}.
|
|
1173
|
+
ValueError
|
|
1174
|
+
If the positions array is not two-dimensional.
|
|
1175
|
+
ValueError
|
|
1176
|
+
If any position falls outside the outermost edges. `np.histogram`
|
|
1177
|
+
would drop it silently; here a dropped position means wrong edges or
|
|
1178
|
+
an unwrapped coordinate, so it is an error.
|
|
1179
|
+
|
|
1180
|
+
Examples
|
|
1181
|
+
--------
|
|
1182
|
+
>>> positions= np.array([[1, 2, 3], [2, 3, 4], [1, 2, 2]])
|
|
1183
|
+
>>> edges = np.array([0, 1, 2, 3, 4, 5])
|
|
1184
|
+
>>> axial_histogram(positions, edges, dim=0)
|
|
1185
|
+
array([0, 2, 1, 0, 0])
|
|
1186
|
+
"""
|
|
1187
|
+
if dim not in {0, 1, 2}:
|
|
1188
|
+
raise ValueError("'dim' must be one of {0, 1, 2}.")
|
|
1189
|
+
if positions.ndim != 2:
|
|
1190
|
+
raise ValueError(
|
|
1191
|
+
"'positions' must be a 2D array with shape (n_atoms, n_dim)."
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
return _histogram_or_raise(positions[:, dim], edges)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def azimuth_cyl_histogram(
|
|
1198
|
+
positions: np.ndarray,
|
|
1199
|
+
edges: np.ndarray,
|
|
1200
|
+
dim: AxisT,
|
|
1201
|
+
) -> np.ndarray:
|
|
1202
|
+
"""
|
|
1203
|
+
Bin the azimuthal angles of the positions about the axis along `dim`,
|
|
1204
|
+
in radians.
|
|
1205
|
+
|
|
1206
|
+
Parameters
|
|
1207
|
+
----------
|
|
1208
|
+
positions : np.ndarray
|
|
1209
|
+
Array of shape (n_atoms, n_dim) containing atom positions, in any
|
|
1210
|
+
order.
|
|
1211
|
+
edges : np.ndarray
|
|
1212
|
+
A monotonically increasing array of bin edges, in radians, including
|
|
1213
|
+
the rightmost edge; the angles lie in ``[-pi, pi]``, as
|
|
1214
|
+
:obj:`numpy.arctan2` returns them. The last bin includes its right
|
|
1215
|
+
edge. Every position must lie within the outermost edges: a
|
|
1216
|
+
coordinate along a periodic axis must already be wrapped into the
|
|
1217
|
+
box, since this function does not wrap.
|
|
1218
|
+
dim : :py:obj:`~.AxisT`
|
|
1219
|
+
The longitudinal axis (0=x, 1=y, 2=z); see
|
|
1220
|
+
:mod:`polyphys.manage.types`.
|
|
1221
|
+
|
|
1222
|
+
Returns
|
|
1223
|
+
-------
|
|
1224
|
+
hist : np.ndarray
|
|
1225
|
+
The counts per bin, shape ``(len(edges) - 1,)``, integer.
|
|
1226
|
+
|
|
1227
|
+
Raises
|
|
1228
|
+
------
|
|
1229
|
+
ValueError
|
|
1230
|
+
If `dim` is not one of {0, 1, 2}.
|
|
1231
|
+
ValueError
|
|
1232
|
+
If the positions array is not two-dimensional.
|
|
1233
|
+
ValueError
|
|
1234
|
+
If any position falls outside the outermost edges. `np.histogram`
|
|
1235
|
+
would drop it silently; here a dropped position means wrong edges or
|
|
1236
|
+
an unwrapped coordinate, so it is an error.
|
|
1237
|
+
|
|
1238
|
+
Examples
|
|
1239
|
+
--------
|
|
1240
|
+
>>> positions= np.array([[1, 0, 0], [0, 1, 0], [-1, 0, 0]])
|
|
1241
|
+
>>> edges = np.linspace(-np.pi, np.pi, 5)
|
|
1242
|
+
>>> azimuth_cyl_histogram(positions, edges, dim=2)
|
|
1243
|
+
array([0, 0, 1, 2])
|
|
1244
|
+
"""
|
|
1245
|
+
if dim not in {0, 1, 2}:
|
|
1246
|
+
raise ValueError("'dim' must be one of {0, 1, 2}.")
|
|
1247
|
+
if positions.ndim != 2:
|
|
1248
|
+
raise ValueError(
|
|
1249
|
+
"'positions' must be a 2D array with shape (n_atoms, n_dim)."
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
transverse_axes = np.roll(np.arange(3), -dim)[1:]
|
|
1253
|
+
azimuthal_angles = np.arctan2(
|
|
1254
|
+
positions[:, transverse_axes[1]], positions[:, transverse_axes[0]]
|
|
1255
|
+
)
|
|
1256
|
+
return _histogram_or_raise(azimuthal_angles, edges)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def planar_cartesian_histogram(
|
|
1260
|
+
positions: np.ndarray,
|
|
1261
|
+
edges: List[np.ndarray],
|
|
1262
|
+
dim: AxisT,
|
|
1263
|
+
) -> np.ndarray:
|
|
1264
|
+
"""
|
|
1265
|
+
Compute a two-dimensional histogram in the plane perpendicular to `dim`,
|
|
1266
|
+
in Cartesian coordinates.
|
|
1267
|
+
|
|
1268
|
+
Parameters
|
|
1269
|
+
----------
|
|
1270
|
+
positions : np.ndarray
|
|
1271
|
+
Array of shape (n_atoms, n_dim) containing atom positions, in any
|
|
1272
|
+
order.
|
|
1273
|
+
edges : List[np.ndarray]
|
|
1274
|
+
Two monotonically increasing arrays of bin edges, one per planar axis,
|
|
1275
|
+
each including its rightmost edge. The last bin along each axis
|
|
1276
|
+
includes its right edge. Every position must lie within the outermost
|
|
1277
|
+
edges along both axes: a coordinate along a periodic axis must already
|
|
1278
|
+
be wrapped into the box, since this function does not wrap.
|
|
1279
|
+
dim : :py:obj:`~.AxisT`
|
|
1280
|
+
Cartesian axis (0=x, 1=y, 2=z). The right-hand rule is used to pass the
|
|
1281
|
+
planar axes to the `np.histogram2d`: When `dim=0` (x), `dim=1` (y) and
|
|
1282
|
+
`dim=2` (z) values are passed respectively. When `dim=1` (y), `dim=2`
|
|
1283
|
+
(z) and `dim=0` (x) values are passed respectively. When `dim=2` (z),
|
|
1284
|
+
`dim=0` (x) and `dim=1` (y) values are passed respectively. See
|
|
1285
|
+
:mod:`polyphys.manage.types`.
|
|
1286
|
+
|
|
1287
|
+
Returns
|
|
1288
|
+
-------
|
|
1289
|
+
hist : np.ndarray
|
|
1290
|
+
The counts per cell, shape ``(len(edges[0]) - 1, len(edges[1]) - 1)``,
|
|
1291
|
+
``float64``, as :func:`numpy.histogram2d` returns them, with rows
|
|
1292
|
+
along the first planar axis.
|
|
1293
|
+
|
|
1294
|
+
Raises
|
|
1295
|
+
------
|
|
1296
|
+
ValueError
|
|
1297
|
+
If `dim` is not one of {0, 1, 2}.
|
|
1298
|
+
ValueError
|
|
1299
|
+
If the positions array is not two-dimensional.
|
|
1300
|
+
ValueError
|
|
1301
|
+
If any position falls outside the outermost edges. `np.histogram`
|
|
1302
|
+
would drop it silently; here a dropped position means wrong edges or
|
|
1303
|
+
an unwrapped coordinate, so it is an error.
|
|
1304
|
+
ValueError
|
|
1305
|
+
If the length of `edges` is not two.
|
|
1306
|
+
|
|
1307
|
+
Examples
|
|
1308
|
+
--------
|
|
1309
|
+
>>> positions= np.array([[1, 1, 0], [2, 2, 0], [3, 3, 0]])
|
|
1310
|
+
>>> edges = [np.array([0, 2, 4]), np.array([0, 2, 4])]
|
|
1311
|
+
>>> planar_cartesian_histogram(positions, edges, dim=2)
|
|
1312
|
+
array([[1., 0.],
|
|
1313
|
+
[0., 2.]])
|
|
1314
|
+
"""
|
|
1315
|
+
if dim not in {0, 1, 2}:
|
|
1316
|
+
raise ValueError("'dim' must be one of {0, 1, 2}.")
|
|
1317
|
+
if positions.ndim != 2:
|
|
1318
|
+
raise ValueError(
|
|
1319
|
+
"'positions' must be a 2D array with shape (n_atoms, n_dim)."
|
|
1320
|
+
)
|
|
1321
|
+
if len(edges) != 2:
|
|
1322
|
+
raise ValueError("'edges' must contain two arrays, one for each axis.")
|
|
1323
|
+
|
|
1324
|
+
t_dims = np.roll(np.arange(3), -dim)[1:] # selecting transverse axes
|
|
1325
|
+
hist, _, _ = np.histogram2d(
|
|
1326
|
+
positions[:, t_dims[0]],
|
|
1327
|
+
positions[:, t_dims[1]],
|
|
1328
|
+
bins=edges,
|
|
1329
|
+
)
|
|
1330
|
+
n_missed = len(positions) - int(hist.sum())
|
|
1331
|
+
if n_missed:
|
|
1332
|
+
raise ValueError(
|
|
1333
|
+
f"{n_missed} of {len(positions)} positions fall outside the "
|
|
1334
|
+
f"edges [{edges[0][0]}, {edges[0][-1]}] x "
|
|
1335
|
+
f"[{edges[1][0]}, {edges[1][-1]}]."
|
|
1336
|
+
)
|
|
1337
|
+
# cast: numpy's stubs return Any; at runtime this is an ndarray.
|
|
1338
|
+
return cast(np.ndarray, hist)
|