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,1881 @@
|
|
|
1
|
+
"""Tests for :mod:`polyphys.analyze.pipeline`.
|
|
2
|
+
|
|
3
|
+
Most inputs are lineage trees from the ``make_lineage_tree`` factory in
|
|
4
|
+
``conftest.py``, written into one probe directory per test, one property per
|
|
5
|
+
subfolder. A tree's values are ``1000 e + 100 w + 10 s + k``
|
|
6
|
+
(``lineage_tree_value``), so two wholes of one ensemble differ by exactly
|
|
7
|
+
100 in every row and the mean, variance, and standard error across them are
|
|
8
|
+
``w1 + 50``, ``5000``, and ``50`` by hand. Bin edges, distance matrices, and
|
|
9
|
+
two-dimensional wholes are written over the tree's files by hand. The hooks
|
|
10
|
+
are also tested on their own with a ``HookContext``; the density kernel is a
|
|
11
|
+
stub that records its calls. The equilibrium values of the wholes are read
|
|
12
|
+
from ensemble CSV files written by hand. Two tests reduce the output of a
|
|
13
|
+
probe run in the test: ``ChainSize`` on chains written by
|
|
14
|
+
``make_chain_files``, which skip without MDAnalysis and take their expected
|
|
15
|
+
names from the parser and their expected means from the probe's own
|
|
16
|
+
results. The golden comparison with the research code's output is written
|
|
17
|
+
blind and runs only with the maintainer's data.
|
|
18
|
+
"""
|
|
19
|
+
import dataclasses
|
|
20
|
+
import importlib
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import warnings
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pandas as pd
|
|
29
|
+
import pytest
|
|
30
|
+
from numpy.testing import assert_allclose, assert_array_equal
|
|
31
|
+
|
|
32
|
+
from polyphys.analyze.clusters import foci_pair_info
|
|
33
|
+
from polyphys.analyze.correlations import acf
|
|
34
|
+
from polyphys.analyze.stats import sem_blocked
|
|
35
|
+
from polyphys.analyze.pipeline import (
|
|
36
|
+
Autocorrelation,
|
|
37
|
+
Derived,
|
|
38
|
+
FociPairDistances,
|
|
39
|
+
HookContext,
|
|
40
|
+
LocalDensities,
|
|
41
|
+
TimeAverage,
|
|
42
|
+
_resolve,
|
|
43
|
+
equilibrium_wholes,
|
|
44
|
+
half_contour,
|
|
45
|
+
reduce_space,
|
|
46
|
+
reduce_stamps,
|
|
47
|
+
space_hists_from_spec,
|
|
48
|
+
space_tseries_from_spec,
|
|
49
|
+
)
|
|
50
|
+
from polyphys.manage import organizer
|
|
51
|
+
from polyphys.tests.synthetic import ChainCyl, RingCub
|
|
52
|
+
from polyphys.spec import (
|
|
53
|
+
AnalysisTable,
|
|
54
|
+
ProjectSpec,
|
|
55
|
+
edge,
|
|
56
|
+
histogram,
|
|
57
|
+
histogram_2d,
|
|
58
|
+
matrix_tseries,
|
|
59
|
+
tseries,
|
|
60
|
+
)
|
|
61
|
+
from polyphys.tests.conftest import lineage_tree_value, write_lineage_tree
|
|
62
|
+
|
|
63
|
+
# A Pandas4Warning marks a deprecated pandas call; fail on it.
|
|
64
|
+
pytestmark = pytest.mark.filterwarnings("error::pandas.errors.Pandas4Warning")
|
|
65
|
+
|
|
66
|
+
# The glob that lists every file of a probe directory built by write_tree:
|
|
67
|
+
# <probe>/<property>/<Parser>-<group>-<stage>/<file>.
|
|
68
|
+
HIERARCHY = "*/*/*"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Fixtures: specs, a probe directory, and tree writers
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.fixture
|
|
75
|
+
def cyl_spec():
|
|
76
|
+
"""A ChainCyl spec whose crowder size is ``dcrowd``."""
|
|
77
|
+
return ProjectSpec(
|
|
78
|
+
parser=ChainCyl, group="bug", divisor=0.025,
|
|
79
|
+
crowder_attr="phi_bulk_c",
|
|
80
|
+
physical_attrs=("space",),
|
|
81
|
+
species_sizes={"Mon": "dmon", "Crd": "dcrowd"},
|
|
82
|
+
tseries_properties=(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture
|
|
87
|
+
def foci_spec():
|
|
88
|
+
"""A RingCub spec: a ring of 20 small and 2 large monomers."""
|
|
89
|
+
return ProjectSpec(
|
|
90
|
+
parser=RingCub, group="bug", divisor=0.025,
|
|
91
|
+
crowder_attr="phi_bulk_c",
|
|
92
|
+
physical_attrs=("space",),
|
|
93
|
+
species_sizes={"Mon": "dmon_small", "Foci": "dmon_large"},
|
|
94
|
+
tseries_properties=(),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.fixture
|
|
99
|
+
def probe_dir(tmp_path):
|
|
100
|
+
"""A probe directory, ``<root>/probes/N-bug-probe``, next to which
|
|
101
|
+
``reduce_space`` writes ``<root>/analysis/``."""
|
|
102
|
+
probe = tmp_path / "probes" / "N-bug-probe"
|
|
103
|
+
probe.mkdir(parents=True)
|
|
104
|
+
return probe
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.fixture
|
|
108
|
+
def write_tree(probe_dir):
|
|
109
|
+
"""Factory: write a lineage tree of one property into the probe
|
|
110
|
+
directory and return it."""
|
|
111
|
+
def _write(parser, prop, **kwargs):
|
|
112
|
+
return write_lineage_tree(probe_dir / prop, parser, prop=prop,
|
|
113
|
+
**kwargs)
|
|
114
|
+
return _write
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.fixture
|
|
118
|
+
def write_edges():
|
|
119
|
+
"""Factory: write one edge file per file of a histogram tree, with the
|
|
120
|
+
same edges for every file unless ``per_whole`` maps a whole index to
|
|
121
|
+
its own edges."""
|
|
122
|
+
def _write(tree, edge_prop, edges, per_whole=None):
|
|
123
|
+
for path, (_, whole, _) in tree.index.items():
|
|
124
|
+
chosen = edges if per_whole is None else per_whole[whole]
|
|
125
|
+
np.save(path.with_name(path.name.replace(tree.prop, edge_prop)),
|
|
126
|
+
chosen)
|
|
127
|
+
return _write
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RecordingKernel:
|
|
131
|
+
"""A density kernel that records its calls and returns twice and three
|
|
132
|
+
times the counts."""
|
|
133
|
+
|
|
134
|
+
def __init__(self):
|
|
135
|
+
self.calls = []
|
|
136
|
+
|
|
137
|
+
def __call__(self, counts, edges, info, direction, diameter):
|
|
138
|
+
self.calls.append((info.whole, counts, edges, direction, diameter))
|
|
139
|
+
return 2.0 * counts, 3.0 * counts
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parsed(tree, parser, attr):
|
|
143
|
+
"""Map each tree file's (ensemble, whole) indices to a parsed name."""
|
|
144
|
+
return {
|
|
145
|
+
index[:2]: getattr(parser(str(path), tree.lineage, tree.group,
|
|
146
|
+
ispath=True), attr)
|
|
147
|
+
for path, index in tree.index.items()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _analysis(probe_dir, stage):
|
|
152
|
+
"""The analysis directory of a stage next to the probe directory."""
|
|
153
|
+
group = probe_dir.name.split("-")[1]
|
|
154
|
+
return probe_dir.parent.parent / "analysis" / f"N-{group}-{stage}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Records
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_derived_rejects_edges_on_non_vector():
|
|
161
|
+
"""Edges belong to a vector histogram only."""
|
|
162
|
+
with pytest.raises(ValueError, match="edges are only valid"):
|
|
163
|
+
Derived("xyHistCrd", {"w": np.zeros((2, 2))}, "matrix",
|
|
164
|
+
edges={"w": np.arange(3.0)})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_derived_rejects_unknown_whole_type():
|
|
168
|
+
"""A derived artifact needs one of the four whole types."""
|
|
169
|
+
with pytest.raises(ValueError, match="whole_type must be"):
|
|
170
|
+
Derived("gyrMon", {"w": np.zeros(2)}, "frame")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_dataframe_output_must_hold_dataframes(probe_dir, cyl_spec,
|
|
174
|
+
write_tree):
|
|
175
|
+
"""A hook that declares ``'dataframe'`` wholes but returns arrays is
|
|
176
|
+
an error, not a silent reinterpretation."""
|
|
177
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
178
|
+
|
|
179
|
+
def hook(wholes, context):
|
|
180
|
+
return (Derived("gyrMon", wholes, "dataframe"),)
|
|
181
|
+
|
|
182
|
+
table = AnalysisTable("bug", "whole", False,
|
|
183
|
+
(tseries("gyrT", "Mon", post_hook=hook),))
|
|
184
|
+
with pytest.raises(ValueError, match="must be a pandas.DataFrame"):
|
|
185
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
186
|
+
save=False)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Vector properties through the lineage
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_two_wholes_give_exact_mean_var_sem(probe_dir, cyl_spec,
|
|
193
|
+
write_tree):
|
|
194
|
+
"""Two wholes 100 apart in every row average to w1 + 50 with variance
|
|
195
|
+
5000 and standard error 50, by the ddof = 1 formulas."""
|
|
196
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1,
|
|
197
|
+
n_wholes=2, n_rows=3)
|
|
198
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
199
|
+
|
|
200
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
201
|
+
save=False)
|
|
202
|
+
|
|
203
|
+
(average,) = result["gyrTMon"].values()
|
|
204
|
+
assert_allclose(average["gyrTMon-mean"],
|
|
205
|
+
lineage_tree_value(1, 1, 0, 3) + 50)
|
|
206
|
+
assert_allclose(average["gyrTMon-var"], 5000.0)
|
|
207
|
+
assert_allclose(average["gyrTMon-sem"], 50.0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_ensemble_csv_has_one_column_per_whole_named_by_parser(
|
|
211
|
+
probe_dir, cyl_spec, write_tree):
|
|
212
|
+
"""The ensemble file holds one column per whole, under the whole names
|
|
213
|
+
the parser reads from the written files."""
|
|
214
|
+
tree = write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1,
|
|
215
|
+
n_wholes=2, n_rows=3)
|
|
216
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
217
|
+
(ens_name,) = set(_parsed(tree, ChainCyl, "ensemble_long").values())
|
|
218
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
219
|
+
|
|
220
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
221
|
+
|
|
222
|
+
ensemble = pd.read_csv(
|
|
223
|
+
_analysis(probe_dir, "ens") / f"{ens_name}-bug-gyrTMon.csv")
|
|
224
|
+
assert list(ensemble.columns) == [names[(1, 1)], names[(1, 2)]]
|
|
225
|
+
assert_allclose(ensemble[names[(1, 2)]], lineage_tree_value(1, 2, 0, 3))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_segment_tseries_concatenate_in_segment_order(probe_dir, cyl_spec,
|
|
229
|
+
write_tree):
|
|
230
|
+
"""A segment-lineage time series joins its segments j01 then j02 into
|
|
231
|
+
the whole file."""
|
|
232
|
+
tree = write_tree(ChainCyl, "gyrTMon", n_ensembles=1, n_wholes=1,
|
|
233
|
+
n_segments=2, n_rows=2)
|
|
234
|
+
(whole_name,) = set(_parsed(tree, ChainCyl, "whole").values())
|
|
235
|
+
table = AnalysisTable("bug", "segment", False,
|
|
236
|
+
(tseries("gyrT", "Mon"),))
|
|
237
|
+
|
|
238
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
239
|
+
|
|
240
|
+
whole = np.load(
|
|
241
|
+
_analysis(probe_dir, "wholeSim") / f"{whole_name}-bug-gyrTMon.npy")
|
|
242
|
+
assert_array_equal(whole, np.concatenate([lineage_tree_value(1, 1, 1, 2),
|
|
243
|
+
lineage_tree_value(1, 1, 2, 2)]))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_segments_are_ordered_naturally(probe_dir, cyl_spec):
|
|
247
|
+
"""Segments j9 and j10 join in numeric order, not in text order, which
|
|
248
|
+
puts j10 first."""
|
|
249
|
+
stem = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000ens1"
|
|
250
|
+
folder = probe_dir / "gyrTMon" / "ChainCyl-bug-segment"
|
|
251
|
+
folder.mkdir(parents=True)
|
|
252
|
+
for seg_id in (9, 10):
|
|
253
|
+
np.save(folder / f"{stem}.j{seg_id}.bug.gyrTMon.npy",
|
|
254
|
+
np.array([float(seg_id)]))
|
|
255
|
+
table = AnalysisTable("bug", "segment", False,
|
|
256
|
+
(tseries("gyrT", "Mon"),))
|
|
257
|
+
|
|
258
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
259
|
+
|
|
260
|
+
whole = np.load(
|
|
261
|
+
_analysis(probe_dir, "wholeSim") / f"{stem}-bug-gyrTMon.npy")
|
|
262
|
+
assert_array_equal(whole, [9.0, 10.0])
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_segment_histograms_sum_and_ensembles_carry_bin_centers(
|
|
266
|
+
probe_dir, cyl_spec, write_tree, write_edges):
|
|
267
|
+
"""A segment-lineage histogram sums its segments element-wise, and its
|
|
268
|
+
ensemble gets the midpoints of the shared edges as ``bin_center``."""
|
|
269
|
+
tree = write_tree(ChainCyl, "rHistMon", n_ensembles=1, n_wholes=2,
|
|
270
|
+
n_segments=2, n_rows=3)
|
|
271
|
+
write_edges(tree, "rEdgeMon", np.array([0.0, 1.0, 2.0, 4.0]))
|
|
272
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
273
|
+
(ens_name,) = set(_parsed(tree, ChainCyl, "ensemble_long").values())
|
|
274
|
+
table = AnalysisTable("bug", "segment", False, (histogram("r", "Mon"),))
|
|
275
|
+
|
|
276
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
277
|
+
|
|
278
|
+
whole = np.load(
|
|
279
|
+
_analysis(probe_dir, "wholeSim") / f"{names[(1, 1)]}-bug-rHistMon.npy")
|
|
280
|
+
assert_array_equal(whole, lineage_tree_value(1, 1, 1, 3)
|
|
281
|
+
+ lineage_tree_value(1, 1, 2, 3))
|
|
282
|
+
ensemble = pd.read_csv(
|
|
283
|
+
_analysis(probe_dir, "ens") / f"{ens_name}-bug-rHistMon.csv")
|
|
284
|
+
assert_array_equal(ensemble["bin_center"], [0.5, 1.5, 3.0])
|
|
285
|
+
average = result["rHistMon"][ens_name]
|
|
286
|
+
assert list(average.columns) == ["bin_center", "rHistMon-mean",
|
|
287
|
+
"rHistMon-var", "rHistMon-sem"]
|
|
288
|
+
assert_allclose(average["rHistMon-mean"], whole + 100)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_mismatched_edges_between_wholes_of_one_ensemble_raise(
|
|
292
|
+
probe_dir, cyl_spec, write_tree, write_edges):
|
|
293
|
+
"""Two wholes of one ensemble with different edges raise, naming the
|
|
294
|
+
ensemble and both wholes."""
|
|
295
|
+
tree = write_tree(ChainCyl, "rHistMon", lineage="whole", n_ensembles=1,
|
|
296
|
+
n_wholes=2, n_rows=3)
|
|
297
|
+
write_edges(tree, "rEdgeMon", None, per_whole={
|
|
298
|
+
1: np.array([0.0, 1.0, 2.0, 3.0]), 2: np.array([0.0, 1.0, 2.0, 3.5])})
|
|
299
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
300
|
+
(ens_name,) = set(_parsed(tree, ChainCyl, "ensemble_long").values())
|
|
301
|
+
table = AnalysisTable("bug", "whole", False, (histogram("r", "Mon"),))
|
|
302
|
+
|
|
303
|
+
with pytest.raises(ValueError, match="different bin edges") as excinfo:
|
|
304
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
305
|
+
save=False)
|
|
306
|
+
|
|
307
|
+
message = str(excinfo.value)
|
|
308
|
+
assert ens_name in message
|
|
309
|
+
assert names[(1, 1)] in message and names[(1, 2)] in message
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_missing_edge_file_for_a_whole_raises(probe_dir, cyl_spec,
|
|
313
|
+
write_tree, write_edges):
|
|
314
|
+
"""A histogram whose edge files do not cover every whole raises."""
|
|
315
|
+
tree = write_tree(ChainCyl, "rHistMon", lineage="whole", n_ensembles=1,
|
|
316
|
+
n_wholes=2, n_rows=3)
|
|
317
|
+
write_edges(tree, "rEdgeMon", np.arange(4.0))
|
|
318
|
+
(first_edge,) = sorted(
|
|
319
|
+
(probe_dir / "rHistMon").rglob("*ens1-bug-rEdgeMon.npy"))
|
|
320
|
+
first_edge.unlink()
|
|
321
|
+
table = AnalysisTable("bug", "whole", False, (histogram("r", "Mon"),))
|
|
322
|
+
|
|
323
|
+
with pytest.raises(ValueError, match="no 'rEdgeMon' file"):
|
|
324
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
325
|
+
save=False)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_property_without_files_raises(probe_dir, cyl_spec, write_tree):
|
|
329
|
+
"""A property that matches no file raises instead of writing nothing
|
|
330
|
+
(the research code's ``Crdtwo`` row was silent)."""
|
|
331
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
332
|
+
table = AnalysisTable("bug", "whole", False,
|
|
333
|
+
(histogram("r", "Crdtwo"),))
|
|
334
|
+
|
|
335
|
+
with pytest.raises(FileNotFoundError, match="rHistCrdtwo"):
|
|
336
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
337
|
+
save=False)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_missing_property_raises_before_any_directory_is_made(
|
|
341
|
+
probe_dir, cyl_spec, write_tree):
|
|
342
|
+
"""A property that matches no file, such as a typo, raises before the
|
|
343
|
+
analysis directories are made or another property is reduced."""
|
|
344
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
345
|
+
table = AnalysisTable("bug", "whole", False,
|
|
346
|
+
(tseries("gyrT", "Mon"), tseries("gyrT", "Mno")))
|
|
347
|
+
|
|
348
|
+
with pytest.raises(FileNotFoundError, match="gyrTMno"):
|
|
349
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
350
|
+
|
|
351
|
+
assert not (probe_dir.parent.parent / "analysis").exists()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_repeated_output_of_an_undeclared_hook_raises(probe_dir, cyl_spec,
|
|
355
|
+
write_tree):
|
|
356
|
+
"""Two outputs of one name from hooks that declare no outputs raise at
|
|
357
|
+
the second instead of overwriting the first."""
|
|
358
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
359
|
+
write_tree(ChainCyl, "fsdTMon", lineage="whole", n_ensembles=1)
|
|
360
|
+
|
|
361
|
+
def hook(wholes, context):
|
|
362
|
+
return (Derived("sizeMon", wholes, "vector"),)
|
|
363
|
+
|
|
364
|
+
table = AnalysisTable("bug", "whole", False, (
|
|
365
|
+
tseries("gyrT", "Mon", post_hook=hook),
|
|
366
|
+
tseries("fsdT", "Mon", post_hook=hook),
|
|
367
|
+
))
|
|
368
|
+
|
|
369
|
+
with pytest.raises(ValueError,
|
|
370
|
+
match="output 'sizeMon' is named more than once"):
|
|
371
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
372
|
+
save=False)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_default_hierarchy_is_the_spec_dir_file_layout(probe_dir, cyl_spec,
|
|
376
|
+
write_tree):
|
|
377
|
+
"""Without ``hierarchy`` the files are listed under
|
|
378
|
+
``<spec.hierarchy>/<spec.hierarchy>``, the research code's layout."""
|
|
379
|
+
tree = write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
380
|
+
# Move the tree's folder under a name the ensemble_long prefix matches.
|
|
381
|
+
tree.paths[0].parent.rename(probe_dir / "N20ac1.0")
|
|
382
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
383
|
+
|
|
384
|
+
result = reduce_space(probe_dir, cyl_spec, table, save=False)
|
|
385
|
+
|
|
386
|
+
assert cyl_spec.hierarchy == "N*"
|
|
387
|
+
assert len(result["gyrTMon"]) == 1
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_empty_glob_raises(probe_dir, cyl_spec):
|
|
391
|
+
"""A hierarchy that matches no file raises."""
|
|
392
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
393
|
+
with pytest.raises(FileNotFoundError, match="No file matches"):
|
|
394
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
395
|
+
save=False)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@pytest.mark.parametrize("prop, n_rows", [
|
|
399
|
+
pytest.param("gyrTMon", 3, id="gyrTMon"),
|
|
400
|
+
pytest.param("xgyrTMon", 5, id="xgyrTMon"),
|
|
401
|
+
])
|
|
402
|
+
def test_file_selection_matches_the_last_name_token_exactly(
|
|
403
|
+
probe_dir, cyl_spec, write_tree, prop, n_rows):
|
|
404
|
+
"""With ``gyrTMon`` and ``xgyrTMon`` files side by side, each property
|
|
405
|
+
reads only its own files, told apart by their row counts."""
|
|
406
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1,
|
|
407
|
+
n_rows=3)
|
|
408
|
+
write_tree(ChainCyl, "xgyrTMon", lineage="whole", n_ensembles=1,
|
|
409
|
+
n_rows=5)
|
|
410
|
+
name = prop[:-len("Mon")]
|
|
411
|
+
table = AnalysisTable("bug", "whole", False, (tseries(name, "Mon"),))
|
|
412
|
+
|
|
413
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
414
|
+
save=False)
|
|
415
|
+
|
|
416
|
+
(average,) = result[prop].values()
|
|
417
|
+
assert len(average) == n_rows
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_outputs_land_in_the_analysis_stage_folders(probe_dir, cyl_spec,
|
|
421
|
+
write_tree):
|
|
422
|
+
"""A segment-lineage run writes the wholeSim, ens, and ensAvg folders
|
|
423
|
+
next to the probe phase, with the organizer's file names."""
|
|
424
|
+
tree = write_tree(ChainCyl, "gyrTMon", n_ensembles=1, n_wholes=2,
|
|
425
|
+
n_segments=2, n_rows=2)
|
|
426
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
427
|
+
(ens_name,) = set(_parsed(tree, ChainCyl, "ensemble_long").values())
|
|
428
|
+
table = AnalysisTable("bug", "segment", False,
|
|
429
|
+
(tseries("gyrT", "Mon"),))
|
|
430
|
+
|
|
431
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
432
|
+
|
|
433
|
+
analysis = probe_dir.parent.parent / "analysis"
|
|
434
|
+
assert sorted(path.name for path in analysis.iterdir()) == [
|
|
435
|
+
"N-bug-ens", "N-bug-ensAvg", "N-bug-wholeSim"]
|
|
436
|
+
assert sorted(path.name for path in (analysis / "N-bug-wholeSim")
|
|
437
|
+
.iterdir()) == sorted(
|
|
438
|
+
f"{name}-bug-gyrTMon.npy" for name in names.values())
|
|
439
|
+
assert [path.name for path in (analysis / "N-bug-ens").iterdir()] == [
|
|
440
|
+
f"{ens_name}-bug-gyrTMon.csv"]
|
|
441
|
+
assert [path.name for path in (analysis / "N-bug-ensAvg").iterdir()] == [
|
|
442
|
+
f"{ens_name}-bug-gyrTMon-ensAvg.csv"]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_whole_lineage_writes_no_wholesim_folder(probe_dir, cyl_spec,
|
|
446
|
+
write_tree):
|
|
447
|
+
"""A whole-lineage run writes the ens and ensAvg folders only."""
|
|
448
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
449
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
450
|
+
|
|
451
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
452
|
+
|
|
453
|
+
analysis = probe_dir.parent.parent / "analysis"
|
|
454
|
+
assert sorted(path.name for path in analysis.iterdir()) == [
|
|
455
|
+
"N-bug-ens", "N-bug-ensAvg"]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_save_false_writes_nothing(probe_dir, cyl_spec, write_tree):
|
|
459
|
+
"""With ``save=False`` no analysis folder appears."""
|
|
460
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
461
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
462
|
+
|
|
463
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY, save=False)
|
|
464
|
+
|
|
465
|
+
assert not (probe_dir.parent.parent / "analysis").exists()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_reduce_space_logs_each_property(probe_dir, cyl_spec, write_tree,
|
|
469
|
+
caplog):
|
|
470
|
+
"""Progress is logged at INFO, one record per property."""
|
|
471
|
+
write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1)
|
|
472
|
+
table = AnalysisTable("bug", "whole", False, (tseries("gyrT", "Mon"),))
|
|
473
|
+
|
|
474
|
+
with caplog.at_level(logging.INFO, logger="polyphys.analyze.pipeline"):
|
|
475
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
476
|
+
save=False)
|
|
477
|
+
|
|
478
|
+
assert any("'gyrTMon'" in record.message for record in caplog.records)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# Matrix and edge properties
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_matrix_outputs_average_element_wise(probe_dir, cyl_spec,
|
|
485
|
+
write_tree):
|
|
486
|
+
"""A two-dimensional histogram's ensemble average is taken element by
|
|
487
|
+
element across the wholes, as one array per statistic."""
|
|
488
|
+
tree = write_tree(ChainCyl, "xyHistCrd", lineage="whole",
|
|
489
|
+
n_ensembles=1, n_wholes=2, n_rows=1)
|
|
490
|
+
matrices = {1: np.array([[1.0, 2.0], [3.0, 4.0]]),
|
|
491
|
+
2: np.array([[3.0, 2.0], [7.0, 0.0]])}
|
|
492
|
+
for path, (_, whole, _) in tree.index.items():
|
|
493
|
+
np.save(path, matrices[whole])
|
|
494
|
+
table = AnalysisTable("all", "whole", False, (histogram_2d("xy", "Crd"),))
|
|
495
|
+
|
|
496
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
497
|
+
save=False)
|
|
498
|
+
|
|
499
|
+
(average,) = result["xyHistCrd"].values()
|
|
500
|
+
assert sorted(average) == ["xyHistCrd-mean", "xyHistCrd-sem",
|
|
501
|
+
"xyHistCrd-var"]
|
|
502
|
+
assert_allclose(average["xyHistCrd-mean"], [[2.0, 2.0], [5.0, 2.0]])
|
|
503
|
+
assert_allclose(average["xyHistCrd-var"], [[2.0, 0.0], [8.0, 8.0]])
|
|
504
|
+
assert_allclose(average["xyHistCrd-sem"], [[1.0, 0.0], [2.0, 2.0]])
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def test_edge_property_passes_shared_edges_through(probe_dir, cyl_spec,
|
|
508
|
+
write_tree):
|
|
509
|
+
"""An edge property's ensemble average is the edges themselves."""
|
|
510
|
+
tree = write_tree(ChainCyl, "xEdge", group="all", lineage="whole",
|
|
511
|
+
n_ensembles=1, n_wholes=2, n_rows=1)
|
|
512
|
+
edges = np.array([-1.0, 0.0, 1.0])
|
|
513
|
+
for path in tree.paths:
|
|
514
|
+
np.save(path, edges)
|
|
515
|
+
table = AnalysisTable("all", "whole", False, (edge("x"),))
|
|
516
|
+
|
|
517
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
518
|
+
save=False)
|
|
519
|
+
|
|
520
|
+
(average,) = result["xEdge"].values()
|
|
521
|
+
assert_array_equal(average, edges)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# Hooks on their own
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_time_average_drops_first_column_and_names_the_output():
|
|
528
|
+
"""``TimeAverage`` averages over the frames, drops column 0 on request,
|
|
529
|
+
and names the output after the property's species."""
|
|
530
|
+
hook = TimeAverage("clustersHist", drop_first_column=True)
|
|
531
|
+
context = HookContext(matrix_tseries("clustersHistT", "Foci"))
|
|
532
|
+
whole = np.array([[0, 1, 2, 3], [0, 3, 4, 5], [0, 5, 6, 7]])
|
|
533
|
+
|
|
534
|
+
(out,) = hook({"w": whole}, context)
|
|
535
|
+
|
|
536
|
+
assert (out.name, out.whole_type) == ("clustersHistFoci", "vector")
|
|
537
|
+
assert_allclose(out.wholes["w"], [3.0, 4.0, 5.0])
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_time_average_keeps_every_column_by_default():
|
|
541
|
+
"""Without the flag, column 0 is averaged like the others."""
|
|
542
|
+
(out,) = TimeAverage("bondsHist")(
|
|
543
|
+
{"w": np.array([[1.0, 2.0], [3.0, 4.0]])},
|
|
544
|
+
HookContext(matrix_tseries("bondsHistT", "Foci")))
|
|
545
|
+
assert_allclose(out.wholes["w"], [2.0, 3.0])
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_autocorrelation_passes_series_through_and_adds_acf(probe_dir,
|
|
549
|
+
cyl_spec,
|
|
550
|
+
write_tree):
|
|
551
|
+
"""``Autocorrelation`` writes the series under its own name and the
|
|
552
|
+
autocorrelation of each whole, equal to ``correlations.acf``, under
|
|
553
|
+
``<property>-acf``."""
|
|
554
|
+
tree = write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1,
|
|
555
|
+
n_wholes=2, n_rows=1)
|
|
556
|
+
rng = np.random.default_rng(7)
|
|
557
|
+
series = {}
|
|
558
|
+
for path, (_, whole, _) in tree.index.items():
|
|
559
|
+
values = np.empty(64)
|
|
560
|
+
values[0] = rng.standard_normal()
|
|
561
|
+
for t in range(1, 64):
|
|
562
|
+
values[t] = 0.6 * values[t - 1] + rng.standard_normal()
|
|
563
|
+
np.save(path, values)
|
|
564
|
+
series[whole] = values
|
|
565
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
566
|
+
(ens_name,) = set(_parsed(tree, ChainCyl, "ensemble_long").values())
|
|
567
|
+
table = AnalysisTable("bug", "whole", False, (
|
|
568
|
+
tseries("gyrT", "Mon", post_hook=Autocorrelation(nlags=5)),))
|
|
569
|
+
|
|
570
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
571
|
+
|
|
572
|
+
assert set(result) == {"gyrTMon", "gyrTMon-acf"}
|
|
573
|
+
ensemble = pd.read_csv(
|
|
574
|
+
_analysis(probe_dir, "ens") / f"{ens_name}-bug-gyrTMon-acf.csv")
|
|
575
|
+
for whole, values in series.items():
|
|
576
|
+
assert_allclose(ensemble[names[(1, whole)]],
|
|
577
|
+
acf(values, 5, alpha=None).values)
|
|
578
|
+
assert (_analysis(probe_dir, "ensAvg")
|
|
579
|
+
/ f"{ens_name}-bug-gyrTMon-acf-ensAvg.csv").exists()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def test_local_densities_calls_the_kernel_once_per_whole(probe_dir, cyl_spec,
|
|
583
|
+
write_tree,
|
|
584
|
+
write_edges):
|
|
585
|
+
"""``LocalDensities`` calls the kernel with each whole's counts, edges,
|
|
586
|
+
direction, and the species' diameter from the spec, and reduces the
|
|
587
|
+
histogram and both profiles on the same bins."""
|
|
588
|
+
tree = write_tree(ChainCyl, "rHistCrd", group="all", lineage="whole",
|
|
589
|
+
n_ensembles=1, n_wholes=2, n_rows=3)
|
|
590
|
+
edges = np.array([0.0, 1.0, 2.0, 3.0])
|
|
591
|
+
write_edges(tree, "rEdgeCrd", edges)
|
|
592
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
593
|
+
kernel = RecordingKernel()
|
|
594
|
+
table = AnalysisTable("all", "whole", False, (
|
|
595
|
+
histogram("r", "Crd", post_hook=LocalDensities()),))
|
|
596
|
+
|
|
597
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
598
|
+
density=kernel, save=False)
|
|
599
|
+
|
|
600
|
+
assert set(result) == {"rHistCrd", "rRhoCrd", "rPhiCrd"}
|
|
601
|
+
assert sorted(call[0] for call in kernel.calls) == sorted(names.values())
|
|
602
|
+
for whole_name, counts, call_edges, direction, diameter in kernel.calls:
|
|
603
|
+
index = next(idx for idx, name in names.items() if name == whole_name)
|
|
604
|
+
assert_array_equal(counts, lineage_tree_value(*index, 0, 3))
|
|
605
|
+
assert_array_equal(call_edges, edges)
|
|
606
|
+
assert direction == "r"
|
|
607
|
+
assert diameter == 1.0 # ac1.0 in the written names
|
|
608
|
+
(rho,) = result["rRhoCrd"].values()
|
|
609
|
+
(phi,) = result["rPhiCrd"].values()
|
|
610
|
+
assert_allclose(rho["rRhoCrd-mean"],
|
|
611
|
+
2.0 * (lineage_tree_value(1, 1, 0, 3) + 50))
|
|
612
|
+
assert_allclose(phi["rPhiCrd-mean"],
|
|
613
|
+
3.0 * (lineage_tree_value(1, 1, 0, 3) + 50))
|
|
614
|
+
assert_array_equal(phi["bin_center"], [0.5, 1.5, 2.5])
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def test_local_densities_without_a_kernel_raises(probe_dir, cyl_spec,
|
|
618
|
+
write_tree, write_edges):
|
|
619
|
+
"""A ``LocalDensities`` property needs the kernel passed to
|
|
620
|
+
``reduce_space``."""
|
|
621
|
+
tree = write_tree(ChainCyl, "rHistCrd", group="all", lineage="whole",
|
|
622
|
+
n_ensembles=1)
|
|
623
|
+
write_edges(tree, "rEdgeCrd", np.arange(4.0))
|
|
624
|
+
table = AnalysisTable("all", "whole", False, (
|
|
625
|
+
histogram("r", "Crd", post_hook=LocalDensities()),))
|
|
626
|
+
|
|
627
|
+
with pytest.raises(ValueError, match="density kernel"):
|
|
628
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
629
|
+
save=False)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_local_densities_needs_a_species_with_a_size(cyl_spec):
|
|
633
|
+
"""A species the spec gives no size for is an error, not a guess."""
|
|
634
|
+
context = HookContext(histogram("r", "Dna"), spec=cyl_spec, group="all",
|
|
635
|
+
edges={"w": np.arange(3.0)},
|
|
636
|
+
density=RecordingKernel())
|
|
637
|
+
with pytest.raises(ValueError, match="'Dna' has no size"):
|
|
638
|
+
LocalDensities()({"w": np.ones(2)}, context)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def test_local_densities_needs_the_spec_and_the_edges(cyl_spec):
|
|
642
|
+
"""Without the study's spec or the histogram's edges the hook cannot
|
|
643
|
+
find the species' size or the bins."""
|
|
644
|
+
context = HookContext(histogram("r", "Crd"), spec=cyl_spec, group="all",
|
|
645
|
+
density=RecordingKernel())
|
|
646
|
+
with pytest.raises(ValueError, match="needs the study's spec and"):
|
|
647
|
+
LocalDensities()({"w": np.ones(2)}, context)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def test_local_densities_rejects_a_non_histogram_property(cyl_spec):
|
|
651
|
+
"""``LocalDensities`` is for ``histogram(...)`` properties only."""
|
|
652
|
+
context = HookContext(tseries("gyrT", "Mon"), spec=cyl_spec,
|
|
653
|
+
density=RecordingKernel())
|
|
654
|
+
with pytest.raises(ValueError, match="needs a histogram"):
|
|
655
|
+
LocalDensities()({"w": np.ones(2)}, context)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# The error of the per-whole normalized profile
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_derived_rejects_a_normalizer_on_non_vector():
|
|
662
|
+
"""A per-whole normalizer belongs to a vector profile only."""
|
|
663
|
+
with pytest.raises(ValueError, match="normalizer is only valid"):
|
|
664
|
+
Derived("xyHistCrd", {"w": np.zeros((2, 2))}, "matrix",
|
|
665
|
+
normalizer="first")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def test_derived_rejects_an_unknown_normalizer():
|
|
669
|
+
"""A per-whole normalizer is 'first', 'last', or 'max'."""
|
|
670
|
+
with pytest.raises(ValueError, match="normalizer must be"):
|
|
671
|
+
Derived("rRhoCrd", {"w": np.zeros(2)}, "vector", normalizer="mean")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@pytest.mark.parametrize("direction, expected", [
|
|
675
|
+
pytest.param("r", "first", id="cylinder-r"),
|
|
676
|
+
pytest.param("z", "max", id="cylinder-z"),
|
|
677
|
+
])
|
|
678
|
+
def test_local_densities_take_the_sum_rule_default_normalizer(
|
|
679
|
+
cyl_spec, direction, expected
|
|
680
|
+
):
|
|
681
|
+
"""The two density profiles carry the ``PROFILE_NORMALIZERS`` entry of
|
|
682
|
+
the spec's geometry and the histogram's direction; the histogram does
|
|
683
|
+
not."""
|
|
684
|
+
whole = ("N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000"
|
|
685
|
+
"ens1")
|
|
686
|
+
context = HookContext(
|
|
687
|
+
histogram(direction, "Crd"), spec=cyl_spec, group="all",
|
|
688
|
+
infos={whole: ChainCyl(whole, "whole", "all")},
|
|
689
|
+
edges={whole: np.arange(3.0)}, density=RecordingKernel())
|
|
690
|
+
hist, rho, phi = LocalDensities()({whole: np.ones(2)}, context)
|
|
691
|
+
assert hist.normalizer is None
|
|
692
|
+
assert (rho.normalizer, phi.normalizer) == (expected, expected)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@pytest.fixture
|
|
696
|
+
def write_density_wholes(probe_dir):
|
|
697
|
+
"""Factory: write the histogram and edge files of two wholes of one
|
|
698
|
+
ChainCyl state point, group 'all', in the probe directory, and
|
|
699
|
+
return the histogram's property name."""
|
|
700
|
+
stem = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000"
|
|
701
|
+
|
|
702
|
+
def _write(direction, counts_1, counts_2, edges=(0.0, 1.0, 2.0, 3.0)):
|
|
703
|
+
for k, counts in enumerate((counts_1, counts_2), start=1):
|
|
704
|
+
np.save(probe_dir / f"{stem}ens{k}-all-{direction}HistCrd.npy",
|
|
705
|
+
np.asarray(counts, dtype=float))
|
|
706
|
+
np.save(probe_dir / f"{stem}ens{k}-all-{direction}EdgeCrd.npy",
|
|
707
|
+
np.asarray(edges))
|
|
708
|
+
return f"{direction}HistCrd"
|
|
709
|
+
|
|
710
|
+
return _write
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@pytest.mark.parametrize("direction, counts_1, counts_2, expected", [
|
|
714
|
+
# 'first': the wholes 2 x [1, 2, 4] and 2 x [2, 2, 2], each over its
|
|
715
|
+
# own bin 0, are [1, 2, 4] and [1, 1, 1]; the sem of two values with
|
|
716
|
+
# ddof = 1 is half their difference.
|
|
717
|
+
pytest.param("r", [1.0, 2.0, 4.0], [2.0, 2.0, 2.0], [0.0, 0.5, 1.5],
|
|
718
|
+
id="cylinder-r-first"),
|
|
719
|
+
# 'max': the ensemble mean 2 x [1.5, 2, 3] peaks in bin 2, so the
|
|
720
|
+
# wholes are [1/4, 1/2, 1] and [1, 1, 1].
|
|
721
|
+
pytest.param("z", [1.0, 2.0, 4.0], [2.0, 2.0, 2.0], [0.375, 0.25, 0.0],
|
|
722
|
+
id="cylinder-z-max"),
|
|
723
|
+
])
|
|
724
|
+
def test_density_profile_error_is_the_spread_over_wholes_of_the_ratio(
|
|
725
|
+
probe_dir, cyl_spec, write_density_wholes, direction, counts_1,
|
|
726
|
+
counts_2, expected
|
|
727
|
+
):
|
|
728
|
+
"""``<prop>-norm-sem`` is the standard error over the wholes of each
|
|
729
|
+
whole's profile divided by its own value in the bin the ensemble's
|
|
730
|
+
normalizer names; the ensemble average itself is not normalized."""
|
|
731
|
+
write_density_wholes(direction, counts_1, counts_2)
|
|
732
|
+
table = AnalysisTable("all", "whole", False, (
|
|
733
|
+
histogram(direction, "Crd", post_hook=LocalDensities()),))
|
|
734
|
+
|
|
735
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy="*",
|
|
736
|
+
density=RecordingKernel(), save=False)
|
|
737
|
+
|
|
738
|
+
(rho,) = result[f"{direction}RhoCrd"].values()
|
|
739
|
+
# Exact: ratios and differences of small dyadic floats
|
|
740
|
+
assert_allclose(rho[f"{direction}RhoCrd-norm-sem"], expected, rtol=1e-15,
|
|
741
|
+
atol=0)
|
|
742
|
+
assert_allclose(rho[f"{direction}RhoCrd-mean"],
|
|
743
|
+
np.add(counts_1, counts_2))
|
|
744
|
+
assert f"{direction}HistCrd-norm-sem" not in next(
|
|
745
|
+
iter(result[f"{direction}HistCrd"].values())).columns
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def test_density_profile_error_is_scale_free(probe_dir, cyl_spec,
|
|
749
|
+
write_density_wholes):
|
|
750
|
+
"""Property: the number density and the volume fraction, which differ
|
|
751
|
+
by a constant factor, carry the same ``-norm-sem``."""
|
|
752
|
+
write_density_wholes("r", [1.0, 2.0, 4.0], [2.0, 2.0, 2.0])
|
|
753
|
+
table = AnalysisTable("all", "whole", False, (
|
|
754
|
+
histogram("r", "Crd", post_hook=LocalDensities()),))
|
|
755
|
+
|
|
756
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy="*",
|
|
757
|
+
density=RecordingKernel(), save=False)
|
|
758
|
+
|
|
759
|
+
(rho,) = result["rRhoCrd"].values()
|
|
760
|
+
(phi,) = result["rPhiCrd"].values()
|
|
761
|
+
# float64 round-off of one division
|
|
762
|
+
assert_allclose(phi["rPhiCrd-norm-sem"], rho["rRhoCrd-norm-sem"],
|
|
763
|
+
rtol=1e-14, atol=0)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def test_density_profile_error_is_nan_and_warns_on_a_zero_reference(
|
|
767
|
+
probe_dir, cyl_spec, write_density_wholes
|
|
768
|
+
):
|
|
769
|
+
"""A whole that is zero in the reference bin gives NaN in every bin of
|
|
770
|
+
``-norm-sem``, with a warning naming the ensemble and the bin."""
|
|
771
|
+
write_density_wholes("r", [0.0, 2.0, 4.0], [2.0, 2.0, 2.0])
|
|
772
|
+
table = AnalysisTable("all", "whole", False, (
|
|
773
|
+
histogram("r", "Crd", post_hook=LocalDensities()),))
|
|
774
|
+
|
|
775
|
+
with pytest.warns(UserWarning, match="zero in its reference bin 0"):
|
|
776
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy="*",
|
|
777
|
+
density=RecordingKernel(), save=False)
|
|
778
|
+
|
|
779
|
+
(rho,) = result["rRhoCrd"].values()
|
|
780
|
+
assert rho["rRhoCrd-norm-sem"].isna().all()
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def test_density_profile_error_is_saved_in_the_ensavg_csv(
|
|
784
|
+
probe_dir, cyl_spec, write_density_wholes
|
|
785
|
+
):
|
|
786
|
+
"""The ``-norm-sem`` column is in the ensAvg file the pipeline writes,
|
|
787
|
+
after the organizer's three statistics."""
|
|
788
|
+
write_density_wholes("r", [1.0, 2.0, 4.0], [2.0, 2.0, 2.0])
|
|
789
|
+
table = AnalysisTable("all", "whole", False, (
|
|
790
|
+
histogram("r", "Crd", post_hook=LocalDensities()),))
|
|
791
|
+
|
|
792
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy="*",
|
|
793
|
+
density=RecordingKernel())
|
|
794
|
+
|
|
795
|
+
(path,) = (probe_dir.parent.parent / "analysis" / "N-all-ensAvg").glob(
|
|
796
|
+
"*-all-rRhoCrd-ensAvg.csv")
|
|
797
|
+
saved = pd.read_csv(path)
|
|
798
|
+
assert list(saved.columns) == ["bin_center", "rRhoCrd-mean",
|
|
799
|
+
"rRhoCrd-var", "rRhoCrd-sem",
|
|
800
|
+
"rRhoCrd-norm-sem"]
|
|
801
|
+
assert_allclose(saved["rRhoCrd-norm-sem"], [0.0, 0.5, 1.5], rtol=1e-15,
|
|
802
|
+
atol=0)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def test_density_profile_error_takes_the_last_bin_as_reference(
|
|
806
|
+
probe_dir, cyl_spec, write_density_wholes
|
|
807
|
+
):
|
|
808
|
+
"""A ``'last'`` normalizer divides each whole by its own value in the
|
|
809
|
+
final bin, not in the first bin or the bin where the mean peaks."""
|
|
810
|
+
write_density_wholes("r", [4.0, 2.0, 1.0], [2.0, 2.0, 2.0])
|
|
811
|
+
|
|
812
|
+
def last_bin(wholes, context):
|
|
813
|
+
return (Derived(context.prop.prop, wholes, "vector", context.edges,
|
|
814
|
+
"last"),)
|
|
815
|
+
|
|
816
|
+
table = AnalysisTable("all", "whole", False, (
|
|
817
|
+
histogram("r", "Crd", post_hook=last_bin),))
|
|
818
|
+
|
|
819
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy="*",
|
|
820
|
+
save=False)
|
|
821
|
+
|
|
822
|
+
(hist,) = result["rHistCrd"].values()
|
|
823
|
+
# The wholes over their own bin 2 are [4, 2, 1] and [1, 1, 1]; the sem
|
|
824
|
+
# of two values with ddof = 1 is half their difference. Bin 0, which
|
|
825
|
+
# 'first' and 'max' would pick, gives [0, 0.25, 0.375] instead. Exact:
|
|
826
|
+
# ratios and differences of small dyadic floats.
|
|
827
|
+
assert_allclose(hist["rHistCrd-norm-sem"], [1.5, 0.5, 0.0], rtol=1e-15,
|
|
828
|
+
atol=0)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# The foci pair distances
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@pytest.fixture
|
|
835
|
+
def foci_context(foci_spec):
|
|
836
|
+
"""A context for ``distMatTFoci`` of the RingCub ring."""
|
|
837
|
+
return HookContext(matrix_tseries("distMatT", "Foci"), spec=foci_spec)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _distance_frames(genomic_pos, separations):
|
|
841
|
+
"""Stack symmetric distance matrices with `genomic_pos` on the diagonal
|
|
842
|
+
of frame 0; `separations` lists the upper triangle of each frame."""
|
|
843
|
+
n_foci = len(genomic_pos)
|
|
844
|
+
first, second = np.triu_indices(n_foci, 1)
|
|
845
|
+
frames = []
|
|
846
|
+
for values in separations:
|
|
847
|
+
frame = np.zeros((n_foci, n_foci))
|
|
848
|
+
frame[first, second] = values
|
|
849
|
+
frame[second, first] = values
|
|
850
|
+
frames.append(frame)
|
|
851
|
+
frames[0][np.diag_indices(n_foci)] = genomic_pos
|
|
852
|
+
return np.stack(frames)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def test_foci_pair_distances_counts_and_tags(foci_context):
|
|
856
|
+
"""Three foci on a ring of 10 over two frames: the tags are the pairs
|
|
857
|
+
of ``foci_pair_info`` with genomic distances in bonds, the counts are
|
|
858
|
+
the hand tallies, and the separations are the frames' values."""
|
|
859
|
+
whole = _distance_frames([0, 4, 8], [[1.5, 2.5, 0.5], [1.5, 2.9, 2.5]])
|
|
860
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0, n_monomers=10)
|
|
861
|
+
|
|
862
|
+
hist, density, series = hook({"w": whole}, foci_context)
|
|
863
|
+
|
|
864
|
+
pairs = foci_pair_info([0, 4, 8], 10, "ring")
|
|
865
|
+
tags = [f"loc{i}loc{j}genDist{d}" for i, j, d in pairs]
|
|
866
|
+
assert tags == ["loc0loc4genDist4", "loc0loc8genDist2",
|
|
867
|
+
"loc4loc8genDist4"]
|
|
868
|
+
assert (hist.name, density.name, series.name) == (
|
|
869
|
+
"pairDistHistFoci", "pairDistRdfFoci", "pairDistTFoci")
|
|
870
|
+
frame = hist.wholes["w"]
|
|
871
|
+
assert list(frame.columns) == tags + ["bin_center"]
|
|
872
|
+
assert frame["loc0loc4genDist4"].to_list() == [0, 2, 0]
|
|
873
|
+
assert frame["loc0loc8genDist2"].to_list() == [0, 0, 2]
|
|
874
|
+
assert frame["loc4loc8genDist4"].to_list() == [1, 0, 1]
|
|
875
|
+
assert_array_equal(frame["bin_center"], [0.5, 1.5, 2.5])
|
|
876
|
+
assert series.wholes["w"]["loc4loc8genDist4"].to_list() == [0.5, 2.5]
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def test_foci_pair_density_integrates_to_one_per_pair(foci_context):
|
|
880
|
+
"""Each pair's density satisfies ``sum(P_k dV_k) = 1`` over the shells."""
|
|
881
|
+
whole = _distance_frames([0, 4, 8], [[1.5, 2.5, 0.5], [1.5, 2.9, 2.5]])
|
|
882
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0, n_monomers=10)
|
|
883
|
+
|
|
884
|
+
_, density, _ = hook({"w": whole}, foci_context)
|
|
885
|
+
|
|
886
|
+
frame = density.wholes["w"]
|
|
887
|
+
edges = np.array([0.0, 1.0, 2.0, 3.0])
|
|
888
|
+
shells = 4.0 * np.pi / 3.0 * (edges[1:] ** 3 - edges[:-1] ** 3)
|
|
889
|
+
for tag in frame.columns.drop("bin_center"):
|
|
890
|
+
assert_allclose(np.sum(frame[tag] * shells), 1.0)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def test_foci_pair_histograms_conserve_the_frames(foci_context):
|
|
894
|
+
"""Every pair has one separation per frame, so each histogram column
|
|
895
|
+
sums to ``n_frames`` when the grid covers every separation."""
|
|
896
|
+
rng = np.random.default_rng(3)
|
|
897
|
+
n_frames = 40
|
|
898
|
+
whole = _distance_frames(
|
|
899
|
+
[0, 4, 8], rng.uniform(0.0, 7.5, size=(n_frames, 3)))
|
|
900
|
+
hook = FociPairDistances(bin_size=0.5, r_max=half_contour(10, 1.5),
|
|
901
|
+
n_monomers=10)
|
|
902
|
+
|
|
903
|
+
hist, _, _ = hook({"w": whole}, foci_context)
|
|
904
|
+
|
|
905
|
+
frame = hist.wholes["w"]
|
|
906
|
+
assert_array_equal(frame.drop(columns="bin_center").sum(axis=0),
|
|
907
|
+
n_frames)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def test_foci_pair_distances_reject_a_separation_above_r_max(foci_context):
|
|
911
|
+
"""A separation above the grid raises and names the whole and the
|
|
912
|
+
value, since a dropped frame would break the normalization."""
|
|
913
|
+
whole = _distance_frames([0, 4, 8], [[1.5, 2.5, 0.5], [1.5, 3.5, 2.5]])
|
|
914
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0, n_monomers=10)
|
|
915
|
+
|
|
916
|
+
with pytest.raises(ValueError, match="whole 'w1'.*3.5.*above r_max"):
|
|
917
|
+
hook({"w1": whole}, foci_context)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def test_foci_pair_distances_accept_a_separation_at_r_max(foci_context):
|
|
921
|
+
"""A separation exactly at an `r_max` that is a whole number of bins
|
|
922
|
+
is on the grid, although ``ceil(r_max / bin_size) * bin_size`` falls
|
|
923
|
+
an ulp below it: 15.9 is 530 bins of 0.03 in decimal arithmetic."""
|
|
924
|
+
whole = _distance_frames([0, 4, 8], [[15.9, 1.0, 1.0]])
|
|
925
|
+
hook = FociPairDistances(bin_size=0.03, r_max=15.9, n_monomers=10)
|
|
926
|
+
|
|
927
|
+
hist, _, _ = hook({"w": whole}, foci_context)
|
|
928
|
+
|
|
929
|
+
frame = hist.wholes["w"]
|
|
930
|
+
assert len(frame) == 530
|
|
931
|
+
assert frame["loc0loc4genDist4"].iloc[-1] == 1
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def test_foci_pair_distances_need_the_spec_for_the_topology():
|
|
935
|
+
"""Without the study's spec the hook cannot know the ring topology."""
|
|
936
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0, n_monomers=10)
|
|
937
|
+
with pytest.raises(ValueError, match="needs the study's spec"):
|
|
938
|
+
hook({"w": _distance_frames([0, 4, 8], [[1.0, 1.0, 1.0]])},
|
|
939
|
+
HookContext(matrix_tseries("distMatT", "Foci")))
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def test_foci_pair_distances_reject_a_non_matrix_whole(foci_context):
|
|
943
|
+
"""A whole that is not a ``(n_frames, n_foci, n_foci)`` array raises."""
|
|
944
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0, n_monomers=10)
|
|
945
|
+
with pytest.raises(ValueError, match="distance matrix time series"):
|
|
946
|
+
hook({"w": np.ones((2, 3))}, foci_context)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def test_foci_pair_distances_resolve_callables_on_the_whole(foci_context):
|
|
950
|
+
"""A number-valued function of the whole's parser instance is read for
|
|
951
|
+
each whole."""
|
|
952
|
+
foci_context = HookContext(
|
|
953
|
+
foci_context.prop, spec=foci_context.spec,
|
|
954
|
+
infos={"w": RingCub(
|
|
955
|
+
"al2.0nl2ml8.0ns20ac1.0nc100l10.0dt0.005bdump1000adump5000ens1",
|
|
956
|
+
"whole", "bug")})
|
|
957
|
+
whole = _distance_frames([0, 11], [[2.5], [2.5]])
|
|
958
|
+
hook = FociPairDistances(
|
|
959
|
+
bin_size=lambda info: 0.5 * info.dmon_small,
|
|
960
|
+
r_max=lambda info: half_contour(
|
|
961
|
+
info.nmon_small + info.nmon_large, 1.5 * info.dmon_small),
|
|
962
|
+
n_monomers=lambda info: info.nmon_small + info.nmon_large)
|
|
963
|
+
|
|
964
|
+
hist, _, _ = hook({"w": whole}, foci_context)
|
|
965
|
+
|
|
966
|
+
frame = hist.wholes["w"]
|
|
967
|
+
assert list(frame.columns) == ["loc0loc11genDist11", "bin_center"]
|
|
968
|
+
# bin_size 0.5 to r_max 11 * 1.5 = 16.5: 33 bins; both frames in bin 5.
|
|
969
|
+
assert len(frame) == 33
|
|
970
|
+
assert frame["loc0loc11genDist11"].to_list() == [0] * 5 + [2] + [0] * 27
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def test_foci_pair_distances_need_a_parser_instance_for_a_callable(
|
|
974
|
+
foci_context):
|
|
975
|
+
"""A callable attribute with no parser instance in the context raises."""
|
|
976
|
+
hook = FociPairDistances(bin_size=lambda info: 1.0, r_max=3.0,
|
|
977
|
+
n_monomers=10)
|
|
978
|
+
with pytest.raises(ValueError, match="no parser instance"):
|
|
979
|
+
hook({"w": _distance_frames([0, 4, 8], [[1.0, 1.0, 1.0]])},
|
|
980
|
+
foci_context)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
@pytest.mark.parametrize("overrides, what", [
|
|
984
|
+
pytest.param({"bin_size": lambda info: -1.0}, "bin_size",
|
|
985
|
+
id="bin_size-negative"),
|
|
986
|
+
pytest.param({"bin_size": lambda info: 0.0}, "bin_size",
|
|
987
|
+
id="bin_size-zero"),
|
|
988
|
+
pytest.param({"r_max": lambda info: float("nan")}, "r_max",
|
|
989
|
+
id="r_max-nan"),
|
|
990
|
+
])
|
|
991
|
+
def test_foci_pair_distances_reject_a_bad_callable_result(
|
|
992
|
+
foci_context, overrides, what):
|
|
993
|
+
"""A callable quantity that does not give a finite positive number
|
|
994
|
+
raises, naming the quantity and the whole."""
|
|
995
|
+
foci_context = HookContext(foci_context.prop, spec=foci_context.spec,
|
|
996
|
+
infos={"w": object()})
|
|
997
|
+
hook = FociPairDistances(
|
|
998
|
+
**({"bin_size": 1.0, "r_max": 3.0, "n_monomers": 10} | overrides))
|
|
999
|
+
with pytest.raises(ValueError,
|
|
1000
|
+
match=f"{what} of whole 'w' must resolve to a finite"):
|
|
1001
|
+
hook({"w": _distance_frames([0, 4, 8], [[1.0, 1.0, 1.0]])},
|
|
1002
|
+
foci_context)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
@pytest.mark.parametrize("result", [
|
|
1006
|
+
pytest.param(10.5, id="fraction"),
|
|
1007
|
+
pytest.param(10.0, id="whole-float"),
|
|
1008
|
+
pytest.param(0, id="zero"),
|
|
1009
|
+
pytest.param("10", id="string"),
|
|
1010
|
+
pytest.param(True, id="bool"),
|
|
1011
|
+
])
|
|
1012
|
+
def test_foci_pair_distances_reject_a_non_integer_n_monomers_result(
|
|
1013
|
+
foci_context, result):
|
|
1014
|
+
"""A callable ``n_monomers`` that does not give a positive integer
|
|
1015
|
+
raises, naming the quantity and the whole, instead of being
|
|
1016
|
+
truncated."""
|
|
1017
|
+
foci_context = HookContext(foci_context.prop, spec=foci_context.spec,
|
|
1018
|
+
infos={"w": object()})
|
|
1019
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0,
|
|
1020
|
+
n_monomers=lambda info: result)
|
|
1021
|
+
with pytest.raises(ValueError, match="n_monomers of whole 'w' must "
|
|
1022
|
+
"resolve to a positive integer"):
|
|
1023
|
+
hook({"w": _distance_frames([0, 4, 8], [[1.0, 1.0, 1.0]])},
|
|
1024
|
+
foci_context)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def test_foci_pair_distances_accept_a_numpy_integer_n_monomers(
|
|
1028
|
+
foci_context):
|
|
1029
|
+
"""A callable ``n_monomers`` may give a NumPy integer; the tags are
|
|
1030
|
+
those of the same ring given as a Python ``int``."""
|
|
1031
|
+
foci_context = HookContext(foci_context.prop, spec=foci_context.spec,
|
|
1032
|
+
infos={"w": object()})
|
|
1033
|
+
hook = FociPairDistances(bin_size=1.0, r_max=3.0,
|
|
1034
|
+
n_monomers=lambda info: np.int64(10))
|
|
1035
|
+
|
|
1036
|
+
hist, _, _ = hook({"w": _distance_frames([0, 4, 8], [[1.0, 1.0, 1.0]])},
|
|
1037
|
+
foci_context)
|
|
1038
|
+
|
|
1039
|
+
assert list(hist.wholes["w"].columns) == [
|
|
1040
|
+
"loc0loc4genDist4", "loc0loc8genDist2", "loc4loc8genDist4",
|
|
1041
|
+
"bin_center"]
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
@pytest.mark.parametrize("overrides, match", [
|
|
1045
|
+
pytest.param({"bin_size": -1.0}, "'bin_size' must be a finite positive",
|
|
1046
|
+
id="bin_size-negative"),
|
|
1047
|
+
pytest.param({"bin_size": 0.0}, "'bin_size' must be a finite positive",
|
|
1048
|
+
id="bin_size-zero"),
|
|
1049
|
+
pytest.param({"bin_size": True}, "'bin_size' must be a finite positive",
|
|
1050
|
+
id="bin_size-bool"),
|
|
1051
|
+
pytest.param({"r_max": float("nan")}, "'r_max' must be a finite positive",
|
|
1052
|
+
id="r_max-nan"),
|
|
1053
|
+
pytest.param({"r_max": float("inf")}, "'r_max' must be a finite positive",
|
|
1054
|
+
id="r_max-inf"),
|
|
1055
|
+
pytest.param({"n_monomers": 10.5}, "'n_monomers' must be a positive int",
|
|
1056
|
+
id="n_monomers-fraction"),
|
|
1057
|
+
pytest.param({"n_monomers": 10.0}, "'n_monomers' must be a positive int",
|
|
1058
|
+
id="n_monomers-whole-float"),
|
|
1059
|
+
pytest.param({"n_monomers": 0}, "'n_monomers' must be a positive int",
|
|
1060
|
+
id="n_monomers-zero"),
|
|
1061
|
+
pytest.param({"n_monomers": -2}, "'n_monomers' must be a positive int",
|
|
1062
|
+
id="n_monomers-negative"),
|
|
1063
|
+
pytest.param({"n_monomers": True}, "'n_monomers' must be a positive int",
|
|
1064
|
+
id="n_monomers-bool"),
|
|
1065
|
+
])
|
|
1066
|
+
def test_foci_pair_distances_reject_a_bad_constant(overrides, match):
|
|
1067
|
+
"""Building the hook raises, naming the quantity, when a constant
|
|
1068
|
+
``bin_size`` or ``r_max`` is not a finite positive number or a
|
|
1069
|
+
constant ``n_monomers`` is not a positive integer."""
|
|
1070
|
+
with pytest.raises(ValueError, match=match):
|
|
1071
|
+
FociPairDistances(
|
|
1072
|
+
**({"bin_size": 1.0, "r_max": 3.0, "n_monomers": 10} | overrides))
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def test_foci_pair_distances_accept_numpy_constants():
|
|
1076
|
+
"""NumPy scalars are valid constants: a float for ``bin_size`` and
|
|
1077
|
+
``r_max``, an integer for ``n_monomers``."""
|
|
1078
|
+
hook = FociPairDistances(bin_size=np.float32(0.5), r_max=np.float64(3.0),
|
|
1079
|
+
n_monomers=np.int64(10))
|
|
1080
|
+
|
|
1081
|
+
assert hook.n_monomers == 10
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def test_resolve_rejects_a_bad_constant():
|
|
1085
|
+
"""``_resolve`` checks a constant as it checks a callable's result,
|
|
1086
|
+
naming the quantity and the whole."""
|
|
1087
|
+
with pytest.raises(ValueError, match="r_max of whole 'w1' must resolve "
|
|
1088
|
+
"to a finite positive number"):
|
|
1089
|
+
_resolve(-1.0, None, "r_max", "w1")
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@pytest.mark.parametrize("n_monomers, bond_max, match", [
|
|
1093
|
+
pytest.param(1, 1.5, "at least 2", id="one-monomer"),
|
|
1094
|
+
pytest.param(10, 0.0, "must be positive", id="zero-bond"),
|
|
1095
|
+
])
|
|
1096
|
+
def test_half_contour_rejects_bad_input(n_monomers, bond_max, match):
|
|
1097
|
+
"""``half_contour`` needs at least two monomers and a positive bond."""
|
|
1098
|
+
with pytest.raises(ValueError, match=match):
|
|
1099
|
+
half_contour(n_monomers, bond_max)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def test_half_contour_is_half_the_bonds_times_the_bond():
|
|
1103
|
+
"""A ring of 11 monomers has at most 5 bonds between two monomers."""
|
|
1104
|
+
assert half_contour(11, 2.0) == 10.0
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def test_dataframe_wholes_of_unequal_length_raise(probe_dir, cyl_spec,
|
|
1108
|
+
write_tree):
|
|
1109
|
+
"""Two 'dataframe' wholes of one ensemble with 3 and 2 rows raise,
|
|
1110
|
+
naming the ensemble and the lengths, instead of averaging over the
|
|
1111
|
+
first whole's rows."""
|
|
1112
|
+
tree = write_tree(ChainCyl, "gyrTMon", lineage="whole", n_ensembles=1,
|
|
1113
|
+
n_wholes=2)
|
|
1114
|
+
names = _parsed(tree, ChainCyl, "whole")
|
|
1115
|
+
(ens_name,) = set(_parsed(tree, ChainCyl, "ensemble_long").values())
|
|
1116
|
+
lengths = {names[(1, 1)]: 3, names[(1, 2)]: 2}
|
|
1117
|
+
|
|
1118
|
+
def hook(wholes, context):
|
|
1119
|
+
frames = {name: pd.DataFrame({"x": np.arange(float(lengths[name]))})
|
|
1120
|
+
for name in wholes}
|
|
1121
|
+
return (Derived("gyrMon", frames, "dataframe"),)
|
|
1122
|
+
|
|
1123
|
+
table = AnalysisTable("bug", "whole", False,
|
|
1124
|
+
(tseries("gyrT", "Mon", post_hook=hook),))
|
|
1125
|
+
with pytest.raises(ValueError, match=re.escape(
|
|
1126
|
+
f"The wholes of ensemble '{ens_name}' have unequal lengths: "
|
|
1127
|
+
f"{names[(1, 1)]} 3, {names[(1, 2)]} 2.")):
|
|
1128
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def test_dataframe_wholes_reduce_to_long_ensembles_and_row_statistics(
|
|
1132
|
+
probe_dir, foci_spec, write_tree):
|
|
1133
|
+
"""Through ``reduce_space``, the pair histograms of two wholes give an
|
|
1134
|
+
ensemble file with every whole's rows and an ensemble average with the
|
|
1135
|
+
mean, variance, and standard error of each pair's counts, row by row."""
|
|
1136
|
+
tree = write_tree(RingCub, "distMatTFoci", lineage="whole",
|
|
1137
|
+
n_ensembles=1, n_wholes=2, n_rows=1)
|
|
1138
|
+
separations = {1: [[1.5], [1.5]], 2: [[2.5], [2.5]]}
|
|
1139
|
+
for path, (_, whole, _) in tree.index.items():
|
|
1140
|
+
np.save(path, _distance_frames([0, 11], separations[whole]))
|
|
1141
|
+
names = _parsed(tree, RingCub, "whole")
|
|
1142
|
+
(ens_name,) = set(_parsed(tree, RingCub, "ensemble_long").values())
|
|
1143
|
+
table = AnalysisTable("bug", "whole", False, (
|
|
1144
|
+
matrix_tseries("distMatT", "Foci", post_hook=FociPairDistances(
|
|
1145
|
+
bin_size=1.0, r_max=3.0,
|
|
1146
|
+
n_monomers=lambda info: info.nmon_small + info.nmon_large)),))
|
|
1147
|
+
|
|
1148
|
+
result = reduce_space(probe_dir, foci_spec, table, hierarchy=HIERARCHY)
|
|
1149
|
+
|
|
1150
|
+
assert set(result) == {"pairDistHistFoci", "pairDistRdfFoci",
|
|
1151
|
+
"pairDistTFoci"}
|
|
1152
|
+
ensemble = pd.read_csv(
|
|
1153
|
+
_analysis(probe_dir, "ens") / f"{ens_name}-bug-pairDistHistFoci.csv")
|
|
1154
|
+
assert list(ensemble.columns) == ["whole", "row", "loc0loc11genDist11",
|
|
1155
|
+
"bin_center"]
|
|
1156
|
+
assert ensemble["whole"].to_list() == [names[(1, 1)]] * 3 + [
|
|
1157
|
+
names[(1, 2)]] * 3
|
|
1158
|
+
assert ensemble["loc0loc11genDist11"].to_list() == [0, 2, 0, 0, 0, 2]
|
|
1159
|
+
average = result["pairDistHistFoci"][ens_name]
|
|
1160
|
+
assert list(average.columns) == [
|
|
1161
|
+
"bin_center", "loc0loc11genDist11-mean", "loc0loc11genDist11-var",
|
|
1162
|
+
"loc0loc11genDist11-sem"]
|
|
1163
|
+
assert_allclose(average["loc0loc11genDist11-mean"], [0.0, 1.0, 1.0])
|
|
1164
|
+
assert_allclose(average["loc0loc11genDist11-var"], [0.0, 2.0, 2.0])
|
|
1165
|
+
assert_allclose(average["loc0loc11genDist11-sem"], [0.0, 1.0, 1.0])
|
|
1166
|
+
saved = pd.read_csv(_analysis(probe_dir, "ensAvg")
|
|
1167
|
+
/ f"{ens_name}-bug-pairDistHistFoci-ensAvg.csv")
|
|
1168
|
+
pd.testing.assert_frame_equal(saved, average)
|
|
1169
|
+
series = pd.read_csv(
|
|
1170
|
+
_analysis(probe_dir, "ens") / f"{ens_name}-bug-pairDistTFoci.csv")
|
|
1171
|
+
assert series["loc0loc11genDist11"].to_list() == [1.5, 1.5, 2.5, 2.5]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
# Stamps
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _write_stamps(probe_dir, parser_name, group, whole_names, n_segments):
|
|
1178
|
+
"""Write one segment stamp file per segment of each whole, with the
|
|
1179
|
+
columns the organizer's stamp reducers read."""
|
|
1180
|
+
folder = probe_dir / "stamps" / f"{parser_name}-{group}-segment"
|
|
1181
|
+
folder.mkdir(parents=True)
|
|
1182
|
+
for ens_id, whole in enumerate(whole_names, start=1):
|
|
1183
|
+
for seg_id in range(1, n_segments + 1):
|
|
1184
|
+
pd.DataFrame({
|
|
1185
|
+
"space": ["N20ac1.0"], "group": [group],
|
|
1186
|
+
"lineage_name": ["segment"],
|
|
1187
|
+
"segment": [f"{whole}.j{seg_id:02d}"], "whole": [whole],
|
|
1188
|
+
"ensemble_long": [whole[:-len(f"ens{ens_id}")]],
|
|
1189
|
+
"ensemble_id": [ens_id], "segment_id": [seg_id],
|
|
1190
|
+
"n_frames": [10 * seg_id],
|
|
1191
|
+
}).to_csv(folder / f"{whole}.j{seg_id:02d}.{group}.stamps.csv",
|
|
1192
|
+
index=False)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def test_reduce_stamps_rolls_segments_up_to_wholes_and_ensembles(probe_dir):
|
|
1196
|
+
"""Segment stamps give one row per whole, with the segments counted and
|
|
1197
|
+
the frames summed, then one row per ensemble with the wholes counted."""
|
|
1198
|
+
stem = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000"
|
|
1199
|
+
wholes = [f"{stem}ens1", f"{stem}ens2"]
|
|
1200
|
+
_write_stamps(probe_dir, "ChainCyl", "bug", wholes, n_segments=2)
|
|
1201
|
+
files = [(str(path),) for path in sorted(probe_dir.rglob("*.stamps.csv"))]
|
|
1202
|
+
|
|
1203
|
+
whole_stamps, ensemble_stamps = reduce_stamps(files, "segment")
|
|
1204
|
+
|
|
1205
|
+
assert whole_stamps["whole"].to_list() == wholes
|
|
1206
|
+
assert whole_stamps["n_segments"].to_list() == [2, 2]
|
|
1207
|
+
assert whole_stamps["n_frames"].to_list() == [30, 30]
|
|
1208
|
+
assert ensemble_stamps["ensemble_long"].to_list() == [stem]
|
|
1209
|
+
assert ensemble_stamps["n_ensembles"].to_list() == [2]
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def test_reduce_stamps_combines_whole_stamps_directly(probe_dir):
|
|
1213
|
+
"""Whole-lineage stamps combine into one row per whole, with their
|
|
1214
|
+
segment columns dropped, then roll up to one row per ensemble."""
|
|
1215
|
+
stem = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000"
|
|
1216
|
+
wholes = [f"{stem}ens1", f"{stem}ens2"]
|
|
1217
|
+
_write_stamps(probe_dir, "ChainCyl", "bug", wholes, n_segments=1)
|
|
1218
|
+
files = [(str(path),) for path in sorted(probe_dir.rglob("*.stamps.csv"))]
|
|
1219
|
+
|
|
1220
|
+
with pytest.warns(UserWarning, match="dropped"):
|
|
1221
|
+
whole_stamps, ensemble_stamps = reduce_stamps(files, "whole")
|
|
1222
|
+
|
|
1223
|
+
assert whole_stamps["whole"].to_list() == wholes
|
|
1224
|
+
assert "segment_id" not in whole_stamps.columns
|
|
1225
|
+
assert ensemble_stamps["n_ensembles"].to_list() == [2]
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def test_reduce_space_writes_the_stamp_files_of_each_stage(probe_dir,
|
|
1229
|
+
cyl_spec):
|
|
1230
|
+
"""With ``stamps=True`` the segment, whole, and ensemble stamps land in
|
|
1231
|
+
the wholeSim, ens, and ensAvg folders."""
|
|
1232
|
+
stem = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000"
|
|
1233
|
+
_write_stamps(probe_dir, "ChainCyl", "bug", [f"{stem}ens1"],
|
|
1234
|
+
n_segments=2)
|
|
1235
|
+
table = AnalysisTable("bug", "segment", True, ())
|
|
1236
|
+
|
|
1237
|
+
reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY)
|
|
1238
|
+
|
|
1239
|
+
assert (_analysis(probe_dir, "wholeSim")
|
|
1240
|
+
/ "N20ac1.0-bug-stamps-segment.csv").exists()
|
|
1241
|
+
assert (_analysis(probe_dir, "ens") / "N20ac1.0-bug-stamps-whole.csv"
|
|
1242
|
+
).exists()
|
|
1243
|
+
assert (_analysis(probe_dir, "ensAvg")
|
|
1244
|
+
/ "N20ac1.0-bug-stamps-ensemble_long.csv").exists()
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
# End to end
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def test_segment_tree_end_to_end(probe_dir, cyl_spec, write_tree,
|
|
1251
|
+
write_edges):
|
|
1252
|
+
"""One segment-lineage run of a time series with its autocorrelation, a
|
|
1253
|
+
histogram with density profiles, and a time-averaged histogram gives
|
|
1254
|
+
the hand-composed averages of the segment, whole, and ensemble steps."""
|
|
1255
|
+
gyr = write_tree(ChainCyl, "gyrTMon", n_ensembles=1, n_wholes=2,
|
|
1256
|
+
n_segments=2, n_rows=3)
|
|
1257
|
+
hist = write_tree(ChainCyl, "rHistMon", n_ensembles=1, n_wholes=2,
|
|
1258
|
+
n_segments=2, n_rows=3)
|
|
1259
|
+
write_edges(hist, "rEdgeMon", np.arange(4.0))
|
|
1260
|
+
clusters = write_tree(ChainCyl, "clustersHistTMon", n_ensembles=1,
|
|
1261
|
+
n_wholes=2, n_segments=2, n_rows=2)
|
|
1262
|
+
for path, (e, w, s) in clusters.index.items():
|
|
1263
|
+
# Two frames per segment, three columns: the tree value plus 0, 1, 2.
|
|
1264
|
+
np.save(path, lineage_tree_value(e, w, s, 2)[:, None]
|
|
1265
|
+
+ np.arange(3.0))
|
|
1266
|
+
kernel = RecordingKernel()
|
|
1267
|
+
table = AnalysisTable("bug", "segment", False, (
|
|
1268
|
+
tseries("gyrT", "Mon", post_hook=Autocorrelation(nlags=2)),
|
|
1269
|
+
histogram("r", "Mon", post_hook=LocalDensities()),
|
|
1270
|
+
matrix_tseries("clustersHistT", "Mon", post_hook=TimeAverage(
|
|
1271
|
+
"clustersHist", drop_first_column=True)),
|
|
1272
|
+
))
|
|
1273
|
+
(ens_name,) = set(_parsed(gyr, ChainCyl, "ensemble_long").values())
|
|
1274
|
+
|
|
1275
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy=HIERARCHY,
|
|
1276
|
+
density=kernel)
|
|
1277
|
+
|
|
1278
|
+
assert set(result) == {"gyrTMon", "gyrTMon-acf", "rHistMon", "rRhoMon",
|
|
1279
|
+
"rPhiMon", "clustersHistMon"}
|
|
1280
|
+
whole_1 = np.concatenate([lineage_tree_value(1, 1, 1, 3),
|
|
1281
|
+
lineage_tree_value(1, 1, 2, 3)])
|
|
1282
|
+
assert_allclose(result["gyrTMon"][ens_name]["gyrTMon-mean"],
|
|
1283
|
+
whole_1 + 50)
|
|
1284
|
+
hist_1 = lineage_tree_value(1, 1, 1, 3) + lineage_tree_value(1, 1, 2, 3)
|
|
1285
|
+
assert_allclose(result["rHistMon"][ens_name]["rHistMon-mean"],
|
|
1286
|
+
hist_1 + 100)
|
|
1287
|
+
assert_allclose(result["rRhoMon"][ens_name]["rRhoMon-mean"],
|
|
1288
|
+
2.0 * (hist_1 + 100))
|
|
1289
|
+
# The time average over the four frames of whole 1 is the mean of its
|
|
1290
|
+
# two segments' values, plus the column offsets 1 and 2; whole 2 is 100
|
|
1291
|
+
# higher, so the ensemble mean is 50 higher than whole 1.
|
|
1292
|
+
frames_1 = np.concatenate([lineage_tree_value(1, 1, 1, 2),
|
|
1293
|
+
lineage_tree_value(1, 1, 2, 2)])
|
|
1294
|
+
clusters_avg = result["clustersHistMon"][ens_name]
|
|
1295
|
+
assert_allclose(clusters_avg["clustersHistMon-mean"],
|
|
1296
|
+
frames_1.mean() + np.array([1.0, 2.0]) + 50)
|
|
1297
|
+
# The wholeSim folder holds the combined wholes and every derived whole.
|
|
1298
|
+
(names,) = [set(_parsed(hist, ChainCyl, "whole").values())]
|
|
1299
|
+
assert sorted(path.name for path in _analysis(probe_dir, "wholeSim")
|
|
1300
|
+
.iterdir()) == sorted(
|
|
1301
|
+
f"{name}-bug-{prop}.npy" for name in names for prop in (
|
|
1302
|
+
"gyrTMon", "gyrTMon-acf", "rHistMon", "rEdgeMon", "rRhoMon",
|
|
1303
|
+
"rPhiMon", "clustersHistTMon", "clustersHistMon"))
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
# A probe's own output, reduced
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
@pytest.fixture
|
|
1310
|
+
def probe_space(probe_dir, make_chain_files):
|
|
1311
|
+
"""Run ``ChainSize`` on two segments of each of two replicates of one
|
|
1312
|
+
ChainCyl state point, saving its ``rfloryT`` series and stamps into
|
|
1313
|
+
one folder per segment of the probe directory. Each chain is straight
|
|
1314
|
+
along z with bond length ``e + s / 10 + f / 100`` for replicate ``e``,
|
|
1315
|
+
segment ``s``, and frame ``f``, so every value differs. Returns the
|
|
1316
|
+
probe's ``rfloryT`` results by (replicate, segment). The dumps carry no
|
|
1317
|
+
time step, so the tests that use it ignore the dump reader's warning
|
|
1318
|
+
that the frames have no ``dt``."""
|
|
1319
|
+
pytest.importorskip("MDAnalysis")
|
|
1320
|
+
from polyphys.probe import ChainSize, make_universe, write_stamps
|
|
1321
|
+
|
|
1322
|
+
stem = "N4r2.5lz10ac1nc0dt0.005bdump1adump1"
|
|
1323
|
+
results = {}
|
|
1324
|
+
for ens in (1, 2):
|
|
1325
|
+
for seg in (1, 2):
|
|
1326
|
+
name = f"{stem}ens{ens}.j{seg:02d}"
|
|
1327
|
+
bonds = ens + seg / 10 + np.arange(3) / 100
|
|
1328
|
+
frames = np.full((3, 4, 3), 10.0)
|
|
1329
|
+
frames[:, :, 2] = 5.0 + bonds[:, None] * np.arange(4)
|
|
1330
|
+
data, dump = make_chain_files(frames, name=name)
|
|
1331
|
+
parser = ChainCyl(name, "segment", "bug")
|
|
1332
|
+
folder = probe_dir / name
|
|
1333
|
+
folder.mkdir()
|
|
1334
|
+
universe = make_universe(data, dump)
|
|
1335
|
+
analysis = ChainSize(universe.select_atoms("resid 1"),
|
|
1336
|
+
("rfloryT",)).run()
|
|
1337
|
+
analysis.save(folder, f"{parser.name}-{parser.group}")
|
|
1338
|
+
write_stamps(parser, analysis.n_frames, folder)
|
|
1339
|
+
results[(ens, seg)] = analysis.results["rfloryT"]
|
|
1340
|
+
return results
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
@pytest.mark.filterwarnings(
|
|
1344
|
+
"ignore:Reader has no dt information:UserWarning")
|
|
1345
|
+
def test_probe_outputs_reduce_to_the_stage_files(probe_dir, cyl_spec,
|
|
1346
|
+
probe_space):
|
|
1347
|
+
"""The files a probe writes parse in the pipeline, which writes each
|
|
1348
|
+
whole of the probed segments to wholeSim and the ensemble and its
|
|
1349
|
+
average to ens and ensAvg, with the stamps of each level, under the
|
|
1350
|
+
names the parser gives the probed segments."""
|
|
1351
|
+
parsers = [ChainCyl(next((probe_dir / f).glob("*.npy")).name, "segment",
|
|
1352
|
+
"bug", ispath=True)
|
|
1353
|
+
for f in sorted(path.name for path in probe_dir.iterdir())]
|
|
1354
|
+
(space,) = {parser.space for parser in parsers}
|
|
1355
|
+
(ensemble,) = {parser.ensemble_long for parser in parsers}
|
|
1356
|
+
wholes = sorted({parser.whole for parser in parsers})
|
|
1357
|
+
table = AnalysisTable("bug", "segment", True, (tseries("rfloryT", "Mon"),))
|
|
1358
|
+
|
|
1359
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy="*/*")
|
|
1360
|
+
|
|
1361
|
+
assert list(result["rfloryTMon"]) == [ensemble]
|
|
1362
|
+
assert sorted(path.name for path in _analysis(probe_dir, "wholeSim")
|
|
1363
|
+
.iterdir()) == sorted(
|
|
1364
|
+
[f"{space}-bug-stamps-segment.csv"]
|
|
1365
|
+
+ [f"{whole}-bug-rfloryTMon.npy" for whole in wholes])
|
|
1366
|
+
assert sorted(path.name for path in _analysis(probe_dir, "ens")
|
|
1367
|
+
.iterdir()) == sorted([
|
|
1368
|
+
f"{space}-bug-stamps-whole.csv",
|
|
1369
|
+
f"{ensemble}-bug-rfloryTMon.csv"])
|
|
1370
|
+
assert sorted(path.name for path in _analysis(probe_dir, "ensAvg")
|
|
1371
|
+
.iterdir()) == sorted([
|
|
1372
|
+
f"{space}-bug-stamps-ensemble_long.csv",
|
|
1373
|
+
f"{ensemble}-bug-rfloryTMon-ensAvg.csv"])
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
@pytest.mark.filterwarnings(
|
|
1377
|
+
"ignore:Reader has no dt information:UserWarning")
|
|
1378
|
+
def test_probe_outputs_reduce_to_the_mean_of_the_wholes(probe_dir, cyl_spec,
|
|
1379
|
+
probe_space):
|
|
1380
|
+
"""The ensemble average of a probed series is, frame by frame, the mean
|
|
1381
|
+
over the replicates of each replicate's segments joined in order: a
|
|
1382
|
+
property of the reduction, computed from the probe's own results."""
|
|
1383
|
+
whole_1 = np.concatenate([probe_space[(1, 1)], probe_space[(1, 2)]])
|
|
1384
|
+
whole_2 = np.concatenate([probe_space[(2, 1)], probe_space[(2, 2)]])
|
|
1385
|
+
table = AnalysisTable("bug", "segment", False,
|
|
1386
|
+
(tseries("rfloryT", "Mon"),))
|
|
1387
|
+
|
|
1388
|
+
result = reduce_space(probe_dir, cyl_spec, table, hierarchy="*/*",
|
|
1389
|
+
save=False)
|
|
1390
|
+
|
|
1391
|
+
(average,) = result["rfloryTMon"].values()
|
|
1392
|
+
# Both sides average the same float64 copies of the probe's values, so
|
|
1393
|
+
# only float64 round-off separates them.
|
|
1394
|
+
assert_allclose(average["rfloryTMon-mean"], (whole_1 + whole_2) / 2,
|
|
1395
|
+
rtol=1e-12)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
# The equilibrium values of the wholes
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
@pytest.fixture
|
|
1402
|
+
def ens_dir(tmp_path):
|
|
1403
|
+
"""An ``ens`` stage directory, ``<root>/analysis/N-bug-ens``."""
|
|
1404
|
+
folder = tmp_path / "analysis" / "N-bug-ens"
|
|
1405
|
+
folder.mkdir(parents=True)
|
|
1406
|
+
return folder
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
@pytest.fixture
|
|
1410
|
+
def write_ensemble(ens_dir):
|
|
1411
|
+
"""Factory: write the ensemble CSV of one property, one column per
|
|
1412
|
+
whole named ``<stem>ens<i>``, and return the whole names."""
|
|
1413
|
+
def _write(prop, columns, stem=None):
|
|
1414
|
+
stem = stem or ("N20r5.5lz25.0ac1.0nc100"
|
|
1415
|
+
"dt0.005bdump1000adump5000")
|
|
1416
|
+
frame = pd.DataFrame({f"{stem}ens{i}": column
|
|
1417
|
+
for i, column in enumerate(columns, start=1)})
|
|
1418
|
+
frame.to_csv(ens_dir / f"{stem}-bug-{prop}.csv", index=False)
|
|
1419
|
+
return list(frame.columns)
|
|
1420
|
+
return _write
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
@pytest.fixture
|
|
1424
|
+
def alternating():
|
|
1425
|
+
"""A series of 256 frames alternating between 0 and 2: its time
|
|
1426
|
+
average is 1, its variance 256/255, and its blocked error 0, since
|
|
1427
|
+
every block of two frames averages to 1."""
|
|
1428
|
+
return np.tile([0.0, 2.0], 128)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def test_equilibrium_wholes_reads_each_ensemble_file_once(
|
|
1432
|
+
ens_dir, write_ensemble, alternating, monkeypatch):
|
|
1433
|
+
"""Two properties over two ensembles read four files, each once."""
|
|
1434
|
+
wholes = (write_ensemble("gyrTMon", [alternating, alternating + 1])
|
|
1435
|
+
+ write_ensemble("gyrTMon", [alternating], stem="N20ac1.0nc0"))
|
|
1436
|
+
write_ensemble("shapeTMon", [alternating, alternating])
|
|
1437
|
+
write_ensemble("shapeTMon", [alternating], stem="N20ac1.0nc0")
|
|
1438
|
+
stamps = pd.DataFrame({"whole": wholes})
|
|
1439
|
+
reads = []
|
|
1440
|
+
read_csv = pd.read_csv
|
|
1441
|
+
|
|
1442
|
+
def counting_read_csv(path, *args, **kwargs):
|
|
1443
|
+
reads.append(Path(path).name)
|
|
1444
|
+
return read_csv(path, *args, **kwargs)
|
|
1445
|
+
|
|
1446
|
+
monkeypatch.setattr(pd, "read_csv", counting_read_csv)
|
|
1447
|
+
|
|
1448
|
+
equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon", "shapeTMon": "shapeMon"},
|
|
1449
|
+
stamps)
|
|
1450
|
+
|
|
1451
|
+
assert len(reads) == 4
|
|
1452
|
+
assert len(set(reads)) == 4
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
@pytest.mark.parametrize("method", [
|
|
1456
|
+
pytest.param("lee", id="lee"),
|
|
1457
|
+
pytest.param("sem_err", id="sem_err"),
|
|
1458
|
+
])
|
|
1459
|
+
def test_equilibrium_wholes_mean_and_var_are_exact_and_sem_is_blocked(
|
|
1460
|
+
ens_dir, write_ensemble, method):
|
|
1461
|
+
"""The mean and variance of each whole are the hand values of an
|
|
1462
|
+
alternating series and numpy's of a random one, and the error is
|
|
1463
|
+
``sem_blocked`` of that whole's column under the chosen rule."""
|
|
1464
|
+
n_frames = 16384
|
|
1465
|
+
rng = np.random.default_rng(7)
|
|
1466
|
+
series = [np.tile([0.0, 2.0], n_frames // 2),
|
|
1467
|
+
rng.standard_normal(n_frames),
|
|
1468
|
+
np.repeat(rng.standard_normal(n_frames // 8), 8)]
|
|
1469
|
+
wholes = write_ensemble("gyrTMon", series)
|
|
1470
|
+
stamps = pd.DataFrame({"whole": wholes})
|
|
1471
|
+
|
|
1472
|
+
table = equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"}, stamps,
|
|
1473
|
+
method=method)
|
|
1474
|
+
|
|
1475
|
+
# The alternating series: deviations of exactly 1, so var = n/(n - 1);
|
|
1476
|
+
# the sums are exact in float64, one rounding in the division.
|
|
1477
|
+
assert table["gyrMon-mean"][0] == pytest.approx(1.0, rel=1e-12)
|
|
1478
|
+
assert table["gyrMon-var"][0] == pytest.approx(
|
|
1479
|
+
n_frames / (n_frames - 1), rel=1e-12)
|
|
1480
|
+
assert table["gyrMon-sem"][0] == 0.0
|
|
1481
|
+
for row in (1, 2):
|
|
1482
|
+
# The same numpy reductions on the same values: round-off only.
|
|
1483
|
+
assert table["gyrMon-mean"][row] == pytest.approx(
|
|
1484
|
+
series[row].mean(), rel=1e-12)
|
|
1485
|
+
assert table["gyrMon-var"][row] == pytest.approx(
|
|
1486
|
+
series[row].var(ddof=1), rel=1e-12)
|
|
1487
|
+
assert table["gyrMon-sem"][row] == pytest.approx(
|
|
1488
|
+
sem_blocked(series[row], method=method), rel=1e-12)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def test_equilibrium_wholes_error_grows_with_the_correlation_time(
|
|
1492
|
+
ens_dir, write_ensemble):
|
|
1493
|
+
"""A series that holds each value for 8 frames has a blocked error
|
|
1494
|
+
about ``sqrt(8)`` times the per-frame one; an independent series has
|
|
1495
|
+
about the same."""
|
|
1496
|
+
rng = np.random.default_rng(7)
|
|
1497
|
+
independent = rng.standard_normal(16384)
|
|
1498
|
+
held = np.repeat(rng.standard_normal(2048), 8)
|
|
1499
|
+
wholes = write_ensemble("gyrTMon", [independent, held])
|
|
1500
|
+
stamps = pd.DataFrame({"whole": wholes})
|
|
1501
|
+
|
|
1502
|
+
table = equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"}, stamps)
|
|
1503
|
+
|
|
1504
|
+
naive = [series.std(ddof=1) / np.sqrt(series.size)
|
|
1505
|
+
for series in (independent, held)]
|
|
1506
|
+
# The bounds are those of the sem_blocked doctest on the same signal:
|
|
1507
|
+
# a statistical band around sqrt(8) = 2.83 and around 1.
|
|
1508
|
+
assert 2.3 < table["gyrMon-sem"][1] / naive[1] < 3.4
|
|
1509
|
+
assert 0.8 < table["gyrMon-sem"][0] / naive[0] < 1.25
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def test_equilibrium_wholes_gives_a_constant_series_zero_error(
|
|
1513
|
+
ens_dir, write_ensemble, alternating):
|
|
1514
|
+
"""A whole whose series never varies has an exact average and no
|
|
1515
|
+
error, instead of the blocking analysis rejecting it."""
|
|
1516
|
+
wholes = write_ensemble("gyrTMon", [np.full(256, 3.0), alternating])
|
|
1517
|
+
stamps = pd.DataFrame({"whole": wholes})
|
|
1518
|
+
|
|
1519
|
+
table = equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"}, stamps)
|
|
1520
|
+
|
|
1521
|
+
assert table["gyrMon-mean"][0] == 3.0
|
|
1522
|
+
assert table["gyrMon-var"][0] == 0.0
|
|
1523
|
+
assert table["gyrMon-sem"][0] == 0.0
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def test_equilibrium_wholes_keeps_the_stamps_order_and_columns(
|
|
1527
|
+
ens_dir, write_ensemble, alternating):
|
|
1528
|
+
"""The table holds the stamps' columns, then the three statistics of
|
|
1529
|
+
each property in the order given, with the rows in the stamps' order,
|
|
1530
|
+
not the files'."""
|
|
1531
|
+
wholes = write_ensemble("gyrTMon", [alternating, alternating + 1])
|
|
1532
|
+
write_ensemble("shapeTMon", [alternating + 2, alternating + 3])
|
|
1533
|
+
stamps = pd.DataFrame({"whole": wholes[::-1], "n_frames": [256, 256]})
|
|
1534
|
+
|
|
1535
|
+
table = equilibrium_wholes(
|
|
1536
|
+
ens_dir, {"shapeTMon": "shapeMon", "gyrTMon": "gyrMon"}, stamps)
|
|
1537
|
+
|
|
1538
|
+
assert list(table.columns) == [
|
|
1539
|
+
"whole", "n_frames", "shapeMon-mean", "shapeMon-var", "shapeMon-sem",
|
|
1540
|
+
"gyrMon-mean", "gyrMon-var", "gyrMon-sem"]
|
|
1541
|
+
assert table["whole"].to_list() == wholes[::-1]
|
|
1542
|
+
assert_allclose(table["gyrMon-mean"], [2.0, 1.0])
|
|
1543
|
+
assert_allclose(table["shapeMon-mean"], [4.0, 3.0])
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def test_equilibrium_wholes_appends_the_topology_to_match_the_stamps(
|
|
1547
|
+
ens_dir, write_ensemble, alternating):
|
|
1548
|
+
"""Stamps keyed by the whole's file name, ``...ens1.ring``, match the
|
|
1549
|
+
ensemble columns once the topology is appended, and the table keeps
|
|
1550
|
+
the stamps' names."""
|
|
1551
|
+
wholes = write_ensemble("gyrTMon", [alternating, alternating + 1])
|
|
1552
|
+
stamps = pd.DataFrame({"whole": [f"{whole}.ring" for whole in wholes]})
|
|
1553
|
+
|
|
1554
|
+
table = equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"}, stamps,
|
|
1555
|
+
topology="ring")
|
|
1556
|
+
|
|
1557
|
+
assert table["whole"].to_list() == [f"{whole}.ring" for whole in wholes]
|
|
1558
|
+
assert_allclose(table["gyrMon-mean"], [1.0, 2.0])
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
@pytest.mark.parametrize("stamps_wholes, match", [
|
|
1562
|
+
pytest.param(slice(0, 1), r"In the files only: \[.*ens2'\]",
|
|
1563
|
+
id="stamps-lack-a-whole"),
|
|
1564
|
+
pytest.param(slice(0, 3), r"In the stamps only: \[.*ens3'\]",
|
|
1565
|
+
id="files-lack-a-whole"),
|
|
1566
|
+
])
|
|
1567
|
+
def test_equilibrium_wholes_rejects_wholes_on_one_side_only(
|
|
1568
|
+
ens_dir, write_ensemble, alternating, stamps_wholes, match):
|
|
1569
|
+
"""A whole in the ensemble files but not in the stamps, or in the
|
|
1570
|
+
stamps but not in the files, raises and is named, instead of a row
|
|
1571
|
+
being dropped or left empty."""
|
|
1572
|
+
wholes = write_ensemble("gyrTMon", [alternating, alternating + 1])
|
|
1573
|
+
stem = wholes[0][:-len("ens1")]
|
|
1574
|
+
stamps = pd.DataFrame({"whole": [f"{stem}ens{i}" for i in (1, 2, 3)]
|
|
1575
|
+
[stamps_wholes]})
|
|
1576
|
+
|
|
1577
|
+
with pytest.raises(ValueError, match=match):
|
|
1578
|
+
equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"}, stamps)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def test_equilibrium_wholes_rejects_repeated_output_names(
|
|
1582
|
+
ens_dir, write_ensemble, alternating):
|
|
1583
|
+
"""Two properties mapped to one output name would overwrite each
|
|
1584
|
+
other's columns, so they are rejected."""
|
|
1585
|
+
wholes = write_ensemble("gyrTMon", [alternating])
|
|
1586
|
+
stamps = pd.DataFrame({"whole": wholes})
|
|
1587
|
+
with pytest.raises(ValueError, match=r"more than once: \['gyrMon'\]"):
|
|
1588
|
+
equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon",
|
|
1589
|
+
"shapeTMon": "gyrMon"}, stamps)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
@pytest.mark.parametrize("stamps, match", [
|
|
1593
|
+
pytest.param(pd.DataFrame({"n_frames": [256]}), "needs a 'whole'",
|
|
1594
|
+
id="no-whole-column"),
|
|
1595
|
+
pytest.param(pd.DataFrame({"whole": ["w1", "w1"]}), "more than once",
|
|
1596
|
+
id="repeated-whole"),
|
|
1597
|
+
])
|
|
1598
|
+
def test_equilibrium_wholes_rejects_bad_stamps(ens_dir, write_ensemble,
|
|
1599
|
+
alternating, stamps, match):
|
|
1600
|
+
"""The stamps need a ``whole`` column that lists each whole once."""
|
|
1601
|
+
write_ensemble("gyrTMon", [alternating])
|
|
1602
|
+
with pytest.raises(ValueError, match=match):
|
|
1603
|
+
equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"}, stamps)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def test_equilibrium_wholes_property_without_files_raises(
|
|
1607
|
+
ens_dir, write_ensemble, alternating):
|
|
1608
|
+
"""A property with no ensemble file raises and is named."""
|
|
1609
|
+
wholes = write_ensemble("gyrTMon", [alternating])
|
|
1610
|
+
stamps = pd.DataFrame({"whole": wholes})
|
|
1611
|
+
with pytest.raises(FileNotFoundError, match="'shapeTMon'"):
|
|
1612
|
+
equilibrium_wholes(ens_dir, {"shapeTMon": "shapeMon"}, stamps)
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def test_equilibrium_wholes_empty_directory_raises(ens_dir):
|
|
1616
|
+
"""An ``ens`` directory without CSV files raises."""
|
|
1617
|
+
with pytest.raises(FileNotFoundError, match="No file matches"):
|
|
1618
|
+
equilibrium_wholes(ens_dir, {"gyrTMon": "gyrMon"},
|
|
1619
|
+
pd.DataFrame({"whole": ["w1"]}))
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
# Golden comparison with the research code, written blind
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _study_module(): # pragma: no cover - tier C helpers
|
|
1626
|
+
"""The TransFociCub spec module. The study folders are not an installed
|
|
1627
|
+
package: import them from the repository root, as projects/conftest.py
|
|
1628
|
+
does for their own tests."""
|
|
1629
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
1630
|
+
return importlib.import_module("projects.trans_foci_cub.spec")
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def _research_folder(private_data_dir, phase, suffix): # pragma: no cover
|
|
1634
|
+
"""The one ``TransFociCub/<phase>/*-bug-<suffix>`` folder of the research
|
|
1635
|
+
code's output under the data directory, or a skip."""
|
|
1636
|
+
parent = private_data_dir / "TransFociCub" / phase
|
|
1637
|
+
folders = sorted(parent.glob(f"*-bug-{suffix}"))
|
|
1638
|
+
if len(folders) != 1:
|
|
1639
|
+
pytest.skip(f"no single *-bug-{suffix} folder under {parent}")
|
|
1640
|
+
return folders[0]
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _bug_table(module): # pragma: no cover
|
|
1644
|
+
"""The study's analysis table of the ``bug`` group."""
|
|
1645
|
+
(table,) = [t for t in module.ANALYSIS if t.group == "bug"]
|
|
1646
|
+
return table
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _cap_autocorrelation_lags(table, n_frames): # pragma: no cover
|
|
1650
|
+
"""The table with each ``Autocorrelation`` hook's lags capped at
|
|
1651
|
+
``n_frames - 1``. The research code's statsmodels ``acf`` clamped lags
|
|
1652
|
+
beyond the series silently, and PolyPhys's rejects them, so a fixture
|
|
1653
|
+
of short wholes needs the cap; the spec itself is unchanged."""
|
|
1654
|
+
return dataclasses.replace(table, properties=tuple(
|
|
1655
|
+
dataclasses.replace(prop, post_hook=Autocorrelation(
|
|
1656
|
+
min(prop.post_hook.nlags, n_frames - 1)))
|
|
1657
|
+
if isinstance(prop.post_hook, Autocorrelation) else prop
|
|
1658
|
+
for prop in table.properties))
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def _assert_ens_avg_matches(produced, reference): # pragma: no cover
|
|
1662
|
+
"""Every ``*-ensAvg.csv`` of `produced` with a counterpart in `reference`
|
|
1663
|
+
equals it, to float64 round-off, and at least one does. The
|
|
1664
|
+
pair-distance artifacts and the ACF band are left out: they changed on
|
|
1665
|
+
purpose (criteria section 4), and the ACFs run on capped lags."""
|
|
1666
|
+
compared = 0
|
|
1667
|
+
for path in sorted(produced.glob("*-ensAvg.csv")):
|
|
1668
|
+
if "pairDist" in path.name or "-acf" in path.name:
|
|
1669
|
+
continue
|
|
1670
|
+
counterpart = reference / path.name
|
|
1671
|
+
if not counterpart.exists():
|
|
1672
|
+
continue
|
|
1673
|
+
pd.testing.assert_frame_equal(
|
|
1674
|
+
pd.read_csv(path), pd.read_csv(counterpart), rtol=1e-10,
|
|
1675
|
+
atol=1e-12, check_like=True)
|
|
1676
|
+
compared += 1
|
|
1677
|
+
assert compared > 0, f"no ensAvg file of {produced} has a counterpart"
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
@pytest.mark.private_data
|
|
1681
|
+
def test_ens_avg_matches_the_research_code( # pragma: no cover - needs data
|
|
1682
|
+
private_data_dir, tmp_path):
|
|
1683
|
+
"""The ensemble averages of one TransFociCub space, reduced from the
|
|
1684
|
+
research code's probe output, match the research code's, for every
|
|
1685
|
+
property it reduced the same way.
|
|
1686
|
+
|
|
1687
|
+
Expects ``<data>/TransFociCub/probes/<space>-bug-probe/`` (the probe
|
|
1688
|
+
phase of one space) and
|
|
1689
|
+
``<data>/TransFociCub/analysis/<prefix>-bug-ensAvg/`` (the research
|
|
1690
|
+
code's ensemble averages). The probe folder is linked into a temporary
|
|
1691
|
+
tree so that nothing is written under the data directory.
|
|
1692
|
+
"""
|
|
1693
|
+
module = _study_module()
|
|
1694
|
+
probe = _research_folder(private_data_dir, "probes", "probe")
|
|
1695
|
+
reference = _research_folder(private_data_dir, "analysis", "ensAvg")
|
|
1696
|
+
linked = tmp_path / "probes" / probe.name
|
|
1697
|
+
linked.parent.mkdir()
|
|
1698
|
+
linked.symlink_to(probe, target_is_directory=True)
|
|
1699
|
+
# The frame count from one whole's gyrTMon header, not its values.
|
|
1700
|
+
n_frames = np.load(next(probe.rglob("*-gyrTMon.npy")),
|
|
1701
|
+
mmap_mode="r").shape[0]
|
|
1702
|
+
table = _cap_autocorrelation_lags(_bug_table(module), n_frames)
|
|
1703
|
+
|
|
1704
|
+
reduce_space(linked, module.SPEC, table)
|
|
1705
|
+
|
|
1706
|
+
produced = tmp_path / "analysis" / f"{probe.name.split('-')[0]}-bug-ensAvg"
|
|
1707
|
+
_assert_ens_avg_matches(produced, reference)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _uncrowded(whole, wholes): # pragma: no cover
|
|
1711
|
+
"""The whole without crowders of the family and replicate of `whole`:
|
|
1712
|
+
the name that differs only in the crowder count and box side,
|
|
1713
|
+
``nc<n>l<side>``, and has ``nc0``."""
|
|
1714
|
+
family = re.sub(r"nc\d+l[\d.]+", "", whole)
|
|
1715
|
+
(match,) = [other for other in wholes if "nc0l" in other
|
|
1716
|
+
and re.sub(r"nc\d+l[\d.]+", "", other) == family]
|
|
1717
|
+
return match
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _probe_tier_b_wholes(module, wholes, probe_dir): # pragma: no cover
|
|
1721
|
+
"""Probe each whole's tier B polymer dump (``polyphys/tests/data/probe/``)
|
|
1722
|
+
with the study's ``ProbeSpec``, opened with the data file of its whole
|
|
1723
|
+
without crowders, and save the chain-size observables, the foci pair
|
|
1724
|
+
quantities, and the stamps under the research names in
|
|
1725
|
+
``probe_dir/<whole>.ring/``."""
|
|
1726
|
+
from polyphys.probe import (
|
|
1727
|
+
ChainSize, PairDistances, make_universe, write_stamps)
|
|
1728
|
+
|
|
1729
|
+
data = Path(__file__).resolve().parents[1] / "data" / "probe"
|
|
1730
|
+
for whole in wholes:
|
|
1731
|
+
topology = _uncrowded(whole, wholes)
|
|
1732
|
+
with warnings.catch_warnings():
|
|
1733
|
+
warnings.filterwarnings(
|
|
1734
|
+
"ignore", "Reader has no dt information", UserWarning)
|
|
1735
|
+
universe = make_universe(
|
|
1736
|
+
data / topology / f"{topology}.ring.all.data.gz",
|
|
1737
|
+
data / whole / f"{whole}.ring.bug.last50.lammpstrj.gz")
|
|
1738
|
+
parser = module.SPEC.parser(f"{whole}.ring", "whole",
|
|
1739
|
+
module.SPEC.group)
|
|
1740
|
+
folder = probe_dir / f"{whole}.ring"
|
|
1741
|
+
folder.mkdir(parents=True)
|
|
1742
|
+
name = f"{parser.name}-{parser.group}"
|
|
1743
|
+
ChainSize.from_spec(universe, module.PROBE).run().save(folder, name)
|
|
1744
|
+
PairDistances.from_spec(universe, module.PROBE, parser).run().save(
|
|
1745
|
+
folder, name)
|
|
1746
|
+
write_stamps(parser, universe.trajectory.n_frames, folder)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
@pytest.mark.private_data
|
|
1750
|
+
@pytest.mark.filterwarnings(
|
|
1751
|
+
"ignore:Reader has no dt information:UserWarning")
|
|
1752
|
+
def test_probe_and_reduce_match_the_research_ens_avg( # pragma: no cover
|
|
1753
|
+
private_data_dir, tmp_path):
|
|
1754
|
+
"""Probing the TransFociCub polymer dumps with the study's ``ProbeSpec``,
|
|
1755
|
+
saving under the research names, and reducing them with ``reduce_space``
|
|
1756
|
+
reproduces the research code's ensemble averages end to end. The wholes
|
|
1757
|
+
are those of the research probe folder; the dumps are their tier B
|
|
1758
|
+
copies, the same 50 frames the research probe read."""
|
|
1759
|
+
pytest.importorskip("MDAnalysis")
|
|
1760
|
+
module = _study_module()
|
|
1761
|
+
research = _research_folder(private_data_dir, "probes", "probe")
|
|
1762
|
+
reference = _research_folder(private_data_dir, "analysis", "ensAvg")
|
|
1763
|
+
wholes = sorted(path.name.removesuffix(".ring")
|
|
1764
|
+
for path in research.iterdir() if path.is_dir())
|
|
1765
|
+
probe_dir = tmp_path / "probes" / research.name
|
|
1766
|
+
_probe_tier_b_wholes(module, wholes, probe_dir)
|
|
1767
|
+
# The tier B polymer dumps hold the wholes' last 50 frames.
|
|
1768
|
+
table = _cap_autocorrelation_lags(_bug_table(module), 50)
|
|
1769
|
+
|
|
1770
|
+
reduce_space(probe_dir, module.SPEC, table)
|
|
1771
|
+
|
|
1772
|
+
produced = (tmp_path / "analysis"
|
|
1773
|
+
/ f"{research.name.split('-')[0]}-bug-ensAvg")
|
|
1774
|
+
_assert_ens_avg_matches(produced, reference)
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
@pytest.mark.private_data
|
|
1778
|
+
@pytest.mark.parametrize("prop", ["gyrTMon", "shapeTMon", "asphericityTMon"])
|
|
1779
|
+
def test_space_tseries_from_spec_matches_the_research_code( # pragma: no cover
|
|
1780
|
+
private_data_dir, prop):
|
|
1781
|
+
"""The space table of a TransFociCub time series, built from the
|
|
1782
|
+
research code's ensemble averages, equals the research code's space
|
|
1783
|
+
table: the same rows by state point and time index, the same
|
|
1784
|
+
statistics, time, and attributes, under the spec's renaming of the
|
|
1785
|
+
crowder volume fraction and the documented mapping of the short names
|
|
1786
|
+
(Usage, "Lineage names")."""
|
|
1787
|
+
spec = _study_module().SPEC
|
|
1788
|
+
ens_avg = _research_folder(private_data_dir, "analysis", "ensAvg")
|
|
1789
|
+
space_dir = _research_folder(private_data_dir, "analysis", "space")
|
|
1790
|
+
theirs = pd.read_csv(
|
|
1791
|
+
space_dir / f"{space_dir.name.split('-')[0]}-bug-{prop}-space.csv")
|
|
1792
|
+
theirs = theirs.rename(columns={"phi_c_bulk": "phi_bulk_c",
|
|
1793
|
+
"phi_c_bulk_round": "phi_bulk_c_round"})
|
|
1794
|
+
# A research short name, parsed at its lineage and rebuilt, gives the
|
|
1795
|
+
# current form.
|
|
1796
|
+
theirs = theirs.assign(**{
|
|
1797
|
+
lineage: theirs[lineage].map(
|
|
1798
|
+
lambda text, lineage=lineage: getattr(
|
|
1799
|
+
spec.parser(text, lineage, spec.group), lineage))
|
|
1800
|
+
for lineage in ("ensemble", "space")})
|
|
1801
|
+
key = ["ensemble_long", "t_index"]
|
|
1802
|
+
|
|
1803
|
+
ours = space_tseries_from_spec(ens_avg, spec, prop)
|
|
1804
|
+
|
|
1805
|
+
ours = ours.sort_values(key).reset_index(drop=True)
|
|
1806
|
+
theirs = theirs.sort_values(key).reset_index(drop=True)
|
|
1807
|
+
assert set(theirs.columns) <= set(ours.columns)
|
|
1808
|
+
# The statistics are float64 values read from the same CSV files; the
|
|
1809
|
+
# integer attributes compare exactly. The research table wrote some
|
|
1810
|
+
# attributes as integers that PolyPhys holds as floats, hence no dtype
|
|
1811
|
+
# check.
|
|
1812
|
+
pd.testing.assert_frame_equal(ours[theirs.columns], theirs, rtol=1e-10,
|
|
1813
|
+
atol=1e-12, check_dtype=False)
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
# The space stage from the spec
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
@pytest.fixture
|
|
1820
|
+
def ens_avg_db(tmp_path):
|
|
1821
|
+
"""An ``ensAvg`` directory with the gyrTMon time series and the rHistMon
|
|
1822
|
+
histogram of one ChainCyl state point with n = 1000 crowders of
|
|
1823
|
+
a = 2 in a cylinder of r = 10.5 and lz = 25."""
|
|
1824
|
+
database = tmp_path / "analysis" / "N200-bug-ensAvg"
|
|
1825
|
+
database.mkdir(parents=True)
|
|
1826
|
+
state = "N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000"
|
|
1827
|
+
pd.DataFrame({"gyrTMon-mean": [1.0, 2.0]}).to_csv(
|
|
1828
|
+
database / f"{state}-bug-gyrTMon-ensAvg.csv", index=False)
|
|
1829
|
+
pd.DataFrame({"rHistMon-mean": [1.0, 3.0]}).to_csv(
|
|
1830
|
+
database / f"{state}-bug-rHistMon-ensAvg.csv", index=False)
|
|
1831
|
+
return database
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
@pytest.mark.parametrize("divisor, rounded", [
|
|
1835
|
+
pytest.param(0.001, 0.329, id="step-0.001"),
|
|
1836
|
+
pytest.param(0.025, 0.325, id="step-0.025"),
|
|
1837
|
+
])
|
|
1838
|
+
def test_space_tseries_from_spec_rounds_with_the_spec_divisor(
|
|
1839
|
+
ens_avg_db, cyl_spec, divisor, rounded):
|
|
1840
|
+
"""The space table rounds phi_bulk_c to the spec's divisor."""
|
|
1841
|
+
spec = dataclasses.replace(cyl_spec, divisor=divisor)
|
|
1842
|
+
|
|
1843
|
+
space = space_tseries_from_spec(ens_avg_db, spec, "gyrTMon")
|
|
1844
|
+
|
|
1845
|
+
# phi_c = n pi a^3 / 6 / (pi Z (D - a)^2 / 4) = 2 n a^3 / (3 Z (D - a)^2)
|
|
1846
|
+
# with n = 1000, a = 2, Z = 2 lz = 50, D = 2 r - 1 = 20:
|
|
1847
|
+
# 16000 / 48600 = 0.32922, which rounds to 0.329 on a grid of 0.001 and
|
|
1848
|
+
# to 0.325 on a grid of 0.025.
|
|
1849
|
+
assert space["phi_bulk_c_round"].tolist() == [rounded, rounded]
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def test_space_tseries_from_spec_matches_the_organizer_with_spec_fields(
|
|
1853
|
+
ens_avg_db, cyl_spec):
|
|
1854
|
+
"""The spec-taking time-series table equals the organizer's table built
|
|
1855
|
+
from the spec's fields one by one."""
|
|
1856
|
+
expected = organizer.space_tseries(
|
|
1857
|
+
f"{ens_avg_db}/", "gyrTMon", cyl_spec.parser, cyl_spec.hierarchy,
|
|
1858
|
+
list(cyl_spec.physical_attrs), cyl_spec.group,
|
|
1859
|
+
divisor=cyl_spec.divisor, crowder_attr=cyl_spec.crowder_attr)
|
|
1860
|
+
|
|
1861
|
+
space = space_tseries_from_spec(ens_avg_db, cyl_spec, "gyrTMon")
|
|
1862
|
+
|
|
1863
|
+
pd.testing.assert_frame_equal(space, expected)
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def test_space_hists_from_spec_matches_the_organizer_with_spec_fields(
|
|
1867
|
+
ens_avg_db, cyl_spec):
|
|
1868
|
+
"""The spec-taking histogram table equals the organizer's table built
|
|
1869
|
+
from the spec's fields one by one, with the bin centers and the
|
|
1870
|
+
normalization passed through."""
|
|
1871
|
+
centers = np.array([0.5, 1.5])
|
|
1872
|
+
expected = organizer.space_hists(
|
|
1873
|
+
f"{ens_avg_db}/", "rHistMon", cyl_spec.parser, cyl_spec.hierarchy,
|
|
1874
|
+
list(cyl_spec.physical_attrs), cyl_spec.group, bin_center=centers,
|
|
1875
|
+
normalize=True, divisor=cyl_spec.divisor,
|
|
1876
|
+
crowder_attr=cyl_spec.crowder_attr)
|
|
1877
|
+
|
|
1878
|
+
space = space_hists_from_spec(ens_avg_db, cyl_spec, "rHistMon",
|
|
1879
|
+
bin_center=centers, normalize=True)
|
|
1880
|
+
|
|
1881
|
+
pd.testing.assert_frame_equal(space, expected)
|