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,785 @@
|
|
|
1
|
+
"""Tests for :mod:`polyphys.analyze.measurer`.
|
|
2
|
+
|
|
3
|
+
Every expected value is hand arithmetic on the positions or bounds written in
|
|
4
|
+
the test, or a closed form: the spherical-segment and sphere-sphere
|
|
5
|
+
intersection volumes from Weisstein, MathWorld, "Spherical Segment" and
|
|
6
|
+
"Sphere-Sphere Intersection", and the sample mean, the unbiased variance, and
|
|
7
|
+
the standard error of the mean for the statistics. Property tests say so in
|
|
8
|
+
their docstrings. Closed-form values are compared at ``rel=1e-12``, far above
|
|
9
|
+
the float64 round-off of the few operations behind them (about 1e-16).
|
|
10
|
+
"""
|
|
11
|
+
import warnings
|
|
12
|
+
from functools import partial
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import numpy as np
|
|
16
|
+
from hypothesis import given, strategies as st
|
|
17
|
+
from hypothesis.extra.numpy import arrays
|
|
18
|
+
from numpy.testing import assert_allclose, assert_array_equal
|
|
19
|
+
|
|
20
|
+
from polyphys.analyze.measurer import (
|
|
21
|
+
apply_pbc_orthogonal,
|
|
22
|
+
pair_distance,
|
|
23
|
+
end_to_end,
|
|
24
|
+
transverse_size,
|
|
25
|
+
max_distance,
|
|
26
|
+
fsd,
|
|
27
|
+
simple_stats,
|
|
28
|
+
sem,
|
|
29
|
+
spherical_segment,
|
|
30
|
+
sphere_sphere_intersection,
|
|
31
|
+
create_bin_edge_and_hist,
|
|
32
|
+
fixedsize_bins,
|
|
33
|
+
radial_cyl_histogram,
|
|
34
|
+
axial_histogram,
|
|
35
|
+
radial_histogram,
|
|
36
|
+
azimuth_cyl_histogram,
|
|
37
|
+
planar_cartesian_histogram
|
|
38
|
+
)
|
|
39
|
+
from polyphys.manage.types import AxisT
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def positions_3d():
|
|
44
|
+
"""Three atom positions in 3D, for tests where their values are
|
|
45
|
+
incidental."""
|
|
46
|
+
return np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_apply_pbc_orthogonal_sets_lengths_and_inverses_on_named_axes():
|
|
50
|
+
"""Each named axis gets its box length and its inverse; the other axes
|
|
51
|
+
keep their values."""
|
|
52
|
+
lengths, inverses = apply_pbc_orthogonal(
|
|
53
|
+
np.zeros(3), np.zeros(3), {0: 10.0, 2: 20.0}
|
|
54
|
+
)
|
|
55
|
+
# float64 round-off of one division; atol for the untouched zeros:
|
|
56
|
+
assert_allclose(lengths, [10.0, 0.0, 20.0], rtol=1e-12, atol=1e-12)
|
|
57
|
+
assert_allclose(inverses, [0.1, 0.0, 0.05], rtol=1e-12, atol=1e-12)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
"length,error,match",
|
|
62
|
+
[
|
|
63
|
+
pytest.param(0.0, ZeroDivisionError, "cannot be zero", id="zero"),
|
|
64
|
+
pytest.param(-5.0, ValueError, "must be positive", id="negative"),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
def test_apply_pbc_orthogonal_rejects_nonpositive_length(length, error, match):
|
|
68
|
+
"""A box length that is zero or negative is rejected."""
|
|
69
|
+
with pytest.raises(error, match=match):
|
|
70
|
+
apply_pbc_orthogonal(np.zeros(3), np.zeros(3), {1: length})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_pair_distance_is_coordinate_difference_without_pbc():
|
|
74
|
+
"""Without periodic boundaries the pair vector is the second position
|
|
75
|
+
minus the first."""
|
|
76
|
+
positions = np.array([[1.0, 2.0, 3.0], [4.0, 2.0, 3.0]])
|
|
77
|
+
result = pair_distance(positions)
|
|
78
|
+
# float64 round-off of one subtraction; atol for the zero components:
|
|
79
|
+
assert_allclose(result, [3.0, 0.0, 0.0], rtol=1e-12, atol=1e-12)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_pair_distance_applies_minimum_image():
|
|
83
|
+
"""A separation of 8.5 along a periodic axis of length 8 folds to 0.5,
|
|
84
|
+
the minimum image."""
|
|
85
|
+
positions = np.array([[1.0, 2.0, 3.0], [9.5, 2.0, 3.0]])
|
|
86
|
+
result = pair_distance(positions, pbc={0: 8.0})
|
|
87
|
+
# float64 round-off of a subtraction and a product; atol for the zeros:
|
|
88
|
+
assert_allclose(result, [0.5, 0.0, 0.0], rtol=1e-12, atol=1e-12)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_pair_distance_rejects_other_than_two_positions():
|
|
92
|
+
"""A positions array that does not hold exactly two atoms is rejected."""
|
|
93
|
+
with pytest.raises(ValueError, match="only works for two atoms"):
|
|
94
|
+
pair_distance(np.array([[1.0, 2.0, 3.0]]))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_pair_distance_treats_an_infinite_length_as_non_periodic():
|
|
98
|
+
"""An infinite box length leaves its axis without the minimum image,
|
|
99
|
+
as in :func:`polyphys.analyze.clusters.pair_distances`, instead of
|
|
100
|
+
giving NaN from ``inf * 0``."""
|
|
101
|
+
positions = np.array([[1.0, 2.0, 3.0], [4.0, 2.0, 3.0]])
|
|
102
|
+
result = pair_distance(positions, pbc={0: np.inf})
|
|
103
|
+
# float64 round-off of one subtraction; atol for the zero components:
|
|
104
|
+
assert_allclose(result, [3.0, 0.0, 0.0], rtol=1e-12, atol=1e-12)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_pair_distance_accepts_integer_positions_with_pbc():
|
|
108
|
+
"""Integer positions are measured in float64: a separation of 9 along a
|
|
109
|
+
periodic axis of length 8 folds to 1."""
|
|
110
|
+
positions = np.array([[1, 2, 3], [10, 2, 3]])
|
|
111
|
+
result = pair_distance(positions, pbc={0: 8.0})
|
|
112
|
+
# float64 round-off of a subtraction and a product; atol for the zeros:
|
|
113
|
+
assert_allclose(result, [1.0, 0.0, 0.0], rtol=1e-12, atol=1e-12)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_end_to_end_of_straight_chain_is_its_length():
|
|
117
|
+
"""Three beads one unit apart on a line are two units end to end."""
|
|
118
|
+
positions = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]])
|
|
119
|
+
result = end_to_end(positions)
|
|
120
|
+
# float64 round-off of centering and a norm:
|
|
121
|
+
assert result == pytest.approx(2.0, rel=1e-12)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_end_to_end_rejects_single_atom():
|
|
125
|
+
"""A chain of fewer than two atoms has no end-to-end distance."""
|
|
126
|
+
with pytest.raises(ValueError, match="at least two atoms"):
|
|
127
|
+
end_to_end(np.array([[0.0, 0.0, 0.0]]))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_transverse_size_is_twice_mean_distance_from_axis():
|
|
131
|
+
"""Two atoms at (0, 0) and (2, 2) in the y-z plane are each sqrt(2) from
|
|
132
|
+
their center, so the transverse size about x is 2 sqrt(2)."""
|
|
133
|
+
positions = np.array([[1, 0, 0], [1, 2, 2]])
|
|
134
|
+
result = transverse_size(positions, axis=0)
|
|
135
|
+
# float64 round-off of centering and a norm:
|
|
136
|
+
assert result == pytest.approx(2 * np.sqrt(2), rel=1e-12)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"measure,positions,axis",
|
|
141
|
+
[
|
|
142
|
+
pytest.param(
|
|
143
|
+
transverse_size, np.array([[1, 0, 0], [1, 2, 2]]), 3,
|
|
144
|
+
id="transverse-size",
|
|
145
|
+
),
|
|
146
|
+
pytest.param(fsd, np.array([[1, 2, 3]]), 5, id="fsd"),
|
|
147
|
+
],
|
|
148
|
+
)
|
|
149
|
+
def test_size_measures_reject_axis_beyond_positions(measure, positions, axis):
|
|
150
|
+
"""An axis index at or beyond the number of coordinates is rejected."""
|
|
151
|
+
with pytest.raises(IndexError, match="out of bounds"):
|
|
152
|
+
measure(positions, axis=axis)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize(
|
|
156
|
+
"measure,positions",
|
|
157
|
+
[
|
|
158
|
+
pytest.param(
|
|
159
|
+
transverse_size, np.array([[1, 0, 0], [1, 2, 2]]),
|
|
160
|
+
id="transverse-size",
|
|
161
|
+
),
|
|
162
|
+
pytest.param(fsd, np.array([[1, 2, 3]]), id="fsd"),
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
def test_size_measures_name_the_axis_out_of_bounds(measure, positions):
|
|
166
|
+
"""The error names the rejected axis and the array shape."""
|
|
167
|
+
with pytest.raises(IndexError,
|
|
168
|
+
match="Axis 3 is out of bounds for positions array"
|
|
169
|
+
" with shape"):
|
|
170
|
+
measure(positions, axis=3)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_max_distance_is_extent_along_each_axis():
|
|
174
|
+
"""The extent along each axis is the spread of that coordinate."""
|
|
175
|
+
positions = np.array([[0, 0, 0], [4, 2, 6]])
|
|
176
|
+
result = max_distance(positions)
|
|
177
|
+
# float64 round-off of centering:
|
|
178
|
+
assert_allclose(result, [4.0, 2.0, 6.0], rtol=1e-12)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_fsd_is_spread_along_axis():
|
|
182
|
+
"""Feret's diameter along y is the spread of the y coordinates, 8 - 2."""
|
|
183
|
+
positions = np.array([[1, 2, 3], [4, 8, 6]])
|
|
184
|
+
result = fsd(positions, axis=1)
|
|
185
|
+
# float64 round-off of centering:
|
|
186
|
+
assert result == pytest.approx(6.0, rel=1e-12)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize(
|
|
190
|
+
"key,expected",
|
|
191
|
+
[
|
|
192
|
+
pytest.param("energy_mean", 2.0, id="mean"),
|
|
193
|
+
pytest.param("energy_var", 1.0, id="unbiased-variance"),
|
|
194
|
+
pytest.param("energy_sem", 1 / np.sqrt(3), id="sem"),
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
def test_simple_stats_gives_mean_variance_and_sem(key, expected):
|
|
198
|
+
"""The statistics of [1, 2, 3] are mean 2, unbiased variance 1, and
|
|
199
|
+
standard error 1 / sqrt(3), under keys prefixed by the entity."""
|
|
200
|
+
result = simple_stats("energy", np.array([1.0, 2.0, 3.0]))
|
|
201
|
+
# float64 round-off of a few sums and a square root:
|
|
202
|
+
assert result[key] == pytest.approx(expected, rel=1e-12)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.parametrize(
|
|
206
|
+
"statistic",
|
|
207
|
+
[
|
|
208
|
+
pytest.param(partial(simple_stats, "energy"), id="simple-stats"),
|
|
209
|
+
pytest.param(sem, id="sem"),
|
|
210
|
+
],
|
|
211
|
+
)
|
|
212
|
+
def test_statistics_reject_empty_data(statistic):
|
|
213
|
+
"""Statistics of an empty array are rejected rather than returned as
|
|
214
|
+
NaN."""
|
|
215
|
+
with pytest.raises(ValueError, match="must not be empty"):
|
|
216
|
+
statistic(np.array([]))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.parametrize(
|
|
220
|
+
"statistic",
|
|
221
|
+
[
|
|
222
|
+
pytest.param(partial(simple_stats, "energy"), id="simple-stats"),
|
|
223
|
+
pytest.param(sem, id="sem"),
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
def test_statistics_reject_a_single_sample(statistic):
|
|
227
|
+
"""One sample has no unbiased variance, since ``ddof=1`` divides by
|
|
228
|
+
``n - 1 = 0``, so it is rejected rather than returned as NaN."""
|
|
229
|
+
with pytest.raises(ValueError, match="at least two samples"):
|
|
230
|
+
statistic(np.array([1.0]))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_sem_is_sample_std_over_root_n():
|
|
234
|
+
"""The standard error of [1, 2, 3] is its unbiased standard deviation, 1,
|
|
235
|
+
over sqrt(3)."""
|
|
236
|
+
result = sem(np.array([1.0, 2.0, 3.0]))
|
|
237
|
+
# float64 round-off of a few sums and a square root:
|
|
238
|
+
assert result == pytest.approx(1 / np.sqrt(3), rel=1e-12)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.parametrize(
|
|
242
|
+
"r,a,b,expected",
|
|
243
|
+
[
|
|
244
|
+
pytest.param(3, 1, 2, 20 * np.pi / 3, id="slab"),
|
|
245
|
+
pytest.param(3, -3, 3, 36 * np.pi, id="full-sphere"),
|
|
246
|
+
pytest.param(3, 4, 5, 0.0, id="both-planes-outside"),
|
|
247
|
+
],
|
|
248
|
+
)
|
|
249
|
+
def test_spherical_segment_volume(r, a, b, expected):
|
|
250
|
+
"""The volume between two planes cutting a sphere is
|
|
251
|
+
pi (r^2 (b - a) - (b^3 - a^3) / 3), with the planes clipped to the
|
|
252
|
+
sphere, and zero when both lie outside it on one side."""
|
|
253
|
+
result = spherical_segment(r, a, b)
|
|
254
|
+
# float64 round-off of a few products; atol for the zero case:
|
|
255
|
+
assert result == pytest.approx(expected, rel=1e-12, abs=1e-12)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_spherical_segment_rejects_nonpositive_radius():
|
|
259
|
+
"""A sphere of negative radius is rejected."""
|
|
260
|
+
with pytest.raises(ValueError, match="must be positive"):
|
|
261
|
+
spherical_segment(-3, 1, 2)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.parametrize(
|
|
265
|
+
"r1,r2,d,expected",
|
|
266
|
+
[
|
|
267
|
+
pytest.param(3, 4, 2, 725 * np.pi / 24, id="partial-overlap"),
|
|
268
|
+
pytest.param(3, 4, 10, 0.0, id="no-overlap"),
|
|
269
|
+
pytest.param(3, 4, 1, 36 * np.pi, id="smaller-inside-larger"),
|
|
270
|
+
pytest.param(3, 0, 1, 0.0, id="second-radius-zero"),
|
|
271
|
+
pytest.param(0, 4, 1, 0.0, id="first-radius-zero"),
|
|
272
|
+
],
|
|
273
|
+
)
|
|
274
|
+
def test_sphere_sphere_intersection_volume(r1, r2, d, expected):
|
|
275
|
+
"""The lens volume follows MathWorld's closed form; it is zero for
|
|
276
|
+
disjoint or zero-radius spheres and the smaller sphere's volume when one
|
|
277
|
+
contains the other."""
|
|
278
|
+
result = sphere_sphere_intersection(r1, r2, d)
|
|
279
|
+
# float64 round-off of a few products; atol for the zero cases:
|
|
280
|
+
assert result == pytest.approx(expected, rel=1e-12, abs=1e-12)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_create_bin_edge_and_hist_unit_edges():
|
|
284
|
+
"""Unit bins over [0, 5] have edges at the integers 0 to 5."""
|
|
285
|
+
edges, _ = create_bin_edge_and_hist(1.0, 0.0, 5.0)
|
|
286
|
+
# float64 round-off of np.linspace over 5 unit steps:
|
|
287
|
+
assert_allclose(edges, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0], rtol=0, atol=1e-12)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_create_bin_edge_and_hist_histogram_is_zero_per_bin():
|
|
291
|
+
"""The histogram has one zero count per bin."""
|
|
292
|
+
_, hist = create_bin_edge_and_hist(1.0, 0.0, 5.0)
|
|
293
|
+
assert_array_equal(hist, np.zeros(5, dtype=np.int64))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_create_bin_edge_and_hist_rejects_inverted_range():
|
|
297
|
+
"""A lower bound above the upper bound is rejected."""
|
|
298
|
+
with pytest.raises(ValueError, match="must be less than"):
|
|
299
|
+
create_bin_edge_and_hist(1.0, 5.0, 0.0)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@pytest.mark.parametrize(
|
|
303
|
+
"bin_type,expected_n_bins",
|
|
304
|
+
[
|
|
305
|
+
pytest.param("ordinary", 5, id="ordinary"),
|
|
306
|
+
pytest.param("nonnegative", 5, id="nonnegative"),
|
|
307
|
+
pytest.param("periodic", 5, id="periodic"),
|
|
308
|
+
],
|
|
309
|
+
)
|
|
310
|
+
def test_fixedsize_bins_unit_bins_over_five(bin_type, expected_n_bins):
|
|
311
|
+
"""Unit bins over [0, 5] give five bins and six edges for every bin
|
|
312
|
+
type."""
|
|
313
|
+
result = fixedsize_bins(1.0, 0.0, 5.0, bin_type=bin_type)
|
|
314
|
+
assert result["n_bins"] == expected_n_bins
|
|
315
|
+
assert len(result["bin_edges"]) == expected_n_bins + 1
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_fixedsize_bins_rejects_inverted_range():
|
|
319
|
+
"""A lower bound above the upper bound is rejected."""
|
|
320
|
+
with pytest.raises(ValueError, match="must be less than"):
|
|
321
|
+
fixedsize_bins(1.0, 5.0, 0.0)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_fixedsize_bins_rejects_unknown_bin_type():
|
|
325
|
+
"""A bin type other than ordinary, nonnegative, or periodic is
|
|
326
|
+
rejected."""
|
|
327
|
+
with pytest.raises(ValueError, match="is an invalid option"):
|
|
328
|
+
fixedsize_bins(1.0, 0.0, 5.0, bin_type="logarithmic")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@pytest.mark.parametrize(
|
|
332
|
+
"make_edges",
|
|
333
|
+
[
|
|
334
|
+
pytest.param(
|
|
335
|
+
lambda path: create_bin_edge_and_hist(
|
|
336
|
+
1.0, 0.0, 5.0, output=path
|
|
337
|
+
)[0],
|
|
338
|
+
id="create-bin-edge-and-hist",
|
|
339
|
+
),
|
|
340
|
+
pytest.param(
|
|
341
|
+
lambda path: fixedsize_bins(
|
|
342
|
+
1.0, 0.0, 5.0, save_bin_edges=path
|
|
343
|
+
)["bin_edges"],
|
|
344
|
+
id="fixedsize-bins",
|
|
345
|
+
),
|
|
346
|
+
],
|
|
347
|
+
)
|
|
348
|
+
def test_bin_edges_saved_to_npy_equal_returned_edges(tmp_path, make_edges):
|
|
349
|
+
"""The edges saved to the named .npy file are the edges returned (a
|
|
350
|
+
round-trip property)."""
|
|
351
|
+
path = tmp_path / "edges"
|
|
352
|
+
edges = make_edges(str(path))
|
|
353
|
+
# .npy stores float64 bit for bit, so the round trip is exact:
|
|
354
|
+
assert_array_equal(np.load(tmp_path / "edges.npy"), edges)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@pytest.mark.parametrize(
|
|
358
|
+
"make_array,expected_dtype",
|
|
359
|
+
[
|
|
360
|
+
pytest.param(
|
|
361
|
+
lambda: fixedsize_bins(1.0, 0.0, 2.0)["collector"],
|
|
362
|
+
np.int64,
|
|
363
|
+
id="fixedsize-collector",
|
|
364
|
+
),
|
|
365
|
+
pytest.param(
|
|
366
|
+
lambda: fixedsize_bins(1.0, 0.0, 2.0)["collector_std"],
|
|
367
|
+
np.float64,
|
|
368
|
+
id="fixedsize-collector-std",
|
|
369
|
+
),
|
|
370
|
+
pytest.param(
|
|
371
|
+
lambda: create_bin_edge_and_hist(1.0, 0.0, 2.0)[1],
|
|
372
|
+
np.int64,
|
|
373
|
+
id="create-bin-edge-and-hist",
|
|
374
|
+
),
|
|
375
|
+
],
|
|
376
|
+
)
|
|
377
|
+
def test_histogram_collectors_are_64_bit(make_array, expected_dtype):
|
|
378
|
+
"""The empty collectors are 64-bit, so per-frame counts accumulated in
|
|
379
|
+
place cannot overflow a 16-bit type."""
|
|
380
|
+
assert make_array().dtype == expected_dtype
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@pytest.mark.parametrize(
|
|
384
|
+
"make_array,power,expected",
|
|
385
|
+
[
|
|
386
|
+
pytest.param(
|
|
387
|
+
lambda: fixedsize_bins(1.0, 0.0, 2.0)["collector"],
|
|
388
|
+
1,
|
|
389
|
+
[60000, 0],
|
|
390
|
+
id="fixedsize-collector",
|
|
391
|
+
),
|
|
392
|
+
pytest.param(
|
|
393
|
+
lambda: fixedsize_bins(1.0, 0.0, 2.0)["collector_std"],
|
|
394
|
+
2,
|
|
395
|
+
[2.0 * 30000**2, 0.0],
|
|
396
|
+
id="fixedsize-collector-std",
|
|
397
|
+
),
|
|
398
|
+
pytest.param(
|
|
399
|
+
lambda: create_bin_edge_and_hist(1.0, 0.0, 2.0)[1],
|
|
400
|
+
1,
|
|
401
|
+
[60000, 0],
|
|
402
|
+
id="create-bin-edge-and-hist",
|
|
403
|
+
),
|
|
404
|
+
],
|
|
405
|
+
)
|
|
406
|
+
def test_histogram_collectors_accumulate_past_int16_limit(
|
|
407
|
+
make_array, power, expected
|
|
408
|
+
):
|
|
409
|
+
"""Two frames of 30000 counts in the first bin add to 60000, past the
|
|
410
|
+
int16 limit of 32767, without wrapping."""
|
|
411
|
+
collector = make_array()
|
|
412
|
+
frame_counts = np.array([30000, 0])
|
|
413
|
+
collector += frame_counts**power
|
|
414
|
+
collector += frame_counts**power
|
|
415
|
+
# integer counts below 2**53 are exact in float64, so no tolerance:
|
|
416
|
+
assert_allclose(collector, expected, rtol=0, atol=0)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@pytest.mark.parametrize(
|
|
420
|
+
"bin_size,lmin,lmax,expected_edges",
|
|
421
|
+
[
|
|
422
|
+
pytest.param(
|
|
423
|
+
0.3, -6.0, 6.0, -6.0 + 0.3 * np.arange(41), id="multiple"
|
|
424
|
+
),
|
|
425
|
+
pytest.param(
|
|
426
|
+
0.7, 0.0, 2.0, np.array([0.0, 0.7, 1.4, 2.1]), id="non-multiple"
|
|
427
|
+
),
|
|
428
|
+
pytest.param(
|
|
429
|
+
0.3, 0.0, 2.1, 0.3 * np.arange(8), id="multiple-ratio-rounds-up"
|
|
430
|
+
),
|
|
431
|
+
],
|
|
432
|
+
)
|
|
433
|
+
def test_create_bin_edge_and_hist_edges_have_bin_size_width(
|
|
434
|
+
bin_size, lmin, lmax, expected_edges
|
|
435
|
+
):
|
|
436
|
+
"""Edges step by bin_size from lmin to the first multiple covering lmax,
|
|
437
|
+
and a range within round-off of a whole number of bins gets no extra
|
|
438
|
+
bin."""
|
|
439
|
+
edges, hist = create_bin_edge_and_hist(bin_size, lmin, lmax)
|
|
440
|
+
# float64 round-off on at most 41 steps of order 1:
|
|
441
|
+
np.testing.assert_allclose(edges, expected_edges, rtol=0, atol=1e-12)
|
|
442
|
+
assert len(hist) == len(expected_edges) - 1
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@pytest.mark.parametrize(
|
|
446
|
+
"bin_size,lmin,lmax",
|
|
447
|
+
[
|
|
448
|
+
pytest.param(0.3, 0.0, 2.1, id="ratio-rounds-up"),
|
|
449
|
+
pytest.param(0.03, 0.0, 15.9, id="ratio-rounds-down"),
|
|
450
|
+
pytest.param(0.3, -6.0, 6.0, id="ratio-exact"),
|
|
451
|
+
],
|
|
452
|
+
)
|
|
453
|
+
def test_create_bin_edge_and_hist_exact_multiple_ends_at_lmax(
|
|
454
|
+
bin_size, lmin, lmax
|
|
455
|
+
):
|
|
456
|
+
"""When the range is a whole number of bins up to round-off, the first and
|
|
457
|
+
last edges are exactly lmin and lmax."""
|
|
458
|
+
edges, _ = create_bin_edge_and_hist(bin_size, lmin, lmax)
|
|
459
|
+
assert edges[0] == lmin
|
|
460
|
+
assert edges[-1] == lmax
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@pytest.mark.parametrize(
|
|
464
|
+
"bin_type,bin_size,lmin,lmax,expected_n_bins",
|
|
465
|
+
[
|
|
466
|
+
pytest.param("ordinary", 0.3, 0.0, 2.1, 7, id="ordinary-rounds-up"),
|
|
467
|
+
pytest.param(
|
|
468
|
+
"ordinary", 0.03, 0.0, 15.9, 530, id="ordinary-rounds-down"
|
|
469
|
+
),
|
|
470
|
+
pytest.param(
|
|
471
|
+
"nonnegative", 0.3, 0.0, 2.1, 7, id="nonnegative-rounds-up"
|
|
472
|
+
),
|
|
473
|
+
pytest.param("periodic", 0.3, 0.0, 2.1, 7, id="periodic-rounds-up"),
|
|
474
|
+
],
|
|
475
|
+
)
|
|
476
|
+
def test_fixedsize_bins_exact_multiple_tiles_range(
|
|
477
|
+
bin_type, bin_size, lmin, lmax, expected_n_bins
|
|
478
|
+
):
|
|
479
|
+
"""A range that is a whole number of bins up to float round-off gets
|
|
480
|
+
exactly that many bins, with the first and last edges at lmin and lmax."""
|
|
481
|
+
result = fixedsize_bins(bin_size, lmin, lmax, bin_type=bin_type)
|
|
482
|
+
assert result["n_bins"] == expected_n_bins
|
|
483
|
+
assert result["bin_edges"][0] == lmin
|
|
484
|
+
assert result["bin_edges"][-1] == lmax
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@pytest.mark.parametrize(
|
|
488
|
+
"bin_size,lmin,lmax,expected_n_bins",
|
|
489
|
+
[
|
|
490
|
+
pytest.param(0.3, -6.0, 6.0, 40, id="step-0.3"),
|
|
491
|
+
pytest.param(np.pi / 36, -np.pi, np.pi, 72, id="azimuth-5-degrees"),
|
|
492
|
+
],
|
|
493
|
+
)
|
|
494
|
+
def test_fixedsize_bins_periodic_spans_one_period(
|
|
495
|
+
bin_size, lmin, lmax, expected_n_bins
|
|
496
|
+
):
|
|
497
|
+
"""A period that is a multiple of bin_size gives exactly one period of
|
|
498
|
+
bins, ending at lmax, with no warning."""
|
|
499
|
+
with warnings.catch_warnings():
|
|
500
|
+
warnings.simplefilter("error")
|
|
501
|
+
result = fixedsize_bins(bin_size, lmin, lmax, bin_type="periodic")
|
|
502
|
+
assert result["n_bins"] == expected_n_bins
|
|
503
|
+
assert len(result["bin_edges"]) == expected_n_bins + 1
|
|
504
|
+
assert len(result["collector"]) == expected_n_bins
|
|
505
|
+
assert result["bin_edges"][0] == lmin
|
|
506
|
+
assert result["bin_edges"][-1] == lmax
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def test_fixedsize_bins_nonnegative_negative_lmin_raises():
|
|
510
|
+
"""A negative lower bound is rejected for nonnegative bins, since clipping
|
|
511
|
+
it to zero would leave the range below zero uncovered."""
|
|
512
|
+
with pytest.raises(ValueError, match="must be nonnegative"):
|
|
513
|
+
fixedsize_bins(1.0, -1.0, 2.0, bin_type="nonnegative")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_fixedsize_bins_periodic_non_multiple_raises():
|
|
517
|
+
"""A period that is not a multiple of bin_size is rejected."""
|
|
518
|
+
with pytest.raises(ValueError, match="multiple of 'bin_size'"):
|
|
519
|
+
fixedsize_bins(0.7, 0.0, 2.0, bin_type="periodic")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@pytest.mark.parametrize(
|
|
523
|
+
"lmin,lmax,expected_edges",
|
|
524
|
+
[
|
|
525
|
+
pytest.param(0.1, 2.5, [0.0, 1.0, 2.0, 3.0], id="lmin-below-dl"),
|
|
526
|
+
pytest.param(0.25, 2.75, [0.0, 1.0, 2.0, 3.0], id="lmin-equals-dl"),
|
|
527
|
+
pytest.param(0.0, 2.5, [0.0, 1.0, 2.0, 3.0], id="lmin-zero"),
|
|
528
|
+
pytest.param(0.5, 3.0, [0.25, 1.25, 2.25, 3.25], id="no-clipping"),
|
|
529
|
+
],
|
|
530
|
+
)
|
|
531
|
+
def test_fixedsize_bins_nonnegative_edges(lmin, lmax, expected_edges):
|
|
532
|
+
"""Nonnegative bins keep width bin_size when the lower end is clipped
|
|
533
|
+
to zero."""
|
|
534
|
+
result = fixedsize_bins(1.0, lmin, lmax, bin_type="nonnegative")
|
|
535
|
+
# float64 round-off on 3 unit steps:
|
|
536
|
+
np.testing.assert_allclose(
|
|
537
|
+
result["bin_edges"], expected_edges, rtol=0, atol=1e-12
|
|
538
|
+
)
|
|
539
|
+
assert result["range"] == pytest.approx(
|
|
540
|
+
(expected_edges[0], expected_edges[-1]), abs=1e-12
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@pytest.mark.parametrize(
|
|
545
|
+
"bin_type,bin_size,lmin,lmax",
|
|
546
|
+
[
|
|
547
|
+
pytest.param("ordinary", 0.3, -6.0, 6.0, id="ordinary-multiple"),
|
|
548
|
+
pytest.param("ordinary", 0.7, 0.0, 2.0, id="ordinary-non-multiple"),
|
|
549
|
+
pytest.param(
|
|
550
|
+
"ordinary", 0.3, 0.0, 2.1, id="ordinary-ratio-rounds-up"
|
|
551
|
+
),
|
|
552
|
+
pytest.param(
|
|
553
|
+
"ordinary", 0.03, 0.0, 15.9, id="ordinary-ratio-rounds-down"
|
|
554
|
+
),
|
|
555
|
+
pytest.param("nonnegative", 1.0, 0.1, 2.5, id="nonnegative-clipped"),
|
|
556
|
+
pytest.param("nonnegative", 0.3, 0.0, 6.0, id="nonnegative-zero"),
|
|
557
|
+
pytest.param("periodic", 0.3, -6.0, 6.0, id="periodic-step-0.3"),
|
|
558
|
+
pytest.param(
|
|
559
|
+
"periodic", np.pi / 36, -np.pi, np.pi, id="periodic-azimuth"
|
|
560
|
+
),
|
|
561
|
+
],
|
|
562
|
+
)
|
|
563
|
+
def test_fixedsize_bins_edges_cover_range_with_bin_size_width(
|
|
564
|
+
bin_type, bin_size, lmin, lmax
|
|
565
|
+
):
|
|
566
|
+
"""Every bin type returns n_bins + 1 edges of width bin_size whose first
|
|
567
|
+
and last edges enclose lmin and lmax exactly, and a collector of n_bins
|
|
568
|
+
slots (a property test)."""
|
|
569
|
+
result = fixedsize_bins(bin_size, lmin, lmax, bin_type=bin_type)
|
|
570
|
+
edges = result["bin_edges"]
|
|
571
|
+
n_bins = result["n_bins"]
|
|
572
|
+
assert len(edges) == n_bins + 1
|
|
573
|
+
assert len(result["collector"]) == n_bins
|
|
574
|
+
# float64 round-off on at most 530 steps of order 1:
|
|
575
|
+
np.testing.assert_allclose(
|
|
576
|
+
np.diff(edges), np.full(n_bins, bin_size), rtol=0, atol=1e-12
|
|
577
|
+
)
|
|
578
|
+
# no slack: a position at exactly lmin or lmax must fall inside the edges
|
|
579
|
+
assert edges[0] <= lmin
|
|
580
|
+
assert edges[-1] >= lmax
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def test_radial_histogram_bins_distances_from_origin():
|
|
584
|
+
"""Distances from the origin are binned; the last edge is inclusive."""
|
|
585
|
+
positions = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 2]])
|
|
586
|
+
edges = np.array([0, 1, 2, 3])
|
|
587
|
+
# distances 1, 1, 2: two in [1, 2), one in [2, 3].
|
|
588
|
+
result = radial_histogram(positions, edges)
|
|
589
|
+
assert_array_equal(result, np.array([0, 2, 1]))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_radial_cyl_histogram_bins_distances_from_axis():
|
|
593
|
+
"""Distances from the y axis are binned in the x-z plane."""
|
|
594
|
+
positions = np.array([[1, 0, 0], [0, 2, 0], [0, 0, 3], [0, 3, 4]])
|
|
595
|
+
edges = np.array([0, 1, 2, 3, 4])
|
|
596
|
+
# distances from the y axis: 1, 0, 3, 4; the last edge is inclusive.
|
|
597
|
+
hist = radial_cyl_histogram(positions, edges, dim=1)
|
|
598
|
+
assert_array_equal(hist, np.array([1, 1, 0, 2]))
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_axial_histogram_bins_coordinates_along_axis():
|
|
602
|
+
"""Coordinates along the x axis are binned."""
|
|
603
|
+
positions = np.array([
|
|
604
|
+
[1, 2, 3],
|
|
605
|
+
[2, 2, 3],
|
|
606
|
+
[3, 2, 3],
|
|
607
|
+
[4, 2, 3]
|
|
608
|
+
])
|
|
609
|
+
edges = np.array([0, 1, 2, 3, 4, 5])
|
|
610
|
+
hist = axial_histogram(positions, edges, dim=0)
|
|
611
|
+
assert_array_equal(hist, np.array([0, 1, 1, 1, 1]))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_azimuth_cyl_histogram_bins_angles_about_axis():
|
|
615
|
+
"""Azimuth angles about the z axis are binned over [-pi, pi]."""
|
|
616
|
+
pos = np.array([[1, 0, 0], [-1, 0, 0], [0, 1, 0]]) # x-y plane
|
|
617
|
+
edges = np.linspace(-np.pi, np.pi, 5)
|
|
618
|
+
# angles 0, pi, pi/2; bins [-pi, -pi/2), [-pi/2, 0), [0, pi/2),
|
|
619
|
+
# [pi/2, pi], the last edge inclusive.
|
|
620
|
+
result = azimuth_cyl_histogram(pos, edges, dim=2)
|
|
621
|
+
assert_array_equal(result, np.array([0, 0, 1, 2]))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def test_planar_cartesian_histogram_bins_in_plane_normal_to_axis():
|
|
625
|
+
"""Positions are binned in the x-y plane when the axis is z."""
|
|
626
|
+
positions = np.array([
|
|
627
|
+
[1, 1, 0], # falls into bin (0,0)
|
|
628
|
+
[2, 2, 0], # falls into bin (1,1)
|
|
629
|
+
[3, 3, 0] # falls into bin (1,1)
|
|
630
|
+
])
|
|
631
|
+
edges = [np.array([0, 2, 4]), np.array([0, 2, 4])]
|
|
632
|
+
hist = planar_cartesian_histogram(positions, edges, dim=2)
|
|
633
|
+
# counts are whole numbers, exact in float64:
|
|
634
|
+
assert_array_equal(hist, np.array([[1.0, 0.0], [0.0, 2.0]]))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@pytest.mark.parametrize(
|
|
638
|
+
"histogram,kwargs",
|
|
639
|
+
[
|
|
640
|
+
pytest.param(radial_histogram, {}, id="radial"),
|
|
641
|
+
pytest.param(radial_cyl_histogram, {"dim": 1}, id="radial-cyl"),
|
|
642
|
+
pytest.param(axial_histogram, {"dim": 0}, id="axial"),
|
|
643
|
+
pytest.param(azimuth_cyl_histogram, {"dim": 2}, id="azimuth-cyl"),
|
|
644
|
+
pytest.param(
|
|
645
|
+
planar_cartesian_histogram, {"dim": 2}, id="planar-cartesian"
|
|
646
|
+
),
|
|
647
|
+
],
|
|
648
|
+
)
|
|
649
|
+
def test_histograms_reject_one_dimensional_positions(histogram, kwargs):
|
|
650
|
+
"""A one-dimensional positions array is rejected."""
|
|
651
|
+
with pytest.raises(ValueError, match="must be a 2D array"):
|
|
652
|
+
histogram(np.array([1.0, 2.0, 3.0]), np.array([0.0, 1.0]), **kwargs)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@pytest.mark.parametrize(
|
|
656
|
+
"histogram",
|
|
657
|
+
[
|
|
658
|
+
pytest.param(radial_cyl_histogram, id="radial-cyl"),
|
|
659
|
+
pytest.param(axial_histogram, id="axial"),
|
|
660
|
+
pytest.param(azimuth_cyl_histogram, id="azimuth-cyl"),
|
|
661
|
+
pytest.param(planar_cartesian_histogram, id="planar-cartesian"),
|
|
662
|
+
],
|
|
663
|
+
)
|
|
664
|
+
def test_histograms_reject_axis_outside_three_dimensions(
|
|
665
|
+
positions_3d, histogram
|
|
666
|
+
):
|
|
667
|
+
"""An axis outside {0, 1, 2} is rejected."""
|
|
668
|
+
with pytest.raises(ValueError, match="must be one of"):
|
|
669
|
+
histogram(positions_3d, np.array([0.0, 1.0]), dim=5)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def test_planar_cartesian_histogram_rejects_other_than_two_edge_arrays(
|
|
673
|
+
positions_3d,
|
|
674
|
+
):
|
|
675
|
+
"""The planar histogram needs exactly one edge array per planar axis."""
|
|
676
|
+
edges = [np.array([0.0, 1.0])] * 3
|
|
677
|
+
with pytest.raises(ValueError, match="must contain two arrays"):
|
|
678
|
+
planar_cartesian_histogram(positions_3d, edges, dim=2)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@pytest.mark.parametrize(
|
|
682
|
+
"histogram,kwargs",
|
|
683
|
+
[
|
|
684
|
+
pytest.param(
|
|
685
|
+
radial_histogram, {"bin_range": (0, 3)}, id="radial"
|
|
686
|
+
),
|
|
687
|
+
pytest.param(
|
|
688
|
+
radial_cyl_histogram,
|
|
689
|
+
{"bin_range": (0, 3), "dim": 2},
|
|
690
|
+
id="radial-cyl",
|
|
691
|
+
),
|
|
692
|
+
pytest.param(
|
|
693
|
+
axial_histogram, {"bin_range": (0, 3), "dim": 0}, id="axial"
|
|
694
|
+
),
|
|
695
|
+
pytest.param(
|
|
696
|
+
azimuth_cyl_histogram,
|
|
697
|
+
{"bin_range": (0, 3), "dim": 2},
|
|
698
|
+
id="azimuth-cyl",
|
|
699
|
+
),
|
|
700
|
+
pytest.param(
|
|
701
|
+
planar_cartesian_histogram,
|
|
702
|
+
{"bin_ranges": [(0, 3), (0, 3)], "dim": 2},
|
|
703
|
+
id="planar-cartesian",
|
|
704
|
+
),
|
|
705
|
+
],
|
|
706
|
+
)
|
|
707
|
+
def test_histograms_reject_range_argument(positions_3d, histogram, kwargs):
|
|
708
|
+
"""The histogram functions no longer accept a range argument, which
|
|
709
|
+
np.histogram ignored when given edges."""
|
|
710
|
+
edges = np.array([0.0, 1.0, 2.0, 3.0])
|
|
711
|
+
with pytest.raises(TypeError, match="unexpected keyword argument"):
|
|
712
|
+
histogram(positions_3d, edges, **kwargs)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@pytest.mark.parametrize(
|
|
716
|
+
"histogram,kwargs",
|
|
717
|
+
[
|
|
718
|
+
pytest.param(radial_histogram, {}, id="radial"),
|
|
719
|
+
pytest.param(radial_cyl_histogram, {"dim": 2}, id="radial-cyl"),
|
|
720
|
+
pytest.param(axial_histogram, {"dim": 0}, id="axial"),
|
|
721
|
+
pytest.param(azimuth_cyl_histogram, {"dim": 2}, id="azimuth-cyl"),
|
|
722
|
+
],
|
|
723
|
+
)
|
|
724
|
+
def test_histograms_reject_positions_beyond_edges(histogram, kwargs):
|
|
725
|
+
"""A position outside the outermost edges raises instead of being
|
|
726
|
+
silently dropped; the message counts the positions that fell outside."""
|
|
727
|
+
positions = np.array(
|
|
728
|
+
[[0.5, 0.0, 0.0], [1.5, 0.0, 0.0], [2.5, 0.0, 0.0], [-9.0, 0.0, 0.0]]
|
|
729
|
+
)
|
|
730
|
+
# (-9, 0, 0) lies beyond the last edge for every histogram: its radial
|
|
731
|
+
# and cylindrical-radial distances are 9, its x coordinate is -9, and its
|
|
732
|
+
# azimuth about z is pi > 3.
|
|
733
|
+
edges = np.array([0.0, 1.0, 2.0, 3.0])
|
|
734
|
+
with pytest.raises(ValueError, match="1 of 4 positions"):
|
|
735
|
+
histogram(positions, edges, **kwargs)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_planar_cartesian_histogram_rejects_positions_beyond_edges():
|
|
739
|
+
"""A position outside the planar edges raises instead of being silently
|
|
740
|
+
dropped."""
|
|
741
|
+
positions = np.array([[1.0, 1.0, 0.0], [3.0, 3.0, 0.0], [9.0, 1.0, 0.0]])
|
|
742
|
+
edges = [np.array([0.0, 2.0, 4.0]), np.array([0.0, 2.0, 4.0])]
|
|
743
|
+
with pytest.raises(ValueError, match="1 of 3 positions"):
|
|
744
|
+
planar_cartesian_histogram(positions, edges, dim=2)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def test_radial_histogram_counts_positions_on_both_outer_edges():
|
|
748
|
+
"""Positions at exactly the first and the last edge are both counted, so
|
|
749
|
+
nothing on the boundary of the range is lost."""
|
|
750
|
+
positions = np.array([[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]])
|
|
751
|
+
edges = np.array([0.0, 1.0, 2.0, 3.0])
|
|
752
|
+
hist = radial_histogram(positions, edges)
|
|
753
|
+
assert_array_equal(hist, np.array([1, 0, 1]))
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# Bounds keep the bin count at most 1000 and every range far wider than the
|
|
757
|
+
# float64 spacing near |lmin| <= 100 (about 1e-14).
|
|
758
|
+
@given(
|
|
759
|
+
bin_size=st.floats(min_value=0.01, max_value=10.0),
|
|
760
|
+
lmin=st.floats(min_value=-100.0, max_value=100.0),
|
|
761
|
+
n_bins_real=st.floats(min_value=0.01, max_value=1000.0),
|
|
762
|
+
dim=st.sampled_from([0, 1, 2]),
|
|
763
|
+
data=st.data(),
|
|
764
|
+
)
|
|
765
|
+
def test_axial_histogram_counts_every_position_in_range_once(
|
|
766
|
+
bin_size: float,
|
|
767
|
+
lmin: float,
|
|
768
|
+
n_bins_real: float,
|
|
769
|
+
dim: AxisT,
|
|
770
|
+
data: st.DataObject,
|
|
771
|
+
) -> None:
|
|
772
|
+
"""Every position in [lmin, lmax] is counted exactly once over the edges
|
|
773
|
+
create_bin_edge_and_hist builds for that range (a conservation
|
|
774
|
+
property)."""
|
|
775
|
+
lmax = lmin + n_bins_real * bin_size
|
|
776
|
+
positions = data.draw(
|
|
777
|
+
arrays(
|
|
778
|
+
np.float64,
|
|
779
|
+
st.tuples(st.integers(min_value=1, max_value=50), st.just(3)),
|
|
780
|
+
elements=st.floats(min_value=lmin, max_value=lmax),
|
|
781
|
+
)
|
|
782
|
+
)
|
|
783
|
+
edges, _ = create_bin_edge_and_hist(bin_size, lmin, lmax)
|
|
784
|
+
hist = axial_histogram(positions, edges, dim=dim)
|
|
785
|
+
assert hist.sum() == positions.shape[0]
|