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,686 @@
1
+ """Tests for :mod:`polyphys.probe.binders`.
2
+
3
+ The inputs are tier A: a ring of ten monomers one unit apart along x and
4
+ four two-patch binders at hand-picked positions over five frames, written
5
+ as a data file and a dump by the ``make_binder_universe`` fixture. Expected
6
+ values are worked by hand from the counting rules of
7
+ :func:`polyphys.analyze.clusters.binding_statistics`, as the clusters guide
8
+ states them: a patch 0.3 off a monomer touches it at a cutoff of 0.5, a
9
+ patch at the midpoint of two neighbors is exactly 0.5 from both, the
10
+ single-patch rule keeps the lower index, cis is a genomic distance of at
11
+ most ``cis_threshold`` bonds, and the loop-length row is the bincount of
12
+ the bridged pairs' distances. The minimum-image distances are checked
13
+ against ``MDAnalysis.lib.distances.distance_array``.
14
+
15
+ The real-frame tests run the Hns studies' probe specs on the tier B polymer
16
+ dumps of their wholes (``polyphys/tests/probe/conftest.py``, "Real
17
+ frames"). Their expected values are the identities the counts obey by
18
+ definition, with the binder numbers of the wholes' names and two patches per
19
+ H-NS core, and the minimum-image distances of ``distance_array``.
20
+ """
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Sequence
23
+
24
+ import numpy as np
25
+ import pytest
26
+ from numpy.testing import (
27
+ assert_allclose,
28
+ assert_array_equal,
29
+ assert_equal,
30
+ )
31
+
32
+ from polyphys.analyze.clusters import threshold_sweep
33
+ from polyphys.manage.parser import ParserBase
34
+ from polyphys.tests.synthetic import RingCubBinders, RingCylBinders
35
+ from polyphys.spec import (
36
+ BINDER_SERIES,
37
+ BinderSpec,
38
+ ProbeSpec,
39
+ ProjectSpec,
40
+ )
41
+ from polyphys.simulate.core import Box
42
+ from polyphys.simulate.data import DataFile, write_data
43
+
44
+ pytest.importorskip("MDAnalysis")
45
+
46
+ import MDAnalysis as mda # noqa: E402
47
+ from MDAnalysis.lib.distances import distance_array # noqa: E402
48
+
49
+ from polyphys.probe import BinderStatistics # noqa: E402
50
+
51
+ # The dump reader warns that the frames carry no time step; the tests
52
+ # count per frame and give no dt.
53
+ pytestmark = pytest.mark.filterwarnings(
54
+ "ignore:Reader has no dt information:UserWarning")
55
+
56
+ # Tolerances for the distances: MDAnalysis holds positions as float32, so a
57
+ # distance carries a relative round-off of about 1e-7, and distance_array
58
+ # may sum the squares in float32; 1e-5 leaves room for both.
59
+ RTOL = 1e-5
60
+ ATOL = 1e-6
61
+
62
+
63
+ @pytest.fixture
64
+ def make_binder_universe(
65
+ tmp_path: Path, make_lammps_dump: Callable[..., Path]
66
+ ) -> Callable[..., Any]:
67
+ """Factory: the universe of a chain and its binders' patches, written
68
+ as a ``bond``-style data file (type 1 and molecule 1 for the chain,
69
+ type 2 and molecule ``2 + k`` for the patches of binder ``k``) and a
70
+ dump of the frames.
71
+
72
+ Takes `chain` of shape ``(n_frames, n_monomers, 3)`` and `patches` of
73
+ shape ``(n_frames, n_patches, 3)``, `box` as three ``(lo, hi)`` pairs,
74
+ and `boundary` as the dump's three flags.
75
+ """
76
+
77
+ def _make(
78
+ chain: Any,
79
+ patches: Any,
80
+ *,
81
+ box: Sequence[Sequence[float]] = ((0.0, 20.0),) * 3,
82
+ boundary: str = "pp pp pp",
83
+ name: str = "binders",
84
+ ) -> Any:
85
+ from polyphys.probe import make_universe
86
+
87
+ chain = np.asarray(chain, dtype=float)
88
+ patches = np.asarray(patches, dtype=float)
89
+ n_monomers, n_patches = chain.shape[1], patches.shape[1]
90
+ frames = np.concatenate([chain, patches], axis=1)
91
+ types = np.array([1] * n_monomers + [2] * n_patches)
92
+ mol_ids = np.array([1] * n_monomers
93
+ + [2 + k // 2 for k in range(n_patches)])
94
+ data = DataFile(
95
+ atom_style="bond",
96
+ box=Box(lo=np.array([lo for lo, _ in box], dtype=float),
97
+ hi=np.array([hi for _, hi in box], dtype=float)),
98
+ ids=np.arange(1, n_monomers + n_patches + 1), types=types,
99
+ positions=frames[0], n_atom_types=2,
100
+ masses=np.array([1.0, 1.0]), mol_ids=mol_ids,
101
+ bonds=np.array([[1, i, i + 1] for i in range(1, n_monomers)]),
102
+ n_bond_types=1)
103
+ data_path = write_data(data, tmp_path / f"{name}.data")
104
+ dump_path = make_lammps_dump(
105
+ name=f"{name}.lammpstrj", positions=frames, box=box,
106
+ boundary=boundary, atom_types=types.tolist())
107
+ return make_universe(data_path, dump_path)
108
+
109
+ return _make
110
+
111
+
112
+ @pytest.fixture
113
+ def ring() -> np.ndarray:
114
+ """One frame of ten monomers at x = 1, ..., 10 and y = z = 10."""
115
+ chain = np.full((10, 3), 10.0)
116
+ chain[:, 0] = np.arange(10) + 1.0
117
+ return chain
118
+
119
+
120
+ @pytest.fixture
121
+ def far() -> np.ndarray:
122
+ """Eight patch positions far from the ring, all distinct."""
123
+ patches = np.full((8, 3), 15.0)
124
+ patches[:, 1] = 12.0 + 0.5 * np.arange(8)
125
+ return patches
126
+
127
+
128
+ @pytest.fixture
129
+ def binding_frames(ring: np.ndarray, far: np.ndarray) -> tuple[np.ndarray,
130
+ np.ndarray]:
131
+ """Five frames of the ring and four binders A (patches 0, 1), B (2, 3),
132
+ C (4, 5), and D (6, 7), each patch 0.3 off its monomer:
133
+
134
+ 0. A free; B dangling from monomer 2; C bridging 4 and 6 (cis, two
135
+ bonds); D bridging 0 and 5 (trans, five bonds).
136
+ 1. As frame 0, but D's first patch at the midpoint of monomers 0 and
137
+ 1, exactly 0.5 from both.
138
+ 2. All free.
139
+ 3. C and D both bridging 4 and 6.
140
+ 4. C with both patches on monomer 4; the rest free.
141
+ """
142
+ chain = np.stack([ring] * 5)
143
+ patches = np.stack([far] * 5)
144
+ patches[0, 2] = [3.0, 10.3, 10.0]
145
+ patches[0, 4] = [5.0, 10.3, 10.0]
146
+ patches[0, 5] = [7.0, 10.3, 10.0]
147
+ patches[0, 6] = [1.0, 10.3, 10.0]
148
+ patches[0, 7] = [6.0, 9.7, 10.0]
149
+ patches[1] = patches[0]
150
+ patches[1, 6] = [1.5, 10.0, 10.0]
151
+ patches[3, 4] = [5.0, 10.3, 10.0]
152
+ patches[3, 5] = [7.0, 10.3, 10.0]
153
+ patches[3, 6] = [5.0, 9.7, 10.0]
154
+ patches[3, 7] = [7.0, 9.7, 10.0]
155
+ patches[4, 4] = [5.0, 10.3, 10.0]
156
+ patches[4, 5] = [5.0, 9.7, 10.0]
157
+ return chain, patches
158
+
159
+
160
+ @pytest.fixture
161
+ def hns_project() -> Callable[[type[ParserBase]], ProjectSpec]:
162
+ """Factory: a project spec of an H-NS parser class that lists the eight
163
+ binding series."""
164
+
165
+ def _make(parser: type[ParserBase]) -> ProjectSpec:
166
+ return ProjectSpec(
167
+ parser=parser, group='nucleoid', divisor=0.01,
168
+ crowder_attr='phi_bulk_c',
169
+ physical_attrs=('space', 'nmon', 'nhns'),
170
+ species_sizes={'Mon': 'dmon', 'Hns': 'dhns'},
171
+ tseries_properties=(
172
+ 'gyrTMon', 'nBoundTHnsPatch', 'nFreeTHnsPatch',
173
+ 'nEngagedTHnsPatch', 'nFreeTHnsCore', 'nBridgeTHnsCore',
174
+ 'nDangleTHnsCore', 'nCisTHnsCore', 'nTransTHnsCore'))
175
+
176
+ return _make
177
+
178
+
179
+ # Hand values
180
+
181
+
182
+ @pytest.mark.parametrize('series, expected', [
183
+ pytest.param('n_patch_monomer_contacts', [5, 6, 0, 4, 2],
184
+ id='contacts'),
185
+ pytest.param('n_patches_free', [3, 3, 8, 4, 6], id='free-patches'),
186
+ pytest.param('n_patches_engaged', [5, 5, 0, 4, 2],
187
+ id='engaged-patches'),
188
+ pytest.param('n_binders_free', [1, 1, 4, 2, 3], id='free-binders'),
189
+ pytest.param('n_binders_bridging', [2, 2, 0, 2, 1], id='bridging'),
190
+ pytest.param('n_binders_dangling', [1, 1, 0, 0, 0], id='dangling'),
191
+ pytest.param('n_pairs_cis', [1, 1, 0, 1, 0], id='cis'),
192
+ pytest.param('n_pairs_trans', [1, 1, 0, 0, 0], id='trans'),
193
+ ])
194
+ def test_counts_follow_the_binding_rules(make_binder_universe,
195
+ binding_frames, series, expected):
196
+ """Each series holds one int64 count per frame: the midpoint patch of
197
+ frame 1 adds one contact but no engaged patch, two binders on one pair
198
+ bridge one cis pair, and two patches on one monomer bridge no pair."""
199
+ universe = make_binder_universe(*binding_frames)
200
+
201
+ analysis = BinderStatistics(
202
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
203
+ cutoff=0.5, topology='ring', cis_threshold=4).run()
204
+
205
+ assert analysis.results[series].dtype == np.int64
206
+ assert_array_equal(analysis.results[series], expected)
207
+
208
+
209
+ def test_loop_length_rows_count_bridged_pairs_by_distance(
210
+ make_binder_universe, binding_frames):
211
+ """A ring of ten has six loop lengths; each row counts the frame's
212
+ distinct bridged pairs at their genomic distance, with column 0 empty."""
213
+ universe = make_binder_universe(*binding_frames)
214
+
215
+ analysis = BinderStatistics(
216
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
217
+ cutoff=0.5, topology='ring', cis_threshold=4).run()
218
+
219
+ histogram = analysis.results['loop_length_histogram']
220
+ assert histogram.dtype == np.int64
221
+ assert_array_equal(histogram, [[0, 0, 1, 0, 0, 1],
222
+ [0, 0, 1, 0, 0, 1],
223
+ [0, 0, 0, 0, 0, 0],
224
+ [0, 0, 1, 0, 0, 0],
225
+ [0, 0, 0, 0, 0, 0]])
226
+
227
+
228
+ @pytest.mark.parametrize('inclusive, expected', [
229
+ # Inclusive: the midpoint patch touches monomers 0 and 1, D bridges 0
230
+ # and 5 through the lower index. Strict: it touches neither, D dangles.
231
+ pytest.param(True, {'n_patch_monomer_contacts': 6,
232
+ 'n_binders_dangling': 1, 'n_binders_bridging': 2,
233
+ 'n_pairs_trans': 1}, id='inclusive'),
234
+ pytest.param(False, {'n_patch_monomer_contacts': 4,
235
+ 'n_binders_dangling': 2, 'n_binders_bridging': 1,
236
+ 'n_pairs_trans': 0}, id='strict'),
237
+ ])
238
+ def test_inclusive_decides_a_pair_at_the_cutoff(make_binder_universe,
239
+ binding_frames, inclusive,
240
+ expected):
241
+ """A patch exactly at the cutoff from two neighbors is in contact with
242
+ both when inclusive and with neither when strict."""
243
+ universe = make_binder_universe(*binding_frames)
244
+
245
+ analysis = BinderStatistics(
246
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
247
+ cutoff=0.5, topology='ring', inclusive=inclusive,
248
+ cis_threshold=4).run(start=1, stop=2)
249
+
250
+ assert {key: int(analysis.results[key][0]) for key in expected} \
251
+ == expected
252
+
253
+
254
+ @pytest.mark.parametrize('topology, cis, trans, row', [
255
+ # Monomers 0 and 8 are two bonds apart the short way round a ring of
256
+ # ten and eight bonds apart on a linear chain.
257
+ pytest.param('ring', 1, 0, [0, 0, 1, 0, 0, 0], id='ring'),
258
+ pytest.param('linear', 0, 1, [0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
259
+ id='linear'),
260
+ ])
261
+ def test_topology_sets_the_genomic_distance(make_binder_universe, ring, far,
262
+ topology, cis, trans, row):
263
+ """A bridge between monomers 0 and 8 is cis at two bonds on a ring and
264
+ trans at eight bonds on a linear chain, whose histogram is longer."""
265
+ patches = far.copy()
266
+ patches[6] = [1.0, 10.3, 10.0]
267
+ patches[7] = [9.0, 10.3, 10.0]
268
+ universe = make_binder_universe(ring[np.newaxis], patches[np.newaxis])
269
+
270
+ analysis = BinderStatistics(
271
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
272
+ cutoff=0.5, topology=topology, cis_threshold=4).run()
273
+
274
+ assert (int(analysis.results['n_pairs_cis'][0]),
275
+ int(analysis.results['n_pairs_trans'][0])) == (cis, trans)
276
+ assert_array_equal(analysis.results['loop_length_histogram'], [row])
277
+
278
+
279
+ @pytest.mark.parametrize('periodic, contacts', [
280
+ pytest.param((True, True, True), 1, id='all-periodic'),
281
+ pytest.param((False, False, True), 0, id='axis-only'),
282
+ pytest.param((False, False, False), 0, id='none'),
283
+ ])
284
+ def test_minimum_image_follows_the_periodic_directions(
285
+ make_binder_universe, ring, far, periodic, contacts):
286
+ """A patch 0.4 across the x face from monomer 0 touches it only when x
287
+ is periodic; a cylinder's z-only image leaves them 19.6 apart."""
288
+ chain = ring.copy()
289
+ chain[:, 0] -= 0.7
290
+ patches = far.copy()
291
+ patches[0] = [19.9, 10.0, 10.0]
292
+ universe = make_binder_universe(chain[np.newaxis], patches[np.newaxis])
293
+
294
+ analysis = BinderStatistics(
295
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
296
+ cutoff=0.5, topology='ring', periodic=periodic, cis_threshold=4).run()
297
+
298
+ assert int(analysis.results['n_patch_monomer_contacts'][0]) == contacts
299
+
300
+
301
+ @pytest.mark.parametrize('periodic, box', [
302
+ pytest.param((False, False, False), False, id='no-box'),
303
+ pytest.param((True, True, True), True, id='full-box'),
304
+ ])
305
+ def test_distances_match_mdanalysis(make_binder_universe, ring, far,
306
+ periodic, box):
307
+ """The stored distance matrix equals distance_array without a box when
308
+ nothing is periodic and with the frame's box when everything is."""
309
+ rng = np.random.default_rng(7)
310
+ chain = ring + rng.uniform(-0.5, 0.5, size=ring.shape)
311
+ patches = rng.uniform(0.0, 20.0, size=far.shape)
312
+ universe = make_binder_universe(chain[np.newaxis], patches[np.newaxis])
313
+ monomers = universe.select_atoms('resid 1')
314
+ patch_atoms = universe.select_atoms('type 2')
315
+
316
+ analysis = BinderStatistics(
317
+ monomers, patch_atoms, cutoff=0.5, topology='ring',
318
+ periodic=periodic, store_distances=True, cis_threshold=4).run()
319
+
320
+ expected = distance_array(
321
+ monomers.positions, patch_atoms.positions,
322
+ box=universe.trajectory.ts.dimensions if box else None)
323
+ assert analysis.results['distances'].shape == (1, 10, 8)
324
+ assert analysis.results['distances'].dtype == np.float64
325
+ assert_allclose(analysis.results['distances'][0], expected, rtol=RTOL,
326
+ atol=ATOL)
327
+
328
+
329
+ # The analysis contract
330
+
331
+
332
+ def test_run_slices_the_frames(make_binder_universe, binding_frames):
333
+ """run(start, stop, step) counts only those frames, in order."""
334
+ universe = make_binder_universe(*binding_frames)
335
+
336
+ analysis = BinderStatistics(
337
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
338
+ cutoff=0.5, topology='ring',
339
+ cis_threshold=4).run(start=1, stop=5, step=2)
340
+
341
+ assert_array_equal(analysis.frames, [1, 3])
342
+ assert_array_equal(analysis.results['n_binders_bridging'], [2, 2])
343
+ assert_array_equal(analysis.results['n_binders_dangling'], [1, 0])
344
+ assert analysis.n_frames == 2
345
+
346
+
347
+ def test_parallel_run_matches_the_serial_run(make_binder_universe,
348
+ binding_frames):
349
+ """Two workers over two parts give the serial arrays: the series, the
350
+ histogram rows, and the distance matrices."""
351
+ universe = make_binder_universe(*binding_frames)
352
+ serial = BinderStatistics(
353
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
354
+ cutoff=0.5, topology='ring', store_distances=True,
355
+ cis_threshold=4).run()
356
+
357
+ parallel = BinderStatistics(
358
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
359
+ cutoff=0.5, topology='ring', store_distances=True,
360
+ cis_threshold=4).run(backend='multiprocessing', n_workers=2,
361
+ n_parts=2)
362
+
363
+ assert parallel.keys == serial.keys
364
+ for key in serial.keys:
365
+ assert_array_equal(parallel.results[key], serial.results[key])
366
+
367
+
368
+ @pytest.mark.parametrize('n_parts', [
369
+ pytest.param(2, id='two-parts'),
370
+ pytest.param(3, id='three-parts'),
371
+ ])
372
+ def test_serial_parts_match_the_multiprocessing_run(
373
+ make_binder_universe, binding_frames, n_parts):
374
+ """The serial backend split into parts gives the frames, times, and
375
+ arrays of the multiprocessing backend over the same parts: the result
376
+ does not depend on the backend."""
377
+ universe = make_binder_universe(*binding_frames)
378
+ chain = universe.select_atoms('resid 1')
379
+ patches = universe.select_atoms('type 2')
380
+ parallel = BinderStatistics(
381
+ chain, patches, cutoff=0.5, topology='ring',
382
+ store_distances=True, cis_threshold=4).run(
383
+ backend='multiprocessing', n_workers=2, n_parts=n_parts)
384
+
385
+ serial = BinderStatistics(
386
+ chain, patches, cutoff=0.5, topology='ring',
387
+ store_distances=True,
388
+ cis_threshold=4).run(backend='serial', n_parts=n_parts)
389
+
390
+ assert_array_equal(serial.frames, parallel.frames)
391
+ assert_array_equal(serial.times, parallel.times)
392
+ assert_equal(dict(serial.results), dict(parallel.results))
393
+
394
+
395
+ def test_stored_distances_swept_at_the_spec_reproduce_the_series(
396
+ make_binder_universe, binding_frames):
397
+ """The threshold sweep of the stored matrices at the run's cutoff, cis
398
+ threshold, and comparison gives the frame means of the saved series and
399
+ histogram rows, so the sweep's grid point at the spec reproduces the
400
+ probe's counts."""
401
+ universe = make_binder_universe(*binding_frames)
402
+ analysis = BinderStatistics(
403
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
404
+ cutoff=0.5, topology='ring', inclusive=True, cis_threshold=4,
405
+ store_distances=True).run()
406
+
407
+ counts, histogram = threshold_sweep(
408
+ analysis.results['distances'], [0.5], [4], 'ring', inclusive=True)
409
+
410
+ # Both sides are an integer sum over five frames divided by five.
411
+ for key in BINDER_SERIES:
412
+ assert counts.loc[0, key] == pytest.approx(
413
+ analysis.results[key].mean(), abs=1e-12)
414
+ assert_allclose(histogram[0],
415
+ analysis.results['loop_length_histogram'].mean(axis=0),
416
+ rtol=1e-12, atol=1e-12)
417
+
418
+
419
+ def test_keys_list_the_series_then_the_histogram_then_the_distances(
420
+ make_binder_universe, binding_frames):
421
+ """keys is the eight series in BINDER_SERIES order, the histogram, and,
422
+ with store_distances, the distances."""
423
+ universe = make_binder_universe(*binding_frames)
424
+ chain, patches = (universe.select_atoms('resid 1'),
425
+ universe.select_atoms('type 2'))
426
+
427
+ without = BinderStatistics(chain, patches, cutoff=0.5, topology='ring',
428
+ cis_threshold=4)
429
+ with_distances = BinderStatistics(chain, patches, cutoff=0.5,
430
+ topology='ring', store_distances=True,
431
+ cis_threshold=4)
432
+
433
+ assert without.keys == (*BINDER_SERIES, 'loop_length_histogram')
434
+ assert with_distances.keys == (*BINDER_SERIES, 'loop_length_histogram',
435
+ 'distances')
436
+
437
+
438
+ @pytest.mark.parametrize('parser, periodic', [
439
+ pytest.param(RingCubBinders, (True, True, True), id='cube'),
440
+ pytest.param(RingCylBinders, (False, False, True), id='cylinder'),
441
+ ])
442
+ def test_from_spec_selects_the_groups_and_carries_the_spec(
443
+ make_binder_universe, binding_frames, hns_project, parser,
444
+ periodic):
445
+ """from_spec picks the spec's chain and patch selections, its binder
446
+ spec, the project's topology, and the geometry's periodic directions."""
447
+ spec = ProbeSpec(
448
+ hns_project(parser), {'Mon': 'resid 1', 'HnsPatch': 'type 2'},
449
+ 'Mon', ('gyrT',), unwrap=True,
450
+ binders=BinderSpec('HnsPatch', 'HnsCore', cutoff=0.685,
451
+ inclusive=True, cis_threshold=4,
452
+ store_distances=True))
453
+ universe = make_binder_universe(*binding_frames)
454
+
455
+ analysis = BinderStatistics.from_spec(universe, spec)
456
+
457
+ assert (analysis.chain.n_atoms, analysis.patches.n_atoms) == (10, 8)
458
+ assert (analysis.cutoff, analysis.inclusive, analysis.cis_threshold,
459
+ analysis.patches_per_binder, analysis.store_distances) == (
460
+ 0.685, True, 4, 2, True)
461
+ assert (analysis.topology, analysis.periodic) == ('ring', periodic)
462
+ assert (analysis.chain_label, analysis.patch_label,
463
+ analysis.binder_label) == ('Mon', 'HnsPatch', 'HnsCore')
464
+
465
+
466
+ def test_from_spec_needs_a_binder_spec(make_binder_universe, binding_frames,
467
+ hns_project):
468
+ """A probe spec without binders cannot build the analysis."""
469
+ spec = ProbeSpec(hns_project(RingCubBinders),
470
+ {'Mon': 'resid 1', 'HnsPatch': 'type 2'}, 'Mon',
471
+ ('gyrT',), unwrap=True)
472
+ universe = make_binder_universe(*binding_frames)
473
+
474
+ with pytest.raises(ValueError, match="has no 'binders'"):
475
+ BinderStatistics.from_spec(universe, spec)
476
+
477
+
478
+ @pytest.mark.parametrize('kwargs, match', [
479
+ pytest.param({'patches_per_binder': 3}, "must be 2, not 3",
480
+ id='three-patches'),
481
+ pytest.param({'cutoff': 0.0}, "'cutoff' must be a finite positive",
482
+ id='zero-cutoff'),
483
+ pytest.param({'cutoff': float('inf')},
484
+ "'cutoff' must be a finite positive", id='infinite-cutoff'),
485
+ pytest.param({'topology': 'star'}, "'topology' must be one of",
486
+ id='bad-topology'),
487
+ pytest.param({'cis_threshold': -1}, "'cis_threshold' must be",
488
+ id='negative-cis'),
489
+ pytest.param({'periodic': (True, True)}, "'periodic' must be three",
490
+ id='two-flags'),
491
+ pytest.param({'periodic': (1, 0, 0)}, "'periodic' must be three",
492
+ id='int-flags'),
493
+ pytest.param({'chain_label': ''}, "'chain_label' must be a non-empty",
494
+ id='empty-label'),
495
+ ])
496
+ def test_rejects_invalid_arguments(make_binder_universe, binding_frames,
497
+ kwargs, match):
498
+ """A valence other than two, a non-positive or infinite cutoff, an
499
+ unknown topology, a negative cis threshold, malformed periodic flags,
500
+ or an empty label raises at construction."""
501
+ universe = make_binder_universe(*binding_frames)
502
+ fields = {'cutoff': 0.5, 'topology': 'ring', 'cis_threshold': 4,
503
+ **kwargs}
504
+
505
+ with pytest.raises(ValueError, match=match):
506
+ BinderStatistics(universe.select_atoms('resid 1'),
507
+ universe.select_atoms('type 2'), **fields)
508
+
509
+
510
+ @pytest.mark.parametrize('chain, patches, match', [
511
+ pytest.param('id 1', 'type 2', "at least two monomers",
512
+ id='one-monomer'),
513
+ pytest.param('resid 1', 'type 3', "positive multiple of 2",
514
+ id='no-patches'),
515
+ pytest.param('resid 1', 'id 11 12 13', "positive multiple of 2",
516
+ id='odd-patches'),
517
+ ])
518
+ def test_rejects_malformed_groups(make_binder_universe, binding_frames,
519
+ chain, patches, match):
520
+ """A chain of one monomer, no patches, or an odd number of patches
521
+ raises at construction."""
522
+ universe = make_binder_universe(*binding_frames)
523
+
524
+ with pytest.raises(ValueError, match=match):
525
+ BinderStatistics(universe.select_atoms(chain),
526
+ universe.select_atoms(patches), cutoff=0.5,
527
+ topology='ring', cis_threshold=4)
528
+
529
+
530
+ def test_rejects_patches_from_another_universe(make_binder_universe,
531
+ binding_frames):
532
+ """The patches must come from the chain's universe."""
533
+ universe = make_binder_universe(*binding_frames)
534
+ other = make_binder_universe(*binding_frames, name='other')
535
+
536
+ with pytest.raises(ValueError, match="universe of 'chain'"):
537
+ BinderStatistics(universe.select_atoms('resid 1'),
538
+ other.select_atoms('type 2'), cutoff=0.5,
539
+ topology='ring', cis_threshold=4)
540
+
541
+
542
+ def test_periodic_direction_needs_a_box():
543
+ """A frame without box dimensions cannot take the minimum image."""
544
+ universe = mda.Universe.empty(4, trajectory=True)
545
+ universe.atoms.positions = np.array(
546
+ [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.3, 0.0], [5.0, 5.0, 5.0]])
547
+ analysis = BinderStatistics(universe.atoms[:2], universe.atoms[2:],
548
+ cutoff=0.5, topology='linear',
549
+ periodic=(True, True, True), cis_threshold=4)
550
+
551
+ with pytest.raises(ValueError, match="carries no box"):
552
+ analysis.run()
553
+
554
+
555
+ # save
556
+
557
+
558
+ @pytest.mark.parametrize('store_distances, names', [
559
+ pytest.param(False, [
560
+ 'nBoundTHnsPatch', 'nFreeTHnsPatch', 'nEngagedTHnsPatch',
561
+ 'nFreeTHnsCore', 'nBridgeTHnsCore', 'nDangleTHnsCore',
562
+ 'nCisTHnsCore', 'nTransTHnsCore', 'loopLengthHistTMon'],
563
+ id='without-distances'),
564
+ pytest.param(True, [
565
+ 'nBoundTHnsPatch', 'nFreeTHnsPatch', 'nEngagedTHnsPatch',
566
+ 'nFreeTHnsCore', 'nBridgeTHnsCore', 'nDangleTHnsCore',
567
+ 'nCisTHnsCore', 'nTransTHnsCore', 'loopLengthHistTMon',
568
+ 'distMatTMonHnsPatch'], id='with-distances'),
569
+ ])
570
+ def test_save_writes_the_research_names(make_binder_universe,
571
+ binding_frames, tmp_path,
572
+ store_distances, names):
573
+ """save writes <name>-<series>.npy per key under the research code's
574
+ names, holding the results."""
575
+ universe = make_binder_universe(*binding_frames)
576
+ analysis = BinderStatistics(
577
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
578
+ cutoff=0.5, topology='ring', store_distances=store_distances,
579
+ cis_threshold=4).run()
580
+
581
+ paths = analysis.save(tmp_path, 'N10ens1-nucleoid')
582
+
583
+ assert [path.name for path in paths] == [
584
+ f'N10ens1-nucleoid-{name}.npy' for name in names]
585
+ for path, key in zip(paths, analysis.keys):
586
+ assert_array_equal(np.load(path), analysis.results[key])
587
+
588
+
589
+ def test_save_uses_the_labels(make_binder_universe, binding_frames,
590
+ tmp_path):
591
+ """The chain, patch, and binder labels name the saved files."""
592
+ universe = make_binder_universe(*binding_frames)
593
+ analysis = BinderStatistics(
594
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
595
+ cutoff=0.5, topology='ring', store_distances=True, chain_label='Dna',
596
+ patch_label='Site', binder_label='Prot', cis_threshold=4).run()
597
+
598
+ paths = analysis.save(tmp_path, 'N10ens1-nucleoid')
599
+
600
+ assert [path.name for path in paths][3:] == [
601
+ 'N10ens1-nucleoid-nFreeTProt.npy', 'N10ens1-nucleoid-nBridgeTProt.npy',
602
+ 'N10ens1-nucleoid-nDangleTProt.npy', 'N10ens1-nucleoid-nCisTProt.npy',
603
+ 'N10ens1-nucleoid-nTransTProt.npy',
604
+ 'N10ens1-nucleoid-loopLengthHistTDna.npy',
605
+ 'N10ens1-nucleoid-distMatTDnaSite.npy']
606
+ assert paths[0].name == 'N10ens1-nucleoid-nBoundTSite.npy'
607
+
608
+
609
+ def test_save_before_run_raises(make_binder_universe, binding_frames,
610
+ tmp_path):
611
+ """save needs the results of a run."""
612
+ universe = make_binder_universe(*binding_frames)
613
+ analysis = BinderStatistics(
614
+ universe.select_atoms('resid 1'), universe.select_atoms('type 2'),
615
+ cutoff=0.5, topology='ring', cis_threshold=4)
616
+
617
+ with pytest.raises(RuntimeError, match=r"Call run\(\) before save"):
618
+ analysis.save(tmp_path, 'N10ens1-nucleoid')
619
+
620
+
621
+ # Real frames
622
+
623
+
624
+ @pytest.fixture
625
+ def real_binders(real_files, real_universe, study_spec):
626
+ """Factory: the study's :meth:`BinderStatistics.from_spec` on a real Hns
627
+ polymer dump, with the universe it reads; not yet run."""
628
+
629
+ def _build(whole):
630
+ files = real_files(whole)
631
+ universe = real_universe(files.topology, files.polymer)
632
+ return (BinderStatistics.from_spec(
633
+ universe, study_spec(whole.study).PROBE), universe)
634
+
635
+ return _build
636
+
637
+
638
+ def test_real_binding_counts_obey_their_identities(real_hns_whole,
639
+ real_binders):
640
+ """In every real frame, each patch is free or engaged, each binder is
641
+ free, dangling, or bridging, the loop histogram counts every distinct
642
+ cis or trans pair, no more pairs than bridges form, and engaged patches
643
+ make at least one contact each."""
644
+ analysis, _ = real_binders(real_hns_whole)
645
+ n_hns = real_hns_whole.n_hns
646
+
647
+ results = analysis.run().results
648
+
649
+ pairs = results['n_pairs_cis'] + results['n_pairs_trans']
650
+ assert_array_equal(results['n_patches_free']
651
+ + results['n_patches_engaged'], np.full(50, 2 * n_hns))
652
+ assert_array_equal(results['n_binders_free']
653
+ + results['n_binders_dangling']
654
+ + results['n_binders_bridging'], np.full(50, n_hns))
655
+ assert_array_equal(results['loop_length_histogram'].sum(axis=1), pairs)
656
+ assert np.all(pairs <= results['n_binders_bridging'])
657
+ assert np.all(results['n_patch_monomer_contacts']
658
+ >= results['n_patches_engaged'])
659
+
660
+
661
+ def test_real_binder_distances_are_the_minimum_image_distances(
662
+ real_hns_whole, real_binders, study_spec, trajectory_arrays,
663
+ reference_distances):
664
+ """The stored monomer-to-patch distances of every real frame are the
665
+ minimum-image distances along the geometry's periodic directions."""
666
+ probe = study_spec(real_hns_whole.study).PROBE
667
+ analysis, universe = real_binders(real_hns_whole)
668
+
669
+ distances = analysis.run().results['distances']
670
+
671
+ arrays = trajectory_arrays(universe)
672
+ expected = reference_distances(
673
+ arrays['positions'][:, analysis.chain.indices],
674
+ arrays['positions'][:, analysis.patches.indices], arrays['boxes'],
675
+ probe.geometry)
676
+ # float32 positions of at most about 150 carry a round-off of about
677
+ # 1e-5 in a separation.
678
+ assert_allclose(distances, expected, rtol=0, atol=5e-5)
679
+
680
+
681
+ def test_real_whole_without_hns_has_no_binders_to_count(real_nh0_whole,
682
+ real_binders):
683
+ """A real Hns whole without H-NS proteins has no patches, which the
684
+ binder analysis rejects."""
685
+ with pytest.raises(ValueError, match="positive multiple of 2"):
686
+ real_binders(real_nh0_whole)