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,1495 @@
1
+ """Tests for :mod:`polyphys.manage.organizer`.
2
+
3
+ Most inputs come from the ``make_lineage_tree`` factory in
4
+ ``polyphys/tests/conftest.py``: tiny ``.npy`` files named for the
5
+ ``ChainCyl`` parser, under a directory whose name contains hyphens, as
6
+ every lineage directory name does. File ``(e, w, s)`` (ensemble, whole,
7
+ segment, each from 1; ``s = 0`` for a whole file) holds
8
+ ``1000 * e + 100 * w + 10 * s + k`` in row ``k``, so every expected sum,
9
+ concatenation, and replicate statistic below is written down by hand from
10
+ that rule. Expected lineage names come from the parser applied to the
11
+ written filename, never from the strings the tree builds.
12
+
13
+ The space tests build a small ``analysis`` phase on disk; their expected
14
+ ``t_index`` and ``time`` values come from the ``dt`` field and the dump
15
+ period of the group's trajectory (``adump`` for ``all``, ``bdump`` for
16
+ ``bug``) encoded in the filenames. The stamp tests use the column layout of the
17
+ SciOps probe phase's stamp files: genealogy columns, parser attributes, and
18
+ ``n_frames``.
19
+ """
20
+ import logging
21
+ import os
22
+ import re
23
+ from pathlib import Path
24
+ from typing import get_args
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+ import pytest
29
+
30
+ from polyphys.manage.organizer import (
31
+ BULK_NAME_MAP,
32
+ children_stamps,
33
+ create_fullname,
34
+ ens_avg_from_bin_edge,
35
+ ens_avg_from_df,
36
+ ens_avg_from_ndarray,
37
+ ens_from_bin_edge,
38
+ ens_from_df,
39
+ ens_from_mat_t,
40
+ ens_from_vec,
41
+ ensemble,
42
+ ensemble_avg,
43
+ make_database,
44
+ parents_stamps,
45
+ rename_bulk_columns,
46
+ save_artifact,
47
+ space_hists,
48
+ space_tseries,
49
+ whole_from_file,
50
+ whole_from_segments,
51
+ )
52
+ from polyphys.tests.synthetic import ChainCyl
53
+ from polyphys.manage.types import PhaseT, StageT
54
+ from polyphys.manage.utils import round_to_multiple
55
+
56
+ # A Pandas4Warning marks a deprecated pandas call; fail on it.
57
+ pytestmark = pytest.mark.filterwarnings("error::pandas.errors.Pandas4Warning")
58
+
59
+
60
+ # Fixtures: lineage trees and the names the parser gives their files
61
+
62
+
63
+ @pytest.fixture
64
+ def segment_tree(make_lineage_tree):
65
+ """Return a segment tree: 2 ensembles x 2 wholes x 2 segments x 3 rows."""
66
+ return make_lineage_tree(ChainCyl, group="bug", n_rows=3)
67
+
68
+
69
+ @pytest.fixture
70
+ def whole_tree(make_lineage_tree):
71
+ """Return a whole tree: 2 ensembles x 2 wholes x 3 rows."""
72
+ return make_lineage_tree(
73
+ ChainCyl, group="bug", lineage="whole", n_rows=3
74
+ )
75
+
76
+
77
+ def _parsed_names(tree, attr):
78
+ """Map each tree file's (ensemble, whole) indices to a parsed name."""
79
+ return {
80
+ index[:2]: getattr(
81
+ ChainCyl(str(path), tree.lineage, tree.group, ispath=True),
82
+ attr,
83
+ )
84
+ for path, index in tree.index.items()
85
+ }
86
+
87
+
88
+ @pytest.fixture
89
+ def segment_whole_names(segment_tree):
90
+ """Return the parsed whole name of each (ensemble, whole) of the tree."""
91
+ return _parsed_names(segment_tree, "whole")
92
+
93
+
94
+ @pytest.fixture
95
+ def whole_names(whole_tree):
96
+ """Return the parsed whole name of each (ensemble, whole) in whole_tree."""
97
+ return _parsed_names(whole_tree, "whole")
98
+
99
+
100
+ @pytest.fixture
101
+ def ensemble_names(whole_tree):
102
+ """Return the parsed ensemble_long name of each ensemble in whole_tree."""
103
+ return {
104
+ e: name for (e, _), name in
105
+ _parsed_names(whole_tree, "ensemble_long").items()
106
+ }
107
+
108
+
109
+ @pytest.fixture
110
+ def write_segments(tmp_path):
111
+ """Factory: write arrays as segments j01, j02, ... of one whole."""
112
+ stem = (
113
+ "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000ens1"
114
+ )
115
+
116
+ def _write(arrays):
117
+ directory = tmp_path / "ChainCyl-bug-segment"
118
+ directory.mkdir(exist_ok=True)
119
+ paths = []
120
+ for seg_id, array in enumerate(arrays, start=1):
121
+ path = directory / f"{stem}.j{seg_id:02d}.bug.binEdge.npy"
122
+ np.save(path, np.asarray(array))
123
+ paths.append(str(path))
124
+ return paths
125
+
126
+ return _write
127
+
128
+
129
+ # Naming and persistence
130
+
131
+
132
+ def test_create_fullname_joins_with_hyphens():
133
+ """A full name is the name, group, and property joined by hyphens."""
134
+ assert create_fullname("w", "bug", "rhoMon") == "w-bug-rhoMon"
135
+
136
+
137
+ def test_save_artifact_writes_ndarray_to_npy(tmp_path):
138
+ """An array is saved as <filename>.npy and loads back unchanged."""
139
+ data = np.arange(3.0)
140
+ save_artifact("w-bug-rhoMon", data, tmp_path)
141
+ np.testing.assert_array_equal(np.load(tmp_path / "w-bug-rhoMon.npy"), data)
142
+
143
+
144
+ def test_save_artifact_writes_dataframe_to_csv_without_index(tmp_path):
145
+ """A DataFrame is saved as <filename>.csv without its index."""
146
+ data = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]}, index=[5, 6])
147
+ save_artifact("w-bug-rhoMon", data, tmp_path)
148
+ pd.testing.assert_frame_equal(
149
+ pd.read_csv(tmp_path / "w-bug-rhoMon.csv"),
150
+ data.reset_index(drop=True),
151
+ )
152
+
153
+
154
+ def test_save_artifact_writes_dict_entries_by_measure(tmp_path):
155
+ """Each '<prop>-<measure>' entry is saved as <filename>-<measure>.npy."""
156
+ data = {"rhoMon-mean": np.array([1.0]), "rhoMon-var": np.array([2.0])}
157
+ save_artifact("w-bug-rhoMon", data, tmp_path)
158
+ assert sorted(os.listdir(tmp_path)) == [
159
+ "w-bug-rhoMon-mean.npy", "w-bug-rhoMon-var.npy"
160
+ ]
161
+ np.testing.assert_array_equal(
162
+ np.load(tmp_path / "w-bug-rhoMon-var.npy"), [2.0]
163
+ )
164
+
165
+
166
+ def test_save_artifact_rejects_unsupported_type(tmp_path):
167
+ """Data that is not an array, a DataFrame, or a dict raises TypeError."""
168
+ with pytest.raises(TypeError, match="Unsupported data type list"):
169
+ save_artifact("w-bug-rhoMon", [1.0, 2.0], tmp_path)
170
+
171
+
172
+ @pytest.mark.parametrize(
173
+ "group, directory",
174
+ [
175
+ pytest.param("bug", "N20-bug-ens", id="with-group"),
176
+ pytest.param(None, "N20-ens", id="without-group"),
177
+ ],
178
+ )
179
+ def test_make_database_creates_sibling_phase_directory(
180
+ tmp_path, group, directory
181
+ ):
182
+ """The new directory is <phase>/<prefix>-<group>-<stage> beside the old."""
183
+ old = tmp_path / "probes" / "N20-bug-segment"
184
+ new = make_database(old, "analysis", "ens", group)
185
+ assert new == str(tmp_path / "analysis" / directory) + "/"
186
+ assert Path(new).is_dir()
187
+
188
+
189
+ def test_make_database_returns_existing_directory(tmp_path):
190
+ """Asking twice for the same directory returns the same path."""
191
+ old = tmp_path / "probes" / "N20-bug-segment"
192
+ first = make_database(old, "analysis", "ens", "bug")
193
+ assert make_database(old, "analysis", "ens", "bug") == first
194
+
195
+
196
+ def test_make_database_logs_reuse_of_existing_directory(
197
+ tmp_path, caplog, capsys
198
+ ):
199
+ """Reusing a directory logs an INFO record naming it and prints nothing."""
200
+ old = tmp_path / "probes" / "N20-bug-segment"
201
+ make_database(old, "analysis", "ens", "bug")
202
+ caplog.set_level(logging.INFO, logger="polyphys.manage.organizer")
203
+
204
+ make_database(old, "analysis", "ens", "bug")
205
+
206
+ assert [(record.levelname, record.getMessage()) for record in
207
+ caplog.records] == [
208
+ ("INFO", f"Reusing the existing directory "
209
+ f"{tmp_path / 'analysis' / 'N20-bug-ens'}.")]
210
+ assert capsys.readouterr().out == ""
211
+
212
+
213
+ def test_make_database_rejects_unknown_phase(tmp_path):
214
+ """A phase outside the phase vocabulary raises ValueError."""
215
+ with pytest.raises(ValueError, match="'nope' is an invalid option"):
216
+ make_database(tmp_path / "probes" / "N20-bug-segment", "nope", "ens",
217
+ "bug")
218
+
219
+
220
+ def test_phase_vocabulary_is_the_settled_set():
221
+ """PhaseT declares the nine phase names of AGENTS.md in data-flow order."""
222
+ assert get_args(PhaseT) == (
223
+ "simsAll", "simsCont", "logs", "trjs", "probes", "analysis", "viz",
224
+ "galaxy", "allInOne",
225
+ )
226
+
227
+
228
+ @pytest.mark.parametrize("phase", get_args(PhaseT))
229
+ def test_make_database_accepts_every_declared_phase(tmp_path, phase):
230
+ """Every phase that PhaseT declares is accepted and names the directory."""
231
+ new = make_database(tmp_path / "probes" / "N20-bug-segment", phase,
232
+ "ens", "bug")
233
+ assert Path(new).parent.name == phase
234
+
235
+
236
+ def test_make_database_rejects_singular_phase_name(tmp_path):
237
+ """The singular 'simAll' of the old PhaseT is no longer accepted."""
238
+ with pytest.raises(ValueError, match="'simAll' is an invalid option"):
239
+ make_database(tmp_path / "probes" / "N20-bug-segment", "simAll",
240
+ "ens", "bug")
241
+
242
+
243
+ def test_make_database_rejects_unknown_stage(tmp_path):
244
+ """A stage outside StageT raises ValueError and names no directory."""
245
+ with pytest.raises(ValueError, match="'ensemble' is an invalid option"):
246
+ make_database(tmp_path / "probes" / "N20-bug-segment", "analysis",
247
+ "ensemble", "bug")
248
+ assert get_args(StageT) == ("segment", "wholeSim", "ens", "ensAvg",
249
+ "space", "galaxy")
250
+
251
+
252
+ def test_make_database_rejects_group_with_hyphen(tmp_path):
253
+ """A group holding the field separator '-' raises ValueError before any
254
+ directory is made, since it would shift every hyphen-split field."""
255
+ old = tmp_path / "probes" / "N20-bug-segment"
256
+
257
+ with pytest.raises(ValueError, match="'bug-x'.*'-'"):
258
+ make_database(old, "analysis", "ens", "bug-x")
259
+
260
+ assert not (tmp_path / "analysis").exists()
261
+
262
+
263
+ def test_make_database_rejects_old_directory_outside_a_phase(tmp_path):
264
+ """An old directory whose parent is not a phase directory raises
265
+ ValueError before any directory is made, instead of placing the new one
266
+ at the wrong depth."""
267
+ old = tmp_path / "root" / "N20-bug-segment"
268
+
269
+ with pytest.raises(ValueError, match="'root' is not a phase"):
270
+ make_database(old, "analysis", "ens", "bug")
271
+
272
+ assert not (tmp_path / "analysis").exists()
273
+
274
+
275
+ # Building wholes
276
+
277
+
278
+ def test_whole_from_segments_tseries_concatenates_in_segment_order(
279
+ segment_tree, segment_whole_names
280
+ ):
281
+ """A tseries whole is its segments' rows, j01 first, then j02."""
282
+ paths = [str(p) for p in segment_tree.paths]
283
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
284
+ "tseries")
285
+ np.testing.assert_array_equal(
286
+ wholes[segment_whole_names[(1, 2)]],
287
+ [1210.0, 1211.0, 1212.0, 1220.0, 1221.0, 1222.0],
288
+ )
289
+
290
+
291
+ def test_whole_from_segments_histogram_sums_segments(
292
+ segment_tree, segment_whole_names
293
+ ):
294
+ """A histogram whole is the element-wise sum of its segments."""
295
+ paths = [str(p) for p in segment_tree.paths]
296
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
297
+ "histogram")
298
+ # (1210 + 1220, 1211 + 1221, 1212 + 1222)
299
+ np.testing.assert_array_equal(
300
+ wholes[segment_whole_names[(1, 2)]], [2430.0, 2432.0, 2434.0]
301
+ )
302
+
303
+
304
+ def test_whole_from_segments_keys_wholes_by_parsed_name(
305
+ segment_tree, segment_whole_names
306
+ ):
307
+ """There is one whole per parsed whole name, whatever the directory."""
308
+ paths = [str(p) for p in segment_tree.paths]
309
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
310
+ "tseries")
311
+ assert "-" in segment_tree.root.name
312
+ assert list(wholes) == list(segment_whole_names.values())
313
+
314
+
315
+ def test_whole_from_segments_bin_edge_keeps_shared_edges(write_segments):
316
+ """Identical segment edges reduce to one copy of them."""
317
+ edges = np.array([0.0, 1.0, 3.0])
318
+ paths = write_segments([edges, edges])
319
+ wholes = whole_from_segments("binEdge", paths, ChainCyl, "bug",
320
+ "bin_edge")
321
+ (whole,) = wholes.values()
322
+ np.testing.assert_array_equal(whole, edges)
323
+
324
+
325
+ def test_whole_from_segments_bin_edge_rejects_differing_edges(write_segments):
326
+ """Segments of one whole with different edges raise ValueError."""
327
+ paths = write_segments([[0.0, 1.0, 3.0], [0.0, 1.0, 4.0]])
328
+ with pytest.raises(ValueError, match="Bin edges are not identical"):
329
+ whole_from_segments("binEdge", paths, ChainCyl, "bug", "bin_edge")
330
+
331
+
332
+ def test_whole_from_segments_saves_each_whole(
333
+ tmp_path, segment_tree, segment_whole_names
334
+ ):
335
+ """save_to writes each whole to <whole>-<group>-<prop>.npy."""
336
+ out = tmp_path / "out"
337
+ out.mkdir()
338
+ paths = [str(p) for p in segment_tree.paths]
339
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
340
+ "tseries", save_to=out)
341
+ name = segment_whole_names[(2, 1)]
342
+ np.testing.assert_array_equal(
343
+ np.load(out / f"{name}-bug-gyrTMon.npy"), wholes[name]
344
+ )
345
+
346
+
347
+ @pytest.mark.parametrize("relation", ["histogram", "tseries", "bin_edge"])
348
+ def test_whole_from_segments_rejects_descending_segment_ids(
349
+ segment_tree, relation
350
+ ):
351
+ """Segments of a whole given in descending segment_id order raise."""
352
+ paths = [str(p) for p in segment_tree.paths[1::-1]]
353
+ with pytest.raises(ValueError, match=r"segment_id .*\[2, 1\]"):
354
+ whole_from_segments("gyrTMon", paths, ChainCyl, "bug", relation)
355
+
356
+
357
+ @pytest.mark.parametrize("relation", ["histogram", "tseries", "bin_edge"])
358
+ def test_whole_from_segments_rejects_duplicate_segment(segment_tree, relation):
359
+ """A segment passed twice raises instead of being counted twice."""
360
+ first = str(segment_tree.paths[0])
361
+ with pytest.raises(ValueError, match=r"segment_id .*\[1, 1\]"):
362
+ whole_from_segments(
363
+ "gyrTMon", [first, first], ChainCyl, "bug", relation
364
+ )
365
+
366
+
367
+ @pytest.mark.parametrize("relation", ["histogram", "tseries", "bin_edge"])
368
+ def test_whole_from_segments_rejects_a_gap_in_segment_ids(tmp_path, relation):
369
+ """Segments j01 and j03 of one whole raise instead of being joined as if
370
+ adjacent, which would shift the time of every later frame."""
371
+ whole = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000ens1"
372
+ paths = []
373
+ for seg_id in (1, 3):
374
+ path = tmp_path / f"{whole}.j{seg_id:02d}.bug.gyrTMon.npy"
375
+ np.save(path, np.arange(3.0))
376
+ paths.append(str(path))
377
+
378
+ with pytest.raises(ValueError, match=re.escape(
379
+ f"The segments of whole '{whole}' skip a segment_id: [1, 3]")):
380
+ whole_from_segments("gyrTMon", paths, ChainCyl, "bug", relation)
381
+
382
+
383
+ def test_whole_from_segments_accepts_interleaved_wholes(segment_tree):
384
+ """Wholes interleaved segment by segment give the same wholes."""
385
+ ordered = [str(p) for p in segment_tree.paths]
386
+ # All j01 files first, then all j02 files; each whole stays in order.
387
+ interleaved = ordered[0::2] + ordered[1::2]
388
+ expected = whole_from_segments("gyrTMon", ordered, ChainCyl, "bug",
389
+ "tseries")
390
+ wholes = whole_from_segments("gyrTMon", interleaved, ChainCyl, "bug",
391
+ "tseries")
392
+ assert list(wholes) == list(expected)
393
+ np.testing.assert_array_equal(
394
+ np.stack(list(wholes.values())), np.stack(list(expected.values()))
395
+ )
396
+
397
+
398
+ def test_whole_from_segments_rejects_unknown_relation(segment_tree):
399
+ """A relation other than histogram, tseries, or bin_edge raises."""
400
+ paths = [str(p) for p in segment_tree.paths]
401
+ with pytest.raises(ValueError, match="'nope' is an invalid option"):
402
+ whole_from_segments("gyrTMon", paths, ChainCyl, "bug", "nope")
403
+
404
+
405
+ def test_whole_from_file_loads_each_whole_by_parsed_name(
406
+ whole_tree, whole_names
407
+ ):
408
+ """Each whole file loads under its parsed whole name."""
409
+ wholes = whole_from_file(whole_tree.paths, ChainCyl, "bug")
410
+ assert "-" in whole_tree.root.name
411
+ assert list(wholes) == list(whole_names.values())
412
+ np.testing.assert_array_equal(
413
+ wholes[whole_names[(2, 1)]], [2100.0, 2101.0, 2102.0]
414
+ )
415
+
416
+
417
+ def test_whole_from_file_accepts_grouped_paths(whole_tree):
418
+ """1-tuples of paths, as sort_filenames gives, load like bare paths."""
419
+ grouped = list(zip(map(str, whole_tree.paths)))
420
+ bare = whole_from_file(whole_tree.paths, ChainCyl, "bug")
421
+ wholes = whole_from_file(grouped, ChainCyl, "bug")
422
+ assert list(wholes) == list(bare)
423
+ np.testing.assert_array_equal(
424
+ np.stack(list(wholes.values())), np.stack(list(bare.values()))
425
+ )
426
+
427
+
428
+ def test_whole_from_file_rejects_two_files_of_one_whole(tmp_path):
429
+ """Two files that parse to one whole name raise, naming both, instead of
430
+ the second silently replacing the first."""
431
+ whole = "N20r5.5lz25.0ac1.0nc100dt0.005bdump1000adump5000ens1"
432
+ paths = []
433
+ for folder in ("a", "b"):
434
+ (tmp_path / folder).mkdir()
435
+ path = tmp_path / folder / f"{whole}-bug-gyrTMon.npy"
436
+ np.save(path, np.arange(3.0))
437
+ paths.append(path)
438
+
439
+ with pytest.raises(ValueError, match=re.escape(
440
+ f"'{paths[0]}' and '{paths[1]}' are both whole '{whole}'")):
441
+ whole_from_file(paths, ChainCyl, "bug")
442
+
443
+
444
+ def test_whole_from_file_rejects_unsupported_path_item():
445
+ """An item that is neither a path nor a sequence of one raises."""
446
+ with pytest.raises(TypeError, match="Unsupported path item"):
447
+ whole_from_file([42], ChainCyl, "bug")
448
+
449
+
450
+ # Grouping wholes into ensembles
451
+
452
+
453
+ def test_ens_from_vec_puts_one_whole_per_column():
454
+ """Each whole becomes a column, in the order of the dictionary."""
455
+ name, ens_df = ens_from_vec(
456
+ ("ens1", {"w2": np.array([1.0, 2.0]), "w1": np.array([3.0, 4.0])})
457
+ )
458
+ assert name == "ens1"
459
+ pd.testing.assert_frame_equal(
460
+ ens_df, pd.DataFrame({"w2": [1.0, 2.0], "w1": [3.0, 4.0]})
461
+ )
462
+
463
+
464
+ def test_ens_from_vec_rejects_wholes_of_unequal_length():
465
+ """Wholes of 3 and 2 values raise, naming the ensemble and each length."""
466
+ with pytest.raises(ValueError, match=re.escape(
467
+ "The wholes of ensemble 'ens1' have unequal lengths: "
468
+ "w1 3, w2 2.")):
469
+ ens_from_vec(("ens1", {"w1": np.arange(3.0), "w2": np.arange(2.0)}))
470
+
471
+
472
+ def test_ens_from_mat_t_stacks_wholes_along_axis_0():
473
+ """Matrices stack into (n_wholes, n_rows, n_cols) in dictionary order."""
474
+ a = np.array([[1.0, 2.0], [3.0, 4.0]])
475
+ b = np.array([[5.0, 6.0], [7.0, 8.0]])
476
+ name, stacked = ens_from_mat_t(("ens1", {"w1": a, "w2": b}))
477
+ assert name == "ens1"
478
+ np.testing.assert_array_equal(stacked, np.stack([a, b]))
479
+
480
+
481
+ def test_ens_from_df_averages_wholes_row_by_row():
482
+ """Whole DataFrames average element-wise into one: (a + b) / 2."""
483
+ a = pd.DataFrame({"x": [1.0, 2.0], "y": [3.0, 4.0]})
484
+ b = pd.DataFrame({"x": [3.0, 6.0], "y": [5.0, 8.0]})
485
+ name, ens_df = ens_from_df(("ens1", {"w1": a, "w2": b}))
486
+ assert name == "ens1"
487
+ pd.testing.assert_frame_equal(ens_df, (a + b) / 2)
488
+
489
+
490
+ def test_ens_from_df_rejects_wholes_of_unequal_length():
491
+ """A 3-row and a 2-row whole raise instead of averaging the last row over
492
+ one replicate."""
493
+ a = pd.DataFrame({"x": [1.0, 2.0, 3.0]})
494
+ b = pd.DataFrame({"x": [3.0, 6.0]})
495
+
496
+ with pytest.raises(ValueError, match=re.escape(
497
+ "The wholes of ensemble 'ens1' have unequal lengths: "
498
+ "w1 3, w2 2.")):
499
+ ens_from_df(("ens1", {"w1": a, "w2": b}))
500
+
501
+
502
+ def test_ens_from_bin_edge_returns_shared_edges():
503
+ """Identical whole edges pass through as a copy of the shared array."""
504
+ edges = np.array([0.0, 1.0, 2.0])
505
+ name, out = ens_from_bin_edge(("ens1", {"w1": edges, "w2": edges.copy()}))
506
+ assert name == "ens1"
507
+ np.testing.assert_array_equal(out, edges)
508
+ assert not np.shares_memory(out, edges)
509
+
510
+
511
+ @pytest.mark.parametrize(
512
+ "other",
513
+ [
514
+ pytest.param(np.array([0.0, 1.0, 3.0]), id="different-values"),
515
+ pytest.param(np.array([0.0, 1.0, 2.0, 3.0]), id="different-lengths"),
516
+ ],
517
+ )
518
+ def test_ens_from_bin_edge_raises_on_mismatch(other):
519
+ """Differing whole edges raise ValueError instead of being merged."""
520
+ with pytest.raises(ValueError, match="Bin edges are not identical"):
521
+ ens_from_bin_edge(
522
+ ("ens1", {"w1": np.array([0.0, 1.0, 2.0]), "w2": other})
523
+ )
524
+
525
+
526
+ def test_ensemble_groups_wholes_by_state_point(
527
+ whole_tree, whole_names, ensemble_names
528
+ ):
529
+ """Wholes of two state points form two ensembles keyed by ensemble_long."""
530
+ wholes = whole_from_file(whole_tree.paths, ChainCyl, "bug")
531
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", "vector")
532
+ assert list(ens) == [ensemble_names[1], ensemble_names[2]]
533
+ assert list(ens[ensemble_names[2]].columns) == [
534
+ whole_names[(2, 1)], whole_names[(2, 2)]
535
+ ]
536
+
537
+
538
+ def test_ensemble_takes_bin_center_from_each_ensemble_edges(
539
+ whole_tree, whole_names, ensemble_names
540
+ ):
541
+ """Each ensemble's bin_center comes from its own wholes' edges."""
542
+ wholes = whole_from_file(whole_tree.paths, ChainCyl, "bug")
543
+ edges_1 = np.array([0.0, 1.0, 3.0, 6.0])
544
+ edges_2 = np.array([0.0, 2.0, 4.0, 8.0])
545
+ whole_edges = {
546
+ whole_names[(1, 1)]: edges_1,
547
+ whole_names[(1, 2)]: edges_1,
548
+ whole_names[(2, 1)]: edges_2,
549
+ whole_names[(2, 2)]: edges_2,
550
+ }
551
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", "vector",
552
+ whole_edges=whole_edges)
553
+ # Analytic midpoints of each ensemble's edges
554
+ np.testing.assert_allclose(
555
+ ens[ensemble_names[1]]["bin_center"], [0.5, 2.0, 4.5]
556
+ )
557
+ np.testing.assert_allclose(
558
+ ens[ensemble_names[2]]["bin_center"], [1.0, 3.0, 6.0]
559
+ )
560
+
561
+
562
+ @pytest.mark.parametrize(
563
+ "whole_type, first, second, expected",
564
+ [
565
+ pytest.param(
566
+ "matrix",
567
+ np.array([[1.0, 2.0]]), np.array([[3.0, 4.0]]),
568
+ np.array([[[1.0, 2.0]], [[3.0, 4.0]]]),
569
+ id="matrix-stacks",
570
+ ),
571
+ pytest.param(
572
+ "dataframe",
573
+ pd.DataFrame({"x": [1.0, 2.0]}), pd.DataFrame({"x": [3.0, 6.0]}),
574
+ np.array([[2.0], [4.0]]),
575
+ id="dataframe-averages",
576
+ ),
577
+ pytest.param(
578
+ "bin_edge",
579
+ np.array([0.0, 1.0]), np.array([0.0, 1.0]),
580
+ np.array([0.0, 1.0]),
581
+ id="bin-edge-shares",
582
+ ),
583
+ ],
584
+ )
585
+ def test_ensemble_merges_by_whole_type(
586
+ whole_names, ensemble_names, whole_type, first, second, expected
587
+ ):
588
+ """matrix stacks, dataframe averages, and bin_edge shares the wholes."""
589
+ wholes = {whole_names[(1, 1)]: first, whole_names[(1, 2)]: second}
590
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", whole_type)
591
+ np.testing.assert_array_equal(np.asarray(ens[ensemble_names[1]]),
592
+ expected)
593
+
594
+
595
+ def test_ensemble_saves_each_ensemble(
596
+ tmp_path, whole_tree, ensemble_names
597
+ ):
598
+ """save_to writes each ensemble to <ensemble_long>-<group>-<prop>.csv."""
599
+ out = tmp_path / "out"
600
+ out.mkdir()
601
+ wholes = whole_from_file(whole_tree.paths, ChainCyl, "bug")
602
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", "vector",
603
+ save_to=out)
604
+ name = ensemble_names[2]
605
+ pd.testing.assert_frame_equal(
606
+ pd.read_csv(out / f"{name}-bug-gyrTMon.csv"), ens[name]
607
+ )
608
+
609
+
610
+ def test_ensemble_rejects_unknown_whole_type(whole_tree):
611
+ """A whole_type outside the four accepted values raises ValueError."""
612
+ wholes = whole_from_file(whole_tree.paths, ChainCyl, "bug")
613
+ with pytest.raises(ValueError, match="'nope' is an invalid option"):
614
+ ensemble("gyrTMon", wholes, ChainCyl, "bug", "nope")
615
+
616
+
617
+ @pytest.fixture
618
+ def hist_wholes():
619
+ """Return two replicate histogram wholes of one state point."""
620
+ base = (
621
+ "N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump"
622
+ "10000ens"
623
+ )
624
+ return {
625
+ f"{base}1": np.array([1.0, 2.0, 3.0]),
626
+ f"{base}2": np.array([3.0, 4.0, 5.0]),
627
+ }
628
+
629
+
630
+ @pytest.fixture
631
+ def hist_edges(hist_wholes):
632
+ """Return the same non-uniform bin edges for every whole in hist_wholes.
633
+
634
+ The edges are non-uniform so a wrong center formula cannot pass by
635
+ accident.
636
+ """
637
+ return {name: np.array([0.0, 1.0, 3.0, 6.0]) for name in hist_wholes}
638
+
639
+
640
+ def test_ensemble_vector_bin_center_is_edge_midpoints(hist_wholes,
641
+ hist_edges):
642
+ """With whole_edges, a vector ensemble's bin_center is the midpoints."""
643
+ ens = ensemble(
644
+ "rhoMon", hist_wholes, ChainCyl, "bug", "vector",
645
+ whole_edges=hist_edges,
646
+ )
647
+ (ens_df,) = ens.values()
648
+ # Analytic midpoints of the edges [0, 1, 3, 6]
649
+ np.testing.assert_allclose(ens_df["bin_center"], [0.5, 2.0, 4.5])
650
+ pd.testing.assert_frame_equal(
651
+ ens_df.drop(columns="bin_center"), pd.DataFrame(hist_wholes)
652
+ )
653
+
654
+
655
+ def test_ensemble_without_whole_edges_has_no_bin_center(hist_wholes):
656
+ """Without whole_edges, a vector ensemble has one column per whole only."""
657
+ ens = ensemble("rhoMon", hist_wholes, ChainCyl, "bug", "vector")
658
+ (ens_df,) = ens.values()
659
+ assert list(ens_df.columns) == list(hist_wholes)
660
+
661
+
662
+ def test_ensemble_avg_keeps_bin_center_from_ensemble(hist_wholes,
663
+ hist_edges):
664
+ """ensemble_avg passes an ensemble's bin_center through unchanged."""
665
+ ens = ensemble(
666
+ "rhoMon", hist_wholes, ChainCyl, "bug", "vector",
667
+ whole_edges=hist_edges,
668
+ )
669
+ (ens_df,) = ens.values()
670
+ avg = ensemble_avg("rhoMon", ens, "bug", "dataframe")
671
+ (avg_df,) = avg.values()
672
+ np.testing.assert_array_equal(avg_df["bin_center"], ens_df["bin_center"])
673
+ assert list(avg_df.columns) == [
674
+ "bin_center", "rhoMon-mean", "rhoMon-var", "rhoMon-sem"
675
+ ]
676
+
677
+
678
+ @pytest.mark.parametrize("whole_type", ["matrix", "dataframe", "bin_edge"])
679
+ def test_ensemble_rejects_whole_edges_for_non_vector(
680
+ whole_type, hist_wholes, hist_edges
681
+ ):
682
+ """whole_edges with any whole_type other than 'vector' raises."""
683
+ with pytest.raises(ValueError, match="'whole_edges' is only valid"):
684
+ ensemble(
685
+ "rhoMon", hist_wholes, ChainCyl, "bug", whole_type,
686
+ whole_edges=hist_edges,
687
+ )
688
+
689
+
690
+ # Averaging over an ensemble
691
+
692
+
693
+ def test_ens_avg_from_ndarray_statistics_along_axis_0():
694
+ """Two wholes a, b give mean (a+b)/2, var (a-b)^2/2, sem |a-b|/2."""
695
+ a = np.array([[1.0, 2.0], [3.0, 4.0]])
696
+ b = np.array([[2.0, 5.0], [3.0, 8.0]])
697
+ out = ens_avg_from_ndarray("gyrTMon", np.stack([a, b]))
698
+ assert list(out) == ["gyrTMon-mean", "gyrTMon-var", "gyrTMon-sem"]
699
+ # Float64 round-off on values of order 10
700
+ np.testing.assert_allclose(out["gyrTMon-mean"], (a + b) / 2, atol=1e-12)
701
+ np.testing.assert_allclose(out["gyrTMon-var"], (a - b) ** 2 / 2,
702
+ atol=1e-12)
703
+ np.testing.assert_allclose(out["gyrTMon-sem"], np.abs(a - b) / 2,
704
+ atol=1e-12)
705
+
706
+
707
+ def test_ens_avg_from_df_leaves_input_unchanged():
708
+ """ens_avg_from_df returns a new frame and leaves its input unchanged."""
709
+ ens_data = pd.DataFrame({
710
+ "bin_center": [1.0, 2.0],
711
+ "w1": [4.0, 5.0],
712
+ "w2": [5.0, 7.0],
713
+ })
714
+ before = ens_data.copy()
715
+ out = ens_avg_from_df("density", ens_data, ["bin_center"])
716
+ assert out is not ens_data
717
+ pd.testing.assert_frame_equal(ens_data, before)
718
+ assert list(out.columns) == [
719
+ "bin_center", "density-mean", "density-var", "density-sem"
720
+ ]
721
+
722
+
723
+ def test_ens_avg_from_df_statistics():
724
+ """Two wholes a, b give mean (a+b)/2, var (a-b)^2/2, sem |a-b|/2."""
725
+ a = np.array([4.0, 5.0])
726
+ b = np.array([5.0, 7.0])
727
+ ens_data = pd.DataFrame({"bin_center": [1.0, 2.0], "w1": a, "w2": b})
728
+ out = ens_avg_from_df("density", ens_data, ["bin_center"])
729
+ np.testing.assert_allclose(out["density-mean"], (a + b) / 2)
730
+ np.testing.assert_allclose(out["density-var"], (a - b) ** 2 / 2)
731
+ np.testing.assert_allclose(out["density-sem"], np.abs(a - b) / 2)
732
+ np.testing.assert_array_equal(out["bin_center"], [1.0, 2.0])
733
+
734
+
735
+ def test_ens_avg_from_bin_edge_copies_1d_edges():
736
+ """A single 1D edge array, as ensemble_avg passes it, is copied."""
737
+ edges = np.array([0.0, 0.5, 1.0])
738
+ out = ens_avg_from_bin_edge(edges)
739
+ np.testing.assert_array_equal(out, edges)
740
+ assert not np.shares_memory(out, edges)
741
+
742
+
743
+ def test_ens_avg_from_bin_edge_reduces_equal_sequence():
744
+ """A sequence of equal edge arrays reduces to one copy of them."""
745
+ edges = np.array([0.0, 0.5, 1.0])
746
+ np.testing.assert_array_equal(
747
+ ens_avg_from_bin_edge([edges, edges.copy()]), edges
748
+ )
749
+
750
+
751
+ def test_ens_avg_from_bin_edge_rejects_differing_sequence():
752
+ """A sequence of differing edge arrays raises ValueError."""
753
+ edges = np.array([0.0, 0.5, 1.0])
754
+ with pytest.raises(ValueError, match="Bin edges are not identical"):
755
+ ens_avg_from_bin_edge([edges, np.array([0.0, 0.5, 2.0])])
756
+
757
+
758
+ def test_ensemble_avg_bin_edge_keeps_edges():
759
+ """ensemble_avg with 'bin_edge' returns each ensemble's edges intact."""
760
+ edges = np.array([0.0, 0.5, 1.0, 1.5])
761
+ out = ensemble_avg("rhoMon", {"ens1": edges}, "all", "bin_edge")
762
+ np.testing.assert_array_equal(out["ens1"], edges)
763
+
764
+
765
+ def test_ensemble_avg_ndarray_averages_each_ensemble():
766
+ """ensemble_avg with 'ndarray' gives each ensemble's statistics dict."""
767
+ a = np.array([[1.0, 2.0]])
768
+ b = np.array([[3.0, 6.0]])
769
+ out = ensemble_avg("gyrTMon", {"ens1": np.stack([a, b])}, "bug",
770
+ "ndarray")
771
+ np.testing.assert_allclose(out["ens1"]["gyrTMon-mean"], [[2.0, 4.0]])
772
+
773
+
774
+ def test_ensemble_avg_rejects_unknown_ens_type():
775
+ """An ens_type outside dataframe, ndarray, and bin_edge raises."""
776
+ with pytest.raises(ValueError, match="'nope' is an invalid option"):
777
+ ensemble_avg("gyrTMon", {"ens1": np.zeros(2)}, "bug", "nope")
778
+
779
+
780
+ @pytest.mark.parametrize(
781
+ "ensembles, ens_type, filenames",
782
+ [
783
+ pytest.param(
784
+ {"ens1": pd.DataFrame({"w1": [1.0], "w2": [3.0]})},
785
+ "dataframe",
786
+ ["ens1-bug-rhoMon-ensAvg.csv"],
787
+ id="dataframe-csv",
788
+ ),
789
+ pytest.param(
790
+ {"ens1": np.zeros((2, 1, 1))},
791
+ "ndarray",
792
+ [
793
+ "ens1-bug-rhoMon-ensAvg-mean.npy",
794
+ "ens1-bug-rhoMon-ensAvg-sem.npy",
795
+ "ens1-bug-rhoMon-ensAvg-var.npy",
796
+ ],
797
+ id="ndarray-npy-per-measure",
798
+ ),
799
+ pytest.param(
800
+ {"ens1": np.array([0.0, 1.0])},
801
+ "bin_edge",
802
+ ["ens1-bug-rhoMon-ensAvg.npy"],
803
+ id="bin-edge-npy",
804
+ ),
805
+ ],
806
+ )
807
+ def test_ensemble_avg_saves_under_ens_avg_name(
808
+ tmp_path, ensembles, ens_type, filenames
809
+ ):
810
+ """save_to writes each result under <ensemble>-<group>-<prop>-ensAvg."""
811
+ ensemble_avg("rhoMon", ensembles, "bug", ens_type, save_to=tmp_path)
812
+ assert sorted(os.listdir(tmp_path)) == filenames
813
+
814
+
815
+ # Stamps
816
+
817
+
818
+ @pytest.fixture
819
+ def segment_stamps():
820
+ """Return stamps of 2 wholes x 3 segments of one state point.
821
+
822
+ n_frames is 10, 20, 30 and gyr is 1, 2, 3 for the segments of w1, and
823
+ gyr is 4, 5, 6 for those of w2.
824
+ """
825
+ return pd.DataFrame({
826
+ "lineage_name": ["w1.j01", "w1.j02", "w1.j03",
827
+ "w2.j01", "w2.j02", "w2.j03"],
828
+ "segment": ["w1.j01", "w1.j02", "w1.j03",
829
+ "w2.j01", "w2.j02", "w2.j03"],
830
+ "whole": ["w1", "w1", "w1", "w2", "w2", "w2"],
831
+ "ensemble_long": ["s1"] * 6,
832
+ "ensemble": ["e1"] * 6,
833
+ "space": ["N20"] * 6,
834
+ "group": ["bug"] * 6,
835
+ "segment_id": [1, 2, 3, 1, 2, 3],
836
+ "ensemble_id": [1, 1, 1, 2, 2, 2],
837
+ "gyr": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
838
+ "n_frames": [10, 20, 30, 10, 20, 30],
839
+ })
840
+
841
+
842
+ @pytest.fixture
843
+ def whole_stamps():
844
+ """Return stamps of 2 state points x 2 wholes, each of 60 frames.
845
+
846
+ The second state point keeps replicates ens1 and ens3, as if ens2 had
847
+ failed, so the number of wholes differs from the last ensemble_id.
848
+ """
849
+ return pd.DataFrame({
850
+ "lineage_name": ["w1", "w2", "w3", "w4"],
851
+ "segment": ["N/A"] * 4,
852
+ "whole": ["w1", "w2", "w3", "w4"],
853
+ "ensemble_long": ["s1", "s1", "s2", "s2"],
854
+ "ensemble": ["e1", "e1", "e2", "e2"],
855
+ "space": ["N20"] * 4,
856
+ "group": ["bug"] * 4,
857
+ "segment_id": [0, 0, 0, 0],
858
+ "ensemble_id": [1, 2, 1, 3],
859
+ "n_frames": [60, 60, 60, 60],
860
+ })
861
+
862
+
863
+ @pytest.fixture
864
+ def write_stamps(tmp_path):
865
+ """Factory: write each row of a stamps frame to its own CSV file."""
866
+
867
+ def _write(stamps):
868
+ directory = tmp_path / "stamps"
869
+ directory.mkdir()
870
+ paths = []
871
+ for row in range(len(stamps)):
872
+ path = directory / f"{stamps.loc[row, 'lineage_name']}-stamps.csv"
873
+ stamps.iloc[[row]].to_csv(path, index=False)
874
+ paths.append((str(path),))
875
+ return paths
876
+
877
+ return _write
878
+
879
+
880
+ def test_children_stamps_concatenates_in_input_order(
881
+ write_stamps, segment_stamps
882
+ ):
883
+ """Segment stamp files combine row by row, in the order given."""
884
+ paths = write_stamps(segment_stamps)
885
+ combined = children_stamps(paths, "segment")
886
+ pd.testing.assert_frame_equal(combined, segment_stamps)
887
+
888
+
889
+ def test_children_stamps_whole_drops_segment_columns(
890
+ write_stamps, whole_stamps
891
+ ):
892
+ """Whole stamps lose 'segment' and 'segment_id', with a UserWarning."""
893
+ paths = write_stamps(whole_stamps)
894
+ with pytest.warns(UserWarning, match="'segment' and 'segment_id'"):
895
+ combined = children_stamps(paths, "whole")
896
+ pd.testing.assert_frame_equal(
897
+ combined, whole_stamps.drop(columns=["segment", "segment_id"])
898
+ )
899
+
900
+
901
+ def test_children_stamps_whole_without_segment_columns_is_unchanged(
902
+ write_stamps, whole_stamps, capsys
903
+ ):
904
+ """Whole stamps without segment columns pass through, printing nothing."""
905
+ stamps = whole_stamps.drop(columns=["segment", "segment_id"])
906
+ paths = write_stamps(stamps)
907
+
908
+ combined = children_stamps(paths, "whole")
909
+
910
+ pd.testing.assert_frame_equal(combined, stamps)
911
+ assert capsys.readouterr().out == ""
912
+
913
+
914
+ @pytest.mark.parametrize(
915
+ "stamps_fixture, child_lineage, filename",
916
+ [
917
+ pytest.param("segment_stamps", "segment",
918
+ "N20-bug-stamps-segment.csv", id="segment"),
919
+ pytest.param("whole_stamps", "whole", "N20-bug-stamps-whole.csv",
920
+ id="whole", marks=pytest.mark.filterwarnings(
921
+ "ignore:'segment' and 'segment_id':UserWarning")),
922
+ ],
923
+ )
924
+ def test_children_stamps_saves_under_rows_lineage(
925
+ request, tmp_path, write_stamps, stamps_fixture, child_lineage, filename
926
+ ):
927
+ """save_to writes the result as <space>-<group>-stamps-<level>.csv, with
928
+ the lineage level of its rows."""
929
+ paths = write_stamps(request.getfixturevalue(stamps_fixture))
930
+
931
+ combined = children_stamps(paths, child_lineage, save_to=tmp_path)
932
+
933
+ pd.testing.assert_frame_equal(pd.read_csv(tmp_path / filename), combined)
934
+
935
+
936
+ @pytest.fixture
937
+ def write_research_stamps(tmp_path):
938
+ """Factory: write each row of a stamps frame, without its 'group'
939
+ column, to its own CSV named <lineage_name><suffix>, as the research
940
+ code's probe did (the group is in the file name, not a column)."""
941
+
942
+ def _write(stamps, suffix="-bug-stamps.csv"):
943
+ directory = tmp_path / "stamps"
944
+ directory.mkdir(exist_ok=True)
945
+ stamps = stamps.drop(columns=["group"])
946
+ paths = []
947
+ for row in range(len(stamps)):
948
+ path = directory / f"{stamps.loc[row, 'lineage_name']}{suffix}"
949
+ stamps.iloc[[row]].to_csv(path, index=False)
950
+ paths.append((str(path),))
951
+ return paths
952
+
953
+ return _write
954
+
955
+
956
+ @pytest.mark.parametrize(
957
+ "stamps_fixture, child_lineage, filename",
958
+ [
959
+ pytest.param("segment_stamps", "segment",
960
+ "N20-bug-stamps-segment.csv", id="segment"),
961
+ pytest.param("whole_stamps", "whole", "N20-bug-stamps-whole.csv",
962
+ id="whole", marks=pytest.mark.filterwarnings(
963
+ "ignore:'segment' and 'segment_id':UserWarning")),
964
+ ],
965
+ )
966
+ def test_children_stamps_without_group_column_takes_it_from_the_file_names(
967
+ request, tmp_path, write_research_stamps, stamps_fixture, child_lineage,
968
+ filename
969
+ ):
970
+ """Stamps without a 'group' column, as the research code wrote them, get
971
+ one after 'space' from their <name>-<group>-stamps.csv file names, and
972
+ save under that group."""
973
+ stamps = request.getfixturevalue(stamps_fixture)
974
+ paths = write_research_stamps(stamps)
975
+
976
+ combined = children_stamps(paths, child_lineage, save_to=tmp_path)
977
+
978
+ assert (combined.columns.get_loc("group")
979
+ == combined.columns.get_loc("space") + 1)
980
+ assert combined["group"].tolist() == ["bug"] * len(combined)
981
+ pd.testing.assert_frame_equal(pd.read_csv(tmp_path / filename), combined)
982
+
983
+
984
+ def test_children_stamps_without_group_rejects_a_name_without_a_group(
985
+ write_research_stamps, segment_stamps
986
+ ):
987
+ """Stamps with neither a 'group' column nor <name>-<group>-stamps.csv
988
+ file names raise ValueError naming the file."""
989
+ paths = write_research_stamps(segment_stamps, suffix="-stamps.csv")
990
+ with pytest.raises(ValueError, match="w1.j01-stamps.csv"):
991
+ children_stamps(paths, "segment")
992
+
993
+
994
+ def test_children_stamps_without_group_rejects_mixed_groups(
995
+ write_research_stamps, segment_stamps
996
+ ):
997
+ """File names that give more than one group raise ValueError naming
998
+ them, instead of taking the first."""
999
+ paths = (write_research_stamps(segment_stamps.iloc[:3].reset_index(
1000
+ drop=True))
1001
+ + write_research_stamps(segment_stamps.iloc[3:].reset_index(
1002
+ drop=True), suffix="-all-stamps.csv"))
1003
+ with pytest.raises(ValueError, match=r"\['all', 'bug'\]"):
1004
+ children_stamps(paths, "segment")
1005
+
1006
+
1007
+ def test_children_stamps_takes_child_lineage_by_keyword(
1008
+ write_stamps, segment_stamps
1009
+ ):
1010
+ """The level of the stamps is the keyword argument child_lineage."""
1011
+ paths = write_stamps(segment_stamps)
1012
+
1013
+ combined = children_stamps(paths, child_lineage="segment")
1014
+
1015
+ assert combined["segment_id"].tolist() == [1, 2, 3, 1, 2, 3]
1016
+
1017
+
1018
+ def test_children_stamps_rejects_unknown_lineage():
1019
+ """A lineage other than segment or whole raises ValueError."""
1020
+ with pytest.raises(ValueError, match="'nope' is an invalid option"):
1021
+ children_stamps([], "nope")
1022
+
1023
+
1024
+ def test_parents_stamps_segment_counts_segments_and_sums_frames(
1025
+ segment_stamps
1026
+ ):
1027
+ """Segment stamps give one row per whole with n_segments and n_frames."""
1028
+ parents = parents_stamps(segment_stamps, "segment")
1029
+ assert parents["whole"].tolist() == ["w1", "w2"]
1030
+ assert parents["n_segments"].tolist() == [3, 3]
1031
+ # 10 + 20 + 30 frames per whole
1032
+ assert parents["n_frames"].tolist() == [60, 60]
1033
+
1034
+
1035
+ def test_parents_stamps_whole_counts_wholes_per_ensemble(whole_stamps):
1036
+ """Whole stamps give one row per state point with n_ensembles."""
1037
+ parents = parents_stamps(whole_stamps, "whole")
1038
+ assert parents["ensemble_long"].tolist() == ["s1", "s2"]
1039
+ assert parents["n_ensembles"].tolist() == [2, 2]
1040
+ assert parents["n_frames"].tolist() == [60, 60]
1041
+ assert {"segment", "segment_id", "whole"}.isdisjoint(parents.columns)
1042
+
1043
+
1044
+ def test_parents_stamps_whole_drops_n_segments(whole_stamps):
1045
+ """Whole stamps built from segments lose n_segments in their parents."""
1046
+ stamps = whole_stamps.assign(n_segments=[3, 3, 3, 3])
1047
+ parents = parents_stamps(stamps, "whole")
1048
+ assert "n_segments" not in parents.columns
1049
+
1050
+
1051
+ def test_parents_stamps_applies_given_aggregation(segment_stamps):
1052
+ """properties replaces the default 'last' aggregation of a column."""
1053
+ parents = parents_stamps(segment_stamps, "segment",
1054
+ properties={"gyr": "mean"})
1055
+ # Means of 1, 2, 3 and of 4, 5, 6
1056
+ assert parents["gyr"].tolist() == pytest.approx([2.0, 5.0])
1057
+
1058
+
1059
+ @pytest.mark.parametrize(
1060
+ "stamps_fixture, lineage, filename",
1061
+ [
1062
+ pytest.param("segment_stamps", "segment", "N20-bug-stamps-whole.csv",
1063
+ id="segment-to-whole"),
1064
+ pytest.param("whole_stamps", "whole",
1065
+ "N20-bug-stamps-ensemble_long.csv",
1066
+ id="whole-to-ensemble_long"),
1067
+ ],
1068
+ )
1069
+ def test_parents_stamps_saves_under_parents_lineage(
1070
+ request, tmp_path, stamps_fixture, lineage, filename
1071
+ ):
1072
+ """save_to writes the parent stamps as <space>-<group>-stamps-<level>.csv,
1073
+ with the lineage level of the parent rows."""
1074
+ stamps = request.getfixturevalue(stamps_fixture)
1075
+ parents = parents_stamps(stamps, lineage, save_to=tmp_path)
1076
+ pd.testing.assert_frame_equal(pd.read_csv(tmp_path / filename), parents)
1077
+
1078
+
1079
+ @pytest.mark.parametrize("column", ["space", "group"])
1080
+ def test_parents_stamps_saving_without_a_name_column_raises(
1081
+ tmp_path, whole_stamps, column
1082
+ ):
1083
+ """Saving parent stamps that lack 'space' or 'group' raises ValueError
1084
+ naming the column, since the file name needs both."""
1085
+ stamps = whole_stamps.drop(columns=[column])
1086
+ with pytest.raises(ValueError, match=f"'{column}'"):
1087
+ parents_stamps(stamps, "whole", save_to=tmp_path)
1088
+
1089
+
1090
+ @pytest.mark.parametrize(
1091
+ "stamps_fixture, lineage",
1092
+ [
1093
+ pytest.param("segment_stamps", "segment", id="segment"),
1094
+ pytest.param("whole_stamps", "whole", id="whole"),
1095
+ ],
1096
+ )
1097
+ def test_parents_stamps_without_lineage_name_drops_child_column(
1098
+ request, stamps_fixture, lineage
1099
+ ):
1100
+ """Stamps without 'lineage_name' still lose the child lineage column."""
1101
+ stamps = request.getfixturevalue(stamps_fixture).drop(
1102
+ columns="lineage_name")
1103
+
1104
+ parents = parents_stamps(stamps, lineage)
1105
+
1106
+ assert lineage not in parents.columns
1107
+
1108
+
1109
+ def test_parents_stamps_without_lineage_name_prints_nothing(
1110
+ segment_stamps, capsys
1111
+ ):
1112
+ """An absent 'lineage_name' column is skipped without printing."""
1113
+ stamps = segment_stamps.drop(columns="lineage_name")
1114
+
1115
+ parents_stamps(stamps, "segment")
1116
+
1117
+ assert capsys.readouterr().out == ""
1118
+
1119
+
1120
+ def test_parents_stamps_takes_child_lineage_by_keyword(segment_stamps):
1121
+ """The level of the child stamps is the keyword argument child_lineage."""
1122
+ parents = parents_stamps(segment_stamps, child_lineage="segment")
1123
+
1124
+ assert parents["n_segments"].tolist() == [3, 3]
1125
+
1126
+
1127
+ @pytest.mark.parametrize(
1128
+ "stamps_fixture, child_lineage, key",
1129
+ [
1130
+ pytest.param("segment_stamps", "segment", "whole", id="segment"),
1131
+ pytest.param("whole_stamps", "whole", "ensemble_long", id="whole"),
1132
+ ],
1133
+ )
1134
+ def test_parents_stamps_rejects_a_missing_parent_name(
1135
+ request, stamps_fixture, child_lineage, key
1136
+ ):
1137
+ """A child row with no parent name raises instead of being dropped by
1138
+ the group-by."""
1139
+ stamps = request.getfixturevalue(stamps_fixture)
1140
+ stamps.loc[0, key] = np.nan
1141
+
1142
+ with pytest.raises(ValueError, match=re.escape(
1143
+ f"1 stamps row has no {key!r}")):
1144
+ parents_stamps(stamps, child_lineage)
1145
+
1146
+
1147
+ @pytest.mark.parametrize("column", ["n_frames", "segment_id", "whole"])
1148
+ def test_parents_stamps_rejects_aggregating_its_own_columns(
1149
+ segment_stamps, column
1150
+ ):
1151
+ """A properties entry for a column the parent aggregates itself raises
1152
+ instead of being overwritten silently."""
1153
+ with pytest.raises(ValueError, match=re.escape(
1154
+ f"properties cannot set {column!r}")):
1155
+ parents_stamps(segment_stamps, "segment", properties={column: "max"})
1156
+
1157
+
1158
+ def test_parents_stamps_rejects_unknown_lineage(segment_stamps):
1159
+ """A lineage other than segment or whole raises ValueError."""
1160
+ with pytest.raises(ValueError, match="'nope' is an invalid option"):
1161
+ parents_stamps(segment_stamps, "nope")
1162
+
1163
+
1164
+ # Space level: ensemble-averaged files of two state points that differ only
1165
+ # in the number of crowders. Both dump every 5000 steps with dt=0.005.
1166
+
1167
+
1168
+ @pytest.fixture
1169
+ def space_states():
1170
+ """Return the ensemble_long names of the two synthetic state points."""
1171
+ return [
1172
+ "N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000",
1173
+ "N200r10.5lz25.0ac2.0nc2000dt0.005bdump2000adump5000",
1174
+ ]
1175
+
1176
+
1177
+ @pytest.fixture
1178
+ def make_ens_avg_space(tmp_path, space_states):
1179
+ """Factory: write one ensAvg CSV per state point, plus a decoy file.
1180
+
1181
+ Takes the property, one '-mean' column per state point, and the group
1182
+ (default 'all'); '-var' and '-sem' are constant. Returns the ensAvg
1183
+ directory, ending with '/'.
1184
+ """
1185
+
1186
+ def _make(prop, means, group="all"):
1187
+ ens_avg_dir = tmp_path / "analysis" / f"N200-{group}-ensAvg"
1188
+ ens_avg_dir.mkdir(parents=True)
1189
+ for state, mean in zip(space_states, means):
1190
+ pd.DataFrame({
1191
+ f"{prop}-mean": mean,
1192
+ f"{prop}-var": np.full(len(mean), 0.1),
1193
+ f"{prop}-sem": np.full(len(mean), 0.05),
1194
+ }).to_csv(ens_avg_dir / f"{state}-{group}-{prop}-ensAvg.csv",
1195
+ index=False)
1196
+ # A file of another property in the same directory must be ignored.
1197
+ pd.DataFrame({"other-mean": [1.0]}).to_csv(
1198
+ ens_avg_dir / f"{space_states[0]}-{group}-other-ensAvg.csv",
1199
+ index=False,
1200
+ )
1201
+ return str(ens_avg_dir) + "/"
1202
+
1203
+ return _make
1204
+
1205
+
1206
+ @pytest.fixture
1207
+ def state_attr(space_states):
1208
+ """Factory: an attribute of each state point, repeated n_rows times."""
1209
+
1210
+ def _values(attr, n_rows):
1211
+ parsers = [ChainCyl(s, "ensemble_long", "all") for s in space_states]
1212
+ return np.repeat([getattr(p, attr) for p in parsers], n_rows)
1213
+
1214
+ return _values
1215
+
1216
+
1217
+ def test_space_tseries_concatenates_ensembles(make_ens_avg_space,
1218
+ state_attr):
1219
+ """space_tseries reads every '-<prop>-ensAvg.csv' file and no other."""
1220
+ database = make_ens_avg_space(
1221
+ "gyrTMon", [[0.0, 1.0, 2.0], [10.0, 11.0, 12.0]]
1222
+ )
1223
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", ["dcyl"],
1224
+ "all")
1225
+ assert list(space.index) == list(range(6))
1226
+ assert "other-mean" not in space.columns
1227
+ # Files are sorted by name, so the nc1000 rows come first.
1228
+ np.testing.assert_allclose(
1229
+ space["gyrTMon-mean"], [0.0, 1.0, 2.0, 10.0, 11.0, 12.0]
1230
+ )
1231
+ np.testing.assert_allclose(space["dcyl"], state_attr("dcyl", 3))
1232
+
1233
+
1234
+ def test_space_tseries_reads_database_without_trailing_separator(
1235
+ make_ens_avg_space):
1236
+ """The directory is joined to the glob as a path, so it reads the same
1237
+ files with or without a trailing separator."""
1238
+ database = make_ens_avg_space(
1239
+ "gyrTMon", [[0.0, 1.0, 2.0], [10.0, 11.0, 12.0]]
1240
+ )
1241
+
1242
+ space = space_tseries(database.rstrip("/"), "gyrTMon", ChainCyl, "N*",
1243
+ [], "all")
1244
+
1245
+ np.testing.assert_allclose(
1246
+ space["gyrTMon-mean"], [0.0, 1.0, 2.0, 10.0, 11.0, 12.0]
1247
+ )
1248
+
1249
+
1250
+ def test_space_tseries_scales_t_index_and_time(make_ens_avg_space):
1251
+ """For all, t_index is frame index times adump; time is t_index * dt."""
1252
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2)
1253
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", [], "all")
1254
+ frames = np.array([0, 1, 2, 0, 1, 2])
1255
+ # adump = 5000 and dt = 0.005 in the filenames
1256
+ np.testing.assert_array_equal(space["t_index"], frames * 5000)
1257
+ np.testing.assert_allclose(space["time"], frames * 5000 * 0.005)
1258
+
1259
+
1260
+ def test_space_tseries_scales_bug_by_bdump(make_ens_avg_space):
1261
+ """For bug, t_index is frame index times bdump, not adump."""
1262
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2,
1263
+ group="bug")
1264
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", [], "bug")
1265
+ frames = np.array([0, 1, 2, 0, 1, 2])
1266
+ # bdump = 2000 and dt = 0.005 in the filenames
1267
+ np.testing.assert_array_equal(space["t_index"], frames * 2000)
1268
+ np.testing.assert_allclose(space["time"], frames * 2000 * 0.005)
1269
+
1270
+
1271
+ def test_space_tseries_rejects_dump_key_argument(make_ens_avg_space):
1272
+ """A dump keyword passed by position fails instead of binding divisor."""
1273
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2)
1274
+ with pytest.raises(TypeError, match="takes 6 positional arguments"):
1275
+ space_tseries(database, "gyrTMon", ChainCyl, "N*", [], "all",
1276
+ "adump")
1277
+
1278
+
1279
+ def test_space_tseries_adds_phi_bulk_c_by_default(make_ens_avg_space,
1280
+ state_attr):
1281
+ """The crowder fraction comes from phi_bulk_c without being listed."""
1282
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2)
1283
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", ["dcyl"],
1284
+ "all")
1285
+ assert "phi_c_bulk" not in space.columns
1286
+ np.testing.assert_allclose(space["phi_bulk_c"],
1287
+ state_attr("phi_bulk_c", 3))
1288
+
1289
+
1290
+ def test_space_tseries_rounds_crowder_with_round_to_multiple(
1291
+ make_ens_avg_space
1292
+ ):
1293
+ """phi_bulk_c_round is round_to_multiple of the crowder column."""
1294
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2)
1295
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", [], "all",
1296
+ divisor=0.05, round_to=2)
1297
+ expected = space["phi_bulk_c"].apply(round_to_multiple, args=(0.05, 2))
1298
+ np.testing.assert_array_equal(space["phi_bulk_c_round"], expected)
1299
+
1300
+
1301
+ def test_space_tseries_reads_named_crowder_attr(make_ens_avg_space,
1302
+ state_attr):
1303
+ """crowder_attr selects the parser attribute that is rounded."""
1304
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2)
1305
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", [], "all",
1306
+ crowder_attr="phi_bulk_m")
1307
+ assert "phi_bulk_c" not in space.columns
1308
+ np.testing.assert_allclose(space["phi_bulk_m"],
1309
+ state_attr("phi_bulk_m", 3))
1310
+
1311
+
1312
+ def test_space_tseries_saves_space_csv(tmp_path, make_ens_avg_space):
1313
+ """is_save writes the returned frame to the analysis space directory."""
1314
+ database = make_ens_avg_space("gyrTMon", [[0.0, 1.0, 2.0]] * 2)
1315
+ space = space_tseries(database, "gyrTMon", ChainCyl, "N*", ["dcyl"],
1316
+ "all", is_save=True)
1317
+ saved = tmp_path / "analysis" / "N200-all-space" / \
1318
+ "N200-all-gyrTMon-space.csv"
1319
+ pd.testing.assert_frame_equal(pd.read_csv(saved), space)
1320
+
1321
+
1322
+ def test_space_hists_concatenates_and_normalizes(make_ens_avg_space,
1323
+ state_attr):
1324
+ """space_hists adds bin centers and each normalized histogram sums to 1."""
1325
+ database = make_ens_avg_space(
1326
+ "rhoMon", [[1.0, 2.0, 1.0], [2.0, 4.0, 2.0]]
1327
+ )
1328
+ bin_center = np.array([0.5, 1.5, 2.5])
1329
+ space = space_hists(database, "rhoMon", ChainCyl, "N*", ["dcyl"],
1330
+ "all", bin_center=bin_center, normalize=True)
1331
+ assert len(space) == 6
1332
+ assert "other-mean" not in space.columns
1333
+ np.testing.assert_allclose(space["bin_center"], np.tile(bin_center, 2))
1334
+ np.testing.assert_allclose(space["bin_center-norm"],
1335
+ np.tile(bin_center / 2.5, 2))
1336
+ # [1, 2, 1] / 4 and [2, 4, 2] / 8
1337
+ np.testing.assert_allclose(space["rhoMon-norm"],
1338
+ [0.25, 0.5, 0.25, 0.25, 0.5, 0.25])
1339
+ np.testing.assert_allclose(space["dcyl"], state_attr("dcyl", 3))
1340
+ np.testing.assert_allclose(space["phi_bulk_c"],
1341
+ state_attr("phi_bulk_c", 3))
1342
+
1343
+
1344
+ def test_space_hists_zero_histogram_normalizes_to_zero(make_ens_avg_space):
1345
+ """An all-zero histogram warns and gets a zero normalized column."""
1346
+ database = make_ens_avg_space("rhoMon", [[0.0, 0.0], [0.0, 0.0]])
1347
+ with pytest.warns(UserWarning, match="All values are zero"):
1348
+ space = space_hists(database, "rhoMon", ChainCyl, "N*", [], "all",
1349
+ normalize=True)
1350
+ np.testing.assert_array_equal(space["rhoMon-norm"], np.zeros(4))
1351
+
1352
+
1353
+ def test_space_hists_saves_space_csv(tmp_path, make_ens_avg_space):
1354
+ """is_save writes the returned frame to the analysis space directory."""
1355
+ database = make_ens_avg_space("rhoMon", [[1.0, 2.0, 1.0]] * 2)
1356
+ space = space_hists(database, "rhoMon", ChainCyl, "N*", [], "all",
1357
+ is_save=True)
1358
+ saved = tmp_path / "analysis" / "N200-all-space" / \
1359
+ "N200-all-rhoMon-space.csv"
1360
+ pd.testing.assert_frame_equal(pd.read_csv(saved), space)
1361
+
1362
+
1363
+ # Integration along the lineage: segment -> whole -> ensemble -> ensAvg
1364
+ # -> space, on one segment tree.
1365
+
1366
+
1367
+ def test_lineage_tseries_ensemble_average_matches_tree_values(
1368
+ segment_tree, ensemble_names
1369
+ ):
1370
+ """Two wholes 100 apart average to 1000e + 150 + 10s + k, var 5000."""
1371
+ paths = [str(p) for p in segment_tree.paths]
1372
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
1373
+ "tseries")
1374
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", "vector")
1375
+ avg = ensemble_avg("gyrTMon", ens, "bug", "dataframe")
1376
+ ens_1 = avg[ensemble_names[1]]
1377
+ np.testing.assert_allclose(
1378
+ ens_1["gyrTMon-mean"],
1379
+ [1160.0, 1161.0, 1162.0, 1170.0, 1171.0, 1172.0],
1380
+ )
1381
+ # var (ddof=1) of two values 100 apart is 100**2 / 2; sem is
1382
+ # sqrt(5000 / 2). Float64 round-off on values of order 1e3.
1383
+ np.testing.assert_allclose(ens_1["gyrTMon-var"], 5000.0, rtol=1e-12)
1384
+ np.testing.assert_allclose(ens_1["gyrTMon-sem"], 50.0, rtol=1e-12)
1385
+
1386
+
1387
+ def test_lineage_saved_ens_avg_feeds_space_tseries(tmp_path, segment_tree):
1388
+ """ensAvg files saved by ensemble_avg are the files space_tseries reads."""
1389
+ ens_avg_dir = tmp_path / "analysis" / "N20-bug-ensAvg"
1390
+ ens_avg_dir.mkdir(parents=True)
1391
+ paths = [str(p) for p in segment_tree.paths]
1392
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
1393
+ "tseries")
1394
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", "vector")
1395
+ ensemble_avg("gyrTMon", ens, "bug", "dataframe", save_to=ens_avg_dir)
1396
+ space = space_tseries(str(ens_avg_dir) + "/", "gyrTMon", ChainCyl,
1397
+ "N*", [], "bug")
1398
+ # 2 ensembles x 6 frames; ensemble 2 is 1000 above ensemble 1
1399
+ np.testing.assert_allclose(
1400
+ space["gyrTMon-mean"],
1401
+ [1160.0, 1161.0, 1162.0, 1170.0, 1171.0, 1172.0,
1402
+ 2160.0, 2161.0, 2162.0, 2170.0, 2171.0, 2172.0],
1403
+ )
1404
+ # bdump = 1000, the dump period of the bug trajectory, in the tree's
1405
+ # filenames
1406
+ np.testing.assert_array_equal(
1407
+ space["t_index"], np.tile(np.arange(6), 2) * 1000
1408
+ )
1409
+
1410
+
1411
+ def test_lineage_histogram_conserves_total_counts(segment_tree):
1412
+ """Histogram counts summed over a space equal those over its segments."""
1413
+ paths = [str(p) for p in segment_tree.paths]
1414
+ wholes = whole_from_segments("gyrTMon", paths, ChainCyl, "bug",
1415
+ "histogram")
1416
+ ens = ensemble("gyrTMon", wholes, ChainCyl, "bug", "vector")
1417
+ avg = ensemble_avg("gyrTMon", ens, "bug", "dataframe")
1418
+ # Ensemble mean times 2 wholes, summed over bins and ensembles
1419
+ total = 2 * pd.concat(avg.values())["gyrTMon-mean"].sum()
1420
+ # Sum of 1000e + 100w + 10s + k over e, w, s in {1, 2} and k in
1421
+ # {0, 1, 2}: 24 * (1500 + 150 + 15) + 8 * (0 + 1 + 2)
1422
+ assert total == pytest.approx(39984.0, rel=1e-12)
1423
+
1424
+
1425
+ # Renaming the bulk-quantity columns of research-code tables
1426
+
1427
+
1428
+ @pytest.fixture
1429
+ def old_bulk_table():
1430
+ """A stamp-like row with every research-code bulk name and two others."""
1431
+ columns = {"ensemble_long": ["N20ac1nc100"], "nmon": [20]}
1432
+ columns.update({old: [float(i)] for i, old in enumerate(BULK_NAME_MAP)})
1433
+ return pd.DataFrame(columns)
1434
+
1435
+
1436
+ @pytest.mark.parametrize(
1437
+ "old, new",
1438
+ [
1439
+ pytest.param("phi_m_bulk", "phi_bulk_m", id="phi_m"),
1440
+ pytest.param("rho_m_bulk", "rho_bulk_m", id="rho_m"),
1441
+ pytest.param("phi_c_bulk", "phi_bulk_c", id="phi_c"),
1442
+ pytest.param("rho_c_bulk", "rho_bulk_c", id="rho_c"),
1443
+ pytest.param("phi_c_bulk_one", "phi_bulk_c_one", id="phi_c_one"),
1444
+ pytest.param("phi_c_bulk_two", "phi_bulk_c_two", id="phi_c_two"),
1445
+ pytest.param("rho_c_bulk_one", "rho_bulk_c_one", id="rho_c_one"),
1446
+ pytest.param("rho_c_bulk_two", "rho_bulk_c_two", id="rho_c_two"),
1447
+ pytest.param("phi_hns_bulk", "phi_bulk_hns", id="phi_hns"),
1448
+ pytest.param("rho_hns_bulk", "rho_bulk_hns", id="rho_hns"),
1449
+ pytest.param("phi_c_bulk_round", "phi_bulk_c_round", id="phi_c_round"),
1450
+ ],
1451
+ )
1452
+ def test_bulk_name_map_pairs_each_old_name_with_its_new_name(old, new):
1453
+ """Every research-code bulk name maps to its <quantity>_bulk_<species>."""
1454
+ assert BULK_NAME_MAP[old] == new
1455
+
1456
+
1457
+ def test_bulk_name_map_has_only_the_eleven_pairs():
1458
+ """The map holds exactly the eleven research-code names and no alias."""
1459
+ assert len(BULK_NAME_MAP) == 11
1460
+
1461
+
1462
+ def test_bulk_name_map_is_read_only():
1463
+ """The map rejects assignment."""
1464
+ with pytest.raises(TypeError, match="does not support item assignment"):
1465
+ BULK_NAME_MAP["phi_c_bulk"] = "other" # type: ignore[index]
1466
+
1467
+
1468
+ def test_rename_bulk_columns_renames_every_mapped_column(old_bulk_table):
1469
+ """Each mapped column takes its new name and keeps its value."""
1470
+ new = rename_bulk_columns(old_bulk_table)
1471
+ for old, expected in BULK_NAME_MAP.items():
1472
+ assert old not in new.columns
1473
+ assert new[expected].tolist() == old_bulk_table[old].tolist()
1474
+
1475
+
1476
+ def test_rename_bulk_columns_keeps_other_columns_and_order(old_bulk_table):
1477
+ """Unmapped columns and the column order are unchanged."""
1478
+ new = rename_bulk_columns(old_bulk_table)
1479
+ expected = [BULK_NAME_MAP.get(c, c) for c in old_bulk_table.columns]
1480
+ assert list(new.columns) == expected
1481
+ assert new["ensemble_long"].tolist() == ["N20ac1nc100"]
1482
+ assert new["nmon"].tolist() == [20]
1483
+
1484
+
1485
+ def test_rename_bulk_columns_does_not_modify_its_input(old_bulk_table):
1486
+ """The input table keeps the research-code names after the call."""
1487
+ before = list(old_bulk_table.columns)
1488
+ rename_bulk_columns(old_bulk_table)
1489
+ assert list(old_bulk_table.columns) == before
1490
+
1491
+
1492
+ def test_rename_bulk_columns_leaves_new_names_unchanged(old_bulk_table):
1493
+ """A table that already carries the PolyPhys names comes back equal."""
1494
+ new = rename_bulk_columns(old_bulk_table)
1495
+ pd.testing.assert_frame_equal(rename_bulk_columns(new), new)