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.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- 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)
|