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,1058 @@
1
+ """
2
+ Tests for :mod:`polyphys.analyze.clusters`.
3
+
4
+ Every expected value is independent of the code under test. It comes from
5
+ one of these sources, named in the test that uses it:
6
+
7
+ H. A hand count on a graph or contact matrix small enough to draw.
8
+
9
+ R1. The Boolean transitive closure of an adjacency matrix, computed in
10
+ :func:`_warshall_closure` by Warshall's algorithm (S. Warshall, "A theorem
11
+ on Boolean matrices", J. ACM 9, 11 (1962)): ``C[i, j]`` is true when a
12
+ path joins ``i`` to ``j``. Two particles are in one cluster exactly when
13
+ ``C[i, j]`` holds, so it is the reference for the cluster labels.
14
+
15
+ R2. Brute force: a double loop over the definition, in
16
+ :func:`_brute_force_distances` (minimum image, one pair at a time),
17
+ :func:`_walk_genomic_distance` (steps along the backbone, each way round
18
+ a ring), :func:`_binder_links_by_definition` (the same-monomer and
19
+ neighboring-monomer rules, one pair of binders at a time), and
20
+ :func:`_sweep_frame_by_frame` (the per-frame kernels called at one grid
21
+ point and one frame at a time, then averaged).
22
+
23
+ R3. :func:`numpy.histogram`, an independent implementation of binning.
24
+
25
+ P. A property: conservation (sizes sum to the particle count, the handshake
26
+ lemma, unit normalization), symmetry, idempotence, or invariance under
27
+ relabeling.
28
+
29
+ Random inputs are drawn by Hypothesis from a seed, then built with
30
+ ``numpy.random.default_rng(seed)``, so every example is reproducible from its
31
+ seed. Float comparisons use ``FLOAT64_RTOL`` where two computations differ
32
+ only in the order of float64 operations; the one binning tolerance is derived
33
+ where it is used.
34
+ """
35
+
36
+ import numpy as np
37
+ import pandas as pd
38
+ import pytest
39
+ from hypothesis import given, strategies as st
40
+ from numpy.testing import assert_allclose, assert_array_equal
41
+
42
+ from polyphys.analyze.clusters import (
43
+ BindingStats,
44
+ FrameBinding,
45
+ binder_contacts,
46
+ binding_statistics,
47
+ cluster_labels,
48
+ contact_matrix,
49
+ count_bonds,
50
+ count_clusters,
51
+ enforce_single_patch_contact,
52
+ find_direct_contacts,
53
+ foci_pair_info,
54
+ genomic_distance,
55
+ monomer_binder_contacts,
56
+ pair_distance_histograms,
57
+ pair_distances,
58
+ pair_separation_density,
59
+ split_binders,
60
+ threshold_sweep,
61
+ )
62
+
63
+ # Relative and absolute tolerance for results that differ only by float64
64
+ # round-off over a handful of operations: far above the 2.2e-16 epsilon.
65
+ FLOAT64_RTOL = 1e-12
66
+ FLOAT64_ATOL = 1e-12
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Independent references (R1, R2)
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _warshall_closure(adjacency: np.ndarray) -> np.ndarray:
75
+ """Boolean transitive closure of `adjacency` by Warshall's algorithm."""
76
+ closure = adjacency.astype(bool) | np.eye(len(adjacency), dtype=bool)
77
+ for k in range(len(adjacency)):
78
+ closure |= closure[:, k, None] & closure[None, k, :]
79
+ return closure
80
+
81
+
82
+ def _brute_force_distances(positions: np.ndarray, box: list) -> np.ndarray:
83
+ """Pair distances with the minimum image, one pair and axis at a time."""
84
+ n_particles = len(positions)
85
+ dist = np.zeros((n_particles, n_particles))
86
+ for i in range(n_particles):
87
+ for j in range(n_particles):
88
+ delta = positions[i] - positions[j]
89
+ for axis, length in enumerate(box):
90
+ if 0 < length < np.inf:
91
+ delta[axis] -= length * np.round(delta[axis] / length)
92
+ dist[i, j] = np.sqrt(np.sum(delta**2))
93
+ return dist
94
+
95
+
96
+ def _walk_genomic_distance(i: int, j: int, topology: str, n: int) -> int:
97
+ """Bonds between monomers `i` and `j`, walking the backbone stepwise."""
98
+ forward = 0
99
+ k = i
100
+ while k != j:
101
+ k = (k + 1) % n
102
+ forward += 1
103
+ if topology == 'linear':
104
+ return forward if i <= j else _walk_genomic_distance(j, i, 'linear', n)
105
+ backward = 0
106
+ k = i
107
+ while k != j:
108
+ k = (k - 1) % n
109
+ backward += 1
110
+ return min(forward, backward)
111
+
112
+
113
+ def _binder_links_by_definition(contacts: np.ndarray, topology: str):
114
+ """Binder links from the definition, one pair of binders at a time."""
115
+ n_monomers, n_binders = contacts.shape
116
+ linked = np.eye(n_binders, dtype=bool)
117
+ for a in range(n_binders):
118
+ for b in range(n_binders):
119
+ if np.any(contacts[:, a] & contacts[:, b]):
120
+ linked[a, b] = True
121
+ for k in range(n_monomers - 1):
122
+ on_pair = np.flatnonzero(contacts[k] | contacts[k + 1])
123
+ for a in on_pair:
124
+ linked[a, on_pair] = True
125
+ if topology == 'ring':
126
+ on_pair = np.flatnonzero(contacts[0] | contacts[-1])
127
+ for a in on_pair:
128
+ linked[a, on_pair] = True
129
+ return linked
130
+
131
+
132
+ def _sweep_frame_by_frame(
133
+ distances: np.ndarray, cutoff: float, cis_threshold: int,
134
+ topology: str, inclusive: bool,
135
+ ) -> tuple[dict, np.ndarray]:
136
+ """The frame means at one grid point, one frame at a time, from the
137
+ per-frame kernels: the eight counts and the loop-length histogram."""
138
+ n_monomers = distances.shape[1]
139
+ stats = BindingStats(n_monomers, topology, cis_threshold)
140
+ for matrix in distances:
141
+ stats.update(find_direct_contacts(matrix, cutoff, inclusive))
142
+ counts = {name: series.mean()
143
+ for name, series in stats.as_dict().items()}
144
+ return counts, stats.loop_length_histogram / len(distances)
145
+
146
+
147
+ def _graph_from_edges(n_particles: int, edges: list) -> np.ndarray:
148
+ """Symmetric adjacency matrix with a true diagonal from an edge list."""
149
+ adjacency = np.eye(n_particles, dtype=bool)
150
+ for i, j in edges:
151
+ adjacency[i, j] = adjacency[j, i] = True
152
+ return adjacency
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Strategies and fixtures
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ @st.composite
161
+ def contact_graphs(draw, max_particles: int = 40) -> np.ndarray:
162
+ """A random symmetric contact matrix with a true diagonal."""
163
+ n_particles = draw(st.integers(min_value=1, max_value=max_particles))
164
+ probability = draw(st.floats(min_value=0.0, max_value=1.0))
165
+ seed = draw(st.integers(min_value=0, max_value=2**32 - 1))
166
+ rng = np.random.default_rng(seed)
167
+ adjacency = rng.random((n_particles, n_particles)) < probability
168
+ adjacency |= adjacency.T
169
+ np.fill_diagonal(adjacency, True)
170
+ return adjacency
171
+
172
+
173
+ @st.composite
174
+ def monomer_binder_matrices(draw) -> np.ndarray:
175
+ """A random monomer-binder contact matrix."""
176
+ n_monomers = draw(st.integers(min_value=1, max_value=12))
177
+ n_binders = draw(st.integers(min_value=1, max_value=8))
178
+ probability = draw(st.floats(min_value=0.0, max_value=0.6))
179
+ seed = draw(st.integers(min_value=0, max_value=2**32 - 1))
180
+ rng = np.random.default_rng(seed)
181
+ return rng.random((n_monomers, n_binders)) < probability
182
+
183
+
184
+ @st.composite
185
+ def distance_stacks(draw) -> np.ndarray:
186
+ """Random monomer-to-patch distance matrices over a few frames, with
187
+ the distances spread across the sweep grid of 0.3 to 1.0."""
188
+ n_frames = draw(st.integers(min_value=1, max_value=4))
189
+ n_monomers = draw(st.integers(min_value=1, max_value=8))
190
+ n_binders = draw(st.integers(min_value=1, max_value=4))
191
+ seed = draw(st.integers(min_value=0, max_value=2**32 - 1))
192
+ rng = np.random.default_rng(seed)
193
+ return rng.uniform(0.3, 1.0, size=(n_frames, n_monomers, 2 * n_binders))
194
+
195
+
196
+ @pytest.fixture
197
+ def sweep_distances() -> np.ndarray:
198
+ """Two frames of four monomers and two binders on a linear chain, every
199
+ distance 2.0 but these; hand-counted in the sweep tests (H).
200
+
201
+ Frame 0: patch 0 is 0.5 from monomer 0, patch 1 is 0.6 from monomer 2,
202
+ patch 2 is exactly 0.685 from monomer 1, patch 3 is 0.7 from monomer 3.
203
+ Frame 1: patches 0 and 1 are 0.5 and 0.6 from monomer 0; binder 1 is far.
204
+ So at 0.55 binder 0 dangles in both frames; from 0.6 it bridges monomers
205
+ 0 and 2 (two bonds) in frame 0 and holds monomer 0 with both patches in
206
+ frame 1, a bridge with no pair; binder 1 engages one patch only at an
207
+ inclusive 0.685.
208
+ """
209
+ distances = np.full((2, 4, 4), 2.0)
210
+ distances[0, 0, 0] = 0.5
211
+ distances[0, 2, 1] = 0.6
212
+ distances[0, 1, 2] = 0.685
213
+ distances[0, 3, 3] = 0.7
214
+ distances[1, 0, 0] = 0.5
215
+ distances[1, 0, 1] = 0.6
216
+ return distances
217
+
218
+
219
+ @pytest.fixture
220
+ def regression_graph() -> np.ndarray:
221
+ """The 7-node graph on which the single-pass merge stopped short (H)."""
222
+ return _graph_from_edges(7, [(0, 6), (1, 6), (2, 3), (3, 4), (4, 6)])
223
+
224
+
225
+ @pytest.fixture
226
+ def hns_toy() -> np.ndarray:
227
+ """Six monomers, four two-patch binders; hand-counted below (H).
228
+
229
+ Binder 0: patch 0 on monomer 0, patch 1 on monomer 2 (bridge, 2 bonds on
230
+ a ring of six, 2 on a line). Binder 1: patch 2 on monomers 3 and 4 (an
231
+ artifact), patch 3 free (dangling). Binder 2: free. Binder 3: patch 6 on
232
+ monomer 5, patch 7 on monomer 0 (bridge, 1 bond on the ring, 5 on a
233
+ line).
234
+ """
235
+ contacts = np.zeros((6, 8), dtype=int)
236
+ contacts[0, 0] = contacts[2, 1] = 1
237
+ contacts[3, 2] = contacts[4, 2] = 1
238
+ contacts[5, 6] = contacts[0, 7] = 1
239
+ return contacts
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Clusters
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ def test_count_clusters_reaches_the_transitive_closure(regression_graph):
248
+ """A merge order that defeated the single pass gives one 6 and one 1."""
249
+ hist = count_clusters(regression_graph)
250
+ assert_array_equal(hist, [0, 1, 0, 0, 0, 0, 1, 0]) # H
251
+
252
+
253
+ @pytest.mark.parametrize(
254
+ ('n_particles', 'edges', 'expected'),
255
+ [
256
+ pytest.param(5, [], [0, 5, 0, 0, 0, 0], id='no-contacts'),
257
+ pytest.param(
258
+ 4, [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
259
+ [0, 0, 0, 0, 1], id='complete-k4',
260
+ ),
261
+ pytest.param(5, [(0, 1), (1, 2), (2, 3)], [0, 1, 0, 0, 1, 0],
262
+ id='path-plus-isolated'),
263
+ pytest.param(
264
+ 6, [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)],
265
+ [0, 0, 0, 2, 0, 0, 0], id='two-triangles',
266
+ ),
267
+ pytest.param(4, [(0, 1), (0, 2), (0, 3)], [0, 0, 0, 0, 1],
268
+ id='star'),
269
+ pytest.param(5, [(4, 3), (3, 2), (2, 1), (1, 0)],
270
+ [0, 0, 0, 0, 0, 1], id='chain-listed-backwards'),
271
+ ],
272
+ )
273
+ def test_count_clusters_known_sizes(n_particles, edges, expected):
274
+ """Hand-drawn graphs give their hand-counted size histograms (H)."""
275
+ hist = count_clusters(_graph_from_edges(n_particles, edges))
276
+ assert_array_equal(hist, expected)
277
+
278
+
279
+ @given(adjacency=contact_graphs())
280
+ def test_contact_matrix_matches_boolean_closure(adjacency):
281
+ """The contact matrix equals the Warshall transitive closure (R1)."""
282
+ assert_array_equal(contact_matrix(adjacency), _warshall_closure(adjacency))
283
+
284
+
285
+ @given(adjacency=contact_graphs())
286
+ def test_cluster_labels_partition_matches_boolean_closure(adjacency):
287
+ """Two particles share a label exactly when the closure joins them (R1)."""
288
+ n_clusters, labels = cluster_labels(adjacency)
289
+ closure = _warshall_closure(adjacency)
290
+ assert_array_equal(labels[:, None] == labels[None, :], closure)
291
+ assert n_clusters == len(np.unique(labels))
292
+
293
+
294
+ @given(adjacency=contact_graphs())
295
+ def test_count_clusters_sizes_sum_to_n_particles(adjacency):
296
+ """Cluster sizes weighted by their counts add up to the particles (P)."""
297
+ hist = count_clusters(adjacency)
298
+ sizes = np.arange(len(hist))
299
+ assert hist.shape == (len(adjacency) + 1,)
300
+ assert hist[0] == 0
301
+ assert int(np.sum(sizes * hist)) == len(adjacency)
302
+
303
+
304
+ @given(adjacency=contact_graphs())
305
+ def test_contact_matrix_is_a_closure(adjacency):
306
+ """The contact matrix is symmetric, reflexive, and its own closure (P)."""
307
+ closure = contact_matrix(adjacency)
308
+ assert_array_equal(closure, closure.T)
309
+ assert np.all(np.diagonal(closure))
310
+ assert_array_equal(closure.astype(int) @ closure.astype(int) > 0, closure)
311
+
312
+
313
+ @given(adjacency=contact_graphs(), seed=st.integers(0, 2**32 - 1))
314
+ def test_count_clusters_is_invariant_under_relabeling(adjacency, seed):
315
+ """Permuting the particles leaves the size histogram unchanged (P)."""
316
+ order = np.random.default_rng(seed).permutation(len(adjacency))
317
+ permuted = adjacency[np.ix_(order, order)]
318
+ assert_array_equal(count_clusters(permuted), count_clusters(adjacency))
319
+
320
+
321
+ def test_cluster_labels_of_no_particles():
322
+ """An empty contact matrix has no clusters and no labels."""
323
+ n_clusters, labels = cluster_labels(np.zeros((0, 0), dtype=bool))
324
+ assert n_clusters == 0
325
+ assert labels.shape == (0,)
326
+
327
+
328
+ @pytest.mark.parametrize(
329
+ 'bad',
330
+ [
331
+ pytest.param(np.ones((2, 3), dtype=bool), id='not-square'),
332
+ pytest.param(np.array([[1, 1], [0, 1]]), id='asymmetric'),
333
+ pytest.param(np.ones(3, dtype=bool), id='one-dimensional'),
334
+ ],
335
+ )
336
+ def test_cluster_labels_rejects_a_bad_matrix(bad):
337
+ """A non-square, asymmetric, or non-matrix input raises."""
338
+ with pytest.raises(ValueError, match='square|symmetric|two-dimensional'):
339
+ cluster_labels(bad)
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Bonds and direct contacts
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ def test_count_bonds_known_degrees():
348
+ """A star of four has one particle with three bonds and three with one."""
349
+ star = _graph_from_edges(4, [(0, 1), (0, 2), (0, 3)])
350
+ assert_array_equal(count_bonds(star), [0, 3, 0, 1]) # H
351
+
352
+
353
+ @given(adjacency=contact_graphs())
354
+ def test_count_bonds_satisfies_the_handshake_lemma(adjacency):
355
+ """Degrees sum to twice the contacts and counts to the particles (P)."""
356
+ hist = count_bonds(adjacency)
357
+ n_particles = len(adjacency)
358
+ n_contacts = (int(adjacency.sum()) - n_particles) // 2
359
+ assert hist.shape == (n_particles,)
360
+ assert int(hist.sum()) == n_particles
361
+ assert int(np.sum(np.arange(n_particles) * hist)) == 2 * n_contacts
362
+
363
+
364
+ def test_count_bonds_rejects_an_asymmetric_matrix():
365
+ """A directed contact matrix has no well-defined degree and raises."""
366
+ with pytest.raises(ValueError, match='symmetric'):
367
+ count_bonds(np.array([[1, 1], [0, 1]]))
368
+
369
+
370
+ @pytest.mark.parametrize(
371
+ ('inclusive', 'expected'),
372
+ [
373
+ pytest.param(True, [[1, 1], [1, 1]], id='inclusive'),
374
+ pytest.param(False, [[1, 0], [0, 1]], id='strict'),
375
+ ],
376
+ )
377
+ def test_find_direct_contacts_at_the_cutoff(inclusive, expected):
378
+ """A pair exactly at the cutoff is a contact only when inclusive."""
379
+ dist = np.array([[0.0, 1.5], [1.5, 0.0]])
380
+ contacts = find_direct_contacts(dist, 1.5, inclusive=inclusive)
381
+ assert contacts.dtype == np.bool_
382
+ assert_array_equal(contacts, np.array(expected, dtype=bool))
383
+
384
+
385
+ @pytest.mark.parametrize('cutoff', [0.0, -1.0])
386
+ def test_find_direct_contacts_rejects_a_nonpositive_cutoff(cutoff):
387
+ """A zero or negative cutoff raises."""
388
+ with pytest.raises(ValueError, match='cutoff must be positive'):
389
+ find_direct_contacts(np.zeros((2, 2)), cutoff)
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Pair distances
394
+ # ---------------------------------------------------------------------------
395
+
396
+
397
+ def test_pair_distances_known_values():
398
+ """A 3-4-5 right triangle gives its side lengths (H)."""
399
+ dist = pair_distances([[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]])
400
+ assert_allclose(dist, [[0, 3, 5], [3, 0, 4], [5, 4, 0]],
401
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
402
+
403
+
404
+ @pytest.mark.parametrize(
405
+ ('box', 'expected'),
406
+ [
407
+ pytest.param([10.0, 0.0], 0.2, id='periodic'),
408
+ pytest.param([0.0, 0.0], 9.8, id='length-zero-is-not-periodic'),
409
+ pytest.param([np.inf, 0.0], 9.8, id='length-inf-is-not-periodic'),
410
+ pytest.param(None, 9.8, id='no-box'),
411
+ ],
412
+ )
413
+ def test_pair_distances_applies_the_minimum_image(box, expected):
414
+ """Points at 0.1 and 9.9 are 0.2 apart only through a periodic edge (H)."""
415
+ dist = pair_distances([[0.1, 0.0], [9.9, 0.0]], box=box)
416
+ assert dist[0, 1] == pytest.approx(expected, abs=FLOAT64_ATOL)
417
+
418
+
419
+ @pytest.mark.parametrize(
420
+ 'box',
421
+ [
422
+ pytest.param([10.0, 10.0, 10.0], id='cubic'),
423
+ pytest.param([0.0, 0.0, 10.0], id='z-only'),
424
+ ],
425
+ )
426
+ @pytest.mark.parametrize('seed', [1, 2, 3])
427
+ def test_pair_distances_matches_brute_force(box, seed):
428
+ """The vectorized distances equal a pair-by-pair minimum image (R2)."""
429
+ positions = np.random.default_rng(seed).uniform(-5.0, 15.0, (25, 3))
430
+ dist = pair_distances(positions, box=box)
431
+ assert_allclose(dist, _brute_force_distances(positions, box),
432
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
433
+ assert_allclose(dist, dist.T, rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
434
+
435
+
436
+ @pytest.mark.parametrize(
437
+ ('positions', 'box', 'message'),
438
+ [
439
+ pytest.param([1.0, 2.0], None, 'n_particles, n_dims',
440
+ id='one-dimensional-positions'),
441
+ pytest.param([[0.0, 0.0]], [10.0], 'one length per dimension',
442
+ id='wrong-box-length'),
443
+ pytest.param([[0.0, 0.0]], [-1.0, 0.0], 'non-negative',
444
+ id='negative-box'),
445
+ ],
446
+ )
447
+ def test_pair_distances_rejects_bad_shapes(positions, box, message):
448
+ """Positions without two axes or a box of the wrong shape raise."""
449
+ with pytest.raises(ValueError, match=message):
450
+ pair_distances(positions, box=box)
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Genomic distance and foci pairs
455
+ # ---------------------------------------------------------------------------
456
+
457
+
458
+ @pytest.mark.parametrize('topology', ['linear', 'ring'])
459
+ @pytest.mark.parametrize(
460
+ ('n_backbone', 'i', 'j'),
461
+ [
462
+ pytest.param(10, 0, 9, id='ends'),
463
+ pytest.param(10, 3, 3, id='same-monomer'),
464
+ pytest.param(10, 2, 7, id='half-way'),
465
+ pytest.param(9, 1, 6, id='odd-ring'),
466
+ pytest.param(10, 8, 1, id='reversed-order'),
467
+ ],
468
+ )
469
+ def test_genomic_distance_matches_walking_the_chain(
470
+ topology, n_backbone, i, j
471
+ ):
472
+ """The genomic distance is the shortest walk along the backbone (R2)."""
473
+ expected = _walk_genomic_distance(i, j, topology, n_backbone)
474
+ assert_array_equal(genomic_distance([i], [j], topology, n_backbone),
475
+ [expected])
476
+
477
+
478
+ def test_genomic_distance_on_a_linear_chain_runs():
479
+ """A linear chain gives |i - j|; the research code raised here (H)."""
480
+ assert_array_equal(genomic_distance([0, 1], [3, 9], 'linear', 10), [3, 8])
481
+
482
+
483
+ def test_genomic_distance_rejects_an_unknown_topology():
484
+ """A topology other than linear or ring raises."""
485
+ with pytest.raises(ValueError, match="topology must be 'linear' or"):
486
+ genomic_distance([0], [1], 'star', 5)
487
+
488
+
489
+ def test_genomic_distance_rejects_an_index_beyond_the_chain():
490
+ """An index at or past n_backbone raises."""
491
+ with pytest.raises(ValueError, match='indices in 0, ..., 4'):
492
+ genomic_distance([0], [5], 'ring', 5)
493
+
494
+
495
+ @pytest.mark.parametrize(
496
+ ('topology', 'expected'),
497
+ [
498
+ pytest.param('linear', [[0, 8, 8]], id='linear'),
499
+ pytest.param('ring', [[0, 8, 2]], id='ring'),
500
+ ],
501
+ )
502
+ def test_foci_pair_info_respects_the_topology(topology, expected):
503
+ """Foci at 0 and 8 of 10 are 8 bonds apart on a line, 2 on a ring (H)."""
504
+ assert_array_equal(foci_pair_info([0, 8], 10, topology), expected)
505
+
506
+
507
+ def test_foci_pair_info_lists_pairs_in_upper_triangle_order():
508
+ """Three foci give three rows in numpy.triu_indices order (H)."""
509
+ info = foci_pair_info([0, 4, 8], 10, 'ring')
510
+ assert_array_equal(info, [[0, 4, 4], [0, 8, 2], [4, 8, 4]])
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # Pair separation histograms and density
515
+ # ---------------------------------------------------------------------------
516
+
517
+
518
+ def test_pair_distance_histograms_known_counts():
519
+ """Hand-placed separations land in their bins; the last edge counts (H)."""
520
+ counts, edges = pair_distance_histograms(
521
+ [[0.5, 1.5, 1.5, 3.0], [2.0, 2.0, 2.0, 2.0]], bin_size=1.0, r_max=3.0
522
+ )
523
+ assert_array_equal(counts, [[1, 2, 1], [0, 0, 4]])
524
+ assert_array_equal(edges, [0.0, 1.0, 2.0, 3.0])
525
+
526
+
527
+ @pytest.mark.parametrize('seed', [11, 12, 13])
528
+ def test_pair_distance_histograms_matches_numpy_histogram(seed):
529
+ """Counts agree with numpy.histogram on the same edges (R3)."""
530
+ series = np.random.default_rng(seed).uniform(0.0, 7.5, (4, 300))
531
+ counts, edges = pair_distance_histograms(series, bin_size=0.5, r_max=7.5)
532
+ reference = [np.histogram(row, bins=edges)[0] for row in series]
533
+ assert_array_equal(counts, reference)
534
+ assert_array_equal(counts.sum(axis=1), [300, 300, 300, 300])
535
+
536
+
537
+ def test_pair_distance_histograms_rounds_the_grid_up_to_whole_bins():
538
+ """A range that is not a multiple of the bin size gets one more bin."""
539
+ counts, edges = pair_distance_histograms([0.0], bin_size=1.0, r_max=2.5)
540
+ assert_array_equal(edges, [0.0, 1.0, 2.0, 3.0])
541
+ assert counts.shape == (3,)
542
+
543
+
544
+ @pytest.mark.parametrize(
545
+ ('bin_size', 'r_max', 'n_bins'),
546
+ [
547
+ pytest.param(0.3, 2.1, 7, id='ratio-above-whole'),
548
+ pytest.param(0.03, 15.9, 530, id='edge-below-r-max'),
549
+ ],
550
+ )
551
+ def test_pair_distance_histograms_counts_whole_bins_without_round_off(
552
+ bin_size, r_max, n_bins
553
+ ):
554
+ """A range that is a whole number of bins in decimal arithmetic gets
555
+ exactly that many, ending at `r_max`, although its float64 ratio or
556
+ product misses the whole number by an ulp."""
557
+ counts, edges = pair_distance_histograms([r_max], bin_size, r_max)
558
+ assert counts.shape == (n_bins,)
559
+ assert edges[-1] == r_max
560
+ assert counts[-1] == 1
561
+
562
+
563
+ def test_pair_distance_histograms_rejects_a_separation_beyond_the_grid():
564
+ """A separation above the last edge raises instead of being dropped."""
565
+ with pytest.raises(ValueError, match='must lie in'):
566
+ pair_distance_histograms([0.5, 3.5], bin_size=1.0, r_max=3.0)
567
+
568
+
569
+ @pytest.mark.parametrize(
570
+ ('bin_size', 'r_max', 'message'),
571
+ [
572
+ pytest.param(0.0, 3.0, 'bin_size must be positive', id='bin-size'),
573
+ pytest.param(1.0, 0.0, 'r_max must be positive', id='r-max'),
574
+ ],
575
+ )
576
+ def test_pair_distance_histograms_rejects_a_nonpositive_grid(
577
+ bin_size, r_max, message
578
+ ):
579
+ """A zero bin size or range raises."""
580
+ with pytest.raises(ValueError, match=message):
581
+ pair_distance_histograms([0.5], bin_size=bin_size, r_max=r_max)
582
+
583
+
584
+ def test_pair_separation_density_known_value():
585
+ """All frames in one shell give one over that shell's volume (H)."""
586
+ edges = np.array([0.0, 1.0, 2.0, 3.0])
587
+ density = pair_separation_density([0, 0, 4], edges)
588
+ shell = 4.0 * np.pi / 3.0 * (27.0 - 8.0)
589
+ assert_allclose(density, [0.0, 0.0, 1.0 / shell],
590
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
591
+
592
+
593
+ @pytest.mark.parametrize('seed', [21, 22])
594
+ def test_pair_separation_density_integrates_to_one_and_keeps_the_moment(seed):
595
+ """The density integrates to 1 and reproduces the mean square (P)."""
596
+ bin_size, r_max = 0.25, 8.0
597
+ series = np.random.default_rng(seed).uniform(0.0, r_max, (3, 400))
598
+ counts, edges = pair_distance_histograms(series, bin_size, r_max)
599
+ density = pair_separation_density(counts, edges)
600
+ shells = 4.0 * np.pi / 3.0 * np.diff(edges**3)
601
+ centers = 0.5 * (edges[1:] + edges[:-1])
602
+ assert_allclose(np.sum(density * shells, axis=1), [1.0, 1.0, 1.0],
603
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
604
+ # Replacing each separation r by its bin center changes r**2 by at most
605
+ # (bin_size / 2) * 2 r_max, so the moment agrees to bin_size * r_max.
606
+ assert_allclose(np.sum(density * shells * centers**2, axis=1),
607
+ np.mean(series**2, axis=1), atol=bin_size * r_max)
608
+
609
+
610
+ def test_pair_separation_density_rejects_an_empty_row():
611
+ """A pair with no counts has no density and raises."""
612
+ with pytest.raises(ValueError, match='positive total'):
613
+ pair_separation_density([[1, 0], [0, 0]], [0.0, 1.0, 2.0])
614
+
615
+
616
+ @pytest.mark.parametrize(
617
+ 'edges',
618
+ [
619
+ pytest.param([0.0, 1.0], id='wrong-length'),
620
+ pytest.param([0.0, 2.0, 1.0], id='not-increasing'),
621
+ pytest.param([-1.0, 0.0, 1.0], id='negative'),
622
+ ],
623
+ )
624
+ def test_pair_separation_density_rejects_bad_edges(edges):
625
+ """Edges of the wrong length, order, or sign raise."""
626
+ with pytest.raises(ValueError, match='edges must'):
627
+ pair_separation_density([1, 1], edges)
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Binding: contact-matrix reshaping
632
+ # ---------------------------------------------------------------------------
633
+
634
+
635
+ def test_enforce_single_patch_contact_keeps_the_lowest_monomer():
636
+ """A patch on monomers 1 and 2 keeps monomer 1; a triple keeps one (H)."""
637
+ contacts = np.array([[0, 1, 0],
638
+ [1, 0, 1],
639
+ [1, 0, 1],
640
+ [0, 0, 1]])
641
+ assert_array_equal(
642
+ enforce_single_patch_contact(contacts).astype(int),
643
+ [[0, 1, 0], [1, 0, 1], [0, 0, 0], [0, 0, 0]],
644
+ )
645
+
646
+
647
+ @given(contacts=monomer_binder_matrices())
648
+ def test_enforce_single_patch_contact_is_idempotent_with_one_per_column(
649
+ contacts
650
+ ):
651
+ """Each column keeps at most one contact; a second pass is a no-op (P)."""
652
+ once = enforce_single_patch_contact(contacts)
653
+ assert np.all(once.sum(axis=0) <= 1)
654
+ assert_array_equal(once.sum(axis=0) > 0, contacts.sum(axis=0) > 0)
655
+ assert_array_equal(enforce_single_patch_contact(once), once)
656
+
657
+
658
+ def test_monomer_binder_contacts_collapses_each_binder_s_patches():
659
+ """A binder touches a monomer when any of its patches does (H)."""
660
+ contacts = np.array([[1, 0, 0, 0],
661
+ [0, 1, 0, 0],
662
+ [0, 0, 0, 1]])
663
+ assert_array_equal(monomer_binder_contacts(contacts, 2).astype(int),
664
+ [[1, 0], [1, 0], [0, 1]])
665
+
666
+
667
+ def test_monomer_binder_contacts_rejects_a_wrong_patch_count():
668
+ """A patch count that does not divide the columns raises."""
669
+ with pytest.raises(ValueError, match='must be a multiple of'):
670
+ monomer_binder_contacts(np.zeros((2, 3)), 2)
671
+
672
+
673
+ def test_split_binders_by_the_number_of_monomers_bound():
674
+ """One contact dangles, two bridge, zero or three go to neither (H)."""
675
+ contacts = np.array([[0, 1, 1, 1],
676
+ [0, 0, 1, 1],
677
+ [0, 0, 0, 1]])
678
+ dangling, bridging = split_binders(contacts)
679
+ assert_array_equal(dangling.astype(int),
680
+ [[0, 1, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]])
681
+ assert_array_equal(bridging.astype(int),
682
+ [[0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 0, 0]])
683
+
684
+
685
+ @pytest.mark.parametrize('topology', ['linear', 'ring'])
686
+ @given(contacts=monomer_binder_matrices())
687
+ def test_binder_contacts_matches_the_definition(topology, contacts):
688
+ """Binders are linked exactly as the pairwise definition says (R2)."""
689
+ assert_array_equal(binder_contacts(contacts, topology),
690
+ _binder_links_by_definition(contacts, topology))
691
+
692
+
693
+ @pytest.mark.parametrize(
694
+ ('topology', 'expected'),
695
+ [
696
+ pytest.param('ring', [[1, 1], [1, 1]], id='ring-wraps'),
697
+ pytest.param('linear', [[1, 0], [0, 1]], id='linear-does-not'),
698
+ ],
699
+ )
700
+ def test_binder_contacts_wraps_only_on_a_ring(topology, expected):
701
+ """Binders on the first and last monomer are linked only on a ring (H)."""
702
+ contacts = np.array([[1, 0], [0, 0], [0, 0], [0, 1]])
703
+ assert_array_equal(binder_contacts(contacts, topology).astype(int),
704
+ expected)
705
+
706
+
707
+ # ---------------------------------------------------------------------------
708
+ # Binding statistics
709
+ # ---------------------------------------------------------------------------
710
+
711
+
712
+ def test_binding_statistics_counts_the_toy_on_a_ring(hns_toy):
713
+ """The toy gives the hand-counted patch, binder, and pair counts (H)."""
714
+ frame = binding_statistics(hns_toy, 'ring', cis_threshold=1)
715
+ assert isinstance(frame, FrameBinding)
716
+ assert frame.n_patch_monomer_contacts == 6
717
+ assert frame.n_patches_engaged == 5
718
+ assert frame.n_patches_free == 3
719
+ assert frame.n_binders_free == 1
720
+ assert frame.n_binders_dangling == 1
721
+ assert frame.n_binders_bridging == 2
722
+ assert frame.n_pairs_cis == 1
723
+ assert frame.n_pairs_trans == 1
724
+ assert_array_equal(frame.bridged_pairs, [[0, 2, 2], [0, 5, 1]])
725
+
726
+
727
+ def test_binding_statistics_counts_the_toy_on_a_linear_chain(hns_toy):
728
+ """On a line the 5-0 bridge is 5 bonds long, so both bridges are trans."""
729
+ frame = binding_statistics(hns_toy, 'linear', cis_threshold=1)
730
+ assert (frame.n_binders_free, frame.n_binders_dangling,
731
+ frame.n_binders_bridging) == (1, 1, 2)
732
+ assert (frame.n_pairs_cis, frame.n_pairs_trans) == (0, 2)
733
+ assert_array_equal(frame.bridged_pairs, [[0, 2, 2], [0, 5, 5]]) # H
734
+
735
+
736
+ def test_binding_statistics_counts_a_shared_pair_once():
737
+ """Two binders bridging the same monomers give one bridged pair (H)."""
738
+ contacts = np.array([[1, 0, 1, 0],
739
+ [0, 1, 0, 1]])
740
+ frame = binding_statistics(contacts, 'linear', cis_threshold=1)
741
+ assert frame.n_binders_bridging == 2
742
+ assert frame.n_pairs_cis == 1
743
+ assert_array_equal(frame.bridged_pairs, [[0, 1, 1]])
744
+
745
+
746
+ def test_binding_statistics_bridge_on_one_monomer_has_no_pair():
747
+ """A binder with both patches on one monomer bridges but joins no pair."""
748
+ frame = binding_statistics([[1, 1]], 'linear', cis_threshold=1)
749
+ assert frame.n_binders_bridging == 1
750
+ assert (frame.n_pairs_cis, frame.n_pairs_trans) == (0, 0)
751
+ assert frame.bridged_pairs.shape == (0, 3)
752
+
753
+
754
+ def test_binding_statistics_rejects_an_odd_number_of_patches():
755
+ """A matrix with an odd number of columns is not two patches per binder."""
756
+ with pytest.raises(ValueError, match='two columns per binder'):
757
+ binding_statistics(np.zeros((2, 3)), 'ring', cis_threshold=1)
758
+
759
+
760
+ def test_binding_statistics_rejects_a_negative_threshold():
761
+ """A negative cis threshold raises."""
762
+ with pytest.raises(ValueError, match='cis_threshold must be non-negative'):
763
+ binding_statistics(np.zeros((2, 2)), 'ring', cis_threshold=-1)
764
+
765
+
766
+ def test_binding_stats_accumulates_frames_and_loop_lengths(hns_toy):
767
+ """Two toy frames and an empty one give three rows and twice the loops."""
768
+ stats = BindingStats(n_monomers=6, topology='ring', cis_threshold=1)
769
+ stats.update(hns_toy)
770
+ stats.update(hns_toy)
771
+ stats.update(np.zeros((6, 8), dtype=int))
772
+ series = stats.as_dict()
773
+ assert stats.n_frames == 3
774
+ assert_array_equal(series['n_binders_bridging'], [2, 2, 0])
775
+ assert_array_equal(series['n_binders_free'], [1, 1, 4])
776
+ assert_array_equal(series['n_pairs_cis'], [1, 1, 0])
777
+ assert_array_equal(series['n_pairs_trans'], [1, 1, 0])
778
+ assert_array_equal(stats.loop_length_histogram, [0, 2, 2, 0]) # H
779
+
780
+
781
+ @pytest.mark.parametrize(
782
+ ('topology', 'expected_length'),
783
+ [
784
+ pytest.param('linear', 6, id='linear'),
785
+ pytest.param('ring', 4, id='ring'),
786
+ ],
787
+ )
788
+ def test_binding_stats_histogram_spans_the_longest_loop(
789
+ topology, expected_length
790
+ ):
791
+ """The histogram has one bin per possible genomic distance."""
792
+ stats = BindingStats(n_monomers=6, topology=topology, cis_threshold=1)
793
+ assert stats.loop_length_histogram.shape == (expected_length,)
794
+ assert stats.n_frames == 0
795
+
796
+
797
+ def test_binding_stats_rejects_a_frame_from_a_longer_chain(hns_toy):
798
+ """A frame whose loop is longer than the chain allows raises."""
799
+ stats = BindingStats(n_monomers=4, topology='linear', cis_threshold=1)
800
+ frame = binding_statistics(hns_toy, 'linear', cis_threshold=1)
801
+ with pytest.raises(ValueError, match='beyond the chain'):
802
+ stats.append(frame)
803
+
804
+
805
+ def test_binding_stats_rejects_a_matrix_with_the_wrong_monomer_count():
806
+ """A contact matrix with another number of rows raises on update."""
807
+ stats = BindingStats(n_monomers=4, topology='linear', cis_threshold=1)
808
+ with pytest.raises(ValueError, match='n_monomers = 4'):
809
+ stats.update(np.zeros((6, 2), dtype=int))
810
+
811
+
812
+ @pytest.mark.parametrize(
813
+ ('n_monomers', 'topology', 'cis_threshold', 'message'),
814
+ [
815
+ pytest.param(0, 'ring', 1, 'n_monomers must be positive',
816
+ id='no-monomers'),
817
+ pytest.param(4, 'star', 1, "topology must be 'linear'",
818
+ id='topology'),
819
+ pytest.param(4, 'ring', -1, 'cis_threshold must be non-negative',
820
+ id='threshold'),
821
+ ],
822
+ )
823
+ def test_binding_stats_rejects_bad_parameters(
824
+ n_monomers, topology, cis_threshold, message
825
+ ):
826
+ """An empty chain, an unknown topology, or a negative threshold raises."""
827
+ with pytest.raises(ValueError, match=message):
828
+ BindingStats(n_monomers=n_monomers, topology=topology,
829
+ cis_threshold=cis_threshold)
830
+
831
+
832
+ # ---------------------------------------------------------------------------
833
+ # Shape and range checks on the remaining inputs
834
+ # ---------------------------------------------------------------------------
835
+
836
+
837
+ def test_find_direct_contacts_rejects_a_non_matrix():
838
+ """A one-dimensional distance input raises."""
839
+ with pytest.raises(ValueError, match='two-dimensional'):
840
+ find_direct_contacts(np.zeros(3), 1.0)
841
+
842
+
843
+ def test_count_bonds_rejects_a_non_square_matrix():
844
+ """A rectangular matrix has no per-particle degree and raises."""
845
+ with pytest.raises(ValueError, match='square'):
846
+ count_bonds(np.ones((2, 3), dtype=bool))
847
+
848
+
849
+ def test_genomic_distance_rejects_an_empty_chain():
850
+ """A chain of no monomers raises."""
851
+ with pytest.raises(ValueError, match='n_backbone must be positive'):
852
+ genomic_distance([0], [0], 'ring', 0)
853
+
854
+
855
+ def test_foci_pair_info_rejects_a_matrix_of_positions():
856
+ """Foci positions must be one-dimensional."""
857
+ with pytest.raises(ValueError, match='one-dimensional'):
858
+ foci_pair_info([[0, 1], [2, 3]], 10, 'ring')
859
+
860
+
861
+ def test_pair_distance_histograms_rejects_three_dimensions():
862
+ """A separation array with more than two axes raises."""
863
+ with pytest.raises(ValueError, match='n_pairs, n_frames'):
864
+ pair_distance_histograms(np.zeros((2, 2, 2)), 1.0, 3.0)
865
+
866
+
867
+ def test_monomer_binder_contacts_rejects_a_nonpositive_patch_count():
868
+ """Zero patches per binder raises."""
869
+ with pytest.raises(ValueError, match='patches_per_binder must be'):
870
+ monomer_binder_contacts(np.zeros((2, 2)), 0)
871
+
872
+
873
+ # ---------------------------------------------------------------------------
874
+ # Threshold sweep
875
+ # ---------------------------------------------------------------------------
876
+
877
+
878
+ @pytest.mark.parametrize(
879
+ ('cutoff', 'cis_threshold', 'expected'),
880
+ [
881
+ # (contacts, engaged, free patches, free, dangling, bridging,
882
+ # cis, trans), each the mean over the two frames.
883
+ pytest.param(0.55, 1, (1.0, 1.0, 3.0, 1.0, 1.0, 0.0, 0.0, 0.0),
884
+ id='dangling-below-0.6'),
885
+ pytest.param(0.65, 1, (2.0, 2.0, 2.0, 1.0, 0.0, 1.0, 0.0, 0.5),
886
+ id='trans-at-cis-1'),
887
+ pytest.param(0.65, 2, (2.0, 2.0, 2.0, 1.0, 0.0, 1.0, 0.5, 0.0),
888
+ id='cis-at-cis-2'),
889
+ pytest.param(0.685, 2, (2.5, 2.5, 1.5, 0.5, 0.5, 1.0, 0.5, 0.0),
890
+ id='binder-1-engages-at-the-cutoff'),
891
+ ],
892
+ )
893
+ def test_threshold_sweep_counts_the_hand_frames(
894
+ sweep_distances, cutoff, cis_threshold, expected
895
+ ):
896
+ """Each row holds the frame means of the eight hand counts (H)."""
897
+ counts, _ = threshold_sweep(
898
+ sweep_distances, [0.55, 0.65, 0.685], [1, 2], 'linear',
899
+ inclusive=True)
900
+ row = counts[(counts['cutoff'] == cutoff)
901
+ & (counts['cis_threshold'] == cis_threshold)]
902
+ assert len(row) == 1
903
+ # The means of small integers over two frames are exact in float64.
904
+ assert_allclose(row.iloc[0, 2:].to_numpy(dtype=float), expected,
905
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
906
+
907
+
908
+ @pytest.mark.parametrize(
909
+ ('inclusive', 'expected'),
910
+ [
911
+ pytest.param(True, 2.5, id='inclusive'),
912
+ pytest.param(False, 2.0, id='strict'),
913
+ ],
914
+ )
915
+ def test_threshold_sweep_inclusive_decides_a_pair_at_the_cutoff(
916
+ sweep_distances, inclusive, expected
917
+ ):
918
+ """A patch exactly at the cutoff is a contact only when inclusive, so
919
+ the mean contact count at 0.685 is 2.5 inclusive and 2.0 strict (H)."""
920
+ counts, _ = threshold_sweep(
921
+ sweep_distances, [0.685], [1], 'linear', inclusive=inclusive)
922
+ assert counts.loc[0, 'n_patch_monomer_contacts'] == pytest.approx(
923
+ expected, abs=FLOAT64_ATOL)
924
+
925
+
926
+ @given(distances=distance_stacks(), inclusive=st.booleans(),
927
+ topology=st.sampled_from(['linear', 'ring']))
928
+ def test_threshold_sweep_matches_the_per_frame_kernels(
929
+ distances, inclusive, topology
930
+ ):
931
+ """Every row and histogram row equals the per-frame kernels run at that
932
+ grid point and averaged over the frames (R2)."""
933
+ cutoffs = [0.5, 0.7, 0.9]
934
+ cis_thresholds = [1, 3]
935
+ counts, histogram = threshold_sweep(
936
+ distances, cutoffs, cis_thresholds, topology, inclusive=inclusive)
937
+ for row, (_, record) in enumerate(counts.iterrows()):
938
+ expected, loops = _sweep_frame_by_frame(
939
+ distances, record['cutoff'], int(record['cis_threshold']),
940
+ topology, inclusive)
941
+ for name, value in expected.items():
942
+ # Both sides are an integer sum divided by n_frames.
943
+ assert record[name] == pytest.approx(value, abs=FLOAT64_ATOL)
944
+ assert_allclose(histogram[row], loops,
945
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
946
+
947
+
948
+ @given(distances=distance_stacks(), inclusive=st.booleans())
949
+ def test_threshold_sweep_contacts_grow_with_the_cutoff(distances, inclusive):
950
+ """Along an increasing grid the contacts and engaged patches never fall
951
+ and the free binders never rise (P: monotonicity of ``dist <= cutoff``)."""
952
+ counts, _ = threshold_sweep(
953
+ distances, [0.4, 0.6, 0.8, 1.0], [2], 'ring', inclusive=inclusive)
954
+ assert np.all(np.diff(counts['n_patch_monomer_contacts']) >= 0)
955
+ assert np.all(np.diff(counts['n_patches_engaged']) >= 0)
956
+ assert np.all(np.diff(counts['n_binders_free']) <= 0)
957
+
958
+
959
+ @given(distances=distance_stacks(),
960
+ topology=st.sampled_from(['linear', 'ring']))
961
+ def test_threshold_sweep_cis_threshold_only_splits_the_pairs(
962
+ distances, topology
963
+ ):
964
+ """Across the cis thresholds of one cutoff, the six other counts, the
965
+ sum of cis and trans pairs, and the histogram row are the same (P)."""
966
+ counts, histogram = threshold_sweep(
967
+ distances, [0.6], [0, 1, 2, 5], topology, inclusive=True)
968
+ fixed = ['n_patch_monomer_contacts', 'n_patches_engaged',
969
+ 'n_patches_free', 'n_binders_free', 'n_binders_dangling',
970
+ 'n_binders_bridging']
971
+ assert counts[fixed].nunique().eq(1).all()
972
+ pairs = counts['n_pairs_cis'] + counts['n_pairs_trans']
973
+ # The two means split one integer sum, so their sum differs across rows
974
+ # only by the round-off of two float64 divisions.
975
+ assert_allclose(pairs, pairs.iloc[0], rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
976
+ assert_array_equal(histogram, np.broadcast_to(histogram[0],
977
+ histogram.shape))
978
+
979
+
980
+ @pytest.mark.parametrize(
981
+ ('topology', 'n_lengths'),
982
+ [
983
+ pytest.param('linear', 4, id='linear'),
984
+ pytest.param('ring', 3, id='ring'),
985
+ ],
986
+ )
987
+ def test_threshold_sweep_histogram_counts_the_bridged_pairs(
988
+ sweep_distances, topology, n_lengths
989
+ ):
990
+ """The histogram has one column per genomic distance of the topology,
991
+ an empty column 0, and rows that sum to the cis and trans pairs (P)."""
992
+ counts, histogram = threshold_sweep(
993
+ sweep_distances, [0.55, 0.65], [1, 2], topology, inclusive=True)
994
+ assert histogram.dtype == np.float64
995
+ assert histogram.shape == (4, n_lengths)
996
+ assert_array_equal(histogram[:, 0], 0.0)
997
+ assert_allclose(histogram.sum(axis=1),
998
+ counts['n_pairs_cis'] + counts['n_pairs_trans'],
999
+ rtol=FLOAT64_RTOL, atol=FLOAT64_ATOL)
1000
+
1001
+
1002
+ def test_threshold_sweep_keeps_the_grid_order_without_repeats(
1003
+ sweep_distances
1004
+ ):
1005
+ """Rows follow the cutoffs as given, cis thresholds inside each, once
1006
+ each; the research script's cumulative tables repeated earlier rows."""
1007
+ counts, histogram = threshold_sweep(
1008
+ sweep_distances, [0.65, 0.55], [4, 2], 'linear', inclusive=True)
1009
+ assert list(counts.columns[:2]) == ['cutoff', 'cis_threshold']
1010
+ assert_allclose(counts['cutoff'], [0.65, 0.65, 0.55, 0.55],
1011
+ rtol=FLOAT64_RTOL)
1012
+ assert_array_equal(counts['cis_threshold'], [4, 2, 4, 2])
1013
+ assert counts.shape == (4, 10)
1014
+ assert histogram.shape[0] == 4
1015
+
1016
+
1017
+ def test_threshold_sweep_accepts_whole_float_cis_thresholds():
1018
+ """A cis threshold given as a whole float counts as that integer."""
1019
+ distances = np.full((2, 3, 2), 2.0)
1020
+ distances[0, 0, 0] = 0.5
1021
+ distances[0, 2, 1] = 0.6
1022
+ as_ints, _ = threshold_sweep(distances, [0.65], [1, 2], 'linear',
1023
+ inclusive=True)
1024
+ as_floats, _ = threshold_sweep(distances, [0.65], [1.0, 2.0], 'linear',
1025
+ inclusive=True)
1026
+ pd.testing.assert_frame_equal(as_floats, as_ints)
1027
+
1028
+
1029
+ @pytest.mark.parametrize(
1030
+ ('distances', 'cutoffs', 'cis_thresholds', 'topology', 'message'),
1031
+ [
1032
+ pytest.param(np.ones((3, 4)), [0.5], [1], 'linear',
1033
+ 'n_frames, n_monomers, n_patches', id='two-dimensional'),
1034
+ pytest.param(np.ones((0, 3, 4)), [0.5], [1], 'linear',
1035
+ 'at least one frame', id='no-frames'),
1036
+ pytest.param(np.ones((1, 3, 4)), [], [1], 'linear',
1037
+ 'cutoffs must not be empty', id='no-cutoffs'),
1038
+ pytest.param(np.ones((1, 3, 4)), [0.5, 0.0], [1], 'linear',
1039
+ 'cutoff must be positive', id='zero-cutoff'),
1040
+ pytest.param(np.ones((1, 3, 4)), [0.5], [], 'linear',
1041
+ 'cis_thresholds must not be empty', id='no-cis'),
1042
+ pytest.param(np.ones((1, 3, 4)), [0.5], [1, -1], 'linear',
1043
+ 'non-negative', id='negative-cis'),
1044
+ pytest.param(np.ones((1, 3, 3)), [0.5], [1], 'linear',
1045
+ 'two patches per binder', id='odd-patches'),
1046
+ pytest.param(np.ones((1, 3, 4)), [0.5], [2.7, 0.5], 'linear',
1047
+ 'whole number of bonds', id='fractional-cis'),
1048
+ pytest.param(np.ones((1, 3, 4)), [0.5], [1], 'star',
1049
+ 'topology', id='bad-topology'),
1050
+ ],
1051
+ )
1052
+ def test_threshold_sweep_rejects_bad_input(
1053
+ distances, cutoffs, cis_thresholds, topology, message
1054
+ ):
1055
+ """A malformed array, grid, or topology raises before any counting."""
1056
+ with pytest.raises(ValueError, match=message):
1057
+ threshold_sweep(distances, cutoffs, cis_thresholds, topology,
1058
+ inclusive=True)