polyphys 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- polyphys-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|