polyphys 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)