polyphys 1.0.0__py3-none-any.whl

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