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,813 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for :mod:`polyphys.analyze.density`.
|
|
3
|
+
|
|
4
|
+
Every expected value is independent of the code under test. It comes from
|
|
5
|
+
one of these sources, named in the test that uses it:
|
|
6
|
+
|
|
7
|
+
A1. Closed-form volumes: a spherical shell ``4 pi (r2**3 - r1**3) / 3``, a
|
|
8
|
+
cylindrical shell ``pi L (r2**2 - r1**2)``, a slab ``pi D**2 dz / 4``,
|
|
9
|
+
and a bead ``pi a**3 / 6``.
|
|
10
|
+
|
|
11
|
+
A2. The spherical segment and the sphere-sphere intersection volume
|
|
12
|
+
(Weisstein, MathWorld, "Spherical Segment" eq. 14 and "Sphere-Sphere
|
|
13
|
+
Intersection" eq. 16), through :mod:`polyphys.analyze.measurer`, whose
|
|
14
|
+
own tests hold their analytic values.
|
|
15
|
+
|
|
16
|
+
A3. The volume of a ball of radius ``R_b`` inside a coaxial cylinder of
|
|
17
|
+
radius ``R < R_b``: ``int_0^R 2 sqrt(R_b**2 - s**2) 2 pi s ds =
|
|
18
|
+
4 pi [R_b**3 - (R_b**2 - R**2)**1.5] / 3``.
|
|
19
|
+
|
|
20
|
+
A4. The averaged slab shares of a bead of diameter 1 in slabs of width 1:
|
|
21
|
+
the cap beyond the next plane has height ``t = x - 1/2`` for a center at
|
|
22
|
+
``x > 1/2``, so the share into a neighbor is ``int_0^{1/2} pi t**2 (3/2
|
|
23
|
+
- t) / 3 dt = pi / 64`` and the share into the bead's own slab is
|
|
24
|
+
``pi / 6 - 2 pi / 64 = 13 pi / 96``.
|
|
25
|
+
|
|
26
|
+
A5. An ideal gas: ``N`` points uniform in a volume ``V`` give a bin count
|
|
27
|
+
that is Poisson with mean ``N V_i / V``, so the density ``N / V`` holds
|
|
28
|
+
within ``5 sqrt(N V_i / V) / V_i`` in every bin.
|
|
29
|
+
|
|
30
|
+
R1. The research code's volume shares, restated in
|
|
31
|
+
:func:`_sciops_concentric_shares`: for a bead at each bin center, the
|
|
32
|
+
difference of the sphere-sphere volumes at consecutive edges.
|
|
33
|
+
|
|
34
|
+
R2. A Monte Carlo estimate of a ball-in-cylinder volume from
|
|
35
|
+
``numpy.random.default_rng(seed)`` with the seed shown, within five
|
|
36
|
+
binomial standard deviations.
|
|
37
|
+
|
|
38
|
+
R3. The research code's axial slab volume. Its ``SpatialDistribution``
|
|
39
|
+
integrates ``pi dcyl**2 / 4`` along ``z`` but passes the cylinder length
|
|
40
|
+
``lcyl`` as the ``dcyl`` argument, so each of its slabs has volume
|
|
41
|
+
``pi lcyl**2 dz / 4``. Its axial ``rho`` and ``phi`` therefore equal the
|
|
42
|
+
closed-form ones (A1) times ``(dcyl / lcyl)**2``; its radial ones carry
|
|
43
|
+
no such factor.
|
|
44
|
+
|
|
45
|
+
P. A property: conservation of the total bead volume, the symmetry
|
|
46
|
+
``V_i S_ij = V_j S_ji``, translation invariance of the periodic slab
|
|
47
|
+
matrix, a uniform fluid returning ``rho v``, or row-wise independence.
|
|
48
|
+
|
|
49
|
+
Float comparisons use ``FLOAT64_ATOL`` where two computations differ only
|
|
50
|
+
in the order of float64 operations; every other tolerance is derived where
|
|
51
|
+
it is used.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import warnings
|
|
56
|
+
|
|
57
|
+
import numpy as np
|
|
58
|
+
import pytest
|
|
59
|
+
from hypothesis import assume, given, settings
|
|
60
|
+
from hypothesis import strategies as st
|
|
61
|
+
from numpy.testing import assert_allclose, assert_array_equal
|
|
62
|
+
|
|
63
|
+
from polyphys.analyze.density import (
|
|
64
|
+
Profiles,
|
|
65
|
+
bin_volumes,
|
|
66
|
+
density_profiles,
|
|
67
|
+
normalize_profile,
|
|
68
|
+
number_density,
|
|
69
|
+
share_matrix,
|
|
70
|
+
sphere_cylinder_intersection,
|
|
71
|
+
volume_fraction,
|
|
72
|
+
)
|
|
73
|
+
from polyphys.analyze.measurer import (
|
|
74
|
+
sphere_sphere_intersection,
|
|
75
|
+
spherical_segment,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Absolute tolerance for results that differ only by float64 round-off over
|
|
79
|
+
# sums of a few hundred terms: far above the 2.2e-16 epsilon, far below any
|
|
80
|
+
# value the tests compare.
|
|
81
|
+
FLOAT64_ATOL = 1e-12
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _bead_volume(diameter: float) -> float:
|
|
85
|
+
"""Return ``pi a**3 / 6`` (A1)."""
|
|
86
|
+
return np.pi * diameter**3 / 6.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Reference computations
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _sciops_concentric_shares(edges: np.ndarray, r_bead: float) -> np.ndarray:
|
|
95
|
+
"""Return the research code's concentric shares, bead at each center (R1).
|
|
96
|
+
|
|
97
|
+
``distributions.py`` built, for the bead centered at ``centers[c]``, the
|
|
98
|
+
share of bin ``k`` as the sphere-sphere volume at edge ``k + 1`` minus
|
|
99
|
+
the one at edge ``k``, with the sphere-sphere formula standing in for
|
|
100
|
+
the sphere-cylinder one along ``r`` in a cylinder.
|
|
101
|
+
"""
|
|
102
|
+
centers = 0.5 * (edges[:-1] + edges[1:])
|
|
103
|
+
shares = np.zeros((centers.size, centers.size))
|
|
104
|
+
for c, center in enumerate(centers):
|
|
105
|
+
previous = 0.0
|
|
106
|
+
for k in range(1, edges.size):
|
|
107
|
+
current = sphere_sphere_intersection(r_bead, edges[k], center)
|
|
108
|
+
shares[c, k - 1] = current - previous
|
|
109
|
+
previous = current
|
|
110
|
+
return shares
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Fixtures
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.fixture
|
|
119
|
+
def make_uniform_counts():
|
|
120
|
+
"""Return a factory that bins ideal-gas points in one geometry (A5).
|
|
121
|
+
|
|
122
|
+
``make_uniform_counts(geometry, direction, edges, n_points, seed)``
|
|
123
|
+
draws points uniform in the ball, the cylinder, or the periodic slab the
|
|
124
|
+
edges span and returns their histogram along the direction.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def factory(geometry, direction, edges, n_points, seed):
|
|
128
|
+
rng = np.random.default_rng(seed)
|
|
129
|
+
outer = edges[-1]
|
|
130
|
+
if (geometry, direction) == ('cubic', 'r'):
|
|
131
|
+
coordinate = outer * np.cbrt(rng.uniform(size=n_points))
|
|
132
|
+
elif (geometry, direction) == ('cylindrical', 'r'):
|
|
133
|
+
coordinate = outer * np.sqrt(rng.uniform(size=n_points))
|
|
134
|
+
else:
|
|
135
|
+
coordinate = rng.uniform(edges[0], outer, size=n_points)
|
|
136
|
+
counts, _ = np.histogram(coordinate, bins=edges)
|
|
137
|
+
return counts
|
|
138
|
+
|
|
139
|
+
return factory
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# bin_volumes (T1, T2, D6)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize(
|
|
148
|
+
'geometry, direction, kwargs, expected',
|
|
149
|
+
[
|
|
150
|
+
pytest.param(
|
|
151
|
+
'cubic', 'r', {}, [4 * np.pi / 3, 28 * np.pi / 3],
|
|
152
|
+
id='spherical-shells',
|
|
153
|
+
),
|
|
154
|
+
pytest.param(
|
|
155
|
+
'cylindrical', 'r', {'lcyl': 3.0}, [3 * np.pi, 9 * np.pi],
|
|
156
|
+
id='cylindrical-shells',
|
|
157
|
+
),
|
|
158
|
+
pytest.param(
|
|
159
|
+
'cylindrical', 'z', {'dcyl': 4.0}, [4 * np.pi, 4 * np.pi],
|
|
160
|
+
id='slabs',
|
|
161
|
+
),
|
|
162
|
+
],
|
|
163
|
+
)
|
|
164
|
+
def test_bin_volumes_match_closed_forms(geometry, direction, kwargs, expected):
|
|
165
|
+
"""Each scheme returns the closed-form shell or slab volume (A1)."""
|
|
166
|
+
volumes = bin_volumes([0.0, 1.0, 2.0], geometry, direction, **kwargs)
|
|
167
|
+
assert_allclose(volumes, expected, rtol=1e-15) # float64 round-off
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize(
|
|
171
|
+
'geometry, direction, kwargs, total',
|
|
172
|
+
[
|
|
173
|
+
pytest.param('cubic', 'r', {}, 4 * np.pi * 5.0**3 / 3,
|
|
174
|
+
id='ball'),
|
|
175
|
+
pytest.param('cylindrical', 'r', {'lcyl': 7.0},
|
|
176
|
+
np.pi * 7.0 * 5.0**2, id='cylinder'),
|
|
177
|
+
pytest.param('cylindrical', 'z', {'dcyl': 3.0},
|
|
178
|
+
np.pi * 3.0**2 * 5.0 / 4, id='slab-stack'),
|
|
179
|
+
],
|
|
180
|
+
)
|
|
181
|
+
def test_bin_volumes_sum_to_the_domain(geometry, direction, kwargs, total):
|
|
182
|
+
"""Unequal bins' volumes telescope to the whole domain's volume (A1)."""
|
|
183
|
+
edges = np.array([0.0, 0.3, 1.1, 2.0, 3.7, 5.0])
|
|
184
|
+
volumes = bin_volumes(edges, geometry, direction, **kwargs)
|
|
185
|
+
assert volumes.sum() == pytest.approx(total, rel=1e-14) # round-off
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_bin_volumes_warns_beyond_half_the_cube():
|
|
189
|
+
"""Shells past half the cube side plus one bin raise a warning."""
|
|
190
|
+
edges = np.arange(0.0, 8.5, 0.5) # outermost 8 > 10/2 + 0.5
|
|
191
|
+
with pytest.warns(UserWarning, match='beyond half the box side'):
|
|
192
|
+
volumes = bin_volumes(edges, 'cubic', 'r', lcube=10.0)
|
|
193
|
+
assert volumes.shape == (16,)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_bin_volumes_is_silent_within_half_the_cube():
|
|
197
|
+
"""Shells that end within half the side plus one bin raise no warning."""
|
|
198
|
+
edges = np.arange(0.0, 5.51, 0.5) # outermost 5.5 == 10/2 + 0.5
|
|
199
|
+
with warnings.catch_warnings():
|
|
200
|
+
warnings.simplefilter('error')
|
|
201
|
+
bin_volumes(edges, 'cubic', 'r', lcube=10.0)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# sphere_cylinder_intersection (T9, T10)
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize(
|
|
210
|
+
'r_cylinder, d, expected',
|
|
211
|
+
[
|
|
212
|
+
pytest.param(2.0, 0.5, _bead_volume(2.0), id='ball-inside'),
|
|
213
|
+
pytest.param(2.0, 3.0, 0.0, id='ball-outside'),
|
|
214
|
+
pytest.param(0.0, 0.0, 0.0, id='empty-cylinder'),
|
|
215
|
+
pytest.param(
|
|
216
|
+
0.6, 0.0, 4 * np.pi * (1 - (1 - 0.36) ** 1.5) / 3,
|
|
217
|
+
id='on-axis-narrow-cylinder',
|
|
218
|
+
),
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
def test_sphere_cylinder_intersection_has_the_analytic_limits(
|
|
222
|
+
r_cylinder, d, expected
|
|
223
|
+
):
|
|
224
|
+
"""The ball-in-cylinder volume matches its closed-form cases (A1, A3)."""
|
|
225
|
+
volume = sphere_cylinder_intersection(1.0, r_cylinder, d)
|
|
226
|
+
# The quadrature agrees with an adaptive reference to about 1e-8 of the
|
|
227
|
+
# ball volume; the limits are reached exactly.
|
|
228
|
+
assert volume == pytest.approx(expected, abs=1e-8)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_sphere_cylinder_intersection_tends_to_the_half_space():
|
|
232
|
+
"""A cylinder wide against the bead cuts it like a plane: half its volume.
|
|
233
|
+
|
|
234
|
+
The planar limit is approached at first order in ``a / R`` (about
|
|
235
|
+
``0.047 a / R`` of the bead volume, with ``a = 2 R_b`` the bead
|
|
236
|
+
diameter), so at ``R = 2000 a`` the two agree to better than ``5e-5``
|
|
237
|
+
relative.
|
|
238
|
+
"""
|
|
239
|
+
volume = sphere_cylinder_intersection(0.5, 2000.0, 2000.0)
|
|
240
|
+
# An adaptive quadrature of the closed-form lens area, independent of
|
|
241
|
+
# the package, gives a relative deviation of 4.74e-5 here.
|
|
242
|
+
assert volume == pytest.approx(_bead_volume(1.0) / 2, rel=5e-5)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_sphere_cylinder_intersection_agrees_with_monte_carlo():
|
|
246
|
+
"""The quadrature agrees with a seeded Monte Carlo estimate (R2)."""
|
|
247
|
+
r_ball, r_cylinder, d = 0.5, 0.7, 0.4
|
|
248
|
+
n_points = 400_000
|
|
249
|
+
rng = np.random.default_rng(20261103)
|
|
250
|
+
cube = rng.uniform(-r_ball, r_ball, size=(n_points, 3))
|
|
251
|
+
in_ball = np.linalg.norm(cube, axis=1) < r_ball
|
|
252
|
+
points = cube[in_ball]
|
|
253
|
+
inside = np.hypot(points[:, 0] + d, points[:, 1]) < r_cylinder
|
|
254
|
+
fraction = inside.mean()
|
|
255
|
+
estimate = fraction * _bead_volume(2 * r_ball)
|
|
256
|
+
# Five binomial standard deviations of the inside fraction.
|
|
257
|
+
sigma = 5 * np.sqrt(fraction * (1 - fraction) / points.shape[0])
|
|
258
|
+
volume = sphere_cylinder_intersection(r_ball, r_cylinder, d)
|
|
259
|
+
assert volume == pytest.approx(estimate, abs=sigma * _bead_volume(1.0))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.mark.parametrize(
|
|
263
|
+
'r_sphere, d, message',
|
|
264
|
+
[
|
|
265
|
+
pytest.param(0.0, 0.5, 'r_sphere must be positive', id='zero-ball'),
|
|
266
|
+
pytest.param(1.0, -0.1, 'd must be non-negative', id='negative-d'),
|
|
267
|
+
],
|
|
268
|
+
)
|
|
269
|
+
def test_sphere_cylinder_intersection_rejects_bad_input(r_sphere, d, message):
|
|
270
|
+
"""A non-positive ball radius or a negative distance raises ValueError."""
|
|
271
|
+
with pytest.raises(ValueError, match=message):
|
|
272
|
+
sphere_cylinder_intersection(r_sphere, 1.0, d)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# share_matrix: analytic entries (T7, T8, T11)
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_share_matrix_center_slab_entries_are_spherical_segments():
|
|
281
|
+
"""Centered slab shares are the segments between the slab planes (A2)."""
|
|
282
|
+
edges = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
|
|
283
|
+
r_bead = 0.75
|
|
284
|
+
share = share_matrix(edges, 2 * r_bead, 'cylindrical', 'z',
|
|
285
|
+
shares='center').toarray()
|
|
286
|
+
center = 1.5
|
|
287
|
+
expected = [
|
|
288
|
+
spherical_segment(r_bead, edges[k] - center, edges[k + 1] - center)
|
|
289
|
+
for k in range(3)
|
|
290
|
+
]
|
|
291
|
+
assert_allclose(share[1, :3], expected, atol=FLOAT64_ATOL)
|
|
292
|
+
assert share[1, 3] == 0.0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_share_matrix_average_slab_entries_match_the_cap_integral():
|
|
296
|
+
"""Averaged slab shares: ``13 pi / 96`` home, ``pi / 64`` beside (A4)."""
|
|
297
|
+
share = share_matrix([0.0, 1.0, 2.0, 3.0, 4.0], 1.0, 'cylindrical',
|
|
298
|
+
'z').toarray()
|
|
299
|
+
assert_allclose(
|
|
300
|
+
share[1], [np.pi / 64, 13 * np.pi / 96, np.pi / 64, 0.0],
|
|
301
|
+
atol=FLOAT64_ATOL,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize(
|
|
306
|
+
'geometry, kwargs',
|
|
307
|
+
[
|
|
308
|
+
pytest.param('cubic', {}, id='spherical'),
|
|
309
|
+
pytest.param('cylindrical', {'intersection': 'exact'},
|
|
310
|
+
id='cylindrical-exact'),
|
|
311
|
+
pytest.param('cylindrical', {'intersection': 'sphere'},
|
|
312
|
+
id='cylindrical-sphere'),
|
|
313
|
+
],
|
|
314
|
+
)
|
|
315
|
+
def test_share_matrix_keeps_a_small_bead_in_its_first_shell(geometry, kwargs):
|
|
316
|
+
"""A centered bead reaching no edge of its shell lies wholly in it (A1).
|
|
317
|
+
|
|
318
|
+
With edges 0, 1, 2 and a bead of radius 0.25, the bead at center 0.5
|
|
319
|
+
spans 0.25 to 0.75 and the one at center 1.5 spans 1.25 to 1.75.
|
|
320
|
+
"""
|
|
321
|
+
share = share_matrix([0.0, 1.0, 2.0], 0.5, geometry, 'r',
|
|
322
|
+
shares='center', **kwargs).toarray()
|
|
323
|
+
v = _bead_volume(0.5)
|
|
324
|
+
assert_allclose(share, [[v, 0.0], [0.0, v]],
|
|
325
|
+
atol=1e-8) # the exact kernel's quadrature level
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@pytest.mark.parametrize(
|
|
329
|
+
'geometry',
|
|
330
|
+
[pytest.param('cubic', id='cubic-r'),
|
|
331
|
+
pytest.param('cylindrical', id='cylindrical-r')],
|
|
332
|
+
)
|
|
333
|
+
def test_share_matrix_center_sphere_reproduces_the_research_code(geometry):
|
|
334
|
+
"""The center mode with the sphere kernel equals the SciOps loop (R1)."""
|
|
335
|
+
edges = np.arange(0.0, 6.01, 0.25)
|
|
336
|
+
share = share_matrix(edges, 1.0, geometry, 'r', shares='center',
|
|
337
|
+
intersection='sphere').toarray()
|
|
338
|
+
assert_allclose(share, _sciops_concentric_shares(edges, 0.5),
|
|
339
|
+
atol=FLOAT64_ATOL)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# share_matrix: properties (T4, T5, T6)
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.parametrize(
|
|
348
|
+
'geometry, direction, shares',
|
|
349
|
+
[
|
|
350
|
+
pytest.param('cubic', 'r', 'average', id='spherical-average'),
|
|
351
|
+
pytest.param('cylindrical', 'r', 'average',
|
|
352
|
+
id='cylindrical-average'),
|
|
353
|
+
pytest.param('cylindrical', 'z', 'average', id='slab-average'),
|
|
354
|
+
pytest.param('cubic', 'r', 'center', id='spherical-center'),
|
|
355
|
+
pytest.param('cylindrical', 'r', 'center', id='cylindrical-center'),
|
|
356
|
+
pytest.param('cylindrical', 'z', 'center', id='slab-center'),
|
|
357
|
+
],
|
|
358
|
+
)
|
|
359
|
+
def test_share_matrix_rows_sum_to_the_bead_volume(geometry, direction, shares):
|
|
360
|
+
"""Every bin whose bead stays in the domain shares out exactly ``v`` (P).
|
|
361
|
+
|
|
362
|
+
Along ``z`` every bin qualifies through the periodic images; along
|
|
363
|
+
``r`` the bins whose outer edge plus the bead radius stays within the
|
|
364
|
+
last edge do.
|
|
365
|
+
"""
|
|
366
|
+
edges = np.arange(0.0, 4.01, 0.25) if direction == 'r' else \
|
|
367
|
+
np.arange(-2.0, 2.01, 0.25)
|
|
368
|
+
share = share_matrix(edges, 1.0, geometry, direction, shares=shares)
|
|
369
|
+
row_sums = np.asarray(share.sum(axis=1)).ravel()
|
|
370
|
+
inside = edges[1:] + 0.5 <= edges[-1] if direction == 'r' else \
|
|
371
|
+
np.ones(edges.size - 1, dtype=bool)
|
|
372
|
+
assert_allclose(row_sums[inside], _bead_volume(1.0), rtol=1e-8)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@pytest.mark.parametrize(
|
|
376
|
+
'geometry, direction, kwargs',
|
|
377
|
+
[
|
|
378
|
+
pytest.param('cubic', 'r', {}, id='spherical'),
|
|
379
|
+
pytest.param('cylindrical', 'r', {'lcyl': 3.0}, id='cylindrical'),
|
|
380
|
+
pytest.param('cylindrical', 'z', {'dcyl': 2.0}, id='slab'),
|
|
381
|
+
],
|
|
382
|
+
)
|
|
383
|
+
def test_share_matrix_average_is_volume_symmetric(geometry, direction, kwargs):
|
|
384
|
+
"""Averaged shares satisfy ``V_i S_ij = V_j S_ji`` in every scheme (P)."""
|
|
385
|
+
edges = np.arange(0.0, 3.01, 0.25) if direction == 'r' else \
|
|
386
|
+
np.arange(-1.5, 1.51, 0.25)
|
|
387
|
+
volumes = bin_volumes(edges, geometry, direction, **kwargs)
|
|
388
|
+
share = share_matrix(edges, 1.0, geometry, direction).toarray()
|
|
389
|
+
weighted = volumes[:, None] * share
|
|
390
|
+
# The exact cylinder kernel is a quadrature: 1e-8 of a bead volume.
|
|
391
|
+
assert_allclose(weighted, weighted.T, atol=1e-8 * _bead_volume(1.0))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@pytest.mark.parametrize(
|
|
395
|
+
'geometry, direction, kwargs',
|
|
396
|
+
[
|
|
397
|
+
pytest.param('cubic', 'r', {}, id='spherical'),
|
|
398
|
+
pytest.param('cylindrical', 'r', {'lcyl': 3.0}, id='cylindrical'),
|
|
399
|
+
pytest.param('cylindrical', 'z', {'dcyl': 2.0}, id='slab'),
|
|
400
|
+
],
|
|
401
|
+
)
|
|
402
|
+
def test_volume_fraction_conserves_the_bead_volume(geometry, direction,
|
|
403
|
+
kwargs):
|
|
404
|
+
"""``sum_i V_i phi_i`` equals the number of beads times ``v`` (P, A1).
|
|
405
|
+
|
|
406
|
+
One bead sits in each bin whose bead stays inside the domain; the
|
|
407
|
+
averaged shares return their total volume to round-off.
|
|
408
|
+
"""
|
|
409
|
+
edges = np.arange(0.0, 4.01, 0.25) if direction == 'r' else \
|
|
410
|
+
np.arange(-2.0, 2.01, 0.25)
|
|
411
|
+
volumes = bin_volumes(edges, geometry, direction, **kwargs)
|
|
412
|
+
inside = edges[1:] + 0.5 <= edges[-1] if direction == 'r' else \
|
|
413
|
+
np.ones(edges.size - 1, dtype=bool)
|
|
414
|
+
counts = inside.astype(float)
|
|
415
|
+
share = share_matrix(edges, 1.0, geometry, direction)
|
|
416
|
+
phi = volume_fraction(number_density(counts, volumes), share)
|
|
417
|
+
total = (volumes * phi).sum()
|
|
418
|
+
assert total == pytest.approx(counts.sum() * _bead_volume(1.0),
|
|
419
|
+
rel=1e-8) # quadrature level
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_share_matrix_wraps_a_bead_across_the_periodic_boundary():
|
|
423
|
+
"""A bead in the last slab reaches into the first as much as into its
|
|
424
|
+
inner neighbor, and the matrix is invariant under a cyclic shift (P)."""
|
|
425
|
+
edges = np.arange(0.0, 4.01, 0.5)
|
|
426
|
+
share = share_matrix(edges, 1.0, 'cylindrical', 'z').toarray()
|
|
427
|
+
assert share[7, 0] > 0.0
|
|
428
|
+
assert share[7, 0] == pytest.approx(share[7, 6], abs=FLOAT64_ATOL)
|
|
429
|
+
shifted = np.roll(np.roll(share, 3, axis=0), 3, axis=1)
|
|
430
|
+
assert_allclose(shifted, share, atol=FLOAT64_ATOL)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@pytest.mark.parametrize(
|
|
434
|
+
'geometry, direction, kwargs, shares',
|
|
435
|
+
[
|
|
436
|
+
pytest.param('cubic', 'r', {}, 'average', id='spherical-average'),
|
|
437
|
+
pytest.param('cylindrical', 'r', {'lcyl': 3.0}, 'average',
|
|
438
|
+
id='cylindrical-average'),
|
|
439
|
+
pytest.param('cylindrical', 'z', {'dcyl': 2.0}, 'average',
|
|
440
|
+
id='slab-average'),
|
|
441
|
+
pytest.param('cubic', 'r', {}, 'center', id='spherical-center'),
|
|
442
|
+
pytest.param('cylindrical', 'z', {'dcyl': 2.0}, 'center',
|
|
443
|
+
id='slab-center'),
|
|
444
|
+
],
|
|
445
|
+
)
|
|
446
|
+
def test_volume_fraction_of_a_uniform_fluid_is_rho_v(geometry, direction,
|
|
447
|
+
kwargs, shares):
|
|
448
|
+
"""A constant density gives ``phi = rho v`` wherever the bead fits (P)."""
|
|
449
|
+
edges = np.arange(0.0, 4.01, 0.25) if direction == 'r' else \
|
|
450
|
+
np.arange(-2.0, 2.01, 0.25)
|
|
451
|
+
rho = np.full(edges.size - 1, 0.3)
|
|
452
|
+
share = share_matrix(edges, 1.0, geometry, direction, shares=shares)
|
|
453
|
+
phi = volume_fraction(rho, share)
|
|
454
|
+
inside = edges[1:] + 0.5 <= edges[-1] if direction == 'r' else \
|
|
455
|
+
np.ones(edges.size - 1, dtype=bool)
|
|
456
|
+
assert_allclose(phi[inside], 0.3 * _bead_volume(1.0), rtol=1e-8)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
# number_density and the ideal gas (T3, T13, T15)
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@pytest.mark.parametrize(
|
|
465
|
+
'geometry, direction, kwargs',
|
|
466
|
+
[
|
|
467
|
+
pytest.param('cubic', 'r', {}, id='ball'),
|
|
468
|
+
pytest.param('cylindrical', 'r', {'lcyl': 2.0}, id='cylinder'),
|
|
469
|
+
pytest.param('cylindrical', 'z', {'dcyl': 2.0}, id='slab-stack'),
|
|
470
|
+
],
|
|
471
|
+
)
|
|
472
|
+
def test_number_density_of_an_ideal_gas_is_flat(make_uniform_counts, geometry,
|
|
473
|
+
direction, kwargs):
|
|
474
|
+
"""Uniform points give ``rho = N / V`` flat to counting noise (A5)."""
|
|
475
|
+
edges = np.arange(0.0, 5.01, 0.5) if direction == 'r' else \
|
|
476
|
+
np.arange(-2.5, 2.51, 0.5)
|
|
477
|
+
n_points = 200_000
|
|
478
|
+
counts = make_uniform_counts(geometry, direction, edges, n_points,
|
|
479
|
+
seed=20261103)
|
|
480
|
+
volumes = bin_volumes(edges, geometry, direction, **kwargs)
|
|
481
|
+
rho = number_density(counts, volumes, n_particles=n_points)
|
|
482
|
+
bulk = n_points / volumes.sum()
|
|
483
|
+
# Five Poisson standard deviations of each bin's count, per volume.
|
|
484
|
+
tolerance = 5 * np.sqrt(bulk * volumes) / volumes
|
|
485
|
+
assert np.all(np.abs(rho - bulk) <= tolerance)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_number_density_divides_by_frames_and_volume():
|
|
489
|
+
"""``rho`` is the count per frame per bin volume (A1)."""
|
|
490
|
+
rho = number_density([6, 12], [2.0, 3.0], n_frames=3)
|
|
491
|
+
assert_allclose(rho, [1.0, 4.0 / 3.0], rtol=1e-15) # round-off
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def test_number_density_treats_rows_as_frames():
|
|
495
|
+
"""A ``(n_frames, n_bins)`` input gives each row's own density (P)."""
|
|
496
|
+
counts = np.array([[2, 4], [6, 0]])
|
|
497
|
+
rho = number_density(counts, [1.0, 2.0])
|
|
498
|
+
assert_allclose(rho, [[2.0, 2.0], [6.0, 0.0]], rtol=1e-15)
|
|
499
|
+
assert_allclose(rho[1], number_density(counts[1], [1.0, 2.0]),
|
|
500
|
+
rtol=1e-15)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_number_density_raises_when_a_bead_is_missing():
|
|
504
|
+
"""Counts not summing to ``n_frames * n_particles`` raise ValueError."""
|
|
505
|
+
with pytest.raises(ValueError, match='a bead was lost'):
|
|
506
|
+
number_density([2, 3], [1.0, 1.0], n_frames=1, n_particles=6)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@pytest.mark.parametrize(
|
|
510
|
+
'counts, volumes, kwargs, message',
|
|
511
|
+
[
|
|
512
|
+
pytest.param([[[1]]], [1.0], {}, 'one- or two-dimensional',
|
|
513
|
+
id='3-d-counts'),
|
|
514
|
+
pytest.param([1, 2], [1.0], {}, 'volumes must have shape',
|
|
515
|
+
id='shape-mismatch'),
|
|
516
|
+
pytest.param([1, -2], [1.0, 1.0], {}, 'non-negative',
|
|
517
|
+
id='negative-count'),
|
|
518
|
+
pytest.param([1, 2], [1.0, 0.0], {}, 'volumes must be positive',
|
|
519
|
+
id='zero-volume'),
|
|
520
|
+
pytest.param([1, 2], [1.0, 1.0], {'n_frames': 0},
|
|
521
|
+
'n_frames must be positive', id='zero-frames'),
|
|
522
|
+
],
|
|
523
|
+
)
|
|
524
|
+
def test_number_density_rejects_bad_input(counts, volumes, kwargs, message):
|
|
525
|
+
"""Mismatched shapes, negative counts, or bad volumes raise ValueError."""
|
|
526
|
+
with pytest.raises(ValueError, match=message):
|
|
527
|
+
number_density(counts, volumes, **kwargs)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_volume_fraction_treats_rows_as_frames():
|
|
531
|
+
"""A ``(n_frames, n_bins)`` density gives each row its own ``phi`` (P)."""
|
|
532
|
+
share = share_matrix([0.0, 1.0, 2.0, 3.0, 4.0], 1.0, 'cylindrical', 'z')
|
|
533
|
+
rho = np.array([[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0]])
|
|
534
|
+
phi = volume_fraction(rho, share)
|
|
535
|
+
assert phi.shape == (2, 4)
|
|
536
|
+
assert_allclose(phi[1], volume_fraction(rho[1], share),
|
|
537
|
+
atol=FLOAT64_ATOL)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@pytest.mark.parametrize(
|
|
541
|
+
'rho, message',
|
|
542
|
+
[
|
|
543
|
+
pytest.param(np.ones((2, 2, 4)), 'one- or two-dimensional',
|
|
544
|
+
id='3-d-rho'),
|
|
545
|
+
pytest.param(np.ones(3), 'has 3 bins but share has 4',
|
|
546
|
+
id='bin-mismatch'),
|
|
547
|
+
],
|
|
548
|
+
)
|
|
549
|
+
def test_volume_fraction_rejects_bad_input(rho, message):
|
|
550
|
+
"""A density of the wrong rank or length raises ValueError."""
|
|
551
|
+
share = share_matrix([0.0, 1.0, 2.0, 3.0, 4.0], 1.0, 'cylindrical', 'z')
|
|
552
|
+
with pytest.raises(ValueError, match=message):
|
|
553
|
+
volume_fraction(rho, share)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
# normalize_profile (T12)
|
|
558
|
+
# ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@pytest.mark.parametrize(
|
|
562
|
+
'mode, kwargs, expected',
|
|
563
|
+
[
|
|
564
|
+
pytest.param('none', {}, [1.0, 3.0], id='none'),
|
|
565
|
+
pytest.param('sum', {}, [0.25, 0.75], id='sum'),
|
|
566
|
+
pytest.param('integral', {'volumes': [2.0, 2.0]}, [0.125, 0.375],
|
|
567
|
+
id='integral'),
|
|
568
|
+
],
|
|
569
|
+
)
|
|
570
|
+
def test_normalize_profile_modes(mode, kwargs, expected):
|
|
571
|
+
"""``'sum'`` sums to 1, ``'integral'`` integrates to 1 against the volumes,
|
|
572
|
+
and ``'none'`` returns the input (A1)."""
|
|
573
|
+
out = normalize_profile([1.0, 3.0], mode, **kwargs)
|
|
574
|
+
assert_allclose(out, expected, rtol=1e-15) # round-off
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_normalize_profile_normalizes_each_row():
|
|
578
|
+
"""A two-dimensional profile is normalized row by row (P)."""
|
|
579
|
+
out = normalize_profile([[1.0, 3.0], [2.0, 2.0]], 'sum')
|
|
580
|
+
assert_allclose(out, [[0.25, 0.75], [0.5, 0.5]], rtol=1e-15)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@pytest.mark.parametrize(
|
|
584
|
+
'profile, mode, kwargs, message',
|
|
585
|
+
[
|
|
586
|
+
pytest.param([1.0], 'max', {}, 'mode must be one of', id='bad-mode'),
|
|
587
|
+
pytest.param([1.0], 'integral', {}, 'volumes are needed',
|
|
588
|
+
id='missing-volumes'),
|
|
589
|
+
pytest.param([1.0, 2.0], 'integral', {'volumes': [1.0]},
|
|
590
|
+
'volumes must have shape', id='volume-shape'),
|
|
591
|
+
pytest.param([0.0, 0.0], 'sum', {}, 'zero normalizer',
|
|
592
|
+
id='zero-sum'),
|
|
593
|
+
pytest.param([[[1.0]]], 'sum', {}, 'one- or two-dimensional',
|
|
594
|
+
id='3-d-profile'),
|
|
595
|
+
],
|
|
596
|
+
)
|
|
597
|
+
def test_normalize_profile_rejects_bad_input(profile, mode, kwargs, message):
|
|
598
|
+
"""A bad mode, missing volumes, or a zero normalizer raises ValueError."""
|
|
599
|
+
with pytest.raises(ValueError, match=message):
|
|
600
|
+
normalize_profile(profile, mode, **kwargs)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ---------------------------------------------------------------------------
|
|
604
|
+
# density_profiles (D4, D5, T12)
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def test_density_profiles_uses_the_effective_slab_diameter():
|
|
609
|
+
"""Along ``z`` the slab area is ``pi (D - a)**2 / 4``, so three beads of
|
|
610
|
+
diameter 1 per unit slab in ``D = 3`` give ``rho = 3 / pi`` and
|
|
611
|
+
``phi = 1/2`` (A1)."""
|
|
612
|
+
out = density_profiles([3, 3, 3, 3], [0.0, 1.0, 2.0, 3.0, 4.0], 1.0,
|
|
613
|
+
'cylindrical', 'z', dcyl=3.0, n_particles=12)
|
|
614
|
+
assert isinstance(out, Profiles)
|
|
615
|
+
assert_allclose(out.volumes, np.pi, rtol=1e-15)
|
|
616
|
+
assert_allclose(out.rho, 3 / np.pi, rtol=1e-15)
|
|
617
|
+
assert_allclose(out.phi, 0.5, rtol=1e-14)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def test_density_profiles_divides_by_frames():
|
|
621
|
+
"""Counts summed over ``n_frames`` give the per-frame density (A1)."""
|
|
622
|
+
out = density_profiles([6, 6, 6, 6], [0.0, 1.0, 2.0, 3.0, 4.0], 1.0,
|
|
623
|
+
'cylindrical', 'z', dcyl=3.0, n_frames=2)
|
|
624
|
+
assert_allclose(out.rho, 3 / np.pi, rtol=1e-15)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def test_density_profiles_integral_normalization_integrates_to_one():
|
|
628
|
+
"""``normalize='integral'`` makes both profiles integrate to 1 (A1)."""
|
|
629
|
+
out = density_profiles([1, 4, 2, 5], [0.0, 1.0, 2.0, 3.0, 4.0], 1.0,
|
|
630
|
+
'cylindrical', 'z', dcyl=3.0, normalize='integral')
|
|
631
|
+
assert (out.rho * out.volumes).sum() == pytest.approx(1.0, rel=1e-14)
|
|
632
|
+
assert (out.phi * out.volumes).sum() == pytest.approx(1.0, rel=1e-14)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def test_density_profiles_rejects_a_bead_wider_than_the_cylinder():
|
|
636
|
+
"""``dcyl`` no larger than the bead diameter raises ValueError."""
|
|
637
|
+
with pytest.raises(ValueError, match='must exceed the bead diameter'):
|
|
638
|
+
density_profiles([1, 1], [0.0, 1.0, 2.0], 1.0, 'cylindrical', 'z',
|
|
639
|
+
dcyl=1.0)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# Input validation shared by the kernels (T14)
|
|
644
|
+
# ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@pytest.mark.parametrize(
|
|
648
|
+
'edges, geometry, direction, kwargs, message',
|
|
649
|
+
[
|
|
650
|
+
pytest.param([1.0], 'cubic', 'r', {}, 'at least two values',
|
|
651
|
+
id='one-edge'),
|
|
652
|
+
pytest.param([[0.0, 1.0]], 'cubic', 'r', {}, 'one-dimensional',
|
|
653
|
+
id='2-d-edges'),
|
|
654
|
+
pytest.param([0.0, np.inf], 'cubic', 'r', {}, 'finite',
|
|
655
|
+
id='infinite-edge'),
|
|
656
|
+
pytest.param([0.0, 1.0, 1.0], 'cubic', 'r', {}, 'strictly increasing',
|
|
657
|
+
id='repeated-edge'),
|
|
658
|
+
pytest.param([-1.0, 1.0], 'cubic', 'r', {}, 'cannot be negative',
|
|
659
|
+
id='negative-radius'),
|
|
660
|
+
pytest.param([0.0, 1.0], 'cubic', 'z', {}, 'is not supported',
|
|
661
|
+
id='cubic-z'),
|
|
662
|
+
pytest.param([0.0, 1.0], 'slit', 'r', {}, 'is not supported',
|
|
663
|
+
id='slit'),
|
|
664
|
+
pytest.param([0.0, 1.0], 'cylindrical', 'r', {}, 'lcyl is needed',
|
|
665
|
+
id='missing-lcyl'),
|
|
666
|
+
pytest.param([0.0, 1.0], 'cylindrical', 'z', {'dcyl': -2.0},
|
|
667
|
+
'dcyl must be positive', id='negative-dcyl'),
|
|
668
|
+
],
|
|
669
|
+
)
|
|
670
|
+
def test_bin_volumes_rejects_bad_input(edges, geometry, direction, kwargs,
|
|
671
|
+
message):
|
|
672
|
+
"""Bad edges, an unsupported pair, or no box length raise ValueError."""
|
|
673
|
+
with pytest.raises(ValueError, match=message):
|
|
674
|
+
bin_volumes(edges, geometry, direction, **kwargs)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@pytest.mark.parametrize(
|
|
678
|
+
'bead_diameter, direction, kwargs, message',
|
|
679
|
+
[
|
|
680
|
+
pytest.param(0.0, 'r', {}, 'bead_diameter must be positive',
|
|
681
|
+
id='zero-bead'),
|
|
682
|
+
pytest.param(5.0, 'z', {}, 'does not fit the periodic length',
|
|
683
|
+
id='bead-wider-than-period'),
|
|
684
|
+
pytest.param(1.0, 'r', {'shares': 'mean'}, 'shares must be one of',
|
|
685
|
+
id='bad-shares'),
|
|
686
|
+
pytest.param(1.0, 'r', {'intersection': 'plane'},
|
|
687
|
+
'intersection must be one of', id='bad-intersection'),
|
|
688
|
+
],
|
|
689
|
+
)
|
|
690
|
+
def test_share_matrix_rejects_bad_input(bead_diameter, direction, kwargs,
|
|
691
|
+
message):
|
|
692
|
+
"""A bad bead size or an unknown mode raises ValueError."""
|
|
693
|
+
with pytest.raises(ValueError, match=message):
|
|
694
|
+
share_matrix([0.0, 1.0, 2.0, 3.0, 4.0], bead_diameter, 'cylindrical',
|
|
695
|
+
direction, **kwargs)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def test_share_matrix_returns_a_sparse_band():
|
|
699
|
+
"""The share matrix is sparse, with about ``a / h + 2`` entries per row."""
|
|
700
|
+
edges = np.arange(-50.0, 50.01, 0.1)
|
|
701
|
+
share = share_matrix(edges, 1.0, 'cylindrical', 'z')
|
|
702
|
+
assert share.shape == (1000, 1000)
|
|
703
|
+
assert share.nnz <= 1000 * 14
|
|
704
|
+
assert_array_equal(np.diff(share.indptr) >= 11, True)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
# Tier C: the research code's output on one real whole (written blind)
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@pytest.mark.private_data # pragma: no cover - needs data
|
|
713
|
+
@pytest.mark.parametrize(
|
|
714
|
+
('direction', 'research_scale'),
|
|
715
|
+
[
|
|
716
|
+
pytest.param('r', lambda meta: 1.0, id='r'),
|
|
717
|
+
# R3: the research code's z slabs have lcyl, not dcyl, as diameter.
|
|
718
|
+
pytest.param('z', lambda meta: (meta['dcyl'] / meta['lcyl']) ** 2,
|
|
719
|
+
id='z'),
|
|
720
|
+
],
|
|
721
|
+
)
|
|
722
|
+
def test_center_sphere_profiles_match_the_research_code(
|
|
723
|
+
private_data_dir, direction, research_scale
|
|
724
|
+
):
|
|
725
|
+
"""The research code's ``rho`` and ``phi`` of one SciOps whole equal the
|
|
726
|
+
center-sphere ones times its known axial slab factor (R1, R3).
|
|
727
|
+
|
|
728
|
+
Expected layout under
|
|
729
|
+
``POLYPHYS_DATA_DIR/TransFociCyl/golden/density/<direction>/``,
|
|
730
|
+
prepared by the maintainer from one whole: ``edges.npy`` and
|
|
731
|
+
``counts.npy`` (the whole's bin edges and summed histogram),
|
|
732
|
+
``rho_sciops.npy`` and ``phi_sciops.npy`` (its ``distributions_generator``
|
|
733
|
+
output), and ``meta.json`` with ``geometry``, ``direction``,
|
|
734
|
+
``bead_diameter``, ``lcyl``, and ``dcyl``. The research code divided by
|
|
735
|
+
the quadrature of a polynomial, never by the number of frames, and used
|
|
736
|
+
the sphere-sphere kernel, which is how the kernels are called here.
|
|
737
|
+
"""
|
|
738
|
+
folder = (private_data_dir / 'TransFociCyl' / 'golden' / 'density'
|
|
739
|
+
/ direction)
|
|
740
|
+
meta = json.loads((folder / 'meta.json').read_text())
|
|
741
|
+
edges = np.load(folder / 'edges.npy')
|
|
742
|
+
counts = np.load(folder / 'counts.npy')
|
|
743
|
+
volumes = bin_volumes(
|
|
744
|
+
edges, meta['geometry'], meta['direction'],
|
|
745
|
+
lcyl=meta['lcyl'], dcyl=meta['dcyl'],
|
|
746
|
+
)
|
|
747
|
+
share = share_matrix(edges, meta['bead_diameter'], meta['geometry'],
|
|
748
|
+
meta['direction'], shares='center',
|
|
749
|
+
intersection='sphere')
|
|
750
|
+
scale = research_scale(meta)
|
|
751
|
+
rho = number_density(counts, volumes)
|
|
752
|
+
phi = volume_fraction(rho, share)
|
|
753
|
+
# scipy's quadrature of a polynomial agrees with the closed form to
|
|
754
|
+
# about 1e-10 relative.
|
|
755
|
+
assert_allclose(scale * rho, np.load(folder / 'rho_sciops.npy'),
|
|
756
|
+
rtol=1e-8)
|
|
757
|
+
assert_allclose(scale * phi, np.load(folder / 'phi_sciops.npy'),
|
|
758
|
+
rtol=1e-8)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# ---------------------------------------------------------------------------
|
|
762
|
+
# share_matrix: generated grids (P)
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@pytest.mark.parametrize('geometry, shares', [
|
|
767
|
+
pytest.param('cubic', 'average', id='spherical-average'),
|
|
768
|
+
pytest.param('cubic', 'center', id='spherical-center'),
|
|
769
|
+
pytest.param('cylindrical', 'average', id='cylindrical-average'),
|
|
770
|
+
pytest.param('cylindrical', 'center', id='cylindrical-center'),
|
|
771
|
+
])
|
|
772
|
+
@settings(max_examples=50, deadline=None)
|
|
773
|
+
@given(n_bins=st.integers(min_value=4, max_value=40),
|
|
774
|
+
width=st.floats(min_value=0.1, max_value=2.0),
|
|
775
|
+
diameter=st.floats(min_value=0.2, max_value=3.0))
|
|
776
|
+
def test_share_matrix_radial_rows_conserve_a_generated_bead_volume(
|
|
777
|
+
geometry, shares, n_bins, width, diameter):
|
|
778
|
+
"""On a generated radial grid, every bin whose bead stays inside the
|
|
779
|
+
last edge shares out exactly the bead volume: volume conservation, with
|
|
780
|
+
no reference value."""
|
|
781
|
+
edges = width * np.arange(n_bins + 1)
|
|
782
|
+
inside = edges[1:] + diameter / 2 <= edges[-1]
|
|
783
|
+
assume(inside.any())
|
|
784
|
+
|
|
785
|
+
share = share_matrix(edges, diameter, geometry, 'r', shares=shares)
|
|
786
|
+
|
|
787
|
+
row_sums = np.asarray(share.sum(axis=1)).ravel()
|
|
788
|
+
# The exact cylinder kernel is a quadrature: 1e-8 of a bead volume.
|
|
789
|
+
assert_allclose(row_sums[inside], _bead_volume(diameter), rtol=1e-8)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@pytest.mark.parametrize('shares', [
|
|
793
|
+
pytest.param('average', id='slab-average'),
|
|
794
|
+
pytest.param('center', id='slab-center'),
|
|
795
|
+
])
|
|
796
|
+
@settings(max_examples=50, deadline=None)
|
|
797
|
+
@given(n_bins=st.integers(min_value=4, max_value=40),
|
|
798
|
+
width=st.floats(min_value=0.1, max_value=2.0),
|
|
799
|
+
diameter=st.floats(min_value=0.2, max_value=3.0))
|
|
800
|
+
def test_share_matrix_slab_rows_conserve_a_generated_bead_volume(
|
|
801
|
+
shares, n_bins, width, diameter):
|
|
802
|
+
"""On a generated periodic axis of a cylinder, every bin shares out
|
|
803
|
+
exactly the bead volume through the periodic images: volume
|
|
804
|
+
conservation, with no reference value."""
|
|
805
|
+
length = n_bins * width
|
|
806
|
+
assume(diameter < length)
|
|
807
|
+
edges = width * np.arange(n_bins + 1) - length / 2
|
|
808
|
+
|
|
809
|
+
share = share_matrix(edges, diameter, 'cylindrical', 'z', shares=shares)
|
|
810
|
+
|
|
811
|
+
row_sums = np.asarray(share.sum(axis=1)).ravel()
|
|
812
|
+
# Closed-form slab kernels: float64 round-off over about 30 bins.
|
|
813
|
+
assert_allclose(row_sums, _bead_volume(diameter), rtol=1e-10)
|