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,779 @@
1
+ """Tests for :mod:`polyphys.probe.histograms`.
2
+
3
+ The inputs are tier A: two species of hand-placed beads in a box of side 20
4
+ about (10, 10, 10), written as a data file and a dump by the
5
+ ``make_chain_universe`` fixture, binned with hand edges of unit or
6
+ half-unit width. Expected values are analytic: the bin of each bead by
7
+ hand from its recentered coordinates; the conservation of each species'
8
+ particle number per frame; the per-block means from the block sums
9
+ divided by the frame counts; and the edges of ``histogram_edges`` from the
10
+ research code's bin sizes over the ranges the maintainer set on
11
+ 2026-11-04 (SciOps ``sciops/probe/prober.py``, ``sum_rule_cyl_all`` and
12
+ ``trans_foci_cub_all``, extended to the wall surface and the cube's
13
+ corner).
14
+
15
+ The real-frame tests bin the tier B and tier C frames of the studies'
16
+ wholes (``polyphys/tests/probe/conftest.py``, "Real frames") with each
17
+ study's own probe spec and parser. Their expected values are conservation:
18
+ every position of a real frame falls inside the edges, so each frame's
19
+ counts sum to the species' particle number in the whole's name, and the run's
20
+ counts to the number of frames times it.
21
+ """
22
+ import numpy as np
23
+ import pytest
24
+ from numpy.testing import (
25
+ assert_allclose,
26
+ assert_array_equal,
27
+ assert_equal,
28
+ )
29
+
30
+ from polyphys.tests.synthetic import ChainCyl, RingCub
31
+ from polyphys.spec import ProbeSpec, ProjectSpec
32
+
33
+ pytest.importorskip("MDAnalysis")
34
+
35
+ from polyphys.probe import SpatialHistograms, histogram_edges # noqa: E402
36
+
37
+ # The dump reader warns that the frames carry no time step; the tests
38
+ # measure per frame and give no dt.
39
+ pytestmark = pytest.mark.filterwarnings(
40
+ "ignore:Reader has no dt information:UserWarning")
41
+
42
+ # Tolerance for the per-block means and the edges, both a few float64
43
+ # operations on exact values.
44
+ RTOL = 1e-12
45
+ ATOL = 1e-12
46
+
47
+
48
+ @pytest.fixture
49
+ def center() -> float:
50
+ """The box center along each axis, for a box from 0 to 20."""
51
+ return 10.0
52
+
53
+
54
+ @pytest.fixture
55
+ def two_species(center) -> np.ndarray:
56
+ """Five frames of six beads: beads 1-3 (type 1, ``Mon``) and 4-6 (type 2,
57
+ ``Crd``), the first of each species moved 0.5 along z per frame.
58
+
59
+ About the center ``c``: bead 1 at ``(c + 2.5, c, c)``, bead 2 at
60
+ ``(c, c + 1, c + 3.2)``, bead 3 at ``(c - 1.5, c - 1.5, c - 2)``, bead 4
61
+ at ``(c + 0.5, c + 0.5, c + 0.5)``, bead 5 at ``(c - 3, c, c - 7)``, and
62
+ bead 6 at ``(c, c - 2.5, c + 6)``.
63
+ """
64
+ first = np.array([
65
+ [2.5, 0.0, 0.0], [0.0, 1.0, 3.2], [-1.5, -1.5, -2.0],
66
+ [0.5, 0.5, 0.5], [-3.0, 0.0, -7.0], [0.0, -2.5, 6.0]]) + center
67
+ frames = np.stack([first.copy() for _ in range(5)])
68
+ frames[:, 0, 2] += 0.5 * np.arange(5)
69
+ frames[:, 3, 2] += 0.5 * np.arange(5)
70
+ return frames
71
+
72
+
73
+ @pytest.fixture
74
+ def types() -> list[int]:
75
+ """The atom type of each of the six beads."""
76
+ return [1, 1, 1, 2, 2, 2]
77
+
78
+
79
+ @pytest.fixture
80
+ def cylinder_edges() -> dict[str, np.ndarray]:
81
+ """Hand edges of a cylinder of radius 4 and length 20: unit bins along
82
+ r, x, and y, half-unit bins along z, 72 bins along theta."""
83
+ return {
84
+ 'r': np.arange(0.0, 4.5, 1.0),
85
+ 'z': np.linspace(-10.0, 10.0, 41),
86
+ 'theta': np.linspace(-np.pi, np.pi, 73),
87
+ 'x': np.arange(-4.0, 4.5, 1.0),
88
+ 'y': np.arange(-4.0, 4.5, 1.0),
89
+ }
90
+
91
+
92
+ @pytest.fixture
93
+ def cube_edges() -> dict[str, np.ndarray]:
94
+ """Hand edges of a cube of side 20: unit bins along r to the corner and
95
+ along x, y, and z."""
96
+ return {
97
+ 'r': np.arange(0.0, 18.5, 1.0),
98
+ 'x': np.arange(-10.0, 10.5, 1.0),
99
+ 'y': np.arange(-10.0, 10.5, 1.0),
100
+ 'z': np.arange(-10.0, 10.5, 1.0),
101
+ }
102
+
103
+
104
+ @pytest.fixture
105
+ def make_analysis(make_chain_universe, two_species, types, cylinder_edges):
106
+ """Factory: the cylinder analysis of the two species, with any argument
107
+ overridden; `positions` replaces the frames."""
108
+
109
+ def _make(positions=None, **overrides):
110
+ universe = make_chain_universe(
111
+ two_species if positions is None else positions,
112
+ atom_types=types, masses=(1.0, 1.0))
113
+ groups = {'Mon': universe.select_atoms('type 1'),
114
+ 'Crd': universe.select_atoms('type 2')}
115
+ kwargs = {'geometry': 'cylindrical', 'block_frames': 2,
116
+ 'recenter': 'half'}
117
+ kwargs.update(overrides)
118
+ return SpatialHistograms(groups, cylinder_edges, **kwargs)
119
+
120
+ return _make
121
+
122
+
123
+ # Hand bins
124
+
125
+
126
+ @pytest.mark.parametrize('key, filled', [
127
+ pytest.param('r', {1: 1, 2: 2}, id='r-1-2.5-and-2.12'),
128
+ pytest.param('theta', {36: 1, 54: 1, 9: 1},
129
+ id='theta-0-pi-over-2-and-minus-3pi-over-4'),
130
+ pytest.param('z', {20: 1, 26: 1, 16: 1}, id='z-0-3.2-and-minus-2'),
131
+ ])
132
+ def test_histograms_bin_each_bead_by_its_recentered_coordinate(
133
+ make_analysis, key, filled):
134
+ """In frame 0 the three Mon beads, at (2.5, 0, 0), (0, 1, 3.2), and
135
+ (-1.5, -1.5, -2) from the center, fill the r, theta, and z bins
136
+ computed by hand from the edges and nothing else."""
137
+ analysis = make_analysis().run()
138
+
139
+ row = analysis.results[f'{key}HistTMon'][0]
140
+
141
+ expected = np.zeros(row.size, dtype=np.int64)
142
+ for index, count in filled.items():
143
+ expected[index] = count
144
+ assert_array_equal(row, expected)
145
+
146
+
147
+ def test_histogram_t_tracks_a_moving_bead(make_analysis):
148
+ """A bead moved 0.5 along z per frame moves one z bin per frame, so the
149
+ per-frame rows differ as the bead does."""
150
+ analysis = make_analysis().run()
151
+
152
+ rows = analysis.results['zHistTMon']
153
+
154
+ assert_array_equal(rows[:, 20:25].diagonal(), np.ones(5, dtype=int))
155
+
156
+
157
+ @pytest.mark.parametrize('plane, cell, mean', [
158
+ pytest.param('xy', (6, 4), 1.0, id='xy-of-(2.5,0)'),
159
+ pytest.param('xz', (6, 20), 0.5, id='xz-of-(2.5,0)'),
160
+ pytest.param('yz', (4, 20), 0.5, id='yz-of-(2.5,0)'),
161
+ ])
162
+ def test_maps_put_a_bead_in_its_cell_with_rows_along_the_first_axis(
163
+ make_analysis, plane, cell, mean):
164
+ """Bead 1 at (2.5, 0, 0) falls in the cell indexed by its first-axis
165
+ coordinate then its second, in the first block's mean map: in both
166
+ frames of the block in the xy plane, in frame 0 only in the planes with
167
+ z, where it moves from the half-unit bin [0, 0.5) to the next."""
168
+ analysis = make_analysis().run()
169
+
170
+ block = analysis.results[f'{plane}HistBlockMon'][0]
171
+
172
+ assert block[cell] == pytest.approx(mean, abs=ATOL)
173
+
174
+
175
+ # Conservation and consistency
176
+
177
+
178
+ @pytest.mark.parametrize('key', ['r', 'z', 'theta'])
179
+ def test_each_frame_conserves_the_particle_number(make_analysis, key):
180
+ """Every frame's counts along a direction sum to the species' three
181
+ beads, for both species."""
182
+ analysis = make_analysis().run()
183
+
184
+ assert_array_equal(analysis.results[f'{key}HistTMon'].sum(axis=1),
185
+ np.full(5, 3))
186
+ assert_array_equal(analysis.results[f'{key}HistTCrd'].sum(axis=1),
187
+ np.full(5, 3))
188
+
189
+
190
+ @pytest.mark.parametrize('key', ['r', 'z', 'theta'])
191
+ def test_summed_histogram_is_the_column_sum_of_the_frames(make_analysis, key):
192
+ """The frame-summed histogram equals the per-frame rows summed down the
193
+ columns, int64."""
194
+ analysis = make_analysis().run()
195
+
196
+ summed = analysis.results[f'{key}HistCrd']
197
+
198
+ assert summed.dtype == np.int64
199
+ assert_array_equal(summed, analysis.results[f'{key}HistTCrd'].sum(axis=0))
200
+
201
+
202
+ @pytest.mark.parametrize('plane', ['xy', 'xz', 'yz'])
203
+ def test_block_means_times_block_frames_give_the_summed_map(make_analysis,
204
+ plane):
205
+ """Each block's mean map times its frame count, summed over the blocks,
206
+ is the frame-summed map, whose total is the beads times the frames."""
207
+ analysis = make_analysis().run()
208
+
209
+ means = analysis.results[f'{plane}HistBlockMon']
210
+ counts = analysis.results['blockFrames']
211
+
212
+ assert_array_equal(counts, [2, 2, 1])
213
+ assert_allclose((means * counts[:, None, None]).sum(axis=0),
214
+ analysis.results[f'{plane}HistMon'], rtol=RTOL, atol=ATOL)
215
+ assert analysis.results[f'{plane}HistMon'].sum() == pytest.approx(
216
+ 15.0, abs=ATOL)
217
+
218
+
219
+ def test_blocks_follow_the_frames_of_the_run(make_analysis):
220
+ """A run over four of the five frames with blocks of two gives two full
221
+ blocks, numbered over the frames visited."""
222
+ analysis = make_analysis().run(start=1, stop=5)
223
+
224
+ assert_array_equal(analysis.results['blockFrames'], [2, 2])
225
+ assert analysis.results['xyHistBlockMon'].shape == (2, 8, 8)
226
+
227
+
228
+ def test_parallel_run_matches_serial(make_analysis):
229
+ """Two workers over two parts, the split falling inside a block, give
230
+ the same per-frame rows, block means, and frame counts as one worker."""
231
+ serial = make_analysis().run()
232
+ parallel = make_analysis().run(backend='multiprocessing', n_workers=2,
233
+ n_parts=2)
234
+
235
+ for key in serial.results:
236
+ assert_allclose(parallel.results[key], serial.results[key],
237
+ rtol=RTOL, atol=ATOL)
238
+
239
+
240
+ @pytest.mark.parametrize('n_parts', [
241
+ pytest.param(2, id='two-parts'),
242
+ pytest.param(3, id='three-parts'),
243
+ ])
244
+ def test_serial_parts_match_the_multiprocessing_run(make_analysis, n_parts):
245
+ """The serial backend split into parts gives the frames, times,
246
+ per-frame rows, block means, and frame counts of the multiprocessing
247
+ backend over the same parts: the result does not depend on the
248
+ backend."""
249
+ parallel = make_analysis().run(backend='multiprocessing', n_workers=2,
250
+ n_parts=n_parts)
251
+
252
+ serial = make_analysis().run(backend='serial', n_parts=n_parts)
253
+
254
+ assert_array_equal(serial.frames, parallel.frames)
255
+ assert_array_equal(serial.times, parallel.times)
256
+ assert_equal(dict(serial.results), dict(parallel.results))
257
+
258
+
259
+ def test_empty_group_gives_zero_histograms(make_chain_universe, two_species,
260
+ types, cylinder_edges):
261
+ """A species a whole lacks yields all-zero rows of the right shape."""
262
+ universe = make_chain_universe(two_species, atom_types=types,
263
+ masses=(1.0, 1.0))
264
+ groups = {'Mon': universe.select_atoms('type 1'),
265
+ 'Hns': universe.select_atoms('type 3')}
266
+
267
+ analysis = SpatialHistograms(groups, cylinder_edges,
268
+ geometry='cylindrical', block_frames=2).run()
269
+
270
+ assert_array_equal(analysis.results['rHistTHns'], np.zeros((5, 4), int))
271
+ assert_array_equal(analysis.results['rHistTMon'].sum(axis=1),
272
+ np.full(5, 3))
273
+
274
+
275
+ # Recentering
276
+
277
+
278
+ @pytest.mark.parametrize('recenter', [
279
+ pytest.param('half', id='half-the-box'),
280
+ pytest.param((10.0, 10.0, 10.0), id='the-same-triple'),
281
+ ])
282
+ def test_recenter_half_and_its_triple_agree(make_analysis, recenter):
283
+ """Half of a box of side 20 and the triple (10, 10, 10) give the same
284
+ bins: the Mon r row [0, 1, 2, 0]."""
285
+ analysis = make_analysis(recenter=recenter).run()
286
+
287
+ assert_array_equal(analysis.results['rHistTMon'][0], [0, 1, 2, 0])
288
+
289
+
290
+ def test_recenter_half_undoes_the_readers_translation(
291
+ make_chain_universe, cylinder_edges):
292
+ """For a box from -10 to 10, which the dump reader moves to the origin,
293
+ 'half' restores the written coordinates: a bead written at x = 2.5 is
294
+ in r bin 2 and x bin 6."""
295
+ positions = np.array([[[2.5, 0.0, 0.0], [0.0, 1.0, 0.0]]])
296
+ universe = make_chain_universe(positions, box=((-10.0, 10.0),) * 3)
297
+
298
+ analysis = SpatialHistograms(
299
+ {'Mon': universe.atoms}, cylinder_edges, geometry='cylindrical',
300
+ block_frames=1, recenter='half').run()
301
+
302
+ assert analysis.results['rHistTMon'][0, 2] == 1
303
+ assert analysis.results['xyHistBlockMon'][0, 6, 4] == pytest.approx(
304
+ 1.0, abs=ATOL)
305
+
306
+
307
+ def test_recenter_none_keeps_the_readers_frame(make_chain_universe,
308
+ cube_edges):
309
+ """With 'none', positions stay in the reader's frame, from 0 to 20, so
310
+ a bead written at (12.5, 10, 10) lies beyond cube edges centered on the
311
+ origin and the run raises instead of binning it."""
312
+ positions = np.array([[[12.5, 10.0, 10.0], [10.0, 10.0, 10.0]]])
313
+ universe = make_chain_universe(positions)
314
+
315
+ analysis = SpatialHistograms(
316
+ {'Mon': universe.atoms}, cube_edges, geometry='cubic',
317
+ block_frames=1, recenter='none')
318
+
319
+ with pytest.raises(ValueError, match="positions fall outside"):
320
+ analysis.run()
321
+
322
+
323
+ # Out-of-range positions
324
+
325
+
326
+ def test_position_outside_a_direction_raises(make_analysis, two_species):
327
+ """A bead beyond the last r edge aborts the run, naming the count."""
328
+ positions = two_species.copy()
329
+ positions[0, 0, 0] = 10.0 + 4.5 # r = 4.5 > 4
330
+
331
+ with pytest.raises(ValueError, match="1 of 3 positions fall outside"):
332
+ make_analysis(positions=positions).run()
333
+
334
+
335
+ def test_position_outside_the_cartesian_edges_raises(
336
+ make_chain_universe, two_species, types, cylinder_edges):
337
+ """A bead inside every one-dimensional range but beyond a Cartesian
338
+ edge of the maps aborts the run, naming the axis and the frame."""
339
+ edges = dict(cylinder_edges)
340
+ edges['x'] = np.arange(-2.0, 2.5, 1.0) # bead 1 at x = 2.5 is beyond
341
+ universe = make_chain_universe(two_species, atom_types=types,
342
+ masses=(1.0, 1.0))
343
+
344
+ analysis = SpatialHistograms(
345
+ {'Mon': universe.select_atoms('type 1')}, edges,
346
+ geometry='cylindrical', block_frames=2)
347
+
348
+ with pytest.raises(ValueError,
349
+ match="1 of 3 Mon positions fall outside the x edges"):
350
+ analysis.run()
351
+
352
+
353
+ @pytest.mark.parametrize('position, axis, expected_bin', [
354
+ pytest.param([20.03, 10.0, 10.0], 'x', 0, id='beyond-hi-x'),
355
+ pytest.param([10.0, -0.02, 10.0], 'y', 19, id='below-lo-y'),
356
+ pytest.param([10.0, 10.0, 20.04], 'z', 0, id='beyond-hi-z'),
357
+ ])
358
+ def test_cube_bins_a_position_past_the_box_as_its_periodic_image(
359
+ make_chain_universe, cube_edges, position, axis, expected_bin):
360
+ """A position a little past a periodic face, as a LAMMPS dump writes
361
+ between reneighborings, is binned as its periodic image."""
362
+ positions = np.array([[position, [10.0, 10.0, 10.0]]])
363
+ universe = make_chain_universe(positions, boundary='pp pp pp')
364
+
365
+ analysis = SpatialHistograms(
366
+ {'Mon': universe.atoms}, cube_edges, geometry='cubic',
367
+ block_frames=1).run()
368
+
369
+ # One bead at the center and one at the periodic image: x = 0.03 and
370
+ # z = 0.04 fall in the first unit bin, y = 19.98 in the last.
371
+ plane = {'x': 'xyHistMon', 'y': 'xyHistMon', 'z': 'xzHistMon'}[axis]
372
+ marginal = analysis.results[plane].sum(axis={'x': 1, 'y': 0,
373
+ 'z': 0}[axis])
374
+ assert marginal[expected_bin] == 1
375
+ assert int(analysis.results['rHistTMon'].sum()) == 2
376
+
377
+
378
+ def test_frame_without_a_box_raises(cube_edges):
379
+ """A frame that carries no box cannot be wrapped, and the run says
380
+ so."""
381
+ import MDAnalysis as mda
382
+
383
+ universe = mda.Universe.empty(1, trajectory=True)
384
+ universe.atoms.positions = np.array([[1.0, 2.0, 3.0]])
385
+ analysis = SpatialHistograms({'Mon': universe.atoms}, cube_edges,
386
+ geometry='cubic', block_frames=1,
387
+ recenter='none')
388
+
389
+ with pytest.raises(ValueError, match="carries no box"):
390
+ analysis.run()
391
+
392
+
393
+ def test_cylinder_bins_a_position_past_the_axis_end_as_its_periodic_image(
394
+ make_analysis, two_species):
395
+ """A position a little past the end of the periodic cylinder axis is
396
+ binned as its periodic image, while the walls still bound x and y."""
397
+ positions = two_species.copy()
398
+ positions[0, 1, 2] = 20.03 # z past the box [0, 20]: image at 0.03
399
+
400
+ analysis = make_analysis(positions=positions).run()
401
+
402
+ # Recentered by half the box, the image is at z = -9.97, in the first
403
+ # half-unit bin of [-10, 10].
404
+ assert analysis.results['zHistTMon'][0, 0] == 1
405
+ assert int(analysis.results['zHistTMon'][0].sum()) == 3
406
+
407
+
408
+ # Cube geometry
409
+
410
+
411
+ def test_cube_bins_r_from_the_box_center(make_chain_universe, cube_edges):
412
+ """In a cube, r is the distance from the box center: a bead at
413
+ (c + 3, c + 4, c) is in r bin 5, and only r is binned."""
414
+ positions = np.array([[[13.0, 14.0, 10.0], [10.0, 10.0, 10.0]]])
415
+ universe = make_chain_universe(positions)
416
+
417
+ analysis = SpatialHistograms(
418
+ {'Mon': universe.atoms}, cube_edges, geometry='cubic',
419
+ block_frames=1).run()
420
+
421
+ assert_array_equal(analysis.results['rHistTMon'][0, [0, 5]], [1, 1])
422
+ assert 'zHistTMon' not in analysis.results
423
+
424
+
425
+ # Validation
426
+
427
+
428
+ @pytest.mark.parametrize('overrides, match', [
429
+ pytest.param({'geometry': 'spherical'}, "'geometry' must be one of",
430
+ id='unknown-geometry'),
431
+ pytest.param({'block_frames': 0}, "'block_frames' must be a positive",
432
+ id='zero-block-frames'),
433
+ pytest.param({'block_frames': 2.0}, "'block_frames' must be a positive",
434
+ id='float-block-frames'),
435
+ pytest.param({'recenter': 'center'}, "'recenter' must be",
436
+ id='unknown-recenter'),
437
+ pytest.param({'recenter': (1.0, 2.0)}, "'recenter' must be",
438
+ id='short-recenter'),
439
+ ])
440
+ def test_invalid_arguments_raise(make_analysis, overrides, match):
441
+ """A bad geometry, block length, or recentering raises at construction."""
442
+ with pytest.raises(ValueError, match=match):
443
+ make_analysis(**overrides)
444
+
445
+
446
+ @pytest.mark.parametrize('edit, match', [
447
+ pytest.param(lambda edges: edges.pop('theta'), r"lacks \['theta'\]",
448
+ id='missing-direction'),
449
+ pytest.param(lambda edges: edges.pop('x'), r"lacks \['x'\]",
450
+ id='missing-cartesian-axis'),
451
+ pytest.param(lambda edges: edges.update(r=np.array([0.0, 2.0, 1.0])),
452
+ "edges along 'r' must be", id='decreasing-edges'),
453
+ pytest.param(lambda edges: edges.update(r=np.array([0.0])),
454
+ "edges along 'r' must be", id='single-edge'),
455
+ ])
456
+ def test_invalid_edges_raise(make_chain_universe, two_species, cylinder_edges,
457
+ edit, match):
458
+ """Edges that lack a key the geometry needs, or are not increasing with
459
+ at least two values, raise at construction."""
460
+ edges = dict(cylinder_edges)
461
+ edit(edges)
462
+ universe = make_chain_universe(two_species)
463
+
464
+ with pytest.raises(ValueError, match=match):
465
+ SpatialHistograms({'Mon': universe.atoms}, edges,
466
+ geometry='cylindrical', block_frames=2)
467
+
468
+
469
+ def test_empty_groups_raise(make_chain_universe, two_species, cylinder_edges):
470
+ """No species at all raises."""
471
+ make_chain_universe(two_species)
472
+
473
+ with pytest.raises(ValueError, match="'groups' must map"):
474
+ SpatialHistograms({}, cylinder_edges, geometry='cylindrical',
475
+ block_frames=2)
476
+
477
+
478
+ def test_groups_from_two_universes_raise(make_chain_universe, two_species,
479
+ cylinder_edges):
480
+ """Species from different universes cannot share one trajectory."""
481
+ first = make_chain_universe(two_species, name='first')
482
+ second = make_chain_universe(two_species, name='second')
483
+
484
+ with pytest.raises(ValueError, match="one universe"):
485
+ SpatialHistograms({'Mon': first.atoms, 'Crd': second.atoms},
486
+ cylinder_edges, geometry='cylindrical',
487
+ block_frames=2)
488
+
489
+
490
+ # histogram_edges and from_spec
491
+
492
+
493
+ @pytest.fixture
494
+ def cylinder_spec():
495
+ """A ChainCyl probe spec with the research code's bin sizes: 0.1 of
496
+ the smaller diameter along r, x, and y, half of it along z, pi/36 along
497
+ theta."""
498
+ project = ProjectSpec(
499
+ parser=ChainCyl, group='bug', divisor=0.025,
500
+ crowder_attr='phi_bulk_c',
501
+ physical_attrs=('space', 'nmon', 'dcyl', 'dcrowd'),
502
+ species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
503
+ tseries_properties=('gyrTMon',))
504
+
505
+ def tenth(info):
506
+ return 0.1 * min(info.dmon, info.dcrowd)
507
+
508
+ def half(info):
509
+ return 0.5 * min(info.dmon, info.dcrowd)
510
+
511
+ return ProbeSpec(
512
+ project, {'Mon': 'type 1', 'Crd': 'type 2'}, 'Mon', ('gyrT',),
513
+ unwrap=False, histogram_species=('Mon', 'Crd'), block_frames=2,
514
+ bin_sizes={'r': tenth, 'z': half, 'theta': np.pi / 36, 'x': tenth,
515
+ 'y': tenth})
516
+
517
+
518
+ def test_histogram_edges_of_a_cylinder(cylinder_spec):
519
+ """For dcyl = 4 and lcyl = 20 with unit beads: r has 25 bins of 0.1 to
520
+ the wall surface 2.5, z 40 bins of 0.5 over [-10, 10], theta 72 bins
521
+ over [-pi, pi], and x and y 50 bins of 0.1 over [-2.5, 2.5]."""
522
+ whole = ChainCyl(
523
+ 'N10r2.5lz10ac1nc0dt0.005bdump1adump1ens1', 'whole', 'all')
524
+
525
+ edges = histogram_edges(cylinder_spec, whole)
526
+
527
+ assert list(edges) == ['r', 'z', 'theta', 'x', 'y']
528
+ assert_allclose(edges['r'], np.linspace(0.0, 2.5, 26), rtol=RTOL,
529
+ atol=ATOL)
530
+ assert_allclose(edges['z'], np.linspace(-10.0, 10.0, 41), rtol=RTOL,
531
+ atol=ATOL)
532
+ assert_allclose(edges['theta'], np.linspace(-np.pi, np.pi, 73),
533
+ rtol=RTOL, atol=ATOL)
534
+ assert_allclose(edges['x'], np.linspace(-2.5, 2.5, 51), rtol=RTOL,
535
+ atol=ATOL)
536
+ assert_array_equal(edges['y'], edges['x'])
537
+
538
+
539
+ def test_histogram_edges_scale_with_the_whole(cylinder_spec):
540
+ """A crowder of diameter 0.5 halves the r bin size, so the same cylinder
541
+ gets 50 r bins."""
542
+ whole = ChainCyl(
543
+ 'N10r2.5lz10ac0.5nc4dt0.005bdump1adump1ens1', 'whole',
544
+ 'all')
545
+
546
+ edges = histogram_edges(cylinder_spec, whole)
547
+
548
+ assert edges['r'].size == 51
549
+
550
+
551
+ def test_histogram_edges_of_a_cube():
552
+ """For lcube = 90: r runs to the corner, sqrt(3) * 45, in bins of 0.3,
553
+ extended upward to a whole bin; x, y, and z have 60 bins of 1.5 over
554
+ [-45, 45]."""
555
+ project = ProjectSpec(
556
+ parser=RingCub, group='bug', divisor=0.025,
557
+ crowder_attr='phi_bulk_c',
558
+ physical_attrs=('space', 'nmon_small', 'lcube'),
559
+ species_sizes={'Mon': 'dmon_small', 'Foci': 'dmon_large',
560
+ 'Crd': 'dcrowd'},
561
+ tseries_properties=('gyrTMon',))
562
+ spec = ProbeSpec(
563
+ project, {'Mon': 'resid 1', 'Crd': 'resid 0'}, 'Mon', ('gyrT',),
564
+ unwrap=True, histogram_species=('Mon',), block_frames=2,
565
+ bin_sizes={'r': 0.3, 'x': 1.5, 'y': 1.5, 'z': 1.5})
566
+ whole = RingCub(
567
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000ens4', 'whole',
568
+ 'all')
569
+
570
+ edges = histogram_edges(spec, whole)
571
+
572
+ corner = np.sqrt(3.0) * 45.0
573
+ assert list(edges) == ['r', 'x', 'y', 'z']
574
+ assert edges['r'][0] == 0.0
575
+ assert edges['r'].size == int(np.ceil(corner / 0.3)) + 1
576
+ assert edges['r'][-1] == pytest.approx(0.3 * np.ceil(corner / 0.3),
577
+ abs=1e-9)
578
+ assert_allclose(edges['z'], np.linspace(-45.0, 45.0, 61), rtol=RTOL,
579
+ atol=ATOL)
580
+
581
+
582
+ def test_histogram_edges_need_every_key(cylinder_spec):
583
+ """A spec lacking a bin size the geometry needs raises, naming it."""
584
+ spec = ProbeSpec(
585
+ cylinder_spec.project, dict(cylinder_spec.selections), 'Mon',
586
+ ('gyrT',), unwrap=False, histogram_species=('Mon',), block_frames=2,
587
+ bin_sizes={'r': 0.1, 'z': 0.5, 'theta': np.pi / 36})
588
+ whole = ChainCyl(
589
+ 'N10r2.5lz10ac1nc0dt0.005bdump1adump1ens1', 'whole', 'all')
590
+
591
+ with pytest.raises(ValueError, match=r"lacks \['x', 'y'\]"):
592
+ histogram_edges(spec, whole)
593
+
594
+
595
+ def test_from_spec_selects_the_species_and_carries_the_spec(
596
+ make_chain_universe, two_species, types, cylinder_spec):
597
+ """from_spec selects each histogram species, builds the whole's edges,
598
+ and takes the geometry, block length, and recentering from the spec."""
599
+ universe = make_chain_universe(two_species, atom_types=types,
600
+ masses=(1.0, 1.0))
601
+ whole = ChainCyl(
602
+ 'N3r2.5lz10ac1nc3dt0.005bdump1adump1ens1', 'whole', 'all')
603
+
604
+ analysis = SpatialHistograms.from_spec(universe, cylinder_spec, whole)
605
+
606
+ assert [group.n_atoms for group in analysis.groups.values()] == [3, 3]
607
+ assert list(analysis.groups) == ['Mon', 'Crd']
608
+ assert (analysis.geometry, analysis.block_frames, analysis.recenter) == (
609
+ 'cylindrical', 2, 'half')
610
+ assert analysis.edges['r'].size == 26
611
+
612
+
613
+ def test_from_spec_needs_histogram_species(make_chain_universe, two_species,
614
+ cylinder_spec):
615
+ """A spec without histogram species cannot build the analysis."""
616
+ spec = ProbeSpec(cylinder_spec.project, dict(cylinder_spec.selections),
617
+ 'Mon', ('gyrT',), unwrap=False)
618
+ universe = make_chain_universe(two_species)
619
+ whole = ChainCyl(
620
+ 'N3r2.5lz10ac1nc3dt0.005bdump1adump1ens1', 'whole', 'all')
621
+
622
+ with pytest.raises(ValueError, match="names no histogram species"):
623
+ SpatialHistograms.from_spec(universe, spec, whole)
624
+
625
+
626
+ # Saving
627
+
628
+
629
+ def test_save_writes_the_research_names(make_analysis, tmp_path):
630
+ """save writes the edges, summed and per-frame histograms per species
631
+ and direction, the Cartesian edges without a species, the summed and
632
+ per-block maps, and the block frame counts, under the research names."""
633
+ analysis = make_analysis().run()
634
+ folder = tmp_path / 'probes'
635
+ folder.mkdir()
636
+
637
+ paths = analysis.save(folder, 'N6ens1-all')
638
+
639
+ names = [path.name for path in paths]
640
+ assert names[:9] == [
641
+ 'N6ens1-all-rEdgeMon.npy', 'N6ens1-all-rHistMon.npy',
642
+ 'N6ens1-all-rHistTMon.npy', 'N6ens1-all-zEdgeMon.npy',
643
+ 'N6ens1-all-zHistMon.npy', 'N6ens1-all-zHistTMon.npy',
644
+ 'N6ens1-all-thetaEdgeMon.npy', 'N6ens1-all-thetaHistMon.npy',
645
+ 'N6ens1-all-thetaHistTMon.npy']
646
+ assert names[18:21] == ['N6ens1-all-xEdge.npy', 'N6ens1-all-yEdge.npy',
647
+ 'N6ens1-all-zEdge.npy']
648
+ assert names[21:23] == ['N6ens1-all-xyHistMon.npy',
649
+ 'N6ens1-all-xyHistBlockMon.npy']
650
+ assert names[-1] == 'N6ens1-all-blockFrames.npy'
651
+ assert len(names) == 34
652
+ assert_array_equal(np.load(paths[2]), analysis.results['rHistTMon'])
653
+ assert_array_equal(np.load(paths[-1]), [2, 2, 1])
654
+
655
+
656
+ def test_save_before_run_raises(make_analysis, tmp_path):
657
+ """save needs the results of run."""
658
+ analysis = make_analysis()
659
+
660
+ with pytest.raises(RuntimeError, match="Call run"):
661
+ analysis.save(tmp_path, 'N6ens1-all')
662
+
663
+
664
+ # Real frames
665
+
666
+
667
+ @pytest.fixture
668
+ def real_histograms(study_spec, real_universe):
669
+ """Factory: :meth:`SpatialHistograms.from_spec` run on a real dump with
670
+ its topology, with the edges of the whole's parser in `group`."""
671
+
672
+ def _run(whole, topology, trajectory, group):
673
+ module = study_spec(whole.study)
674
+ parser = module.SPEC.parser(f"{whole.whole}.ring", 'whole', group)
675
+ universe = real_universe(topology, trajectory)
676
+ return SpatialHistograms.from_spec(universe, module.PROBE,
677
+ parser).run()
678
+
679
+ return _run
680
+
681
+
682
+ @pytest.fixture
683
+ def directions():
684
+ """The one-dimensional histograms of each geometry."""
685
+ return {'cubic': ('r',), 'cylindrical': ('r', 'z', 'theta')}
686
+
687
+
688
+ def _counted(analysis, species, directions):
689
+ """Per species and direction: the distinct per-frame sums, and the sum
690
+ over the run."""
691
+ return {
692
+ f"{d}{s}": (np.unique(analysis.results[f"{d}HistT{s}"].sum(axis=1))
693
+ .tolist(), int(analysis.results[f"{d}Hist{s}"].sum()))
694
+ for s in species for d in directions}
695
+
696
+
697
+ def test_real_window_histograms_conserve_every_species(
698
+ real_window_whole, real_files, real_histograms, study_spec,
699
+ directions):
700
+ """Every position of a real ``all`` window falls inside the edges: each
701
+ frame counts every particle of each species once, and the run counts it
702
+ once per frame."""
703
+ probe = study_spec(real_window_whole.study).PROBE
704
+ files = real_files(real_window_whole)
705
+ counts = real_window_whole.window_counts()
706
+
707
+ analysis = real_histograms(real_window_whole, files.data, files.window,
708
+ 'all')
709
+
710
+ assert _counted(analysis, probe.histogram_species,
711
+ directions[probe.geometry]) == {
712
+ f"{d}{s}": ([counts[s]], counts[s] * real_window_whole.n_window_frames)
713
+ for s in probe.histogram_species
714
+ for d in directions[probe.geometry]}
715
+
716
+
717
+ def test_real_polymer_histograms_conserve_every_species(
718
+ real_polymer_whole, real_files, real_histograms, study_spec,
719
+ directions):
720
+ """Every position of a real polymer dump falls inside the edges of its
721
+ whole: each frame counts every particle of each species once, and the
722
+ crowders, absent from the dump, not at all."""
723
+ probe = study_spec(real_polymer_whole.study).PROBE
724
+ files = real_files(real_polymer_whole)
725
+ counts = real_polymer_whole.polymer_counts()
726
+
727
+ analysis = real_histograms(real_polymer_whole, files.topology,
728
+ files.polymer, real_polymer_whole.group)
729
+
730
+ assert _counted(analysis, probe.histogram_species,
731
+ directions[probe.geometry]) == {
732
+ f"{d}{s}": ([counts[s]], counts[s] * 50)
733
+ for s in probe.histogram_species
734
+ for d in directions[probe.geometry]}
735
+
736
+
737
+ def test_real_window_histograms_have_one_int64_row_per_frame_and_bin(
738
+ real_window_whole, real_files, real_histograms, study_spec,
739
+ directions):
740
+ """Each per-frame histogram of a real window is int64 with one row per
741
+ frame and one column per bin of the whole's edges."""
742
+ module = study_spec(real_window_whole.study)
743
+ probe = module.PROBE
744
+ files = real_files(real_window_whole)
745
+ edges = histogram_edges(probe, module.SPEC.parser(
746
+ f"{real_window_whole.whole}.ring", 'whole', 'all'))
747
+
748
+ analysis = real_histograms(real_window_whole, files.data, files.window,
749
+ 'all')
750
+
751
+ assert {f"{d}{s}": (analysis.results[f"{d}HistT{s}"].dtype,
752
+ analysis.results[f"{d}HistT{s}"].shape)
753
+ for s in probe.histogram_species
754
+ for d in directions[probe.geometry]} == {
755
+ f"{d}{s}": (np.dtype(np.int64),
756
+ (real_window_whole.n_window_frames, edges[d].size - 1))
757
+ for s in probe.histogram_species
758
+ for d in directions[probe.geometry]}
759
+
760
+
761
+ @pytest.mark.private_data # pragma: no cover - needs data
762
+ def test_crowded_window_histograms_conserve_every_species(
763
+ real_crowded_whole, private_window_files, real_histograms, study_spec,
764
+ directions):
765
+ """Every position of a crowded ``all`` window, crowders at the wall and
766
+ in the corners included, falls inside the edges: each frame counts every
767
+ particle of each species once."""
768
+ probe = study_spec(real_crowded_whole.study).PROBE
769
+ window, data = private_window_files(real_crowded_whole)
770
+ counts = real_crowded_whole.window_counts()
771
+
772
+ analysis = real_histograms(real_crowded_whole, data, window, 'all')
773
+
774
+ assert _counted(analysis, probe.histogram_species,
775
+ directions[probe.geometry]) == {
776
+ f"{d}{s}": ([counts[s]],
777
+ counts[s] * real_crowded_whole.n_window_frames)
778
+ for s in probe.histogram_species
779
+ for d in directions[probe.geometry]}