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,391 @@
1
+ """Tests for :mod:`polyphys.probe.core`.
2
+
3
+ The inputs are tier A: a data file and a dump of one linear chain written
4
+ by the ``make_chain_files`` fixture, and parsers built from hand-written
5
+ names. Expected values are the positions the fixture wrote and the
6
+ attributes the parser sets.
7
+
8
+ The real-frame tests read the tier B and tier C files of the studies'
9
+ wholes (``polyphys/tests/probe/conftest.py``, "Real frames"). Their
10
+ expected values are the dump headers and coordinates as the package's own
11
+ LAMMPS readers, :func:`polyphys.simulate.dump.iter_dump` and
12
+ :func:`polyphys.simulate.data.read_data`, read them, and the particle
13
+ counts in the wholes' names.
14
+ """
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import textwrap
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import pytest
24
+ from numpy.testing import assert_allclose, assert_array_equal
25
+
26
+ import polyphys
27
+ from polyphys.manage.organizer import children_stamps
28
+ from polyphys.tests.synthetic import ChainCyl
29
+ from polyphys.simulate.data import read_data
30
+ from polyphys.simulate.dump import iter_dump
31
+
32
+ pytest.importorskip("MDAnalysis")
33
+
34
+ from polyphys.probe import make_universe, write_stamps # noqa: E402
35
+ from polyphys.probe.binders import BinderStatistics # noqa: E402
36
+ from polyphys.probe.chain_size import ChainSize # noqa: E402
37
+ from polyphys.probe.core import ( # noqa: E402
38
+ _ClosingMultiprocessing, _ProbeAnalysis)
39
+ from polyphys.probe.histograms import SpatialHistograms # noqa: E402
40
+ from polyphys.probe.pairs import PairDistances # noqa: E402
41
+
42
+ # The dump reader warns that the frames carry no time step; the tests that
43
+ # give no dt measure per frame.
44
+ pytestmark = pytest.mark.filterwarnings(
45
+ "ignore:Reader has no dt information:UserWarning")
46
+
47
+
48
+ @pytest.fixture
49
+ def chain_frames() -> np.ndarray:
50
+ """Five frames of a 10-bead chain along x, moved 0.1 along z per frame."""
51
+ first = np.zeros((10, 3))
52
+ first[:, 0] = np.arange(10) + 5.0
53
+ first[:, 1:] = 10.0
54
+ return np.stack([first + [0.0, 0.0, 0.1 * k] for k in range(5)])
55
+
56
+
57
+ @pytest.fixture
58
+ def whole_parser() -> ChainCyl:
59
+ """A ChainCyl parser of a whole, from a hand-written name."""
60
+ return ChainCyl(
61
+ 'N10r2.5lz10ac1nc0dt0.005bdump1adump1ens1', 'whole', 'bug')
62
+
63
+
64
+ # make_universe
65
+
66
+
67
+ def test_make_universe_reads_the_chain_and_its_frames(
68
+ make_chain_files, chain_frames):
69
+ """The universe has the data file's atoms, all in residue 1, and the
70
+ dump's frames."""
71
+ data_path, dump_path = make_chain_files(chain_frames)
72
+
73
+ universe = make_universe(data_path, dump_path)
74
+
75
+ assert (universe.atoms.n_atoms, universe.trajectory.n_frames) == (10, 5)
76
+ assert_array_equal(universe.select_atoms('resid 1').ids, np.arange(1, 11))
77
+
78
+
79
+ def test_make_universe_positions_follow_the_dump_frame_by_frame(
80
+ make_chain_files, chain_frames):
81
+ """Frame k of the trajectory holds the dump's frame k, in atom-ID
82
+ order."""
83
+ data_path, dump_path = make_chain_files(chain_frames)
84
+ universe = make_universe(data_path, dump_path)
85
+
86
+ read = np.stack([universe.atoms.positions.copy()
87
+ for _ in universe.trajectory])
88
+
89
+ # 1e-5: the dump holds six decimals and MDAnalysis reads float32.
90
+ assert_allclose(read, chain_frames, atol=1e-5)
91
+
92
+
93
+ def test_make_universe_passes_extra_keywords_through(make_chain_files,
94
+ chain_frames):
95
+ """A keyword such as dt reaches the MDAnalysis universe."""
96
+ data_path, dump_path = make_chain_files(chain_frames)
97
+
98
+ universe = make_universe(data_path, dump_path, dt=2.0)
99
+
100
+ assert universe.trajectory.dt == 2.0
101
+
102
+
103
+ def test_make_universe_rejects_a_fixed_keyword(make_chain_files,
104
+ chain_frames):
105
+ """A keyword that make_universe fixes cannot be overridden."""
106
+ data_path, dump_path = make_chain_files(chain_frames)
107
+
108
+ with pytest.raises(ValueError, match=r"\['format'\] are fixed"):
109
+ make_universe(data_path, dump_path, format='DCD')
110
+
111
+
112
+ # write_stamps
113
+
114
+
115
+ def test_write_stamps_columns_are_genealogy_group_attributes_and_frames(
116
+ whole_parser, tmp_path):
117
+ """The stamps have the lineage names, group, exactly the parser's
118
+ attributes in order, then n_frames, with the parser's values."""
119
+ path = write_stamps(whole_parser, 7, tmp_path)
120
+
121
+ stamps = pd.read_csv(path)
122
+
123
+ assert path.name == f'{whole_parser.name}-bug-stamps.csv'
124
+ assert stamps.columns.tolist() == (
125
+ ['whole', 'ensemble_long', 'ensemble', 'space', 'group']
126
+ + whole_parser.attributes + ['n_frames'])
127
+ assert stamps['whole'].tolist() == [whole_parser.name]
128
+ assert stamps['group'].tolist() == ['bug']
129
+ assert stamps['n_frames'].tolist() == [7]
130
+ for attr in whole_parser.attributes:
131
+ assert stamps.loc[0, attr] == getattr(whole_parser, attr), attr
132
+
133
+
134
+ def test_write_stamps_reads_back_through_children_stamps(whole_parser,
135
+ tmp_path):
136
+ """The organizer combines two written stamps into one row per whole."""
137
+ second = ChainCyl(
138
+ 'N10r2.5lz10ac1nc0dt0.005bdump1adump1ens2', 'whole', 'bug')
139
+ paths = [(str(write_stamps(parser, n, tmp_path)),)
140
+ for parser, n in ((whole_parser, 3), (second, 4))]
141
+
142
+ combined = children_stamps(paths, 'whole')
143
+
144
+ assert combined['ensemble_id'].tolist() == [1, 2]
145
+ assert combined['n_frames'].tolist() == [3, 4]
146
+
147
+
148
+ @pytest.mark.parametrize('n_frames', [
149
+ pytest.param(-1, id='negative'),
150
+ pytest.param(2.5, id='float'),
151
+ pytest.param(True, id='bool'),
152
+ ])
153
+ def test_write_stamps_rejects_a_bad_frame_count(whole_parser, tmp_path,
154
+ n_frames):
155
+ """n_frames must be a non-negative integer."""
156
+ with pytest.raises(ValueError, match="non-negative integer"):
157
+ write_stamps(whole_parser, n_frames, tmp_path)
158
+
159
+
160
+ # The multiprocessing backend
161
+
162
+
163
+ def test_multiprocessing_run_leaves_no_leaked_semaphore():
164
+ """A run on the multiprocessing backend lets its workers exit, so the
165
+ resource tracker finds no leaked semaphore at shutdown."""
166
+ script = textwrap.dedent("""\
167
+ import numpy as np
168
+ import MDAnalysis as mda
169
+ from polyphys.probe.chain_size import ChainSize
170
+
171
+ if __name__ == '__main__':
172
+ frames = np.zeros((4, 2, 3))
173
+ frames[:, 1, 2] = [1.0, 2.0, 3.0, 4.0]
174
+ universe = mda.Universe.empty(2, trajectory=True)
175
+ universe.add_TopologyAttr('masses', np.ones(2))
176
+ universe.load_new(frames, format='MEMORY')
177
+ ChainSize(universe.atoms, ('rfloryT',)).run(
178
+ backend='multiprocessing', n_workers=2, n_parts=2)
179
+ """)
180
+ # The child and its workers import this checkout's package.
181
+ env = dict(os.environ,
182
+ PYTHONPATH=str(Path(polyphys.__file__).parents[1]))
183
+
184
+ completed = subprocess.run(
185
+ [sys.executable, '-W', 'default', '-c', script], env=env,
186
+ capture_output=True, text=True, timeout=120, check=False)
187
+
188
+ assert completed.returncode == 0, completed.stderr
189
+ assert 'leaked semaphore' not in completed.stderr
190
+
191
+
192
+ def test_closing_multiprocessing_apply_passes_a_worker_error_on():
193
+ """An error raised in a worker reaches the caller, after the pool is
194
+ terminated and joined."""
195
+ backend = _ClosingMultiprocessing(n_workers=1)
196
+
197
+ # int of a non-numeric string raises ValueError in the worker.
198
+ with pytest.raises(ValueError, match="invalid literal for int"):
199
+ backend.apply(int, ['1', 'x'])
200
+
201
+
202
+ @pytest.mark.parametrize('analysis_class', [
203
+ pytest.param(BinderStatistics, id='BinderStatistics'),
204
+ pytest.param(ChainSize, id='ChainSize'),
205
+ pytest.param(PairDistances, id='PairDistances'),
206
+ pytest.param(SpatialHistograms, id='SpatialHistograms'),
207
+ ])
208
+ def test_every_probe_analysis_runs_on_the_closing_pool(analysis_class):
209
+ """Every probe analysis takes its run, and so its multiprocessing
210
+ backend, from the shared probe base."""
211
+ assert analysis_class.run is _ProbeAnalysis.run
212
+
213
+
214
+ # Real frames
215
+
216
+
217
+ def test_make_universe_opens_every_real_polymer_dump(
218
+ real_polymer_whole, real_files, real_universe):
219
+ """A polymer dump opens with its topology, with the dump's atoms and
220
+ all 50 of its frames."""
221
+ files = real_files(real_polymer_whole)
222
+ n_atoms = next(iter_dump(files.polymer)).n_atoms
223
+
224
+ universe = real_universe(files.topology, files.polymer)
225
+
226
+ assert (universe.atoms.n_atoms, universe.trajectory.n_frames) == (
227
+ n_atoms, 50)
228
+
229
+
230
+ def test_make_universe_opens_every_real_window(real_window_whole, real_files,
231
+ real_universe):
232
+ """An ``all`` window opens with its whole's data file, with the dump's
233
+ atoms and every frame of the window."""
234
+ files = real_files(real_window_whole)
235
+ n_atoms = next(iter_dump(files.window)).n_atoms
236
+
237
+ universe = real_universe(files.data, files.window)
238
+
239
+ assert (universe.atoms.n_atoms, universe.trajectory.n_frames) == (
240
+ n_atoms, real_window_whole.n_window_frames)
241
+
242
+
243
+ def test_make_universe_positions_are_the_dump_coordinates_from_the_origin(
244
+ real_polymer_whole, real_files, real_universe, trajectory_arrays):
245
+ """Every frame's positions are the dump's coordinates, sorted by atom
246
+ ID, less the box's lower corner."""
247
+ files = real_files(real_polymer_whole)
248
+ expected = np.stack([frame.positions() - frame.box.lo
249
+ for frame in iter_dump(files.polymer)])
250
+
251
+ positions = trajectory_arrays(
252
+ real_universe(files.topology, files.polymer))['positions']
253
+
254
+ # float32 positions of at most about 165 carry an absolute round-off
255
+ # of about 1e-5.
256
+ assert_allclose(positions, expected, rtol=0, atol=2e-5)
257
+
258
+
259
+ def test_spec_selections_pick_the_species_of_a_real_polymer_dump(
260
+ real_polymer_whole, real_files, real_universe, study_spec):
261
+ """Each selection of the study's probe spec picks, in the polymer dump,
262
+ the atoms the whole's name gives its species."""
263
+ probe = study_spec(real_polymer_whole.study).PROBE
264
+ files = real_files(real_polymer_whole)
265
+ universe = real_universe(files.topology, files.polymer)
266
+
267
+ counts = {species: universe.select_atoms(selection).n_atoms
268
+ for species, selection in probe.selections.items()}
269
+
270
+ assert counts == real_polymer_whole.polymer_counts()
271
+
272
+
273
+ def test_spec_selections_pick_the_species_of_a_real_window(
274
+ real_window_whole, real_files, real_universe, study_spec):
275
+ """Each selection of the study's probe spec picks, in the ``all``
276
+ window, the atoms the whole's name gives its species, crowders
277
+ included."""
278
+ probe = study_spec(real_window_whole.study).PROBE
279
+ files = real_files(real_window_whole)
280
+ universe = real_universe(files.data, files.window)
281
+
282
+ counts = {species: universe.select_atoms(selection).n_atoms
283
+ for species, selection in probe.selections.items()}
284
+
285
+ assert counts == real_window_whole.window_counts()
286
+
287
+
288
+ @pytest.mark.parametrize('study, whole, expected', [
289
+ pytest.param('hns_cub', 'N200kbmm2nh30ac2l25epshc1nc10743ens1',
290
+ {'nmon': 200, 'nhns': 30, 'dcrowd': 2.0, 'lcube': 50.0,
291
+ 'eps_hc': 1.0, 'ncrowd': 10743, 'ensemble_id': 1},
292
+ id='HnsCub'),
293
+ pytest.param('hns_cyl', 'N200kbmm2r4.5nh30ac2lz75epshc1nc386ens2',
294
+ {'nmon': 200, 'nhns': 30, 'dcrowd': 2.0, 'dcyl': 8.0,
295
+ 'lcyl': 150.0, 'ncrowd': 386, 'ensemble_id': 2},
296
+ id='HnsCyl'),
297
+ pytest.param('trans_foci_cub',
298
+ 'al6nl5ml216ns400ac1nc112776l27dt0.005bdump2000adump5000'
299
+ 'ens1',
300
+ {'dmon_large': 6.0, 'nmon_large': 5, 'mmon_large': 216.0,
301
+ 'nmon_small': 400, 'ncrowd': 112776, 'lcube': 54.0},
302
+ id='TransFociCub'),
303
+ pytest.param('trans_foci_cyl',
304
+ 'epss5epsl5r10.5al6nl5ml216ns400ac1nc0lz82.5dt0.005'
305
+ 'bdump2000adump5000ens2',
306
+ {'dmon_large': 6.0, 'nmon_small': 400, 'ncrowd': 0,
307
+ 'dcyl': 20.0, 'lcyl': 165.0, 'ensemble_id': 2},
308
+ id='TransFociCyl'),
309
+ ])
310
+ def test_write_stamps_of_a_real_whole_carry_its_name(
311
+ study_spec, tmp_path, study, whole, expected):
312
+ """The stamps of a real whole hold the values its name encodes and the
313
+ 50 frames of its polymer dump."""
314
+ spec = study_spec(study).SPEC
315
+ parser = spec.parser(f"{whole}.ring", 'whole', spec.group)
316
+
317
+ stamps = pd.read_csv(write_stamps(parser, 50, tmp_path))
318
+
319
+ assert {key: stamps.loc[0, key] for key in expected} == expected
320
+ # A whole's lineage name never carries the topology suffix ``.ring``
321
+ # (ParserBase._set_parents).
322
+ assert (stamps.loc[0, 'whole'], int(stamps.loc[0, 'n_frames'])) == (
323
+ whole, 50)
324
+
325
+
326
+ @pytest.mark.private_data # pragma: no cover - needs data
327
+ def test_borrowed_topology_is_the_crowded_whole_own_polymer_topology(
328
+ real_crowded_whole, real_files, private_window_files):
329
+ """The polymer atoms of a crowded whole's own data file have the IDs,
330
+ types, molecule IDs, masses, and bonds of the data file its polymer
331
+ dump borrows."""
332
+ _, own_path = private_window_files(real_crowded_whole)
333
+ borrowed = read_data(real_files(real_crowded_whole).topology)
334
+ own = read_data(own_path)
335
+ n_polymer = borrowed.n_atoms
336
+ in_polymer = np.all(own.bonds[:, 1:] <= n_polymer, axis=1)
337
+
338
+ def as_set(bonds):
339
+ # Each run's write_data numbers its bonds in its own order, so the
340
+ # bonds are compared as a set: sorted by type and atoms.
341
+ return bonds[np.lexsort((bonds[:, 2], bonds[:, 1], bonds[:, 0]))]
342
+
343
+ assert_array_equal(own.ids[:n_polymer], borrowed.ids)
344
+ assert_array_equal(own.types[:n_polymer], borrowed.types)
345
+ assert_array_equal(own.mol_ids[:n_polymer], borrowed.mol_ids)
346
+ assert_array_equal(own.masses[borrowed.types - 1],
347
+ borrowed.masses[borrowed.types - 1])
348
+ assert_array_equal(as_set(own.bonds[in_polymer]),
349
+ as_set(borrowed.bonds))
350
+
351
+
352
+ @pytest.mark.private_data # pragma: no cover - needs data
353
+ def test_write_stamps_of_a_real_whole_match_the_research_code(
354
+ real_transfocicub_whole, study_spec, research_probe_file, tmp_path):
355
+ """The stamps of a TransFociCub whole hold the research code's lineage
356
+ names under the documented mapping (Usage, "Lineage names"), the same
357
+ attribute values under the spec's renaming, every research attribute
358
+ but ``eps_others``, and the 50 frames the research probe counted."""
359
+ whole = real_transfocicub_whole
360
+ spec = study_spec(whole.study).SPEC
361
+ parser = spec.parser(f"{whole.whole}.ring", 'whole', spec.group)
362
+ theirs = pd.read_csv(research_probe_file(whole, 'stamps', ext='csv'))
363
+ # The research names of the bulk fractions and densities and their
364
+ # PolyPhys names (projects/trans_foci_cub/spec.py, module docstring).
365
+ renamed = {'phi_c_bulk': 'phi_bulk_c', 'rho_c_bulk': 'rho_bulk_c',
366
+ 'phi_m_bulk': 'phi_bulk_m', 'rho_m_bulk': 'rho_bulk_m'}
367
+ names = ('lineage_name', 'whole', 'ensemble_long', 'ensemble', 'space')
368
+
369
+ ours = pd.read_csv(write_stamps(parser, 50, tmp_path))
370
+
371
+ # A research whole name keeps ``.ring``; a short name parsed at its
372
+ # lineage and rebuilt gives the current form.
373
+ assert ours.loc[0, 'whole'] == theirs.loc[0, 'whole'].removesuffix(
374
+ '.ring')
375
+ assert ours.loc[0, 'ensemble_long'] == theirs.loc[0, 'ensemble_long']
376
+ assert {lineage: ours.loc[0, lineage] for lineage in ('ensemble', 'space')
377
+ } == {lineage: getattr(spec.parser(theirs.loc[0, lineage],
378
+ lineage, spec.group), lineage)
379
+ for lineage in ('ensemble', 'space')}
380
+ attributes = [column for column in theirs.columns
381
+ if column not in names and column != 'n_frames']
382
+ unmatched = [column for column in attributes
383
+ if renamed.get(column, column) not in ours.columns]
384
+ assert unmatched == ['eps_others']
385
+ matched = [column for column in attributes if column not in unmatched]
386
+ # Both codes compute the fractions in float64 from the same integers.
387
+ assert_allclose(
388
+ ours.loc[0, [renamed.get(column, column) for column in matched]]
389
+ .to_numpy(dtype=float),
390
+ theirs.loc[0, matched].to_numpy(dtype=float), rtol=1e-12, atol=0)
391
+ assert int(theirs.loc[0, 'n_frames']) == int(ours.loc[0, 'n_frames']) == 50