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,1138 @@
1
+ """
2
+ ==========================================================
3
+ :mod:`polyphys.analyze.density`
4
+ ==========================================================
5
+
6
+
7
+ The :mod:`polyphys.analyze.density` module turns a histogram of bead centers
8
+ along one coordinate into two profiles: the local number density, which
9
+ counts centers per bin volume, and the local volume fraction, which counts
10
+ the volume the beads occupy. The second is the harder one, because a bead of
11
+ finite size reaches into the bins next to the one that holds its center.
12
+ Every routine is a NumPy or SciPy kernel on arrays: reading frames, binning
13
+ positions, and averaging over frames are the caller's work, and the only
14
+ geometric inputs are the bin edges, the bead diameter, and the box lengths.
15
+
16
+ Notation
17
+ ========
18
+
19
+ :math:`x`
20
+ The binned coordinate: the axial position :math:`z` of a cylinder, or
21
+ the distance :math:`r` from the axis of a cylinder or from a point in a
22
+ cube. Lengths are in the caller's unit, the monomer diameter in this
23
+ package.
24
+ :math:`e_0 < e_1 < \\dots < e_B`
25
+ The bin edges, ``edges``; bin :math:`i` is :math:`[e_i, e_{i+1})`, so
26
+ there are :math:`B` bins, ``n_bins``.
27
+ :math:`V_i`
28
+ The volume of bin :math:`i`, ``volumes``: a slab
29
+ :math:`\\pi D^2 (e_{i+1} - e_i) / 4`, a cylindrical shell
30
+ :math:`\\pi L (e_{i+1}^2 - e_i^2)`, or a spherical shell
31
+ :math:`4 \\pi (e_{i+1}^3 - e_i^3) / 3`.
32
+ :math:`H_i`, :math:`F`
33
+ The number of bead centers counted in bin :math:`i` over :math:`F`
34
+ frames, ``counts`` and ``n_frames``.
35
+ :math:`\\rho_i = H_i / (F V_i)`
36
+ The local number density of bin :math:`i`, ``rho``, in beads per cubic
37
+ length unit.
38
+ :math:`a`, :math:`R_b = a / 2`, :math:`v = \\pi a^3 / 6`
39
+ The bead diameter ``bead_diameter``, its radius, and its volume.
40
+ :math:`s(x \\to j)`
41
+ The *share kernel*: the volume of one bead whose center is at coordinate
42
+ :math:`x` that lies inside bin :math:`j`. It sums to :math:`v` over the
43
+ bins when the bead is inside the domain.
44
+ :math:`S_{ij}`
45
+ The *share matrix*, ``share``: the volume of a test bead centered in bin
46
+ :math:`i` that lies in bin :math:`j`, with the bead at the bin center
47
+ (``shares='center'``) or averaged over the bin (``shares='average'``).
48
+ :math:`\\phi_i = \\sum_j S_{ij} \\rho_j`
49
+ The local volume fraction of bin :math:`i`, ``phi``, dimensionless.
50
+
51
+ Contents
52
+ ========
53
+
54
+ - :func:`bin_volumes`: the closed-form bin volumes.
55
+ - :func:`share_matrix`: the share matrix, built once per ``(edges,
56
+ bead_diameter)``.
57
+ - :func:`sphere_cylinder_intersection`: the volume of a ball inside a
58
+ coaxial cylinder, the kernel of the radial direction in a cylinder.
59
+ - :func:`number_density` and :func:`volume_fraction`: the two profiles from
60
+ counts, volumes, and the share matrix.
61
+ - :func:`normalize_profile`: an explicit normalization.
62
+ - :func:`density_profiles` and :class:`Profiles`: the one-call form.
63
+
64
+ The volume fraction
65
+ ===================
66
+
67
+ The volume fraction field is the indicator of "inside some bead", and in the
68
+ continuum its average over a region is the density convolved with a ball of
69
+ radius :math:`R_b`. With the density piecewise constant on the bins, the
70
+ value at a point :math:`x` is :math:`\\sum_j \\rho_j\\, s(x \\to j)`. The
71
+ research code evaluated this at the bin center, :math:`\\phi_i = \\sum_j
72
+ \\rho_j\\, s(c_i \\to j)` (thesis, appendix "Discrete local distributions
73
+ in the cylindrical coordinate system", eq. 7); ``shares='center'`` keeps
74
+ that form. The default, ``shares='average'``, averages the kernel over the
75
+ bead's position in its bin with the bin's own measure,
76
+
77
+ .. math::
78
+
79
+ \\bar{s}(i \\to j) = \\frac{1}{V_i} \\int_{e_i}^{e_{i+1}}
80
+ s(x \\to j)\\, w(x)\\, dx, \\qquad
81
+ w(x)\\,dx = A\\,dx,\\; 2 \\pi L x\\,dx,\\; 4 \\pi x^2\\,dx,
82
+
83
+ which is the exact bin average under the one assumption already made for
84
+ :math:`\\rho`. Because the indicator is symmetric in the two points,
85
+ :math:`V_i\\, \\bar{s}(i \\to j) = V_j\\, \\bar{s}(j \\to i)`, and from
86
+ that one identity the averaged form conserves the total bead volume,
87
+ :math:`\\sum_i V_i \\phi_i = \\sum_j H_j v / F`, and returns
88
+ :math:`\\phi = \\rho v` for a uniform fluid, both to round-off. The
89
+ centered form gets the second right and misses the first by
90
+ :math:`O(h^2)` along :math:`r`: up to 2.65 % of a bead volume for
91
+ cylindrical shells and 7.27 % for spherical shells at a bin width
92
+ :math:`h = a / 4`, and 0.71 % and 1.88 % at :math:`a / 8`, largest in the
93
+ innermost bins. Both forms return :math:`\\rho v` only in the bins whose
94
+ test bead stays inside the last edge; in the outermost radial bins the
95
+ bead reaches past it and the row sums of the share matrix fall short of
96
+ :math:`v`.
97
+
98
+ The kernels
99
+ ===========
100
+
101
+ A slab's kernel is a spherical segment
102
+ (:func:`~polyphys.analyze.measurer.spherical_segment`), and the averaged slab
103
+ kernel is its second antiderivative in closed form. A spherical shell's kernel
104
+ is a difference of sphere-sphere intersection volumes
105
+ (:func:`~polyphys.analyze.measurer.sphere_sphere_intersection`). A cylindrical
106
+ shell's kernel is a difference of ball-in-cylinder volumes,
107
+ :func:`sphere_cylinder_intersection`, which has no closed form and is
108
+ integrated numerically (``intersection='exact'``); the research code used the
109
+ sphere-sphere volume in its place (``intersection='sphere'``), whose error is
110
+ about :math:`0.047\\, a / R` of a bead volume at an edge of radius :math:`R`,
111
+ with :math:`a = 2 R_b` the bead diameter, first order and independent of the
112
+ bin width. The shells' averaged kernels are integrated by Gauss-Legendre rules
113
+ on the pieces between the kernels' breakpoints, exactly where the integrand is
114
+ polynomial.
115
+
116
+ The axial direction of a cylinder is periodic: a bead that reaches past one
117
+ end of the domain reaches into the bins at the other end. The radial
118
+ directions are not.
119
+
120
+ Conventions the kernels do not fix
121
+ ==================================
122
+
123
+ :func:`bin_volumes` takes the slab diameter the caller passes.
124
+ :func:`density_profiles` passes :math:`D_\\mathrm{eff} = D - a`, the
125
+ diameter of the region a bead center can reach, so that a uniform crowder
126
+ fluid returns the bulk volume fraction as the parsers define it,
127
+ :math:`\\phi_c = N_c v_c / (\\pi D_\\mathrm{eff}^2 L / 4)`. Along
128
+ :math:`r` no such choice arises. A spherical shell beyond half the box
129
+ length of a periodic cube overstates the volume inside the box, so
130
+ :func:`bin_volumes` warns there when it is told the box length.
131
+
132
+ Dependencies
133
+ ============
134
+ - `numpy` and `scipy.sparse`.
135
+ - :mod:`polyphys.analyze.measurer`:
136
+ :func:`~polyphys.analyze.measurer.spherical_segment` and
137
+ :func:`~polyphys.analyze.measurer.sphere_sphere_intersection`.
138
+ - :mod:`polyphys.manage.types`: :data:`~polyphys.manage.types.GeometryT`
139
+ and :data:`~polyphys.manage.types.DirectionT`.
140
+
141
+ References
142
+ ==========
143
+ - Weisstein, Eric W. "Spherical Segment." From MathWorld--A Wolfram Web
144
+ Resource, eq. 14. https://mathworld.wolfram.com/SphericalSegment.html
145
+ - Weisstein, Eric W. "Sphere-Sphere Intersection." From MathWorld--A
146
+ Wolfram Web Resource, eq. 16.
147
+ https://mathworld.wolfram.com/Sphere-SphereIntersection.html
148
+ - Weisstein, Eric W. "Circle-Circle Intersection." From MathWorld--A
149
+ Wolfram Web Resource, eq. 15.
150
+ https://mathworld.wolfram.com/Circle-CircleIntersection.html
151
+ - A. H. Sadeghi Isfahani, PhD thesis, University of Waterloo (2024),
152
+ appendix "Discrete local distributions in the cylindrical coordinate
153
+ system", for the centered form.
154
+ - C. Jeon, C. Hyeon, Y. Jung, and B.-Y. Ha, Soft Matter 12, 9786-9796
155
+ (2016), https://doi.org/10.1039/c6sm01924b, for the use of these profiles
156
+ in the density sum rule (:mod:`polyphys.analyze.sumrule`).
157
+
158
+ .. versionadded:: 0.6.0
159
+ """
160
+
161
+ import warnings
162
+ from collections.abc import Callable, Sequence
163
+ from dataclasses import dataclass
164
+ from typing import Literal
165
+
166
+ import numpy as np
167
+ import numpy.typing as npt
168
+ from scipy.sparse import coo_array, csr_array
169
+
170
+ from ..manage.types import DirectionT, GeometryT
171
+ from .measurer import sphere_sphere_intersection, spherical_segment
172
+
173
+ __all__ = [
174
+ 'IntersectionMode',
175
+ 'NormalizeMode',
176
+ 'Profiles',
177
+ 'ShareMode',
178
+ 'bin_volumes',
179
+ 'density_profiles',
180
+ 'normalize_profile',
181
+ 'number_density',
182
+ 'share_matrix',
183
+ 'sphere_cylinder_intersection',
184
+ 'volume_fraction',
185
+ ]
186
+
187
+ FloatArray = npt.NDArray[np.float64]
188
+
189
+ ShareMode = Literal['average', 'center']
190
+ """How the test bead sits in its bin, averaged over the bin or at its
191
+ center as in the research code.
192
+
193
+ .. versionadded:: 0.6.0
194
+ """
195
+
196
+ IntersectionMode = Literal['exact', 'sphere']
197
+ """The kernel of a cylindrical shell, the ball-in-cylinder volume or the
198
+ sphere-sphere volume the research code used in its place.
199
+
200
+ .. versionadded:: 0.6.0
201
+ """
202
+
203
+ NormalizeMode = Literal['none', 'sum', 'integral']
204
+ """No normalization, division by the sum over bins, or division by the
205
+ integral against the bin volumes.
206
+
207
+ .. versionadded:: 0.6.0
208
+ """
209
+
210
+ # The bin scheme behind each supported (geometry, direction): the power p of
211
+ # the coordinate in the bin measure w(x) dx = x**p dx (up to a constant).
212
+ _SchemeT = Literal['slab', 'cylinder', 'sphere']
213
+ _SCHEMES: dict[tuple[str, str], _SchemeT] = {
214
+ ('cylindrical', 'z'): 'slab',
215
+ ('cylindrical', 'r'): 'cylinder',
216
+ ('cubic', 'r'): 'sphere',
217
+ }
218
+ _MEASURE_POWER: dict[_SchemeT, int] = {'slab': 0, 'cylinder': 1, 'sphere': 2}
219
+
220
+ # Gauss-Legendre nodes per polynomial piece. The position average of a
221
+ # shell's kernel is a polynomial of degree at most 7 on each piece, so 16
222
+ # nodes are exact there; the ball-in-cylinder integrand is smooth on each
223
+ # piece with a half-power end point, where 32 nodes give about 1e-8.
224
+ _N_GAUSS_POSITION = 16
225
+ _N_GAUSS_AXIAL = 32
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Validation
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def _check_edges(edges: npt.ArrayLike, scheme: _SchemeT) -> FloatArray:
234
+ """Return `edges` as float64 after checking the bin-edge contract."""
235
+ out = np.asarray(edges, dtype=np.float64)
236
+ if out.ndim != 1 or out.size < 2:
237
+ raise ValueError(
238
+ 'edges must be a one-dimensional array of at least two values;'
239
+ f' got shape {out.shape}.'
240
+ )
241
+ if not np.all(np.isfinite(out)):
242
+ raise ValueError('edges must be finite.')
243
+ if not np.all(np.diff(out) > 0):
244
+ raise ValueError('edges must be strictly increasing.')
245
+ if scheme != 'slab' and out[0] < 0:
246
+ raise ValueError(
247
+ f'a radial edge cannot be negative; got edges[0] = {out[0]}.'
248
+ )
249
+ return out
250
+
251
+
252
+ def _check_scheme(geometry: str, direction: str) -> _SchemeT:
253
+ """Return the bin scheme of a supported (geometry, direction) pair."""
254
+ try:
255
+ return _SCHEMES[(geometry, direction)]
256
+ except KeyError:
257
+ supported = ', '.join(f'{g!r}, {d!r}' for g, d in _SCHEMES)
258
+ raise ValueError(
259
+ f"({geometry!r}, {direction!r}) is not supported; the supported"
260
+ f' (geometry, direction) pairs are ({supported}).'
261
+ ) from None
262
+
263
+
264
+ def _check_positive(name: str, value: float | None) -> float:
265
+ """Return `value` as a float after checking that it is given and > 0."""
266
+ if value is None:
267
+ raise ValueError(f'{name} is needed here.')
268
+ if not np.isfinite(value) or value <= 0:
269
+ raise ValueError(f'{name} must be positive; got {value}.')
270
+ return float(value)
271
+
272
+
273
+ def _check_choice(name: str, value: str, choices: Sequence[str]) -> None:
274
+ """Raise if `value` is not one of `choices`."""
275
+ if value not in choices:
276
+ raise ValueError(
277
+ f'{name} must be one of {", ".join(map(repr, choices))};'
278
+ f' got {value!r}.'
279
+ )
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Bin volumes
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def bin_volumes(
288
+ edges: npt.ArrayLike,
289
+ geometry: GeometryT,
290
+ direction: DirectionT,
291
+ *,
292
+ lcyl: float | None = None,
293
+ dcyl: float | None = None,
294
+ lcube: float | None = None,
295
+ ) -> FloatArray:
296
+ """
297
+ Compute the volume of each bin along one coordinate, in closed form.
298
+
299
+ Parameters
300
+ ----------
301
+ edges : array_like
302
+ The bin edges, shape ``(n_bins + 1,)``, finite and strictly
303
+ increasing; non-negative for a radial direction.
304
+ geometry : :py:obj:`~.GeometryT`
305
+ ``'cylindrical'`` or ``'cubic'``.
306
+ direction : :py:obj:`~.DirectionT`
307
+ ``'r'`` for shells, or ``'z'`` for the slabs of a cylinder.
308
+ lcyl : float, optional
309
+ The cylinder length, needed for ``('cylindrical', 'r')``.
310
+ dcyl : float, optional
311
+ The diameter of the slab, needed for ``('cylindrical', 'z')``. The
312
+ kernel takes it as given; :func:`density_profiles` passes the
313
+ effective diameter :math:`D - a`.
314
+ lcube : float, optional
315
+ The side of the periodic cube, for ``('cubic', 'r')``. When given,
316
+ an outermost edge beyond half the side by more than one bin width
317
+ raises a warning, since such a shell overstates the volume inside
318
+ the box; the volumes are still returned.
319
+
320
+ Returns
321
+ -------
322
+ numpy.ndarray
323
+ The bin volumes, shape ``(n_bins,)``, in the cube of the length
324
+ unit: :math:`4\\pi (e_{i+1}^3 - e_i^3) / 3` for a spherical shell,
325
+ :math:`\\pi L (e_{i+1}^2 - e_i^2)` for a cylindrical shell, and
326
+ :math:`\\pi D^2 (e_{i+1} - e_i) / 4` for a slab.
327
+
328
+ Raises
329
+ ------
330
+ ValueError
331
+ If `edges` breaks its contract, if the pair is not supported, or if
332
+ the box length the pair needs is missing or not positive.
333
+
334
+ Warns
335
+ -----
336
+ UserWarning
337
+ If `lcube` is given and ``edges[-1] > lcube / 2 + max bin width``.
338
+
339
+ Notes
340
+ -----
341
+ Time and space are :math:`O(n_\\mathrm{bins})`. The half-side warning
342
+ follows MAICoS's ``DensitySphere``, which warns when its ``rmax``
343
+ exceeds half the smallest box vector and computes the shell volumes
344
+ regardless.
345
+
346
+ Examples
347
+ --------
348
+ >>> import numpy as np
349
+ >>> from polyphys.analyze.density import bin_volumes
350
+ >>> np.round(bin_volumes([0, 1, 2], 'cubic', 'r'), 5)
351
+ array([ 4.18879, 29.32153])
352
+ >>> np.round(bin_volumes([0, 1, 2], 'cylindrical', 'r', lcyl=3), 5)
353
+ array([ 9.42478, 28.27433])
354
+ >>> np.round(bin_volumes([-1, 0, 1], 'cylindrical', 'z', dcyl=2), 5)
355
+ array([3.14159, 3.14159])
356
+
357
+ .. versionadded:: 0.6.0
358
+ """
359
+ scheme = _check_scheme(geometry, direction)
360
+ e = _check_edges(edges, scheme)
361
+ if scheme == 'sphere':
362
+ volumes = 4.0 * np.pi * np.diff(e**3) / 3.0
363
+ if lcube is not None:
364
+ side = _check_positive('lcube', lcube)
365
+ if e[-1] > side / 2.0 + np.max(np.diff(e)):
366
+ warnings.warn(
367
+ f'the outermost edge {e[-1]} lies beyond half the box'
368
+ f' side {side / 2.0}; the spherical shells there'
369
+ ' overstate the volume inside the box.',
370
+ UserWarning,
371
+ stacklevel=2,
372
+ )
373
+ elif scheme == 'cylinder':
374
+ length = _check_positive('lcyl', lcyl)
375
+ volumes = np.pi * length * np.diff(e**2)
376
+ else:
377
+ diameter = _check_positive('dcyl', dcyl)
378
+ volumes = np.pi * diameter**2 * np.diff(e) / 4.0
379
+ return np.asarray(volumes, dtype=np.float64)
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Kernels
384
+ # ---------------------------------------------------------------------------
385
+
386
+
387
+ def _gauss_pieces(
388
+ lo: float, hi: float, breakpoints: npt.ArrayLike, n_points: int
389
+ ) -> tuple[FloatArray, FloatArray]:
390
+ """
391
+ Return Gauss-Legendre nodes and weights for ``[lo, hi]`` split at the
392
+ breakpoints that fall strictly inside it.
393
+ """
394
+ cuts = np.asarray(breakpoints, dtype=np.float64)
395
+ cuts = np.unique(cuts[(cuts > lo) & (cuts < hi)])
396
+ bounds = np.concatenate(([lo], cuts, [hi]))
397
+ t, w = np.polynomial.legendre.leggauss(n_points)
398
+ half = 0.5 * np.diff(bounds)
399
+ mid = 0.5 * (bounds[:-1] + bounds[1:])
400
+ nodes = mid[:, None] + half[:, None] * t[None, :]
401
+ weights = half[:, None] * w[None, :]
402
+ return nodes.ravel(), weights.ravel()
403
+
404
+
405
+ def _lens_area(r: FloatArray, r_cyl: float, d: FloatArray) -> FloatArray:
406
+ """
407
+ Return the area of the intersection of a disc of radius `r` with a disc
408
+ of radius `r_cyl` whose centers are `d` apart (MathWorld, Circle-Circle
409
+ Intersection, eq. 15), broadcast over `r` and `d`.
410
+ """
411
+ r, d = np.broadcast_arrays(r, d)
412
+ small = np.minimum(r, r_cyl)
413
+ area = np.where(d <= np.abs(r_cyl - r), np.pi * small**2, 0.0)
414
+ lens = (d > np.abs(r_cyl - r)) & (d < r + r_cyl)
415
+ rl, dl = r[lens], d[lens]
416
+ cos_r = np.clip((dl**2 + rl**2 - r_cyl**2) / (2.0 * dl * rl), -1.0, 1.0)
417
+ cos_c = np.clip((dl**2 + r_cyl**2 - rl**2) / (2.0 * dl * r_cyl), -1, 1)
418
+ root = np.sqrt(
419
+ np.maximum(
420
+ (-dl + rl + r_cyl) * (dl + rl - r_cyl) * (dl - rl + r_cyl)
421
+ * (dl + rl + r_cyl),
422
+ 0.0,
423
+ )
424
+ )
425
+ area[lens] = (
426
+ rl**2 * np.arccos(cos_r) + r_cyl**2 * np.arccos(cos_c) - 0.5 * root
427
+ )
428
+ return np.asarray(area, dtype=np.float64)
429
+
430
+
431
+ def _ball_in_cylinder(
432
+ r_ball: float, r_cyl: float, d: FloatArray
433
+ ) -> FloatArray:
434
+ """
435
+ Return the volume of a ball of radius `r_ball` inside the coaxial
436
+ cylinder of radius `r_cyl`, for an array of axial distances `d` of the
437
+ ball's center from the axis.
438
+
439
+ The ball is sliced by planes normal to the axis; the slice at height
440
+ ``z`` is a disc of radius ``sqrt(r_ball**2 - z**2)`` and its share is a
441
+ lens area. The integral over ``z`` is a Gauss-Legendre rule on the
442
+ pieces between the heights where the lens changes regime.
443
+ """
444
+ d = np.asarray(d, dtype=np.float64)
445
+ full = 4.0 * np.pi * r_ball**3 / 3.0
446
+ out = np.where(r_cyl >= d + r_ball, full, 0.0)
447
+ cut = (r_cyl > 0.0) & (r_cyl < d + r_ball) & (r_cyl > d - r_ball)
448
+ if not np.any(cut):
449
+ return np.asarray(out, dtype=np.float64)
450
+ dc = d[cut]
451
+ # Heights where the slice disc stops containing the cylinder's disc
452
+ # (z_in) and where it stops being cut by it (z_out).
453
+ z_in = np.sqrt(np.maximum(r_ball**2 - (r_cyl + dc) ** 2, 0.0))
454
+ z_out = np.sqrt(np.maximum(r_ball**2 - (r_cyl - dc) ** 2, 0.0))
455
+ bounds = np.stack(
456
+ [np.zeros_like(dc), z_in, z_out, np.full_like(dc, r_ball)], axis=1
457
+ )
458
+ t, w = np.polynomial.legendre.leggauss(_N_GAUSS_AXIAL)
459
+ half = 0.5 * np.diff(bounds, axis=1) # (n, 3)
460
+ mid = 0.5 * (bounds[:, :-1] + bounds[:, 1:])
461
+ z = mid[:, :, None] + half[:, :, None] * t # (n, 3, m)
462
+ radius = np.sqrt(np.maximum(r_ball**2 - z**2, 0.0))
463
+ area = _lens_area(radius, r_cyl, dc[:, None, None])
464
+ out[cut] = 2.0 * np.sum(area * w * half[:, :, None], axis=(1, 2))
465
+ return np.asarray(out, dtype=np.float64)
466
+
467
+
468
+ def sphere_cylinder_intersection(
469
+ r_sphere: float, r_cylinder: float, d: float
470
+ ) -> float:
471
+ """
472
+ Compute the volume of a ball that lies inside a cylinder whose axis
473
+ passes at distance `d` from the ball's center.
474
+
475
+ Parameters
476
+ ----------
477
+ r_sphere : float
478
+ Radius of the ball. Must be positive.
479
+ r_cylinder : float
480
+ Radius of the cylinder; zero or negative gives an empty cylinder.
481
+ d : float
482
+ Distance of the ball's center from the cylinder's axis, ``>= 0``.
483
+
484
+ Returns
485
+ -------
486
+ float
487
+ The volume of the ball inside the cylinder.
488
+
489
+ Raises
490
+ ------
491
+ ValueError
492
+ If `r_sphere` is not positive or `d` is negative.
493
+
494
+ Notes
495
+ -----
496
+ The ball is sliced by planes normal to the axis. At height :math:`z`
497
+ the slice is a disc of radius :math:`\\sqrt{R_b^2 - z^2}` whose center
498
+ is :math:`d` from the axis, and the cylinder cuts the plane in a disc of
499
+ radius :math:`R` about the axis; the slice's share is the lens area of
500
+ the two discs (MathWorld, Circle-Circle Intersection, eq. 15), and the
501
+ volume is its integral over :math:`z`. The integral has no closed form,
502
+ so it is a Gauss-Legendre rule with 32 nodes on each of the three
503
+ pieces between the heights where the lens changes regime; the result
504
+ agrees with an adaptive quadrature to about :math:`10^{-8}` of the
505
+ ball's volume. Time is :math:`O(1)`.
506
+
507
+ Two limits are exact: the ball lies inside the cylinder when
508
+ :math:`R \\ge d + R_b`, and outside it when :math:`R \\le d - R_b`. On
509
+ the axis, :math:`d = 0`, the volume inside a cylinder of radius
510
+ :math:`R < R_b` is :math:`\\tfrac{4\\pi}{3}\\,[R_b^3 - (R_b^2 -
511
+ R^2)^{3/2}]`.
512
+
513
+ Examples
514
+ --------
515
+ >>> from polyphys.analyze.density import sphere_cylinder_intersection
516
+ >>> round(sphere_cylinder_intersection(1.0, 2.0, 0.5), 10)
517
+ 4.1887902048
518
+ >>> sphere_cylinder_intersection(1.0, 2.0, 3.5)
519
+ 0.0
520
+
521
+ On the axis with a narrower cylinder, the closed form above gives
522
+ :math:`\\tfrac{4\\pi}{3}(1 - 0.8^3)`:
523
+
524
+ >>> round(sphere_cylinder_intersection(1.0, 0.6, 0.0), 6)
525
+ 2.04413
526
+ >>> round(4 * 3.141592653589793 / 3 * (1 - 0.8**3), 6)
527
+ 2.04413
528
+
529
+ .. versionadded:: 0.6.0
530
+ """
531
+ radius = _check_positive('r_sphere', r_sphere)
532
+ if d < 0:
533
+ raise ValueError(f'd must be non-negative; got {d}.')
534
+ return float(
535
+ _ball_in_cylinder(radius, float(r_cylinder), np.array([float(d)]))[0]
536
+ )
537
+
538
+
539
+ def _segment_second_antiderivative(u: FloatArray, r_ball: float) -> FloatArray:
540
+ """
541
+ Return :math:`G_2(u) = \\int_{-\\infty}^{u} G`, where :math:`G(u)` is
542
+ the volume of a ball of radius `r_ball` below the plane at signed height
543
+ `u` from its center (the spherical segment from :math:`-R_b` to
544
+ :math:`u`); so :math:`G_2` is the second antiderivative of the ball's
545
+ cross-section area, used to average a slab's share over the bead's
546
+ position in closed form.
547
+ """
548
+ r = r_ball
549
+ full = 4.0 * np.pi * r**3 / 3.0
550
+ uc = np.clip(u, -r, r)
551
+ inside = np.pi * (
552
+ r**2 * (uc + r) ** 2 / 2.0 - (uc**4 - r**4) / 12.0
553
+ - r**3 * (uc + r) / 3.0
554
+ )
555
+ return np.asarray(
556
+ np.where(u >= r, full * u, np.where(u <= -r, 0.0, inside)),
557
+ dtype=np.float64,
558
+ )
559
+
560
+
561
+ _segment = np.vectorize(spherical_segment, otypes=[np.float64])
562
+ _sphere_sphere = np.vectorize(sphere_sphere_intersection, otypes=[np.float64])
563
+
564
+
565
+ def _slab_entries(
566
+ edges: FloatArray, r_ball: float, shares: ShareMode
567
+ ) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp], FloatArray]:
568
+ """
569
+ Return the ``(row, col, value)`` triples of the share matrix of periodic
570
+ slabs: the volume of a test bead in slab `row` that lies in slab `col`,
571
+ with the slabs that wrap around the ends counted through their images.
572
+ """
573
+ n_bins = edges.size - 1
574
+ period = edges[-1] - edges[0]
575
+ if 2.0 * r_ball >= period:
576
+ raise ValueError(
577
+ f'a bead of diameter {2 * r_ball} does not fit the periodic'
578
+ f' length {period}.'
579
+ )
580
+ widths = np.diff(edges)
581
+ centers = 0.5 * (edges[:-1] + edges[1:])
582
+ reach = int(np.ceil((r_ball + widths.max()) / widths.min())) + 1
583
+ rows_i = np.arange(n_bins)
584
+ rows, cols, vals = [], [], []
585
+ for offset in range(-reach, reach + 1):
586
+ unwrapped = rows_i + offset
587
+ image = np.floor_divide(unwrapped, n_bins)
588
+ col = unwrapped - image * n_bins
589
+ lo = edges[col] + image * period
590
+ hi = edges[col + 1] + image * period
591
+ if shares == 'center':
592
+ value = _segment(r_ball, lo - centers, hi - centers)
593
+ else:
594
+ g2 = _segment_second_antiderivative
595
+ value = (
596
+ g2(hi - edges[:-1], r_ball) - g2(hi - edges[1:], r_ball)
597
+ - g2(lo - edges[:-1], r_ball) + g2(lo - edges[1:], r_ball)
598
+ ) / widths
599
+ keep = value > 0.0
600
+ rows.append(rows_i[keep])
601
+ cols.append(col[keep])
602
+ vals.append(value[keep])
603
+ return (
604
+ np.concatenate(rows), np.concatenate(cols), np.concatenate(vals)
605
+ )
606
+
607
+
608
+ def _shell_entries(
609
+ edges: FloatArray,
610
+ r_ball: float,
611
+ scheme: _SchemeT,
612
+ shares: ShareMode,
613
+ inside: Callable[[float, FloatArray], FloatArray],
614
+ ) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp], FloatArray]:
615
+ """
616
+ Return the ``(row, col, value)`` triples of the share matrix of
617
+ concentric shells. `inside(edge, x)` is the volume of a bead centered at
618
+ the radial positions `x` that lies within radius `edge`; a shell's share
619
+ is the difference between its two edges.
620
+ """
621
+ n_bins = edges.size - 1
622
+ power = _MEASURE_POWER[scheme]
623
+ centers = 0.5 * (edges[:-1] + edges[1:])
624
+ rows, cols, vals = [], [], []
625
+ for i in range(n_bins):
626
+ lo, hi = edges[i], edges[i + 1]
627
+ j_lo = max(int(np.searchsorted(edges, lo - r_ball, 'right')) - 1, 0)
628
+ j_hi = min(
629
+ int(np.searchsorted(edges, hi + r_ball, 'left')) - 1, n_bins - 1
630
+ )
631
+ shell_edges = edges[j_lo: j_hi + 2]
632
+ if shares == 'center':
633
+ x = np.array([centers[i]])
634
+ weights = np.array([1.0])
635
+ norm = 1.0
636
+ else:
637
+ cuts = np.concatenate(
638
+ (np.abs(shell_edges - r_ball), shell_edges + r_ball)
639
+ )
640
+ x, weights = _gauss_pieces(lo, hi, cuts, _N_GAUSS_POSITION)
641
+ weights = weights * x**power
642
+ norm = (hi ** (power + 1) - lo ** (power + 1)) / (power + 1)
643
+ within = np.stack([inside(edge, x) for edge in shell_edges])
644
+ value = (np.diff(within, axis=0) @ weights) / norm
645
+ keep = value > 0.0
646
+ rows.append(np.full(int(keep.sum()), i))
647
+ cols.append(np.arange(j_lo, j_hi + 1)[keep])
648
+ vals.append(value[keep])
649
+ return (
650
+ np.concatenate(rows), np.concatenate(cols), np.concatenate(vals)
651
+ )
652
+
653
+
654
+ def share_matrix(
655
+ edges: npt.ArrayLike,
656
+ bead_diameter: float,
657
+ geometry: GeometryT,
658
+ direction: DirectionT,
659
+ *,
660
+ shares: ShareMode = 'average',
661
+ intersection: IntersectionMode = 'exact',
662
+ ) -> csr_array:
663
+ """
664
+ Build the share matrix: the volume of a test bead centered in each bin
665
+ that lies in each other bin.
666
+
667
+ Parameters
668
+ ----------
669
+ edges : array_like
670
+ The bin edges, shape ``(n_bins + 1,)``, finite and strictly
671
+ increasing; non-negative for a radial direction.
672
+ bead_diameter : float
673
+ The bead diameter :math:`a`, positive; along the periodic ``'z'`` it
674
+ must be smaller than the domain length.
675
+ geometry : :py:obj:`~.GeometryT`
676
+ ``'cylindrical'`` or ``'cubic'``.
677
+ direction : :py:obj:`~.DirectionT`
678
+ ``'r'`` for shells, or ``'z'`` for the periodic slabs of a cylinder.
679
+ shares : :py:data:`ShareMode`, default ``'average'``
680
+ ``'average'`` averages the test bead over its bin with the bin's
681
+ measure; ``'center'`` places it at the bin center, as the research
682
+ code did.
683
+ intersection : :py:data:`IntersectionMode`, default ``'exact'``
684
+ The kernel of a cylindrical shell: :func:`sphere_cylinder_intersection`
685
+ or the sphere-sphere volume the research code used. Ignored for the
686
+ other pairs, whose kernels are exact closed forms.
687
+
688
+ Returns
689
+ -------
690
+ scipy.sparse.csr_array
691
+ ``S``, shape ``(n_bins, n_bins)``, with ``S[i, j]`` the volume of a
692
+ test bead of bin ``i`` inside bin ``j``; ``S @ rho`` is the volume
693
+ fraction. Each row has about ``a / h + 2`` nonzero entries. Along a
694
+ radial direction the row sum falls short of the bead volume in the
695
+ outermost bins, where the bead reaches past the last edge.
696
+
697
+ Raises
698
+ ------
699
+ ValueError
700
+ If `edges` breaks its contract, if the pair is not supported, if
701
+ `bead_diameter` is not positive or does not fit the period, or if
702
+ `shares` or `intersection` is not a known mode.
703
+
704
+ Notes
705
+ -----
706
+ The nonzero band is found with :func:`numpy.searchsorted`; the entries
707
+ are evaluated as the module docstring describes. Time is
708
+ :math:`O(n_\\mathrm{bins} \\, k)` kernel evaluations with
709
+ :math:`k \\approx a / h + 2` the band width (times the Gauss nodes for
710
+ ``'average'``, and times the axial nodes for ``'exact'``), and space
711
+ is :math:`O(n_\\mathrm{bins} \\, k)`. The matrix depends on the edges
712
+ and the bead only, so build it once per space and reuse it for every
713
+ whole and every frame.
714
+
715
+ With ``shares='average'`` the matrix satisfies
716
+ :math:`V_i S_{ij} = V_j S_{ji}`, which makes :func:`volume_fraction`
717
+ conserve the total bead volume and return :math:`\\rho v` for a uniform
718
+ fluid, both to round-off. With ``shares='center'`` the row sums are
719
+ still the bead volume, but the symmetry, and with it the conservation,
720
+ holds only to :math:`O(h^2)` along :math:`r`. The symmetry also needs
721
+ the kernel to be the true indicator, so with ``intersection='sphere'``
722
+ it holds only to :math:`O(a / R)`.
723
+
724
+ Examples
725
+ --------
726
+ Four periodic slabs of width 1 and a bead of diameter 1: at the bin
727
+ center the bead lies wholly in its own slab, so each row is the bead
728
+ volume :math:`\\pi / 6` on the diagonal:
729
+
730
+ >>> import numpy as np
731
+ >>> from polyphys.analyze.density import share_matrix
732
+ >>> s = share_matrix([0, 1, 2, 3, 4], 1.0, 'cylindrical', 'z',
733
+ ... shares='center')
734
+ >>> np.round(s.toarray(), 4)
735
+ array([[0.5236, 0. , 0. , 0. ],
736
+ [0. , 0.5236, 0. , 0. ],
737
+ [0. , 0. , 0.5236, 0. ],
738
+ [0. , 0. , 0. , 0.5236]])
739
+
740
+ Averaged over the bin, the bead reaches into both neighbors, the last
741
+ slab's into the first through the periodic boundary, and every row
742
+ still sums to :math:`\\pi / 6`:
743
+
744
+ >>> s = share_matrix([0, 1, 2, 3, 4], 1.0, 'cylindrical', 'z')
745
+ >>> np.allclose(s.sum(axis=1), np.pi / 6)
746
+ True
747
+ >>> bool(s[3, 0] > 0 and s[3, 0] == s[3, 2])
748
+ True
749
+
750
+ .. versionadded:: 0.6.0
751
+ """
752
+ scheme = _check_scheme(geometry, direction)
753
+ e = _check_edges(edges, scheme)
754
+ r_ball = 0.5 * _check_positive('bead_diameter', bead_diameter)
755
+ _check_choice('shares', shares, ('average', 'center'))
756
+ _check_choice('intersection', intersection, ('exact', 'sphere'))
757
+ if scheme == 'slab':
758
+ rows, cols, vals = _slab_entries(e, r_ball, shares)
759
+ else:
760
+ if scheme == 'cylinder' and intersection == 'exact':
761
+ def inside(edge: float, x: FloatArray) -> FloatArray:
762
+ return _ball_in_cylinder(r_ball, edge, x)
763
+ else:
764
+ def inside(edge: float, x: FloatArray) -> FloatArray:
765
+ return np.asarray(
766
+ _sphere_sphere(r_ball, edge, x), dtype=np.float64
767
+ )
768
+ rows, cols, vals = _shell_entries(e, r_ball, scheme, shares, inside)
769
+ n_bins = e.size - 1
770
+ return csr_array(
771
+ coo_array((vals, (rows, cols)), shape=(n_bins, n_bins))
772
+ )
773
+
774
+
775
+ # ---------------------------------------------------------------------------
776
+ # Profiles
777
+ # ---------------------------------------------------------------------------
778
+
779
+
780
+ def number_density(
781
+ counts: npt.ArrayLike,
782
+ volumes: npt.ArrayLike,
783
+ *,
784
+ n_frames: int = 1,
785
+ n_particles: int | None = None,
786
+ ) -> FloatArray:
787
+ """
788
+ Compute the local number density from bin counts and bin volumes.
789
+
790
+ Parameters
791
+ ----------
792
+ counts : array_like
793
+ The bead centers counted per bin, shape ``(n_bins,)`` summed over
794
+ the frames, or ``(n_frames, n_bins)`` with one row per frame (then
795
+ leave `n_frames` at 1). Non-negative.
796
+ volumes : array_like
797
+ The bin volumes, shape ``(n_bins,)``, positive; see
798
+ :func:`bin_volumes`.
799
+ n_frames : int, default 1
800
+ The number of frames the counts were summed over.
801
+ n_particles : int, optional
802
+ The number of beads of this species. When given, each row of
803
+ `counts` must sum to ``n_frames * n_particles``, so that no bead
804
+ was dropped when the positions were binned.
805
+
806
+ Returns
807
+ -------
808
+ numpy.ndarray
809
+ :math:`\\rho = H / (F V)`, the same shape as `counts`, in beads per
810
+ cubic length unit.
811
+
812
+ Raises
813
+ ------
814
+ ValueError
815
+ If the shapes do not match, a count is negative, a volume is not
816
+ positive, `n_frames` is not positive, or the counts do not sum to
817
+ ``n_frames * n_particles``.
818
+
819
+ Notes
820
+ -----
821
+ Time and space are :math:`O(n_\\mathrm{frames} \\, n_\\mathrm{bins})`.
822
+
823
+ Examples
824
+ --------
825
+ >>> from polyphys.analyze.density import number_density
826
+ >>> number_density([2, 4], [1.0, 2.0], n_frames=2)
827
+ array([1., 1.])
828
+ >>> number_density([2, 4], [1.0, 2.0], n_frames=2, n_particles=3)
829
+ array([1., 1.])
830
+
831
+ .. versionadded:: 0.6.0
832
+ """
833
+ h = np.asarray(counts, dtype=np.float64)
834
+ v = np.asarray(volumes, dtype=np.float64)
835
+ if h.ndim not in (1, 2):
836
+ raise ValueError(
837
+ f'counts must be one- or two-dimensional; got {h.ndim}'
838
+ ' dimension(s).'
839
+ )
840
+ if v.ndim != 1 or v.shape[0] != h.shape[-1]:
841
+ raise ValueError(
842
+ f'volumes must have shape ({h.shape[-1]},) to match counts;'
843
+ f' got {v.shape}.'
844
+ )
845
+ if np.any(h < 0):
846
+ raise ValueError('counts must be non-negative.')
847
+ if not np.all(v > 0):
848
+ raise ValueError('volumes must be positive.')
849
+ if n_frames < 1:
850
+ raise ValueError(f'n_frames must be positive; got {n_frames}.')
851
+ if n_particles is not None:
852
+ expected = n_frames * n_particles
853
+ totals = h.sum(axis=-1)
854
+ if np.any(totals != expected):
855
+ raise ValueError(
856
+ f'counts sum to {totals.tolist()}, not to n_frames *'
857
+ f' n_particles = {expected}; a bead was lost or double'
858
+ ' counted when the positions were binned.'
859
+ )
860
+ return np.asarray(h / (n_frames * v), dtype=np.float64)
861
+
862
+
863
+ def volume_fraction(rho: npt.ArrayLike, share: csr_array) -> FloatArray:
864
+ """
865
+ Compute the local volume fraction from the number density and the share
866
+ matrix.
867
+
868
+ Parameters
869
+ ----------
870
+ rho : array_like
871
+ The local number density, shape ``(n_bins,)`` or ``(n_frames,
872
+ n_bins)``; see :func:`number_density`.
873
+ share : scipy.sparse.csr_array
874
+ The share matrix of the same bins and the bead's diameter; see
875
+ :func:`share_matrix`.
876
+
877
+ Returns
878
+ -------
879
+ numpy.ndarray
880
+ :math:`\\phi_i = \\sum_j S_{ij} \\rho_j`, the same shape as `rho`,
881
+ dimensionless.
882
+
883
+ Raises
884
+ ------
885
+ ValueError
886
+ If `rho` is not one- or two-dimensional, or its last axis does not
887
+ match `share`.
888
+
889
+ Notes
890
+ -----
891
+ One sparse matrix-vector product per row of `rho`: time
892
+ :math:`O(n_\\mathrm{frames} \\, n_\\mathrm{bins} \\, k)` with :math:`k`
893
+ the band width, space :math:`O(n_\\mathrm{frames} \\, n_\\mathrm{bins})`.
894
+
895
+ Examples
896
+ --------
897
+ A uniform density through the averaged share matrix returns
898
+ :math:`\\rho v` in every slab, here :math:`(3 / \\pi) (\\pi / 6) = 1/2`:
899
+
900
+ >>> import numpy as np
901
+ >>> from polyphys.analyze.density import share_matrix, volume_fraction
902
+ >>> s = share_matrix([0, 1, 2, 3, 4], 1.0, 'cylindrical', 'z')
903
+ >>> np.round(volume_fraction(np.full(4, 3 / np.pi), s), 12)
904
+ array([0.5, 0.5, 0.5, 0.5])
905
+
906
+ .. versionadded:: 0.6.0
907
+ """
908
+ density = np.asarray(rho, dtype=np.float64)
909
+ if density.ndim not in (1, 2):
910
+ raise ValueError(
911
+ f'rho must be one- or two-dimensional; got {density.ndim}'
912
+ ' dimension(s).'
913
+ )
914
+ if density.shape[-1] != share.shape[1]:
915
+ raise ValueError(
916
+ f'rho has {density.shape[-1]} bins but share has'
917
+ f' {share.shape[1]}.'
918
+ )
919
+ if density.ndim == 1:
920
+ return np.asarray(share @ density, dtype=np.float64)
921
+ return np.asarray((share @ density.T).T, dtype=np.float64)
922
+
923
+
924
+ def normalize_profile(
925
+ profile: npt.ArrayLike,
926
+ mode: NormalizeMode,
927
+ volumes: npt.ArrayLike | None = None,
928
+ ) -> FloatArray:
929
+ """
930
+ Normalize a profile along its bins.
931
+
932
+ Parameters
933
+ ----------
934
+ profile : array_like
935
+ A profile, shape ``(n_bins,)`` or ``(n_frames, n_bins)``.
936
+ mode : :py:data:`NormalizeMode`
937
+ ``'none'`` returns a copy; ``'sum'`` divides each row by its sum
938
+ over the bins, as the research code did; ``'integral'`` divides
939
+ each row by :math:`\\sum_i p_i V_i`, so that the result integrates
940
+ to 1 against the bin volumes.
941
+ volumes : array_like, optional
942
+ The bin volumes, shape ``(n_bins,)``; needed for ``'integral'``.
943
+
944
+ Returns
945
+ -------
946
+ numpy.ndarray
947
+ The normalized profile, the same shape as `profile`.
948
+
949
+ Raises
950
+ ------
951
+ ValueError
952
+ If `mode` is unknown, if ``'integral'`` is asked for without
953
+ `volumes` or with volumes of the wrong shape, or if a row's
954
+ normalizer is zero.
955
+
956
+ Notes
957
+ -----
958
+ A normalized profile must not be passed to
959
+ :mod:`polyphys.analyze.sumrule`, whose species sum needs the physical
960
+ scale of every profile. Time and space are
961
+ :math:`O(n_\\mathrm{frames} \\, n_\\mathrm{bins})`.
962
+
963
+ Examples
964
+ --------
965
+ >>> from polyphys.analyze.density import normalize_profile
966
+ >>> normalize_profile([1.0, 3.0], 'sum')
967
+ array([0.25, 0.75])
968
+ >>> normalize_profile([1.0, 3.0], 'integral', volumes=[2.0, 2.0])
969
+ array([0.125, 0.375])
970
+
971
+ .. versionadded:: 0.6.0
972
+ """
973
+ _check_choice('mode', mode, ('none', 'sum', 'integral'))
974
+ p = np.array(profile, dtype=np.float64)
975
+ if p.ndim not in (1, 2):
976
+ raise ValueError(
977
+ f'profile must be one- or two-dimensional; got {p.ndim}'
978
+ ' dimension(s).'
979
+ )
980
+ if mode == 'none':
981
+ return p
982
+ if mode == 'sum':
983
+ normalizer = p.sum(axis=-1, keepdims=True)
984
+ else:
985
+ if volumes is None:
986
+ raise ValueError("volumes are needed for mode 'integral'.")
987
+ v = np.asarray(volumes, dtype=np.float64)
988
+ if v.shape != (p.shape[-1],):
989
+ raise ValueError(
990
+ f'volumes must have shape ({p.shape[-1]},); got {v.shape}.'
991
+ )
992
+ normalizer = (p * v).sum(axis=-1, keepdims=True)
993
+ if np.any(normalizer == 0):
994
+ raise ValueError('a profile with a zero normalizer cannot be'
995
+ ' normalized.')
996
+ return np.asarray(p / normalizer, dtype=np.float64)
997
+
998
+
999
+ @dataclass(frozen=True)
1000
+ class Profiles:
1001
+ """
1002
+ The local number density and volume fraction of one species along one
1003
+ coordinate.
1004
+
1005
+ .. versionadded:: 0.6.0
1006
+
1007
+ Attributes
1008
+ ----------
1009
+ rho : numpy.ndarray
1010
+ The local number density, shape ``(n_bins,)`` or ``(n_frames,
1011
+ n_bins)``, in beads per cubic length unit, normalized as asked.
1012
+ phi : numpy.ndarray
1013
+ The local volume fraction, the same shape, dimensionless,
1014
+ normalized as asked.
1015
+ volumes : numpy.ndarray
1016
+ The bin volumes, shape ``(n_bins,)``.
1017
+ """
1018
+
1019
+ rho: FloatArray
1020
+ phi: FloatArray
1021
+ volumes: FloatArray
1022
+
1023
+
1024
+ def density_profiles(
1025
+ counts: npt.ArrayLike,
1026
+ edges: npt.ArrayLike,
1027
+ bead_diameter: float,
1028
+ geometry: GeometryT,
1029
+ direction: DirectionT,
1030
+ *,
1031
+ n_frames: int = 1,
1032
+ n_particles: int | None = None,
1033
+ lcyl: float | None = None,
1034
+ dcyl: float | None = None,
1035
+ lcube: float | None = None,
1036
+ shares: ShareMode = 'average',
1037
+ intersection: IntersectionMode = 'exact',
1038
+ normalize: NormalizeMode = 'none',
1039
+ ) -> Profiles:
1040
+ """
1041
+ Compute the local number density and volume fraction of one species in
1042
+ one call.
1043
+
1044
+ Parameters
1045
+ ----------
1046
+ counts : array_like
1047
+ The bead centers counted per bin, shape ``(n_bins,)`` summed over
1048
+ the frames or ``(n_frames, n_bins)``; see :func:`number_density`.
1049
+ edges : array_like
1050
+ The bin edges, shape ``(n_bins + 1,)``.
1051
+ bead_diameter : float
1052
+ The bead diameter :math:`a` of the species.
1053
+ geometry : :py:obj:`~.GeometryT`
1054
+ ``'cylindrical'`` or ``'cubic'``.
1055
+ direction : :py:obj:`~.DirectionT`
1056
+ ``'r'`` or ``'z'``.
1057
+ n_frames : int, default 1
1058
+ The number of frames summed into `counts`.
1059
+ n_particles : int, optional
1060
+ The number of beads of the species, to check that none was lost.
1061
+ lcyl, dcyl, lcube : float, optional
1062
+ The cylinder length and diameter and the cube side, as
1063
+ :func:`bin_volumes` needs them. Along ``'z'`` the slabs take the
1064
+ effective diameter ``dcyl - bead_diameter``, the diameter of the
1065
+ region a bead center can reach, so that a uniform fluid returns the
1066
+ bulk volume fraction as the parsers define it.
1067
+ shares : :py:data:`ShareMode`, default ``'average'``
1068
+ See :func:`share_matrix`.
1069
+ intersection : :py:data:`IntersectionMode`, default ``'exact'``
1070
+ See :func:`share_matrix`.
1071
+ normalize : :py:data:`NormalizeMode`, default ``'none'``
1072
+ Applied to both profiles; see :func:`normalize_profile`. Leave it
1073
+ at ``'none'`` for profiles that feed :mod:`polyphys.analyze.sumrule`.
1074
+
1075
+ Returns
1076
+ -------
1077
+ Profiles
1078
+ The two profiles and the bin volumes.
1079
+
1080
+ Raises
1081
+ ------
1082
+ ValueError
1083
+ As the four functions it calls raise, and if ``dcyl <=
1084
+ bead_diameter`` along ``'z'``.
1085
+
1086
+ Notes
1087
+ -----
1088
+ This builds the share matrix for one call. For many wholes or frames on
1089
+ the same bins, call :func:`share_matrix` once and
1090
+ :func:`number_density` and :func:`volume_fraction` per profile. Time is
1091
+ that of :func:`share_matrix` plus :math:`O(n_\\mathrm{frames} \\,
1092
+ n_\\mathrm{bins} \\, k)`.
1093
+
1094
+ Examples
1095
+ --------
1096
+ Three beads of diameter 1 per slab in a cylinder of effective diameter
1097
+ 2 (so ``dcyl = 3``): the slab area is :math:`\\pi`, the density
1098
+ :math:`3 / \\pi`, and the volume fraction :math:`(3 / \\pi)(\\pi / 6) =
1099
+ 1 / 2`:
1100
+
1101
+ >>> import numpy as np
1102
+ >>> from polyphys.analyze.density import density_profiles
1103
+ >>> out = density_profiles([3, 3, 3, 3], [0, 1, 2, 3, 4], 1.0,
1104
+ ... 'cylindrical', 'z', dcyl=3.0, n_particles=12)
1105
+ >>> np.round(out.rho, 6)
1106
+ array([0.95493, 0.95493, 0.95493, 0.95493])
1107
+ >>> np.round(out.phi, 12)
1108
+ array([0.5, 0.5, 0.5, 0.5])
1109
+
1110
+ .. versionadded:: 0.6.0
1111
+ """
1112
+ scheme = _check_scheme(geometry, direction)
1113
+ a = _check_positive('bead_diameter', bead_diameter)
1114
+ slab_diameter = dcyl
1115
+ if scheme == 'slab':
1116
+ diameter = _check_positive('dcyl', dcyl)
1117
+ if diameter <= a:
1118
+ raise ValueError(
1119
+ f'dcyl = {diameter} must exceed the bead diameter {a}.'
1120
+ )
1121
+ slab_diameter = diameter - a
1122
+ volumes = bin_volumes(
1123
+ edges, geometry, direction, lcyl=lcyl, dcyl=slab_diameter,
1124
+ lcube=lcube,
1125
+ )
1126
+ share = share_matrix(
1127
+ edges, a, geometry, direction, shares=shares,
1128
+ intersection=intersection,
1129
+ )
1130
+ rho = number_density(
1131
+ counts, volumes, n_frames=n_frames, n_particles=n_particles
1132
+ )
1133
+ phi = volume_fraction(rho, share)
1134
+ return Profiles(
1135
+ rho=normalize_profile(rho, normalize, volumes),
1136
+ phi=normalize_profile(phi, normalize, volumes),
1137
+ volumes=volumes,
1138
+ )