polyphys 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)