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,917 @@
|
|
|
1
|
+
"""Tests for :mod:`polyphys.analyze.sumrule`.
|
|
2
|
+
|
|
3
|
+
Every expected value is hand arithmetic on the written ``-mean`` columns and
|
|
4
|
+
the sizes encoded in the filenames: crowder diameter ``ac1.5``
|
|
5
|
+
(``ChainCyl``) or ``ac1.5`` (``RingCub``), cylinder radius
|
|
6
|
+
``r10.5``, so ``D = 2 * 10.5 - 1 = 20`` and ``D mod a_c = 0.5``. The sum-rule
|
|
7
|
+
tests build a small ``analysis`` phase on disk with one ensAvg CSV per state
|
|
8
|
+
point. The spec-taking tests take ``phi_bulk_c`` and ``phi_bulk_m`` from the
|
|
9
|
+
analytic volume fraction ``2 n a**3 / (3 Z (D - a)**2)`` of ``n`` spheres of
|
|
10
|
+
diameter ``a`` in a cylinder of length ``Z = 2 * lz`` and diameter ``D``, or
|
|
11
|
+
check a result against ``space_profiles`` called with the spec's fields. Two
|
|
12
|
+
tests state properties instead: the ``Phi`` and ``Rho`` forms of
|
|
13
|
+
the rule normalize to one curve, and the normalized sum is one wherever the
|
|
14
|
+
rule holds.
|
|
15
|
+
"""
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import warnings
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import pytest
|
|
24
|
+
from hypothesis import given, settings
|
|
25
|
+
from hypothesis import strategies as st
|
|
26
|
+
from hypothesis.extra.numpy import arrays
|
|
27
|
+
|
|
28
|
+
from polyphys.analyze.sumrule import (
|
|
29
|
+
PROFILE_NORMALIZERS,
|
|
30
|
+
SUM_NORMALIZERS,
|
|
31
|
+
normalize_r,
|
|
32
|
+
normalize_z,
|
|
33
|
+
space_profiles,
|
|
34
|
+
space_profiles_from_spec,
|
|
35
|
+
sum_rule_profiles,
|
|
36
|
+
)
|
|
37
|
+
from polyphys.tests.synthetic import ChainCyl, RingCub, RingCubTwoCrowders
|
|
38
|
+
from polyphys.manage.utils import round_to_multiple
|
|
39
|
+
from polyphys.spec import ProjectSpec
|
|
40
|
+
|
|
41
|
+
# A Pandas4Warning marks a deprecated pandas call; fail on it.
|
|
42
|
+
pytestmark = pytest.mark.filterwarnings("error::pandas.errors.Pandas4Warning")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def radial_profile():
|
|
47
|
+
"""A scaled radial profile of three bins; its first value is 2."""
|
|
48
|
+
return pd.DataFrame({"bin_center": [0.5, 1.5, 2.5],
|
|
49
|
+
"rPhiCrd-scale": [2.0, 4.0, 1.0]})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def make_profile_space(tmp_path):
|
|
54
|
+
"""Factory: write one '<state>-all-<prop>-ensAvg.csv' per state point.
|
|
55
|
+
|
|
56
|
+
Takes the property, the bin centers, one '-mean' column per state
|
|
57
|
+
point, the ensemble_long names (default: two ChainCyl points), and
|
|
58
|
+
optionally one '-norm-sem' column per state point. Returns the ensAvg
|
|
59
|
+
directory, ending with '/'.
|
|
60
|
+
"""
|
|
61
|
+
cyl_states = [
|
|
62
|
+
"N200r10.5lz25.0ac1.5nc1000dt0.005bdump2000adump5000",
|
|
63
|
+
"N200r10.5lz25.0ac1.5nc2000dt0.005bdump2000adump5000",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def _make(prop, bin_center, means, states=cyl_states, group="all",
|
|
67
|
+
norm_sems=None):
|
|
68
|
+
ens_avg_dir = tmp_path / "analysis" / f"N200-{group}-ensAvg"
|
|
69
|
+
ens_avg_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
if norm_sems is None:
|
|
71
|
+
norm_sems = [None] * len(means)
|
|
72
|
+
for state, mean, norm_sem in zip(states, means, norm_sems):
|
|
73
|
+
frame = pd.DataFrame({
|
|
74
|
+
"bin_center": bin_center,
|
|
75
|
+
f"{prop}-mean": mean,
|
|
76
|
+
f"{prop}-var": np.full(len(mean), 0.1),
|
|
77
|
+
f"{prop}-sem": np.full(len(mean), 0.05),
|
|
78
|
+
})
|
|
79
|
+
if norm_sem is not None:
|
|
80
|
+
frame[f"{prop}-norm-sem"] = norm_sem
|
|
81
|
+
frame.to_csv(ens_avg_dir / f"{state}-{group}-{prop}-ensAvg.csv",
|
|
82
|
+
index=False)
|
|
83
|
+
return str(ens_avg_dir) + "/"
|
|
84
|
+
|
|
85
|
+
return _make
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture
|
|
89
|
+
def cyl_sizes():
|
|
90
|
+
"""The species-to-size map of a ChainCyl study."""
|
|
91
|
+
return {"Mon": "dmon", "Crd": "dcrowd"}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"normalizer, expected",
|
|
96
|
+
[
|
|
97
|
+
pytest.param("first", [1.0, 2.0, 0.5], id="first"),
|
|
98
|
+
pytest.param("max", [0.5, 1.0, 0.25], id="max"),
|
|
99
|
+
pytest.param("last", [2.0, 4.0, 1.0], id="last"),
|
|
100
|
+
pytest.param(8.0, [0.25, 0.5, 0.125], id="number"),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
def test_normalize_r_divides_by_the_chosen_reference(radial_profile,
|
|
104
|
+
normalizer, expected):
|
|
105
|
+
"""'first', 'max', 'last', or a number is the divisor of '-scale'."""
|
|
106
|
+
out = normalize_r("rPhiCrd", radial_profile, normalizer)
|
|
107
|
+
# Exact: ratios of small floats
|
|
108
|
+
np.testing.assert_allclose(out["rPhiCrd-norm"], expected, rtol=1e-15)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_normalize_r_calls_a_function_with_the_frame(radial_profile):
|
|
112
|
+
"""A callable normalizer receives the whole frame and sets the value."""
|
|
113
|
+
out = normalize_r("rPhiCrd", radial_profile,
|
|
114
|
+
lambda df: df["rPhiCrd-scale"].iloc[:2].mean())
|
|
115
|
+
assert out["rPhiCrd-normalizer"].tolist() == [3.0, 3.0, 3.0]
|
|
116
|
+
np.testing.assert_allclose(out["rPhiCrd-norm"], [2 / 3, 4 / 3, 1 / 3],
|
|
117
|
+
rtol=1e-15)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize(
|
|
121
|
+
"normalizer, message",
|
|
122
|
+
[
|
|
123
|
+
pytest.param("mean", "'mean' is an invalid option", id="string"),
|
|
124
|
+
pytest.param([1.0], "must be 'first', 'last', 'max'", id="list"),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
def test_normalize_r_rejects_an_unknown_normalizer(radial_profile,
|
|
128
|
+
normalizer, message):
|
|
129
|
+
"""A normalizer that is no literal, number, or callable raises."""
|
|
130
|
+
with pytest.raises(ValueError, match=message):
|
|
131
|
+
normalize_r("rPhiCrd", radial_profile, normalizer)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_normalize_r_zero_reference_warns_and_zeroes():
|
|
135
|
+
"""A zero reference value warns and sets every '-norm' to zero."""
|
|
136
|
+
profile = pd.DataFrame({"rPhiCrd-scale": [0.0, 1.0, 2.0]})
|
|
137
|
+
with pytest.warns(UserWarning, match="normalizer of 'rPhiCrd' is zero"):
|
|
138
|
+
out = normalize_r("rPhiCrd", profile, "first")
|
|
139
|
+
assert out["rPhiCrd-norm"].tolist() == [0.0, 0.0, 0.0]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_normalize_leaves_the_input_unchanged(radial_profile):
|
|
143
|
+
"""normalize_r and normalize_z return new frames and add no column."""
|
|
144
|
+
before = radial_profile.copy()
|
|
145
|
+
normalize_r("rPhiCrd", radial_profile)
|
|
146
|
+
normalize_z("rPhiCrd", radial_profile, fold=False)
|
|
147
|
+
pd.testing.assert_frame_equal(radial_profile, before)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_normalize_z_folds_an_even_count_onto_abs_z():
|
|
151
|
+
"""Mirror bins of an even count average into ceil(n/2) rows at |z|."""
|
|
152
|
+
profile = pd.DataFrame({"bin_center": [-1.5, -0.5, 0.5, 1.5],
|
|
153
|
+
"zRhoMon-scale": [1.0, 3.0, 3.0, 1.0]})
|
|
154
|
+
out = normalize_z("zRhoMon", profile)
|
|
155
|
+
assert out["bin_center"].tolist() == [0.5, 1.5]
|
|
156
|
+
np.testing.assert_allclose(out["zRhoMon-norm"], [1.0, 1 / 3], rtol=1e-15)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_normalize_z_keeps_the_center_bin_of_an_odd_count():
|
|
160
|
+
"""The center bin of an odd count is kept as it is, first in |z| order."""
|
|
161
|
+
profile = pd.DataFrame({"bin_center": [-1.0, 0.0, 1.0],
|
|
162
|
+
"zRhoMon-scale": [2.0, 4.0, 2.0]})
|
|
163
|
+
out = normalize_z("zRhoMon", profile)
|
|
164
|
+
assert out["bin_center"].tolist() == [0.0, 1.0]
|
|
165
|
+
np.testing.assert_allclose(out["zRhoMon-norm"], [1.0, 0.5], rtol=1e-15)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_normalize_z_averages_mirror_bins_of_an_asymmetric_profile():
|
|
169
|
+
"""Folding averages bin i with bin n-1-i: [1, 2, 3, 4] gives [2.5, 2.5]."""
|
|
170
|
+
profile = pd.DataFrame({"bin_center": [-1.5, -0.5, 0.5, 1.5],
|
|
171
|
+
"zRhoMon-scale": [1.0, 2.0, 3.0, 4.0]})
|
|
172
|
+
out = normalize_z("zRhoMon", profile)
|
|
173
|
+
# (2 + 3) / 2 and (1 + 4) / 2, each over the maximum 4
|
|
174
|
+
np.testing.assert_allclose(out["zRhoMon-norm"], [2.5 / 4, 2.5 / 4],
|
|
175
|
+
rtol=1e-15)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_normalize_z_without_fold_keeps_every_row():
|
|
179
|
+
"""fold=False normalizes by the maximum and keeps all n rows."""
|
|
180
|
+
profile = pd.DataFrame({"bin_center": [-1.5, -0.5, 0.5, 1.5],
|
|
181
|
+
"zRhoMon-scale": [1.0, 2.0, 3.0, 4.0]})
|
|
182
|
+
out = normalize_z("zRhoMon", profile, fold=False)
|
|
183
|
+
assert len(out) == 4
|
|
184
|
+
np.testing.assert_allclose(out["zRhoMon-norm"], [0.25, 0.5, 0.75, 1.0],
|
|
185
|
+
rtol=1e-15)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_normalize_z_folds_var_and_sem_like_any_column():
|
|
189
|
+
"""Folding averages '-var' and '-sem' linearly, as documented."""
|
|
190
|
+
profile = pd.DataFrame({"bin_center": [-1.5, -0.5, 0.5, 1.5],
|
|
191
|
+
"zRhoMon-scale": [1.0, 1.0, 1.0, 1.0],
|
|
192
|
+
"zRhoMon-var": [1.0, 1.0, 1.0, 1.0],
|
|
193
|
+
"zRhoMon-sem": [0.2, 0.4, 0.6, 0.8]})
|
|
194
|
+
out = normalize_z("zRhoMon", profile)
|
|
195
|
+
assert out["zRhoMon-var"].tolist() == [1.0, 1.0]
|
|
196
|
+
np.testing.assert_allclose(out["zRhoMon-sem"], [0.5, 0.5], rtol=1e-15)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.parametrize(
|
|
200
|
+
"centers",
|
|
201
|
+
[
|
|
202
|
+
pytest.param([-2.0, -1.0, 0.5, 1.5], id="shifted-half"),
|
|
203
|
+
pytest.param([-1.0, 0.1, 1.0], id="off-center-middle"),
|
|
204
|
+
pytest.param([1.5, 0.5, -0.5, -1.5], id="decreasing"),
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
def test_normalize_z_fold_rejects_bins_not_symmetric_about_zero(centers):
|
|
208
|
+
"""Folding pairs bin i with bin n-1-i, which is its mirror only when
|
|
209
|
+
the centers increase and are symmetric about z = 0."""
|
|
210
|
+
profile = pd.DataFrame({"bin_center": centers,
|
|
211
|
+
"zRhoMon-scale": np.ones(len(centers))})
|
|
212
|
+
with pytest.raises(ValueError, match="symmetric about z = 0"):
|
|
213
|
+
normalize_z("zRhoMon", profile)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_normalize_z_fold_accepts_round_off_asymmetry():
|
|
217
|
+
"""Centers that miss their mirror by float64 round-off still fold."""
|
|
218
|
+
profile = pd.DataFrame({"bin_center": [-1.5, -0.5, 0.5 + 1e-15, 1.5],
|
|
219
|
+
"zRhoMon-scale": [1.0, 3.0, 3.0, 1.0]})
|
|
220
|
+
out = normalize_z("zRhoMon", profile)
|
|
221
|
+
assert len(out) == 2
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.mark.parametrize(
|
|
225
|
+
"centers,curve,expected",
|
|
226
|
+
[
|
|
227
|
+
pytest.param([-1.5, -0.5, 0.5, 1.5], [0.1, 0.2, 0.3, 0.4],
|
|
228
|
+
[0.5, 0.5], id="even"),
|
|
229
|
+
pytest.param([-1.0, 0.0, 1.0], [0.25, 0.5, 0.25], [0.5, 0.5],
|
|
230
|
+
id="odd"),
|
|
231
|
+
],
|
|
232
|
+
)
|
|
233
|
+
def test_normalize_z_folds_the_normalized_curve_onto_abs_z(
|
|
234
|
+
centers, curve, expected
|
|
235
|
+
):
|
|
236
|
+
"""Folding sums mirror bins of ``-scale-normalized_curve``, so the
|
|
237
|
+
folded curve is the distribution of |z| and still sums to 1:
|
|
238
|
+
P(|z|) = P(z) + P(-z) for z != 0 (analytic)."""
|
|
239
|
+
profile = pd.DataFrame({"bin_center": centers,
|
|
240
|
+
"zRhoMon-scale": np.ones(len(centers)),
|
|
241
|
+
"zRhoMon-scale-normalized_curve": curve})
|
|
242
|
+
out = normalize_z("zRhoMon", profile)
|
|
243
|
+
# float64 round-off of one addition:
|
|
244
|
+
np.testing.assert_allclose(out["zRhoMon-scale-normalized_curve"],
|
|
245
|
+
expected, rtol=1e-15)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_normalize_z_fold_needs_bin_center():
|
|
249
|
+
"""Folding a frame without 'bin_center' raises ValueError."""
|
|
250
|
+
with pytest.raises(ValueError, match="needs a 'bin_center' column"):
|
|
251
|
+
normalize_z("zRhoMon", pd.DataFrame({"zRhoMon-scale": [1.0, 2.0]}))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_default_normalizer_tables_follow_the_research_rule():
|
|
255
|
+
"""Profiles use 'first' for cylinder r and 'max' otherwise; sums 'last'."""
|
|
256
|
+
assert PROFILE_NORMALIZERS == {("cylindrical", "r"): "first",
|
|
257
|
+
("cylindrical", "z"): "max",
|
|
258
|
+
("cubic", "r"): "max"}
|
|
259
|
+
assert SUM_NORMALIZERS == {("cylindrical", "r"): "first",
|
|
260
|
+
("cylindrical", "z"): "last",
|
|
261
|
+
("cubic", "r"): "last"}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_space_profiles_scales_phi_by_the_species_size(make_profile_space,
|
|
265
|
+
cyl_sizes):
|
|
266
|
+
"""A 'Phi' profile is divided by the species diameter from the name."""
|
|
267
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
268
|
+
[[0.2, 0.3, 0.1]])
|
|
269
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
270
|
+
"all", cyl_sizes)
|
|
271
|
+
assert space["rPhiCrd-scaler"].tolist() == [1.5, 1.5, 1.5]
|
|
272
|
+
assert space["rPhiCrd-sumrule_constant"].tolist() == [1.5, 1.5, 1.5]
|
|
273
|
+
np.testing.assert_allclose(space["rPhiCrd-scale"],
|
|
274
|
+
[0.2 / 1.5, 0.3 / 1.5, 0.1 / 1.5], rtol=1e-15)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_space_profiles_scales_rho_by_the_size_squared(make_profile_space,
|
|
278
|
+
cyl_sizes):
|
|
279
|
+
"""A 'Rho' profile is multiplied by the species diameter squared."""
|
|
280
|
+
database = make_profile_space("rRhoCrd", [0.5, 1.5, 2.5],
|
|
281
|
+
[[0.2, 0.3, 0.1]])
|
|
282
|
+
space = space_profiles(database, "Rho", "r", "Crd", ChainCyl, "N*", [],
|
|
283
|
+
"all", cyl_sizes)
|
|
284
|
+
assert space["rRhoCrd-scaler"].tolist() == [2.25, 2.25, 2.25]
|
|
285
|
+
np.testing.assert_allclose(space["rRhoCrd-scale"],
|
|
286
|
+
[0.2 * 2.25, 0.3 * 2.25, 0.1 * 2.25],
|
|
287
|
+
rtol=1e-15)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_space_profiles_phi_and_rho_normalize_to_the_same_curve(
|
|
291
|
+
make_profile_space, cyl_sizes
|
|
292
|
+
):
|
|
293
|
+
"""With rho = 6 phi / (pi a^3), 'Phi' and 'Rho' give one '-norm' curve.
|
|
294
|
+
|
|
295
|
+
Property: phi / a and rho a^2 differ by the constant pi / 6, which the
|
|
296
|
+
normalization cancels.
|
|
297
|
+
"""
|
|
298
|
+
phi = np.array([0.2, 0.3, 0.1])
|
|
299
|
+
rho = 6 * phi / (np.pi * 1.5 ** 3)
|
|
300
|
+
make_profile_space("rPhiCrd", [0.5, 1.5, 2.5], [phi])
|
|
301
|
+
database = make_profile_space("rRhoCrd", [0.5, 1.5, 2.5], [rho])
|
|
302
|
+
by_phi = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
303
|
+
"all", cyl_sizes)
|
|
304
|
+
by_rho = space_profiles(database, "Rho", "r", "Crd", ChainCyl, "N*", [],
|
|
305
|
+
"all", cyl_sizes)
|
|
306
|
+
# float64 round-off of a few multiplications
|
|
307
|
+
np.testing.assert_allclose(by_rho["rRhoCrd-norm"], by_phi["rPhiCrd-norm"],
|
|
308
|
+
rtol=1e-12)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_space_profiles_cylinder_r_normalizes_by_the_axis_value(
|
|
312
|
+
make_profile_space, cyl_sizes
|
|
313
|
+
):
|
|
314
|
+
"""In a cylinder, an r profile is divided by its r = 0 bin by default."""
|
|
315
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
316
|
+
[[0.2, 0.3, 0.1]])
|
|
317
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
318
|
+
"all", cyl_sizes)
|
|
319
|
+
np.testing.assert_allclose(space["rPhiCrd-normalizer"], [0.2 / 1.5] * 3,
|
|
320
|
+
rtol=1e-15)
|
|
321
|
+
np.testing.assert_allclose(space["rPhiCrd-norm"], [1.0, 1.5, 0.5],
|
|
322
|
+
rtol=1e-15)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_space_profiles_cube_r_normalizes_by_the_maximum(make_profile_space):
|
|
326
|
+
"""In a cube, an r profile is divided by its maximum and not recentered."""
|
|
327
|
+
state = "al5.0nl5ml125.0ns400ac1.5nc100l100.0dt0.005bdump2000adump5000"
|
|
328
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
329
|
+
[[0.2, 0.3, 0.1]], states=[state])
|
|
330
|
+
space = space_profiles(database, "Phi", "r", "Crd", RingCub,
|
|
331
|
+
"al*", [], "all",
|
|
332
|
+
{"Mon": "dmon_small", "Crd": "dcrowd",
|
|
333
|
+
"Foci": "dmon_large"})
|
|
334
|
+
np.testing.assert_allclose(space["rPhiCrd-norm"], [2 / 3, 1.0, 1 / 3],
|
|
335
|
+
rtol=1e-15)
|
|
336
|
+
np.testing.assert_allclose(space["bin_center-dcrowd"],
|
|
337
|
+
[1 / 1.5, 3 / 1.5, 5 / 1.5], rtol=1e-15)
|
|
338
|
+
assert not [c for c in space.columns if "recentered" in c]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_space_profiles_z_folds_and_normalizes_by_the_maximum(
|
|
342
|
+
make_profile_space, cyl_sizes
|
|
343
|
+
):
|
|
344
|
+
"""A z profile is folded about z = 0, divided by its maximum, and not
|
|
345
|
+
recentered."""
|
|
346
|
+
database = make_profile_space("zPhiCrd", [-1.5, -0.5, 0.5, 1.5],
|
|
347
|
+
[[0.1, 0.3, 0.3, 0.1]])
|
|
348
|
+
space = space_profiles(database, "Phi", "z", "Crd", ChainCyl, "N*", [],
|
|
349
|
+
"all", cyl_sizes)
|
|
350
|
+
assert space["bin_center"].tolist() == [0.5, 1.5]
|
|
351
|
+
np.testing.assert_allclose(space["zPhiCrd-norm"], [1.0, 1 / 3], rtol=1e-15)
|
|
352
|
+
np.testing.assert_allclose(space["bin_center-dcrowd"], [1 / 1.5, 3 / 1.5],
|
|
353
|
+
rtol=1e-15)
|
|
354
|
+
assert not [c for c in space.columns if "recentered" in c]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_space_profiles_recenters_cylinder_r_on_the_wall(make_profile_space,
|
|
358
|
+
cyl_sizes):
|
|
359
|
+
"""For r in a cylinder, 2b/a_c - (D mod a_c)/a_c and the normalized
|
|
360
|
+
b - (D mod a_c) are added, with D = 20 and a_c = 1.5 from the name."""
|
|
361
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
362
|
+
[[0.2, 0.3, 0.1]])
|
|
363
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
364
|
+
"all", cyl_sizes)
|
|
365
|
+
# D mod a_c = 20 mod 1.5 = 0.5, so the shift is 1/3 crowder radii
|
|
366
|
+
np.testing.assert_allclose(space["bin_center-dcrowd"],
|
|
367
|
+
[1 / 1.5, 3 / 1.5, 5 / 1.5], rtol=1e-15)
|
|
368
|
+
np.testing.assert_allclose(space["bin_center-dcrowd-recentered"],
|
|
369
|
+
[1 / 1.5 - 1 / 3, 3 / 1.5 - 1 / 3,
|
|
370
|
+
5 / 1.5 - 1 / 3], rtol=1e-14)
|
|
371
|
+
np.testing.assert_allclose(space["bin_center-recentered-norm"],
|
|
372
|
+
[0.0, 0.5, 1.0], rtol=1e-15, atol=1e-15)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_space_profiles_adds_one_column_set_per_crowder(make_profile_space):
|
|
376
|
+
"""Two crowders give 'bin_center-<size>' for each, in a cube."""
|
|
377
|
+
state = ("al5.0nl5ns400ac1.0ab2.0nc100nb50l100.0dt0.005"
|
|
378
|
+
"bdump2000adump5000")
|
|
379
|
+
database = make_profile_space("rPhiCrdOne", [0.5, 1.5, 2.5],
|
|
380
|
+
[[0.2, 0.3, 0.1]], states=[state])
|
|
381
|
+
space = space_profiles(
|
|
382
|
+
database, "Phi", "r", "CrdOne", RingCubTwoCrowders,
|
|
383
|
+
"al*", [], "all",
|
|
384
|
+
{"Dna": "dmon_small", "CrdOne": "dcrowd_one", "CrdTwo": "dcrowd_two",
|
|
385
|
+
"Foci": "dmon_large"},
|
|
386
|
+
crowders=("CrdOne", "CrdTwo"),
|
|
387
|
+
)
|
|
388
|
+
np.testing.assert_allclose(space["bin_center-dcrowd_one"],
|
|
389
|
+
[1.0, 3.0, 5.0], rtol=1e-15)
|
|
390
|
+
np.testing.assert_allclose(space["bin_center-dcrowd_two"],
|
|
391
|
+
[0.5, 1.5, 2.5], rtol=1e-15)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_space_profiles_two_crowders_in_a_cylinder_recenter_each(
|
|
395
|
+
make_profile_space
|
|
396
|
+
):
|
|
397
|
+
"""Two crowder labels of one size in a cylinder give equal recentered
|
|
398
|
+
columns, each suffixed by its size attribute.
|
|
399
|
+
|
|
400
|
+
Property: the research code's bidisperse copy raised KeyError here.
|
|
401
|
+
"""
|
|
402
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
403
|
+
[[0.2, 0.3, 0.1]])
|
|
404
|
+
space = space_profiles(
|
|
405
|
+
database, "Phi", "r", "Crd", ChainCyl, "N*", [], "all",
|
|
406
|
+
{"Mon": "dmon", "Crd": "dcrowd", "Big": "dmon"},
|
|
407
|
+
crowders=("Crd", "Big"),
|
|
408
|
+
)
|
|
409
|
+
assert "bin_center-recentered-norm-dcrowd" in space.columns
|
|
410
|
+
assert "bin_center-recentered-norm-dmon" in space.columns
|
|
411
|
+
# a_c = 1: 2b - (20 mod 1) = 2b
|
|
412
|
+
np.testing.assert_allclose(space["bin_center-dmon-recentered"],
|
|
413
|
+
[1.0, 3.0, 5.0], rtol=1e-15)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_space_profiles_concatenates_files_in_name_order(make_profile_space,
|
|
417
|
+
cyl_sizes):
|
|
418
|
+
"""Every matching file contributes its rows, sorted by name, with its
|
|
419
|
+
ensemble_long name as a column."""
|
|
420
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
421
|
+
[[0.2, 0.3, 0.1], [0.4, 0.2, 0.2]])
|
|
422
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
423
|
+
"all", cyl_sizes)
|
|
424
|
+
assert len(space) == 6
|
|
425
|
+
assert space["ensemble_long"].str.extract(r"nc(\d+)")[0].tolist() == (
|
|
426
|
+
["1000"] * 3 + ["2000"] * 3)
|
|
427
|
+
np.testing.assert_allclose(space["rPhiCrd-norm"],
|
|
428
|
+
[1.0, 1.5, 0.5, 1.0, 0.5, 0.5], rtol=1e-15)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_space_profiles_adds_attributes_and_rounded_phi_c(make_profile_space,
|
|
432
|
+
cyl_sizes):
|
|
433
|
+
"""physical_attrs, phi_bulk_c, and phi_bulk_c_round come from the name."""
|
|
434
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
435
|
+
[[0.2, 0.3, 0.1]])
|
|
436
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*",
|
|
437
|
+
["nmon", "dcyl"], "all", cyl_sizes, divisor=0.05,
|
|
438
|
+
round_to=2)
|
|
439
|
+
info = ChainCyl(
|
|
440
|
+
"N200r10.5lz25.0ac1.5nc1000dt0.005bdump2000adump5000",
|
|
441
|
+
"ensemble_long", "all")
|
|
442
|
+
assert space["nmon"].tolist() == [200] * 3
|
|
443
|
+
assert space["dcyl"].tolist() == [20.0] * 3
|
|
444
|
+
assert space["phi_bulk_c"].tolist() == [info.phi_bulk_c] * 3
|
|
445
|
+
assert space["phi_bulk_c_round"].tolist() == [
|
|
446
|
+
round_to_multiple(info.phi_bulk_c, 0.05, 2)] * 3
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def test_space_profiles_two_argument_normalizer_sees_the_parser(
|
|
450
|
+
make_profile_space, cyl_sizes
|
|
451
|
+
):
|
|
452
|
+
"""A callable normalizer receives the frame and the file's parser."""
|
|
453
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
454
|
+
[[0.2, 0.3, 0.1]])
|
|
455
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
456
|
+
"all", cyl_sizes,
|
|
457
|
+
normalizer=lambda df, info: info.dcrowd)
|
|
458
|
+
assert space["rPhiCrd-normalizer"].tolist() == [1.5, 1.5, 1.5]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_space_profiles_records_and_logs_an_override(make_profile_space,
|
|
462
|
+
cyl_sizes, caplog):
|
|
463
|
+
"""An explicit normalizer is used, recorded, and logged with the
|
|
464
|
+
default it replaces."""
|
|
465
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
466
|
+
[[0.2, 0.3, 0.1]])
|
|
467
|
+
caplog.set_level(logging.INFO, logger="polyphys.analyze.sumrule")
|
|
468
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
469
|
+
"all", cyl_sizes, normalizer="max")
|
|
470
|
+
np.testing.assert_allclose(space["rPhiCrd-normalizer"], [0.3 / 1.5] * 3,
|
|
471
|
+
rtol=1e-15)
|
|
472
|
+
assert [r.getMessage() for r in caplog.records] == [
|
|
473
|
+
"Normalizing rPhiCrd profiles with 'max' instead of the cylindrical"
|
|
474
|
+
" r default 'first'."]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_space_profiles_saves_with_the_direction_in_the_name(
|
|
478
|
+
make_profile_space, cyl_sizes, tmp_path
|
|
479
|
+
):
|
|
480
|
+
"""is_save writes '<space>-<group>-<dir><quantity><species>-normalized\
|
|
481
|
+
Rescaled-space.csv' in the analysis phase's space directory."""
|
|
482
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
483
|
+
[[0.2, 0.3, 0.1]])
|
|
484
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
485
|
+
"all", cyl_sizes, is_save=True)
|
|
486
|
+
saved = pd.read_csv(tmp_path / "analysis" / "N200-all-space" /
|
|
487
|
+
"N200-all-rPhiCrd-normalizedRescaled-space.csv")
|
|
488
|
+
pd.testing.assert_frame_equal(saved, space)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@pytest.mark.parametrize(
|
|
492
|
+
"kwargs, error, message",
|
|
493
|
+
[
|
|
494
|
+
pytest.param({"quantity": "Psi"}, ValueError,
|
|
495
|
+
"'Psi' is an invalid option", id="quantity"),
|
|
496
|
+
pytest.param({"species": "Hns"}, KeyError,
|
|
497
|
+
"Species 'Hns' is not in 'species_sizes'", id="species"),
|
|
498
|
+
pytest.param({"crowders": ("Crd", "Hns")}, KeyError,
|
|
499
|
+
r"Crowders \['Hns'\] are not in", id="crowder"),
|
|
500
|
+
pytest.param({"parser": RingCub, "direction": "z"},
|
|
501
|
+
ValueError, "No density profile along 'z' in a cubic",
|
|
502
|
+
id="cubic-z"),
|
|
503
|
+
pytest.param({"hierarchy": "M*"}, ValueError,
|
|
504
|
+
"No 'M\\*-all-rPhiCrd-ensAvg.csv' file", id="no-file"),
|
|
505
|
+
],
|
|
506
|
+
)
|
|
507
|
+
def test_space_profiles_rejects_bad_arguments(make_profile_space, cyl_sizes,
|
|
508
|
+
kwargs, error, message):
|
|
509
|
+
"""An unknown quantity, species, crowder, (geometry, direction) pair, or
|
|
510
|
+
an empty match raises before any frame is built."""
|
|
511
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
512
|
+
[[0.2, 0.3, 0.1]])
|
|
513
|
+
args = dict(input_database=database, quantity="Phi", direction="r",
|
|
514
|
+
species="Crd", parser=ChainCyl, hierarchy="N*",
|
|
515
|
+
physical_attrs=[], group="all", species_sizes=cyl_sizes)
|
|
516
|
+
args.update(kwargs)
|
|
517
|
+
with pytest.raises(error, match=message):
|
|
518
|
+
space_profiles(**args)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@pytest.mark.parametrize(
|
|
522
|
+
"means",
|
|
523
|
+
[
|
|
524
|
+
pytest.param([0.2, 0.3, 0.5], id="sums-to-one"),
|
|
525
|
+
pytest.param([0.2, 0.3, 0.5 + 1e-7], id="within-tolerance"),
|
|
526
|
+
],
|
|
527
|
+
)
|
|
528
|
+
def test_space_profiles_warns_on_a_profile_that_sums_to_one(
|
|
529
|
+
make_profile_space, cyl_sizes, means
|
|
530
|
+
):
|
|
531
|
+
"""A '-mean' column summing to one within 1e-6 warns, naming the file,
|
|
532
|
+
and the file is processed regardless."""
|
|
533
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5], [means])
|
|
534
|
+
with pytest.warns(UserWarning, match="rPhiCrd-ensAvg.csv' sums to one"):
|
|
535
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*",
|
|
536
|
+
[], "all", cyl_sizes)
|
|
537
|
+
assert len(space) == 3
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@pytest.mark.parametrize(
|
|
541
|
+
"means",
|
|
542
|
+
[
|
|
543
|
+
pytest.param([0.2, 0.3, 0.1], id="raw"),
|
|
544
|
+
pytest.param([0.2, 0.3, 0.5 + 1e-5], id="outside-tolerance"),
|
|
545
|
+
],
|
|
546
|
+
)
|
|
547
|
+
def test_space_profiles_does_not_warn_on_a_raw_profile(make_profile_space,
|
|
548
|
+
cyl_sizes, means):
|
|
549
|
+
"""A '-mean' column that does not sum to one within 1e-6 raises no
|
|
550
|
+
warning."""
|
|
551
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5], [means])
|
|
552
|
+
with warnings.catch_warnings():
|
|
553
|
+
warnings.simplefilter("error")
|
|
554
|
+
space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
555
|
+
"all", cyl_sizes)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_space_profiles_carries_norm_sem_through(make_profile_space,
|
|
559
|
+
cyl_sizes):
|
|
560
|
+
"""A file's '-norm-sem' column passes through unchanged under the
|
|
561
|
+
default normalizer."""
|
|
562
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
563
|
+
[[0.2, 0.3, 0.1]],
|
|
564
|
+
norm_sems=[[0.0, 0.1, 0.2]])
|
|
565
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*", [],
|
|
566
|
+
"all", cyl_sizes)
|
|
567
|
+
assert space["rPhiCrd-norm-sem"].tolist() == [0.0, 0.1, 0.2]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def test_space_profiles_folds_norm_sem_along_z(make_profile_space, cyl_sizes):
|
|
571
|
+
"""Along z, '-norm-sem' is averaged with its mirror bin like every
|
|
572
|
+
other numeric column."""
|
|
573
|
+
database = make_profile_space("zPhiCrd", [-1.5, -0.5, 0.5, 1.5],
|
|
574
|
+
[[0.1, 0.3, 0.3, 0.1]],
|
|
575
|
+
norm_sems=[[0.2, 0.4, 0.6, 0.8]])
|
|
576
|
+
space = space_profiles(database, "Phi", "z", "Crd", ChainCyl, "N*", [],
|
|
577
|
+
"all", cyl_sizes)
|
|
578
|
+
# Exact: (0.4 + 0.6) / 2 and (0.2 + 0.8) / 2 in float64
|
|
579
|
+
np.testing.assert_allclose(space["zPhiCrd-norm-sem"], [0.5, 0.5],
|
|
580
|
+
rtol=1e-15)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def test_space_profiles_drops_norm_sem_under_an_explicit_normalizer(
|
|
584
|
+
make_profile_space, cyl_sizes
|
|
585
|
+
):
|
|
586
|
+
"""An explicit normalizer drops a file's '-norm-sem', with a warning
|
|
587
|
+
that names the file and both normalizers."""
|
|
588
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
589
|
+
[[0.2, 0.3, 0.1]],
|
|
590
|
+
norm_sems=[[0.0, 0.1, 0.2]])
|
|
591
|
+
with pytest.warns(UserWarning,
|
|
592
|
+
match=r"Dropping 'rPhiCrd-norm-sem' of '.*rPhiCrd-"
|
|
593
|
+
r"ensAvg.csv'.*'first', not under 'max'"):
|
|
594
|
+
space = space_profiles(database, "Phi", "r", "Crd", ChainCyl, "N*",
|
|
595
|
+
[], "all", cyl_sizes, normalizer="max")
|
|
596
|
+
assert "rPhiCrd-norm-sem" not in space.columns
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@pytest.fixture
|
|
600
|
+
def make_cyl_spec():
|
|
601
|
+
"""Factory: a ChainCyl ProjectSpec; keyword arguments override fields."""
|
|
602
|
+
def _make(**overrides):
|
|
603
|
+
fields = {
|
|
604
|
+
"parser": ChainCyl, "group": "bug", "divisor": 0.025,
|
|
605
|
+
"crowder_attr": "phi_bulk_c", "physical_attrs": ("nmon", "dcyl"),
|
|
606
|
+
"species_sizes": {"Mon": "dmon", "Crd": "dcrowd"},
|
|
607
|
+
"tseries_properties": ("gyrTMon",),
|
|
608
|
+
}
|
|
609
|
+
return ProjectSpec(**{**fields, **overrides})
|
|
610
|
+
|
|
611
|
+
return _make
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@pytest.mark.parametrize(
|
|
615
|
+
"kwargs",
|
|
616
|
+
[
|
|
617
|
+
pytest.param({}, id="defaults"),
|
|
618
|
+
pytest.param({"normalizer": "last", "round_to": 2}, id="overrides"),
|
|
619
|
+
],
|
|
620
|
+
)
|
|
621
|
+
def test_space_profiles_from_spec_matches_space_profiles_with_spec_fields(
|
|
622
|
+
make_profile_space, make_cyl_spec, kwargs):
|
|
623
|
+
"""The spec-taking table equals space_profiles called with the spec's
|
|
624
|
+
fields one by one."""
|
|
625
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
626
|
+
[[0.2, 0.3, 0.1], [0.4, 0.2, 0.2]])
|
|
627
|
+
spec = make_cyl_spec(divisor=0.05)
|
|
628
|
+
expected = space_profiles(
|
|
629
|
+
database, "Phi", "r", "Crd", spec.parser, spec.hierarchy,
|
|
630
|
+
list(spec.physical_attrs), "all", spec.species_sizes,
|
|
631
|
+
divisor=spec.divisor, crowder_attr=spec.crowder_attr, **kwargs)
|
|
632
|
+
space = space_profiles_from_spec(database, spec, "Phi", "r", "Crd",
|
|
633
|
+
**kwargs)
|
|
634
|
+
pd.testing.assert_frame_equal(space, expected)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@pytest.mark.parametrize(
|
|
638
|
+
"divisor, rounded",
|
|
639
|
+
[
|
|
640
|
+
pytest.param(0.001, 0.329, id="step-0.001"),
|
|
641
|
+
pytest.param(0.025, 0.325, id="step-0.025"),
|
|
642
|
+
],
|
|
643
|
+
)
|
|
644
|
+
def test_space_profiles_from_spec_rounds_with_the_spec_divisor(
|
|
645
|
+
make_profile_space, make_cyl_spec, divisor, rounded):
|
|
646
|
+
"""phi_bulk_c_round rounds phi_bulk_c to a multiple of the spec's
|
|
647
|
+
divisor."""
|
|
648
|
+
# n = 1000 crowders of a = 2 in D = 20, Z = 50: phi_c = 16000 / 48600
|
|
649
|
+
# = 0.32922, which rounds to 0.329 on a grid of 0.001 and to 0.325 on a
|
|
650
|
+
# grid of 0.025.
|
|
651
|
+
database = make_profile_space(
|
|
652
|
+
"rPhiCrd", [0.5, 1.5, 2.5], [[0.2, 0.3, 0.1]],
|
|
653
|
+
states=["N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000"
|
|
654
|
+
"adump5000"])
|
|
655
|
+
space = space_profiles_from_spec(database, make_cyl_spec(divisor=divisor),
|
|
656
|
+
"Phi", "r", "Crd")
|
|
657
|
+
assert space["phi_bulk_c_round"].tolist() == [rounded] * 3
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_space_profiles_from_spec_rounds_the_spec_crowder_attr(
|
|
661
|
+
make_profile_space, make_cyl_spec):
|
|
662
|
+
"""The spec's crowder_attr names the attribute that is added and
|
|
663
|
+
rounded."""
|
|
664
|
+
# N = 200 monomers of a = 1 in D = 20, Z = 50: phi_m = 400 / 54150
|
|
665
|
+
# = 0.0073869, which rounds to 0.007 on a grid of 0.001.
|
|
666
|
+
database = make_profile_space(
|
|
667
|
+
"rPhiCrd", [0.5, 1.5, 2.5], [[0.2, 0.3, 0.1]],
|
|
668
|
+
states=["N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000"
|
|
669
|
+
"adump5000"])
|
|
670
|
+
spec = make_cyl_spec(divisor=0.001, crowder_attr="phi_bulk_m")
|
|
671
|
+
space = space_profiles_from_spec(database, spec, "Phi", "r", "Crd")
|
|
672
|
+
# rel: float64 round-off of the parser's arithmetic is far below the
|
|
673
|
+
# five significant figures of the analytic value.
|
|
674
|
+
assert space["phi_bulk_m"].tolist() == pytest.approx([400 / 54150] * 3,
|
|
675
|
+
rel=1e-12)
|
|
676
|
+
assert space["phi_bulk_c_round"].tolist() == [0.007] * 3
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@pytest.mark.parametrize(
|
|
680
|
+
"written, kwargs",
|
|
681
|
+
[
|
|
682
|
+
pytest.param("all", {}, id="default-all"),
|
|
683
|
+
pytest.param("bug", {"group": "bug"}, id="override-bug"),
|
|
684
|
+
],
|
|
685
|
+
)
|
|
686
|
+
def test_space_profiles_from_spec_reads_the_group_profiles(
|
|
687
|
+
make_profile_space, make_cyl_spec, written, kwargs):
|
|
688
|
+
"""The profiles come from the 'all' group, not spec.group, unless group
|
|
689
|
+
names another."""
|
|
690
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
691
|
+
[[0.2, 0.3, 0.1]], group=written)
|
|
692
|
+
space = space_profiles_from_spec(database, make_cyl_spec(), "Phi", "r",
|
|
693
|
+
"Crd", **kwargs)
|
|
694
|
+
# Phi / a_c with a_c = 1.5; rtol: float64 round-off of one division.
|
|
695
|
+
np.testing.assert_allclose(space["rPhiCrd-scale"],
|
|
696
|
+
np.array([0.2, 0.3, 0.1]) / 1.5, rtol=1e-15)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def test_space_profiles_from_spec_saves_from_a_path(make_profile_space,
|
|
700
|
+
make_cyl_spec, tmp_path):
|
|
701
|
+
"""A path-like database with save=True writes the table that
|
|
702
|
+
space_profiles names."""
|
|
703
|
+
database = make_profile_space("rPhiCrd", [0.5, 1.5, 2.5],
|
|
704
|
+
[[0.2, 0.3, 0.1]])
|
|
705
|
+
space = space_profiles_from_spec(Path(database), make_cyl_spec(), "Phi",
|
|
706
|
+
"r", "Crd", save=True)
|
|
707
|
+
saved = pd.read_csv(tmp_path / "analysis" / "N200-all-space" /
|
|
708
|
+
"N200-all-rPhiCrd-normalizedRescaled-space.csv")
|
|
709
|
+
pd.testing.assert_frame_equal(saved, space)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@pytest.fixture
|
|
713
|
+
def species_profiles():
|
|
714
|
+
"""Factory: the scaled profiles of two species over two state points.
|
|
715
|
+
|
|
716
|
+
Takes the monomer and crowder '-scale' values of each state point
|
|
717
|
+
(three bins each), optionally their '-norm-sem' values, and returns
|
|
718
|
+
the mapping sum_rule_profiles takes.
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
def _make(mon, crd, bin_center=(0.5, 1.5, 2.5), mon_sem=None,
|
|
722
|
+
crd_sem=None):
|
|
723
|
+
keys = {"ensemble_long": ["s1"] * 3 + ["s2"] * 3,
|
|
724
|
+
"bin_center": list(bin_center) * 2}
|
|
725
|
+
profiles = {
|
|
726
|
+
"Mon": pd.DataFrame({**keys, "rPhiMon-scale": np.ravel(mon)}),
|
|
727
|
+
"Crd": pd.DataFrame({**keys, "rPhiCrd-scale": np.ravel(crd)}),
|
|
728
|
+
}
|
|
729
|
+
if mon_sem is not None:
|
|
730
|
+
profiles["Mon"]["rPhiMon-norm-sem"] = np.ravel(mon_sem)
|
|
731
|
+
if crd_sem is not None:
|
|
732
|
+
profiles["Crd"]["rPhiCrd-norm-sem"] = np.ravel(crd_sem)
|
|
733
|
+
return profiles
|
|
734
|
+
|
|
735
|
+
return _make
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_sum_rule_profiles_adds_norm_sem_in_quadrature(species_profiles):
|
|
739
|
+
"""'<prop>Sum-norm-sem' is the square root of the sum of the species'
|
|
740
|
+
squared '-norm-sem', bin by bin."""
|
|
741
|
+
profiles = species_profiles(
|
|
742
|
+
mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
743
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]],
|
|
744
|
+
mon_sem=[[0.3, 0.0, 0.06], [0.0, 0.3, 0.0]],
|
|
745
|
+
crd_sem=[[0.4, 0.0, 0.08], [0.0, 0.4, 0.0]],
|
|
746
|
+
)
|
|
747
|
+
total = sum_rule_profiles(profiles, "rPhi", "cylindrical", "r")
|
|
748
|
+
# 3-4-5 triangles: sqrt(0.09 + 0.16) and sqrt(0.0036 + 0.0064);
|
|
749
|
+
# float64 round-off of the squares
|
|
750
|
+
np.testing.assert_allclose(total["rPhiSum-norm-sem"],
|
|
751
|
+
[0.5, 0.0, 0.1, 0.0, 0.5, 0.0], rtol=1e-12,
|
|
752
|
+
atol=1e-15)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_sum_rule_profiles_leaves_the_error_out_when_a_species_lacks_it(
|
|
756
|
+
species_profiles
|
|
757
|
+
):
|
|
758
|
+
"""Without '-norm-sem' on every species, the sum has no error column."""
|
|
759
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
760
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]],
|
|
761
|
+
mon_sem=[[0.3, 0.0, 0.0], [0.0, 0.3, 0.0]])
|
|
762
|
+
total = sum_rule_profiles(profiles, "rPhi", "cylindrical", "r")
|
|
763
|
+
assert "rPhiSum-norm-sem" not in total.columns
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def test_sum_rule_profiles_sums_species_per_bin(species_profiles):
|
|
767
|
+
"""'<prop>Sum-scale' is the per-bin sum of every species' '-scale'."""
|
|
768
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
769
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
770
|
+
total = sum_rule_profiles(profiles, "rPhi", "cylindrical", "r")
|
|
771
|
+
np.testing.assert_allclose(total["rPhiSum-scale"],
|
|
772
|
+
[1.0, 1.0, 1.0, 1.0, 1.0, 2.0], rtol=1e-15)
|
|
773
|
+
assert total["ensemble_long"].tolist() == ["s1"] * 3 + ["s2"] * 3
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def test_sum_rule_profiles_far_field_is_the_crowder_term(species_profiles):
|
|
777
|
+
"""With 'last', the normalizer is the crowder far-field term where the
|
|
778
|
+
monomer term vanishes: Jeon et al. (2016), eqn (7)."""
|
|
779
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
780
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
781
|
+
total = sum_rule_profiles(profiles, "rPhi", "cubic", "r")
|
|
782
|
+
assert total["rPhiSum-normalizer"].tolist() == [1.0] * 3 + [2.0] * 3
|
|
783
|
+
np.testing.assert_allclose(total["rPhiSum-norm"],
|
|
784
|
+
[1.0, 1.0, 1.0, 0.5, 0.5, 1.0], rtol=1e-15)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def test_sum_rule_profiles_norm_is_one_where_the_rule_holds(species_profiles):
|
|
788
|
+
"""Property: when phi_m/a + phi_c/a_c is constant in every state point,
|
|
789
|
+
'<prop>Sum-norm' is identically one whatever the constant."""
|
|
790
|
+
profiles = species_profiles(mon=[[0.7, 0.2, 0.1], [1.5, 0.5, 0.0]],
|
|
791
|
+
crd=[[0.3, 0.8, 0.9], [0.5, 1.5, 2.0]])
|
|
792
|
+
total = sum_rule_profiles(profiles, "rPhi", "cylindrical", "r")
|
|
793
|
+
assert total["rPhiSum-normalizer"].tolist() == [1.0] * 3 + [2.0] * 3
|
|
794
|
+
np.testing.assert_allclose(total["rPhiSum-norm"], np.ones(6), rtol=1e-15)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def test_sum_rule_profiles_without_by_treats_the_input_as_one(
|
|
798
|
+
species_profiles
|
|
799
|
+
):
|
|
800
|
+
"""An empty 'by' normalizes the whole input by one reference value."""
|
|
801
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
802
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
803
|
+
total = sum_rule_profiles(profiles, "rPhi", "cubic", "r", by=())
|
|
804
|
+
assert total["rPhiSum-normalizer"].tolist() == [2.0] * 6
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def test_sum_rule_profiles_records_and_logs_an_override(species_profiles,
|
|
808
|
+
caplog):
|
|
809
|
+
"""An explicit normalizer is used and logged with the default."""
|
|
810
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
811
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
812
|
+
caplog.set_level(logging.INFO, logger="polyphys.analyze.sumrule")
|
|
813
|
+
total = sum_rule_profiles(profiles, "rPhi", "cubic", "r",
|
|
814
|
+
normalizer=4.0)
|
|
815
|
+
assert total["rPhiSum-normalizer"].tolist() == [4.0] * 6
|
|
816
|
+
assert [r.getMessage() for r in caplog.records] == [
|
|
817
|
+
"Normalizing the rPhi sum with 4.0 instead of the cubic r default "
|
|
818
|
+
"'last'."]
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def test_sum_rule_profiles_rejects_a_missing_state_point_key(
|
|
822
|
+
species_profiles):
|
|
823
|
+
"""A row with no 'ensemble_long' raises instead of being dropped from
|
|
824
|
+
the normalized sum."""
|
|
825
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
826
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
827
|
+
for frame in profiles.values():
|
|
828
|
+
frame.loc[1, "ensemble_long"] = np.nan
|
|
829
|
+
|
|
830
|
+
with pytest.raises(ValueError, match=re.escape(
|
|
831
|
+
"1 row has no value in the 'by' columns ['ensemble_long']")):
|
|
832
|
+
sum_rule_profiles(profiles, "rPhi", "cubic", "r")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def test_sum_rule_profiles_keeps_the_input_row_order(species_profiles):
|
|
836
|
+
"""State points whose rows are not contiguous come back in the input
|
|
837
|
+
order, each normalized by its own reference: the result is invariant
|
|
838
|
+
to how the rows are grouped."""
|
|
839
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
840
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
841
|
+
order = [0, 3, 1, 4, 2, 5]
|
|
842
|
+
shuffled = {label: frame.iloc[order].reset_index(drop=True)
|
|
843
|
+
for label, frame in profiles.items()}
|
|
844
|
+
expected = sum_rule_profiles(profiles, "rPhi", "cubic", "r")
|
|
845
|
+
|
|
846
|
+
total = sum_rule_profiles(shuffled, "rPhi", "cubic", "r")
|
|
847
|
+
|
|
848
|
+
pd.testing.assert_frame_equal(
|
|
849
|
+
total, expected.iloc[order].reset_index(drop=True))
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def test_sum_rule_profiles_rejects_mismatched_bins(species_profiles):
|
|
853
|
+
"""Species whose 'bin_center' differ in any row raise ValueError."""
|
|
854
|
+
profiles = species_profiles(mon=[[0.8, 0.4, 0.0], [0.6, 0.2, 0.0]],
|
|
855
|
+
crd=[[0.2, 0.6, 1.0], [0.4, 0.8, 2.0]])
|
|
856
|
+
profiles["Crd"]["bin_center"] += 0.5
|
|
857
|
+
with pytest.raises(ValueError, match="'Crd' profiles do not match"):
|
|
858
|
+
sum_rule_profiles(profiles, "rPhi", "cubic", "r")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
@pytest.mark.parametrize(
|
|
862
|
+
"profiles, geometry, direction, message",
|
|
863
|
+
[
|
|
864
|
+
pytest.param({}, "cubic", "r", "'profiles' is empty", id="empty"),
|
|
865
|
+
pytest.param({"Mon": pd.DataFrame()}, "cubic", "z",
|
|
866
|
+
"No density profile along 'z' in a cubic", id="cubic-z"),
|
|
867
|
+
],
|
|
868
|
+
)
|
|
869
|
+
def test_sum_rule_profiles_rejects_bad_arguments(profiles, geometry,
|
|
870
|
+
direction, message):
|
|
871
|
+
"""No profiles, or a (geometry, direction) pair without a default,
|
|
872
|
+
raises ValueError."""
|
|
873
|
+
with pytest.raises(ValueError, match=message):
|
|
874
|
+
sum_rule_profiles(profiles, "rPhi", geometry, direction)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# Generated profiles
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _profiles_of_a_constant_sum(
|
|
881
|
+
prop: str, total: float, weights: np.ndarray
|
|
882
|
+
) -> dict[str, pd.DataFrame]:
|
|
883
|
+
"""Split `total` over the species in every bin, in the proportions of
|
|
884
|
+
the columns of `weights`, shape ``(n_species, n_bins)``: scaled profiles
|
|
885
|
+
whose sum over species is `total` everywhere, where the sum rule holds
|
|
886
|
+
(Jeon et al., 2016, eqns 5 and 7)."""
|
|
887
|
+
shares = total * weights / weights.sum(axis=0)
|
|
888
|
+
keys = {"ensemble_long": ["s1"] * weights.shape[1],
|
|
889
|
+
"bin_center": np.arange(weights.shape[1]) + 0.5}
|
|
890
|
+
return {f"Sp{i}": pd.DataFrame({**keys, f"{prop}Sp{i}-scale": share})
|
|
891
|
+
for i, share in enumerate(shares)}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
@pytest.mark.parametrize("geometry, direction, prop", [
|
|
895
|
+
pytest.param("cylindrical", "r", "rPhi", id="cylinder-r"),
|
|
896
|
+
pytest.param("cylindrical", "z", "zPhi", id="cylinder-z"),
|
|
897
|
+
pytest.param("cubic", "r", "rRho", id="cube-r"),
|
|
898
|
+
])
|
|
899
|
+
@settings(max_examples=50, deadline=None)
|
|
900
|
+
@given(
|
|
901
|
+
total=st.floats(min_value=0.01, max_value=10.0),
|
|
902
|
+
weights=arrays(np.float64,
|
|
903
|
+
st.tuples(st.integers(min_value=1, max_value=4),
|
|
904
|
+
st.integers(min_value=2, max_value=30)),
|
|
905
|
+
elements=st.floats(min_value=0.01, max_value=1.0)),
|
|
906
|
+
)
|
|
907
|
+
def test_sum_rule_profiles_are_one_where_a_generated_rule_holds(
|
|
908
|
+
geometry, direction, prop, total, weights):
|
|
909
|
+
"""Species profiles that add up to one constant in every bin give a
|
|
910
|
+
normalized sum of one in every bin: the sum rule's own statement, with
|
|
911
|
+
no reference value."""
|
|
912
|
+
profiles = _profiles_of_a_constant_sum(prop, total, weights)
|
|
913
|
+
|
|
914
|
+
summed = sum_rule_profiles(profiles, prop, geometry, direction)
|
|
915
|
+
|
|
916
|
+
# float64 round-off of a sum of at most four shares and one division.
|
|
917
|
+
np.testing.assert_allclose(summed[f"{prop}Sum-norm"], 1.0, rtol=1e-12)
|