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,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)