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,1738 @@
|
|
|
1
|
+
"""
|
|
2
|
+
==========================================================
|
|
3
|
+
:mod:`polyphys.analyze.clusters`
|
|
4
|
+
==========================================================
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
The :mod:`polyphys.analyze.clusters` module turns the positions of a group of
|
|
8
|
+
particles in one frame into contact and cluster statistics, and the contacts
|
|
9
|
+
between a polymer and two-patch binding proteins into binding statistics.
|
|
10
|
+
Every routine is a pure NumPy or SciPy kernel on one frame, except
|
|
11
|
+
:func:`threshold_sweep`, which recounts saved frames over a grid of cutoffs;
|
|
12
|
+
reading frames, choosing the particles, and accumulating over frames are
|
|
13
|
+
otherwise the caller's work.
|
|
14
|
+
|
|
15
|
+
Notation
|
|
16
|
+
========
|
|
17
|
+
|
|
18
|
+
``n_particles``
|
|
19
|
+
The number of particles in the group, :math:`n`.
|
|
20
|
+
``positions``
|
|
21
|
+
Their coordinates, shape ``(n_particles, n_dims)``, in the caller's length
|
|
22
|
+
unit; every length below is in that unit.
|
|
23
|
+
``dist``
|
|
24
|
+
The matrix of center-to-center distances, shape
|
|
25
|
+
``(n_particles, n_particles)``, symmetric with a zero diagonal.
|
|
26
|
+
``cutoff``
|
|
27
|
+
A length :math:`r_c`. Two particles are in *direct contact* when their
|
|
28
|
+
center-to-center distance is at most :math:`r_c` (``inclusive=True``) or
|
|
29
|
+
below it (``inclusive=False``); this is the contact criterion of Sevick,
|
|
30
|
+
Monson, and Ottino (1988).
|
|
31
|
+
``direct_contacts``
|
|
32
|
+
The boolean *direct-contact matrix* :math:`A`, ``A[i, j]`` true when
|
|
33
|
+
particles ``i`` and ``j`` are in direct contact. For a self-distance
|
|
34
|
+
matrix it is symmetric and its diagonal is true, since every particle is
|
|
35
|
+
at distance zero from itself. It is the adjacency matrix of the *contact
|
|
36
|
+
graph*, whose vertices are the particles and whose edges are the direct
|
|
37
|
+
contacts; ``n_contacts`` is its number of edges, :math:`E`.
|
|
38
|
+
*cluster*
|
|
39
|
+
A connected component of the contact graph: a maximal set of particles
|
|
40
|
+
joined by chains of direct contacts. Its *size* is its number of
|
|
41
|
+
particles, from 1 to ``n_particles``.
|
|
42
|
+
``contact_matrix``
|
|
43
|
+
The boolean *contact matrix* :math:`C`, ``C[i, j]`` true when ``i`` and
|
|
44
|
+
``j`` are in the same cluster: the transitive closure of :math:`A`, block
|
|
45
|
+
all-ones after sorting the particles by cluster.
|
|
46
|
+
*bonds*
|
|
47
|
+
The number of other particles a particle is in direct contact with: its
|
|
48
|
+
degree in the contact graph, from 0 to ``n_particles - 1``.
|
|
49
|
+
*genomic distance*
|
|
50
|
+
For two monomers of one polymer, the number of backbone bonds between them
|
|
51
|
+
along the chain: ``|i - j|`` on a linear chain, and the shorter way round,
|
|
52
|
+
``min(|i - j|, n_backbone - |i - j|)``, on a ring of ``n_backbone``
|
|
53
|
+
monomers.
|
|
54
|
+
*patch*, *binder*
|
|
55
|
+
A binder is a protein with two binding sites, its *patches*; binder ``k``
|
|
56
|
+
owns patches ``2k`` and ``2k + 1``. A binder is *free* when neither patch
|
|
57
|
+
touches a monomer, *dangling* when one does, and *bridging* when both do.
|
|
58
|
+
A bridge joins two monomers; it is *cis* when their genomic distance is at
|
|
59
|
+
most ``cis_threshold`` bonds and *trans* when it is larger.
|
|
60
|
+
|
|
61
|
+
Contents
|
|
62
|
+
========
|
|
63
|
+
|
|
64
|
+
Distances and contacts:
|
|
65
|
+
:func:`pair_distances` builds ``dist`` from ``positions``, with the minimum
|
|
66
|
+
image in the periodic directions of an orthogonal box;
|
|
67
|
+
:func:`find_direct_contacts` applies the cutoff.
|
|
68
|
+
|
|
69
|
+
Clusters:
|
|
70
|
+
:func:`cluster_labels` labels the connected components of the contact graph
|
|
71
|
+
with :func:`scipy.sparse.csgraph.connected_components`;
|
|
72
|
+
:func:`count_clusters` histograms their sizes; :func:`contact_matrix` expands
|
|
73
|
+
the labels to :math:`C`; :func:`count_bonds` histograms the degrees.
|
|
74
|
+
|
|
75
|
+
Genomic distances and pair separations:
|
|
76
|
+
:func:`genomic_distance` and :func:`foci_pair_info` measure distances along
|
|
77
|
+
the backbone; :func:`pair_distance_histograms` bins the time series of pair
|
|
78
|
+
separations on one shared grid, and :func:`pair_separation_density` turns the
|
|
79
|
+
counts into the probability density of the separation vector.
|
|
80
|
+
|
|
81
|
+
Binding:
|
|
82
|
+
:func:`enforce_single_patch_contact`, :func:`monomer_binder_contacts`,
|
|
83
|
+
:func:`split_binders`, and :func:`binder_contacts` reshape a monomer-patch
|
|
84
|
+
contact matrix; :func:`binding_statistics` counts free, dangling, bridging,
|
|
85
|
+
cis, and trans binders in one frame as a :class:`FrameBinding`,
|
|
86
|
+
:class:`BindingStats` accumulates frames and the loop-length histogram, and
|
|
87
|
+
:func:`threshold_sweep` recounts the saved distance matrices of a whole at
|
|
88
|
+
every cutoff and cis threshold of a grid, the research code's
|
|
89
|
+
choice-of-threshold sweep.
|
|
90
|
+
|
|
91
|
+
Conventions
|
|
92
|
+
===========
|
|
93
|
+
|
|
94
|
+
- A contact matrix is boolean. Integer 0/1 input is accepted everywhere and
|
|
95
|
+
converted; any nonzero entry counts as a contact.
|
|
96
|
+
- Cluster labels are integers ``0, ..., n_clusters - 1``; which cluster gets
|
|
97
|
+
which label is an implementation detail of SciPy, so compare partitions
|
|
98
|
+
(``labels[i] == labels[j]``), not label values.
|
|
99
|
+
- Size and degree histograms are indexed by the size or degree itself:
|
|
100
|
+
``count_clusters(A)[s]`` is the number of clusters of size ``s`` and has
|
|
101
|
+
length ``n_particles + 1`` with ``[0]`` always 0; ``count_bonds(A)[d]`` is
|
|
102
|
+
the number of particles with ``d`` bonds and has length ``n_particles``.
|
|
103
|
+
- Genomic distances are in bonds, never in monomers between.
|
|
104
|
+
- Contact cutoffs, cis thresholds, and bin ranges are project constants and
|
|
105
|
+
have no defaults here.
|
|
106
|
+
- Complexities are stated per call, for one frame, in ``n_particles``
|
|
107
|
+
(:math:`n`), ``n_contacts`` (:math:`E`), and the other sizes named in each
|
|
108
|
+
docstring.
|
|
109
|
+
|
|
110
|
+
References
|
|
111
|
+
==========
|
|
112
|
+
|
|
113
|
+
- E. M. Sevick, P. A. Monson, and J. M. Ottino, "Monte Carlo calculations of
|
|
114
|
+
cluster statistics in continuum models of composite morphology",
|
|
115
|
+
J. Chem. Phys. 88, 1198-1206 (1988). https://doi.org/10.1063/1.454720
|
|
116
|
+
- J. Hopcroft and R. Tarjan, "Algorithm 447: efficient algorithms for graph
|
|
117
|
+
manipulation", Commun. ACM 16, 372-378 (1973).
|
|
118
|
+
https://doi.org/10.1145/362248.362272
|
|
119
|
+
- SciPy, :func:`scipy.sparse.csgraph.connected_components`.
|
|
120
|
+
https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csgraph.connected_components.html
|
|
121
|
+
- M. Rubinstein and R. H. Colby, Polymer Physics (Oxford University Press,
|
|
122
|
+
2003), Chapter 2.
|
|
123
|
+
|
|
124
|
+
.. versionadded:: 0.6.0
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
from dataclasses import dataclass, field
|
|
128
|
+
from typing import Literal, Sequence
|
|
129
|
+
|
|
130
|
+
import numpy as np
|
|
131
|
+
import numpy.typing as npt
|
|
132
|
+
import pandas as pd
|
|
133
|
+
from scipy.sparse import csr_array
|
|
134
|
+
from scipy.sparse.csgraph import connected_components
|
|
135
|
+
|
|
136
|
+
from .measurer import create_bin_edge_and_hist
|
|
137
|
+
|
|
138
|
+
__all__ = [
|
|
139
|
+
'BindingStats',
|
|
140
|
+
'FrameBinding',
|
|
141
|
+
'Topology',
|
|
142
|
+
'binder_contacts',
|
|
143
|
+
'binding_statistics',
|
|
144
|
+
'cluster_labels',
|
|
145
|
+
'contact_matrix',
|
|
146
|
+
'count_bonds',
|
|
147
|
+
'count_clusters',
|
|
148
|
+
'enforce_single_patch_contact',
|
|
149
|
+
'find_direct_contacts',
|
|
150
|
+
'foci_pair_info',
|
|
151
|
+
'genomic_distance',
|
|
152
|
+
'monomer_binder_contacts',
|
|
153
|
+
'pair_distance_histograms',
|
|
154
|
+
'pair_distances',
|
|
155
|
+
'pair_separation_density',
|
|
156
|
+
'split_binders',
|
|
157
|
+
'threshold_sweep',
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
Topology = Literal['linear', 'ring']
|
|
161
|
+
"""The polymer topologies the genomic distance is defined for."""
|
|
162
|
+
|
|
163
|
+
# The eight counts of a FrameBinding, in the order BindingStats.as_dict and
|
|
164
|
+
# threshold_sweep report them.
|
|
165
|
+
_BINDING_COUNTS = (
|
|
166
|
+
'n_patch_monomer_contacts', 'n_patches_engaged',
|
|
167
|
+
'n_patches_free', 'n_binders_free', 'n_binders_dangling',
|
|
168
|
+
'n_binders_bridging', 'n_pairs_cis', 'n_pairs_trans',
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _as_contacts(matrix: npt.ArrayLike, name: str) -> npt.NDArray[np.bool_]:
|
|
173
|
+
"""
|
|
174
|
+
Copy `matrix` to a two-dimensional boolean array.
|
|
175
|
+
|
|
176
|
+
Raises
|
|
177
|
+
------
|
|
178
|
+
ValueError
|
|
179
|
+
If `matrix` is not two-dimensional.
|
|
180
|
+
"""
|
|
181
|
+
contacts = np.asarray(matrix)
|
|
182
|
+
if contacts.ndim != 2:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f'{name} must be a two-dimensional matrix; got {contacts.ndim}'
|
|
185
|
+
' dimension(s).'
|
|
186
|
+
)
|
|
187
|
+
return contacts.astype(np.bool_, copy=False)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _check_topology(topology: str) -> None:
|
|
191
|
+
"""Raise ``ValueError`` unless `topology` is ``'linear'`` or ``'ring'``."""
|
|
192
|
+
if topology not in ('linear', 'ring'):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"topology must be 'linear' or 'ring'; got '{topology}'."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def pair_distances(
|
|
199
|
+
positions: npt.ArrayLike,
|
|
200
|
+
box: npt.ArrayLike | None = None,
|
|
201
|
+
) -> npt.NDArray[np.float64]:
|
|
202
|
+
"""
|
|
203
|
+
Compute the matrix of center-to-center distances between all pairs.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
positions : array_like
|
|
208
|
+
Coordinates of the particles, shape ``(n_particles, n_dims)``, in the
|
|
209
|
+
caller's length unit.
|
|
210
|
+
box : array_like, optional
|
|
211
|
+
Lengths of an orthogonal periodic box, shape ``(n_dims,)``, in the
|
|
212
|
+
same unit. A direction with length ``0`` or ``inf`` is not periodic.
|
|
213
|
+
With ``None``, no direction is periodic.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
numpy.ndarray
|
|
218
|
+
``dist``, shape ``(n_particles, n_particles)``, ``float64``: the
|
|
219
|
+
distance between particles ``i`` and ``j`` at ``[i, j]``; symmetric,
|
|
220
|
+
with a zero diagonal.
|
|
221
|
+
|
|
222
|
+
Raises
|
|
223
|
+
------
|
|
224
|
+
ValueError
|
|
225
|
+
If `positions` is not two-dimensional, if `box` has not one length
|
|
226
|
+
per dimension, or if a box length is negative or NaN.
|
|
227
|
+
|
|
228
|
+
Notes
|
|
229
|
+
-----
|
|
230
|
+
In a periodic direction of length :math:`L` the separation :math:`\\Delta`
|
|
231
|
+
is replaced by its minimum image, :math:`\\Delta - L\\,
|
|
232
|
+
\\mathrm{round}(\\Delta / L)`, which lies in :math:`[-L/2, L/2]`: the
|
|
233
|
+
distance to the nearest periodic image of the other particle. It is the
|
|
234
|
+
distance a contact criterion should use, provided the cutoff is below
|
|
235
|
+
:math:`L / 2`; for wrapped coordinates it is the only way to recover a
|
|
236
|
+
pair that straddles the boundary. Positions need not be wrapped.
|
|
237
|
+
|
|
238
|
+
Time is :math:`O(n^2 d)` for :math:`n` particles in :math:`d` dimensions.
|
|
239
|
+
Space is :math:`O(n^2)`: at most four ``(n, n)`` ``float64`` arrays at
|
|
240
|
+
once, the running sum of squares, the separation along one axis, and,
|
|
241
|
+
along a periodic axis, the temporaries of its minimum image. The
|
|
242
|
+
dimensions are accumulated one at a time, so no ``(d, n, n)`` temporary
|
|
243
|
+
is built.
|
|
244
|
+
|
|
245
|
+
Examples
|
|
246
|
+
--------
|
|
247
|
+
A 3-4-5 right triangle:
|
|
248
|
+
|
|
249
|
+
>>> import numpy as np
|
|
250
|
+
>>> from polyphys.analyze.clusters import pair_distances
|
|
251
|
+
>>> pair_distances([[0.0, 0.0], [3.0, 0.0], [3.0, 4.0]])
|
|
252
|
+
array([[0., 3., 5.],
|
|
253
|
+
[3., 0., 4.],
|
|
254
|
+
[5., 4., 0.]])
|
|
255
|
+
|
|
256
|
+
Two particles at ``x = 0.1`` and ``x = 9.9`` are ``0.2`` apart through
|
|
257
|
+
the boundary of a box of length ``10`` and ``9.8`` apart without it:
|
|
258
|
+
|
|
259
|
+
>>> positions = [[0.1, 0.0], [9.9, 0.0]]
|
|
260
|
+
>>> round(float(pair_distances(positions, box=[10.0, 0.0])[0, 1]), 10)
|
|
261
|
+
0.2
|
|
262
|
+
>>> round(float(pair_distances(positions)[0, 1]), 10)
|
|
263
|
+
9.8
|
|
264
|
+
|
|
265
|
+
.. versionadded:: 0.6.0
|
|
266
|
+
"""
|
|
267
|
+
pos = np.asarray(positions, dtype=np.float64)
|
|
268
|
+
if pos.ndim != 2:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
'positions must have shape (n_particles, n_dims); got'
|
|
271
|
+
f' {pos.ndim} dimension(s).'
|
|
272
|
+
)
|
|
273
|
+
n_particles, n_dims = pos.shape
|
|
274
|
+
if box is None:
|
|
275
|
+
lengths = np.zeros(n_dims)
|
|
276
|
+
else:
|
|
277
|
+
lengths = np.asarray(box, dtype=np.float64)
|
|
278
|
+
if lengths.shape != (n_dims,):
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f'box must have one length per dimension, shape ({n_dims},);'
|
|
281
|
+
f' got shape {lengths.shape}.'
|
|
282
|
+
)
|
|
283
|
+
if np.any(np.isnan(lengths)) or np.any(lengths < 0):
|
|
284
|
+
raise ValueError('box lengths must be non-negative numbers.')
|
|
285
|
+
dist_sq = np.zeros((n_particles, n_particles), dtype=np.float64)
|
|
286
|
+
for axis in range(n_dims):
|
|
287
|
+
delta = pos[:, axis, None] - pos[None, :, axis]
|
|
288
|
+
length = lengths[axis]
|
|
289
|
+
if 0 < length < np.inf:
|
|
290
|
+
delta -= length * np.round(delta / length)
|
|
291
|
+
dist_sq += delta * delta
|
|
292
|
+
return np.sqrt(dist_sq)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def find_direct_contacts(
|
|
296
|
+
dist: npt.ArrayLike,
|
|
297
|
+
cutoff: float,
|
|
298
|
+
inclusive: bool = True,
|
|
299
|
+
) -> npt.NDArray[np.bool_]:
|
|
300
|
+
"""
|
|
301
|
+
Mark the pairs whose distance is within a cutoff as direct contacts.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
dist : array_like
|
|
306
|
+
Center-to-center distances, shape ``(n_rows, n_cols)``, in the
|
|
307
|
+
caller's length unit: a self-distance matrix from
|
|
308
|
+
:func:`pair_distances`, or the distances between two groups, such as
|
|
309
|
+
monomers along the rows and binder patches along the columns.
|
|
310
|
+
cutoff : float
|
|
311
|
+
The contact distance :math:`r_c`, in the same unit; positive.
|
|
312
|
+
inclusive : bool, default True
|
|
313
|
+
Count a pair at exactly the cutoff as a contact (``dist <= cutoff``)
|
|
314
|
+
or not (``dist < cutoff``).
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
numpy.ndarray
|
|
319
|
+
``direct_contacts``, the same shape as `dist`, ``bool``. For a
|
|
320
|
+
self-distance matrix it is symmetric with a true diagonal.
|
|
321
|
+
|
|
322
|
+
Raises
|
|
323
|
+
------
|
|
324
|
+
ValueError
|
|
325
|
+
If `cutoff` is not positive or `dist` is not two-dimensional.
|
|
326
|
+
|
|
327
|
+
Notes
|
|
328
|
+
-----
|
|
329
|
+
This is the contact criterion of Sevick, Monson, and Ottino (1988),
|
|
330
|
+
J. Chem. Phys. 88, 1198, https://doi.org/10.1063/1.454720, for which the
|
|
331
|
+
clusters are the connected components of the resulting graph. The cutoff
|
|
332
|
+
is a project constant: in the foci projects it is the sum of a large
|
|
333
|
+
monomer's and a crowder's diameter, the largest separation at which no
|
|
334
|
+
crowder fits between the two.
|
|
335
|
+
|
|
336
|
+
Time and space are :math:`O(n_\\mathrm{rows} n_\\mathrm{cols})`.
|
|
337
|
+
|
|
338
|
+
Examples
|
|
339
|
+
--------
|
|
340
|
+
>>> import numpy as np
|
|
341
|
+
>>> from polyphys.analyze.clusters import find_direct_contacts
|
|
342
|
+
>>> dist = np.array([[0.0, 1.0, 2.5],
|
|
343
|
+
... [1.0, 0.0, 1.5],
|
|
344
|
+
... [2.5, 1.5, 0.0]])
|
|
345
|
+
>>> find_direct_contacts(dist, 1.5).astype(int)
|
|
346
|
+
array([[1, 1, 0],
|
|
347
|
+
[1, 1, 1],
|
|
348
|
+
[0, 1, 1]])
|
|
349
|
+
>>> find_direct_contacts(dist, 1.5, inclusive=False).astype(int)
|
|
350
|
+
array([[1, 1, 0],
|
|
351
|
+
[1, 1, 0],
|
|
352
|
+
[0, 0, 1]])
|
|
353
|
+
|
|
354
|
+
.. versionadded:: 0.6.0
|
|
355
|
+
"""
|
|
356
|
+
if not cutoff > 0:
|
|
357
|
+
raise ValueError(f'cutoff must be positive; got {cutoff}.')
|
|
358
|
+
distances = np.asarray(dist, dtype=np.float64)
|
|
359
|
+
if distances.ndim != 2:
|
|
360
|
+
raise ValueError(
|
|
361
|
+
f'dist must be a two-dimensional matrix; got {distances.ndim}'
|
|
362
|
+
' dimension(s).'
|
|
363
|
+
)
|
|
364
|
+
if inclusive:
|
|
365
|
+
return np.asarray(distances <= cutoff, dtype=np.bool_)
|
|
366
|
+
return np.asarray(distances < cutoff, dtype=np.bool_)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cluster_labels(
|
|
370
|
+
direct_contacts: npt.ArrayLike,
|
|
371
|
+
) -> tuple[int, npt.NDArray[np.int64]]:
|
|
372
|
+
"""
|
|
373
|
+
Label the clusters, the connected components of the contact graph.
|
|
374
|
+
|
|
375
|
+
Parameters
|
|
376
|
+
----------
|
|
377
|
+
direct_contacts : array_like
|
|
378
|
+
The direct-contact matrix :math:`A`, shape
|
|
379
|
+
``(n_particles, n_particles)``, boolean or 0/1, symmetric. The
|
|
380
|
+
diagonal is ignored.
|
|
381
|
+
|
|
382
|
+
Returns
|
|
383
|
+
-------
|
|
384
|
+
n_clusters : int
|
|
385
|
+
The number of clusters, from 0 (no particles) to ``n_particles``.
|
|
386
|
+
labels : numpy.ndarray
|
|
387
|
+
Shape ``(n_particles,)``, ``int64``: ``labels[i]`` is the cluster of
|
|
388
|
+
particle ``i``, in ``0, ..., n_clusters - 1``. Which cluster gets
|
|
389
|
+
which label is not specified; compare ``labels[i] == labels[j]``.
|
|
390
|
+
|
|
391
|
+
Raises
|
|
392
|
+
------
|
|
393
|
+
ValueError
|
|
394
|
+
If `direct_contacts` is not a square symmetric matrix.
|
|
395
|
+
|
|
396
|
+
Notes
|
|
397
|
+
-----
|
|
398
|
+
Two particles are in the same cluster when a chain of direct contacts
|
|
399
|
+
joins them, so the clusters are the connected components of the graph
|
|
400
|
+
with adjacency matrix :math:`A`. They are found with
|
|
401
|
+
:func:`scipy.sparse.csgraph.connected_components` (``directed=False``),
|
|
402
|
+
a depth-first search that runs in time and space proportional to
|
|
403
|
+
:math:`n + E` once the graph is sparse (Hopcroft and Tarjan, 1973,
|
|
404
|
+
Commun. ACM 16, 372, https://doi.org/10.1145/362248.362272). Building the
|
|
405
|
+
sparse graph from the dense matrix costs :math:`O(n^2)` time and
|
|
406
|
+
:math:`O(E)` space, so the call is :math:`O(n^2)` in all; the Python-level
|
|
407
|
+
merge of matrix columns it replaces was :math:`O(n^3)` and could stop
|
|
408
|
+
short of the closure.
|
|
409
|
+
|
|
410
|
+
Examples
|
|
411
|
+
--------
|
|
412
|
+
A path 0-1-2 and an isolated particle 3:
|
|
413
|
+
|
|
414
|
+
>>> import numpy as np
|
|
415
|
+
>>> from polyphys.analyze.clusters import cluster_labels
|
|
416
|
+
>>> contacts = np.array([[1, 1, 0, 0],
|
|
417
|
+
... [1, 1, 1, 0],
|
|
418
|
+
... [0, 1, 1, 0],
|
|
419
|
+
... [0, 0, 0, 1]])
|
|
420
|
+
>>> n_clusters, labels = cluster_labels(contacts)
|
|
421
|
+
>>> n_clusters
|
|
422
|
+
2
|
|
423
|
+
>>> bool(labels[0] == labels[2]), bool(labels[0] == labels[3])
|
|
424
|
+
(True, False)
|
|
425
|
+
|
|
426
|
+
.. versionadded:: 0.6.0
|
|
427
|
+
"""
|
|
428
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
429
|
+
n_particles = contacts.shape[0]
|
|
430
|
+
if contacts.shape != (n_particles, n_particles):
|
|
431
|
+
raise ValueError(
|
|
432
|
+
'direct_contacts must be square; got shape'
|
|
433
|
+
f' {contacts.shape}.'
|
|
434
|
+
)
|
|
435
|
+
if not np.array_equal(contacts, contacts.T):
|
|
436
|
+
raise ValueError('direct_contacts must be symmetric.')
|
|
437
|
+
if n_particles == 0:
|
|
438
|
+
return 0, np.zeros(0, dtype=np.int64)
|
|
439
|
+
n_clusters, labels = connected_components(
|
|
440
|
+
csr_array(contacts.astype(np.int8)),
|
|
441
|
+
directed=False,
|
|
442
|
+
return_labels=True,
|
|
443
|
+
)
|
|
444
|
+
return int(n_clusters), np.asarray(labels, dtype=np.int64)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def count_clusters(direct_contacts: npt.ArrayLike) -> npt.NDArray[np.int64]:
|
|
448
|
+
"""
|
|
449
|
+
Histogram the cluster sizes of one frame.
|
|
450
|
+
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
direct_contacts : array_like
|
|
454
|
+
The direct-contact matrix :math:`A`, shape
|
|
455
|
+
``(n_particles, n_particles)``, boolean or 0/1, symmetric.
|
|
456
|
+
|
|
457
|
+
Returns
|
|
458
|
+
-------
|
|
459
|
+
numpy.ndarray
|
|
460
|
+
Shape ``(n_particles + 1,)``, ``int64``: ``[s]`` is the number of
|
|
461
|
+
clusters of size ``s``. ``[0]`` is always 0, since no cluster is
|
|
462
|
+
empty, and ``sum(s * hist[s])`` is ``n_particles``.
|
|
463
|
+
|
|
464
|
+
Raises
|
|
465
|
+
------
|
|
466
|
+
ValueError
|
|
467
|
+
If `direct_contacts` is not a square symmetric matrix.
|
|
468
|
+
|
|
469
|
+
Notes
|
|
470
|
+
-----
|
|
471
|
+
The sizes are the multiplicities of the labels from
|
|
472
|
+
:func:`cluster_labels`, counted with :func:`numpy.bincount` twice: once to
|
|
473
|
+
get each cluster's size, once to histogram the sizes. This replaces
|
|
474
|
+
reading the sizes off the eigenvalues of the contact matrix, which is
|
|
475
|
+
:math:`O(n^3)` and meaningful only when that matrix is exactly block
|
|
476
|
+
all-ones. Time is :math:`O(n^2)`, dominated by :func:`cluster_labels`;
|
|
477
|
+
space is :math:`O(n + E)`.
|
|
478
|
+
|
|
479
|
+
Examples
|
|
480
|
+
--------
|
|
481
|
+
A path 0-1-2 and an isolated particle 3 give one cluster of size 3 and
|
|
482
|
+
one of size 1:
|
|
483
|
+
|
|
484
|
+
>>> import numpy as np
|
|
485
|
+
>>> from polyphys.analyze.clusters import count_clusters
|
|
486
|
+
>>> contacts = np.array([[1, 1, 0, 0],
|
|
487
|
+
... [1, 1, 1, 0],
|
|
488
|
+
... [0, 1, 1, 0],
|
|
489
|
+
... [0, 0, 0, 1]])
|
|
490
|
+
>>> count_clusters(contacts)
|
|
491
|
+
array([0, 1, 0, 1, 0])
|
|
492
|
+
|
|
493
|
+
.. versionadded:: 0.6.0
|
|
494
|
+
"""
|
|
495
|
+
_, labels = cluster_labels(direct_contacts)
|
|
496
|
+
n_particles = labels.shape[0]
|
|
497
|
+
sizes = np.bincount(labels)
|
|
498
|
+
return np.bincount(sizes, minlength=n_particles + 1).astype(np.int64)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def contact_matrix(direct_contacts: npt.ArrayLike) -> npt.NDArray[np.bool_]:
|
|
502
|
+
"""
|
|
503
|
+
Expand the direct contacts to the full contact matrix of the clusters.
|
|
504
|
+
|
|
505
|
+
Parameters
|
|
506
|
+
----------
|
|
507
|
+
direct_contacts : array_like
|
|
508
|
+
The direct-contact matrix :math:`A`, shape
|
|
509
|
+
``(n_particles, n_particles)``, boolean or 0/1, symmetric.
|
|
510
|
+
|
|
511
|
+
Returns
|
|
512
|
+
-------
|
|
513
|
+
numpy.ndarray
|
|
514
|
+
The contact matrix :math:`C`, the same shape, ``bool``: ``[i, j]``
|
|
515
|
+
is true when ``i`` and ``j`` are in the same cluster. Symmetric, with
|
|
516
|
+
a true diagonal; it is its own transitive closure, so
|
|
517
|
+
``(C @ C > 0) == C``.
|
|
518
|
+
|
|
519
|
+
Raises
|
|
520
|
+
------
|
|
521
|
+
ValueError
|
|
522
|
+
If `direct_contacts` is not a square symmetric matrix.
|
|
523
|
+
|
|
524
|
+
Notes
|
|
525
|
+
-----
|
|
526
|
+
:math:`C` is the transitive closure of :math:`A` in the sense of Sevick,
|
|
527
|
+
Monson, and Ottino (1988): ``C[i, j]`` is true when some chain of direct
|
|
528
|
+
contacts joins ``i`` to ``j``. It is built as ``labels[i] == labels[j]``
|
|
529
|
+
from :func:`cluster_labels`. Time is :math:`O(n^2)`; space is one
|
|
530
|
+
``(n, n)`` boolean array.
|
|
531
|
+
|
|
532
|
+
Examples
|
|
533
|
+
--------
|
|
534
|
+
>>> import numpy as np
|
|
535
|
+
>>> from polyphys.analyze.clusters import contact_matrix
|
|
536
|
+
>>> contacts = np.array([[1, 1, 0, 0],
|
|
537
|
+
... [1, 1, 1, 0],
|
|
538
|
+
... [0, 1, 1, 0],
|
|
539
|
+
... [0, 0, 0, 1]])
|
|
540
|
+
>>> contact_matrix(contacts).astype(int)
|
|
541
|
+
array([[1, 1, 1, 0],
|
|
542
|
+
[1, 1, 1, 0],
|
|
543
|
+
[1, 1, 1, 0],
|
|
544
|
+
[0, 0, 0, 1]])
|
|
545
|
+
|
|
546
|
+
.. versionadded:: 0.6.0
|
|
547
|
+
"""
|
|
548
|
+
_, labels = cluster_labels(direct_contacts)
|
|
549
|
+
return np.asarray(labels[:, None] == labels[None, :], dtype=np.bool_)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def count_bonds(direct_contacts: npt.ArrayLike) -> npt.NDArray[np.int64]:
|
|
553
|
+
"""
|
|
554
|
+
Histogram the number of bonds, the direct contacts per particle.
|
|
555
|
+
|
|
556
|
+
Parameters
|
|
557
|
+
----------
|
|
558
|
+
direct_contacts : array_like
|
|
559
|
+
The direct-contact matrix :math:`A`, shape
|
|
560
|
+
``(n_particles, n_particles)``, boolean or 0/1, symmetric. A true
|
|
561
|
+
diagonal is allowed and does not count as a bond.
|
|
562
|
+
|
|
563
|
+
Returns
|
|
564
|
+
-------
|
|
565
|
+
numpy.ndarray
|
|
566
|
+
Shape ``(n_particles,)``, ``int64``: ``[d]`` is the number of
|
|
567
|
+
particles with ``d`` bonds, ``d`` from 0 to ``n_particles - 1``.
|
|
568
|
+
``sum(hist)`` is ``n_particles`` and ``sum(d * hist[d])`` is twice
|
|
569
|
+
the number of direct contacts.
|
|
570
|
+
|
|
571
|
+
Raises
|
|
572
|
+
------
|
|
573
|
+
ValueError
|
|
574
|
+
If `direct_contacts` is not a square symmetric matrix.
|
|
575
|
+
|
|
576
|
+
Notes
|
|
577
|
+
-----
|
|
578
|
+
A particle's bonds are its degree in the contact graph: its row sum
|
|
579
|
+
minus its diagonal entry. Time and space are :math:`O(n^2)`.
|
|
580
|
+
|
|
581
|
+
Examples
|
|
582
|
+
--------
|
|
583
|
+
A star: particle 0 touches 1, 2, and 3, which touch nothing else, so one
|
|
584
|
+
particle has three bonds and three have one.
|
|
585
|
+
|
|
586
|
+
>>> import numpy as np
|
|
587
|
+
>>> from polyphys.analyze.clusters import count_bonds
|
|
588
|
+
>>> star = np.array([[1, 1, 1, 1],
|
|
589
|
+
... [1, 1, 0, 0],
|
|
590
|
+
... [1, 0, 1, 0],
|
|
591
|
+
... [1, 0, 0, 1]])
|
|
592
|
+
>>> count_bonds(star)
|
|
593
|
+
array([0, 3, 0, 1])
|
|
594
|
+
|
|
595
|
+
.. versionadded:: 0.6.0
|
|
596
|
+
"""
|
|
597
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
598
|
+
n_particles = contacts.shape[0]
|
|
599
|
+
if contacts.shape != (n_particles, n_particles):
|
|
600
|
+
raise ValueError(
|
|
601
|
+
'direct_contacts must be square; got shape'
|
|
602
|
+
f' {contacts.shape}.'
|
|
603
|
+
)
|
|
604
|
+
if not np.array_equal(contacts, contacts.T):
|
|
605
|
+
raise ValueError('direct_contacts must be symmetric.')
|
|
606
|
+
degrees = contacts.sum(axis=1) - np.diagonal(contacts).astype(np.int64)
|
|
607
|
+
return np.bincount(degrees, minlength=n_particles).astype(np.int64)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def genomic_distance(
|
|
611
|
+
atoms_a: npt.ArrayLike,
|
|
612
|
+
atoms_b: npt.ArrayLike,
|
|
613
|
+
topology: Topology,
|
|
614
|
+
n_backbone: int,
|
|
615
|
+
) -> npt.NDArray[np.int64]:
|
|
616
|
+
"""
|
|
617
|
+
Count the backbone bonds between paired monomers along the chain.
|
|
618
|
+
|
|
619
|
+
Parameters
|
|
620
|
+
----------
|
|
621
|
+
atoms_a, atoms_b : array_like
|
|
622
|
+
Backbone indices of the paired monomers, integers in
|
|
623
|
+
``0, ..., n_backbone - 1``, in any two shapes that broadcast
|
|
624
|
+
together.
|
|
625
|
+
topology : {'linear', 'ring'}
|
|
626
|
+
Whether the chain is open or closed.
|
|
627
|
+
n_backbone : int
|
|
628
|
+
The number of monomers along the backbone; positive.
|
|
629
|
+
|
|
630
|
+
Returns
|
|
631
|
+
-------
|
|
632
|
+
numpy.ndarray
|
|
633
|
+
The genomic distance of each pair, the broadcast shape of the inputs,
|
|
634
|
+
``int64``, in bonds: ``|a - b|`` on a linear chain and
|
|
635
|
+
``min(|a - b|, n_backbone - |a - b|)`` on a ring, the shorter way
|
|
636
|
+
round. The number of monomers between the pair is one less.
|
|
637
|
+
|
|
638
|
+
Raises
|
|
639
|
+
------
|
|
640
|
+
ValueError
|
|
641
|
+
If `topology` is not ``'linear'`` or ``'ring'``, if `n_backbone` is
|
|
642
|
+
not positive, or if an index is outside ``0, ..., n_backbone - 1``.
|
|
643
|
+
|
|
644
|
+
Notes
|
|
645
|
+
-----
|
|
646
|
+
Time and space are :math:`O(m)` for :math:`m` pairs.
|
|
647
|
+
|
|
648
|
+
Examples
|
|
649
|
+
--------
|
|
650
|
+
On a ring of 10 monomers, 0 and 9 are neighbors:
|
|
651
|
+
|
|
652
|
+
>>> from polyphys.analyze.clusters import genomic_distance
|
|
653
|
+
>>> genomic_distance([0, 1], [3, 9], 'linear', 10)
|
|
654
|
+
array([3, 8])
|
|
655
|
+
>>> genomic_distance([0, 1], [3, 9], 'ring', 10)
|
|
656
|
+
array([3, 2])
|
|
657
|
+
|
|
658
|
+
.. versionadded:: 0.6.0
|
|
659
|
+
"""
|
|
660
|
+
_check_topology(topology)
|
|
661
|
+
if n_backbone < 1:
|
|
662
|
+
raise ValueError(f'n_backbone must be positive; got {n_backbone}.')
|
|
663
|
+
a = np.asarray(atoms_a, dtype=np.int64)
|
|
664
|
+
b = np.asarray(atoms_b, dtype=np.int64)
|
|
665
|
+
for name, idx in (('atoms_a', a), ('atoms_b', b)):
|
|
666
|
+
if idx.size and (idx.min() < 0 or idx.max() >= n_backbone):
|
|
667
|
+
raise ValueError(
|
|
668
|
+
f'{name} must hold indices in 0, ..., {n_backbone - 1}.'
|
|
669
|
+
)
|
|
670
|
+
separation = np.abs(a - b)
|
|
671
|
+
if topology == 'ring':
|
|
672
|
+
separation = np.minimum(separation, n_backbone - separation)
|
|
673
|
+
return np.asarray(separation, dtype=np.int64)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def foci_pair_info(
|
|
677
|
+
genomic_pos: npt.ArrayLike,
|
|
678
|
+
n_monomers: int,
|
|
679
|
+
topology: Topology,
|
|
680
|
+
) -> npt.NDArray[np.int64]:
|
|
681
|
+
"""
|
|
682
|
+
List every pair of foci with its genomic distance.
|
|
683
|
+
|
|
684
|
+
Parameters
|
|
685
|
+
----------
|
|
686
|
+
genomic_pos : array_like
|
|
687
|
+
Backbone indices of the foci, the tagged monomers, shape
|
|
688
|
+
``(n_foci,)``, integers in ``0, ..., n_monomers - 1``.
|
|
689
|
+
n_monomers : int
|
|
690
|
+
The number of monomers along the backbone, foci included.
|
|
691
|
+
topology : {'linear', 'ring'}
|
|
692
|
+
Whether the chain is open or closed.
|
|
693
|
+
|
|
694
|
+
Returns
|
|
695
|
+
-------
|
|
696
|
+
numpy.ndarray
|
|
697
|
+
Shape ``(n_pairs, 3)``, ``int64``, one row per pair in the order of
|
|
698
|
+
``numpy.triu_indices(n_foci, 1)``: the two backbone indices and their
|
|
699
|
+
genomic distance in bonds. ``n_pairs`` is ``n_foci (n_foci - 1) / 2``.
|
|
700
|
+
|
|
701
|
+
Raises
|
|
702
|
+
------
|
|
703
|
+
ValueError
|
|
704
|
+
As :func:`genomic_distance`, or if `genomic_pos` is not
|
|
705
|
+
one-dimensional.
|
|
706
|
+
|
|
707
|
+
Notes
|
|
708
|
+
-----
|
|
709
|
+
The row order matches the upper triangle of a foci distance matrix, so
|
|
710
|
+
``dist[np.triu_indices(n_foci, 1)]`` gives the separations in the same
|
|
711
|
+
order. Time and space are :math:`O(n_\\mathrm{foci}^2)`.
|
|
712
|
+
|
|
713
|
+
Examples
|
|
714
|
+
--------
|
|
715
|
+
Three foci on a ring of 10 monomers:
|
|
716
|
+
|
|
717
|
+
>>> from polyphys.analyze.clusters import foci_pair_info
|
|
718
|
+
>>> foci_pair_info([0, 4, 8], 10, 'ring')
|
|
719
|
+
array([[0, 4, 4],
|
|
720
|
+
[0, 8, 2],
|
|
721
|
+
[4, 8, 4]])
|
|
722
|
+
|
|
723
|
+
.. versionadded:: 0.6.0
|
|
724
|
+
"""
|
|
725
|
+
positions = np.asarray(genomic_pos, dtype=np.int64)
|
|
726
|
+
if positions.ndim != 1:
|
|
727
|
+
raise ValueError(
|
|
728
|
+
'genomic_pos must be one-dimensional; got'
|
|
729
|
+
f' {positions.ndim} dimension(s).'
|
|
730
|
+
)
|
|
731
|
+
first, second = np.triu_indices(positions.shape[0], 1)
|
|
732
|
+
pos_a = positions[first]
|
|
733
|
+
pos_b = positions[second]
|
|
734
|
+
distance = genomic_distance(pos_a, pos_b, topology, n_monomers)
|
|
735
|
+
return np.column_stack((pos_a, pos_b, distance)).astype(np.int64)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def pair_distance_histograms(
|
|
739
|
+
distances: npt.ArrayLike,
|
|
740
|
+
bin_size: float,
|
|
741
|
+
r_max: float,
|
|
742
|
+
) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]:
|
|
743
|
+
"""
|
|
744
|
+
Histogram the separation time series of each pair on one shared grid.
|
|
745
|
+
|
|
746
|
+
Parameters
|
|
747
|
+
----------
|
|
748
|
+
distances : array_like
|
|
749
|
+
Pair separations over the frames, shape ``(n_pairs, n_frames)`` or
|
|
750
|
+
``(n_frames,)`` for one pair, in the caller's length unit;
|
|
751
|
+
non-negative and finite.
|
|
752
|
+
bin_size : float
|
|
753
|
+
The bin width :math:`\\Delta r`, positive.
|
|
754
|
+
r_max : float
|
|
755
|
+
The upper end of the grid, positive. The grid runs from 0 to the
|
|
756
|
+
first multiple of `bin_size` at or above `r_max`, so every pair,
|
|
757
|
+
whole, and ensemble shares the same bins; a range that is a whole
|
|
758
|
+
number of bins up to float64 round-off ends exactly at `r_max`, as
|
|
759
|
+
:func:`polyphys.analyze.measurer.create_bin_edge_and_hist` gives.
|
|
760
|
+
|
|
761
|
+
Returns
|
|
762
|
+
-------
|
|
763
|
+
counts : numpy.ndarray
|
|
764
|
+
Shape ``(n_pairs, n_bins)``, or ``(n_bins,)`` for a one-dimensional
|
|
765
|
+
input, ``int64``: the number of frames whose separation falls in each
|
|
766
|
+
bin. Each row sums to ``n_frames``.
|
|
767
|
+
edges : numpy.ndarray
|
|
768
|
+
Shape ``(n_bins + 1,)``, ``float64``: the bin edges, equally
|
|
769
|
+
spaced ``bin_size`` apart from 0. Bin ``k`` is
|
|
770
|
+
``[edges[k], edges[k + 1])``, except the last, which also includes
|
|
771
|
+
its upper edge, as
|
|
772
|
+
:func:`numpy.histogram` does.
|
|
773
|
+
|
|
774
|
+
Raises
|
|
775
|
+
------
|
|
776
|
+
ValueError
|
|
777
|
+
If `bin_size` or `r_max` is not positive, if a separation is
|
|
778
|
+
negative, NaN, or above the last edge, or if `distances` has more
|
|
779
|
+
than two dimensions.
|
|
780
|
+
|
|
781
|
+
Notes
|
|
782
|
+
-----
|
|
783
|
+
A separation above the grid raises rather than being dropped, because a
|
|
784
|
+
dropped frame would silently break the normalization of
|
|
785
|
+
:func:`pair_separation_density`. Choose `r_max` from the system, such as
|
|
786
|
+
the largest separation the box allows, not from the data: a grid set by
|
|
787
|
+
each whole's own maximum gives every whole a different number of bins,
|
|
788
|
+
and an average over wholes is then taken over different sets of wholes
|
|
789
|
+
in different bins.
|
|
790
|
+
|
|
791
|
+
Time is :math:`O(n_\\mathrm{pairs} n_\\mathrm{frames} \\log
|
|
792
|
+
n_\\mathrm{bins})`, from a binary search of the edges per frame; space is
|
|
793
|
+
:math:`O(n_\\mathrm{pairs} (n_\\mathrm{frames} + n_\\mathrm{bins}))`.
|
|
794
|
+
|
|
795
|
+
Examples
|
|
796
|
+
--------
|
|
797
|
+
>>> from polyphys.analyze.clusters import pair_distance_histograms
|
|
798
|
+
>>> counts, edges = pair_distance_histograms(
|
|
799
|
+
... [[0.5, 1.5, 1.5, 3.0], [2.0, 2.0, 2.0, 2.0]], bin_size=1.0,
|
|
800
|
+
... r_max=3.0)
|
|
801
|
+
>>> counts
|
|
802
|
+
array([[1, 2, 1],
|
|
803
|
+
[0, 0, 4]])
|
|
804
|
+
>>> edges
|
|
805
|
+
array([0., 1., 2., 3.])
|
|
806
|
+
|
|
807
|
+
.. versionadded:: 0.6.0
|
|
808
|
+
.. versionchanged:: 0.9.0
|
|
809
|
+
Count the bins with the round-off tolerance of
|
|
810
|
+
:func:`polyphys.analyze.measurer.create_bin_edge_and_hist`.
|
|
811
|
+
"""
|
|
812
|
+
if not bin_size > 0:
|
|
813
|
+
raise ValueError(f'bin_size must be positive; got {bin_size}.')
|
|
814
|
+
if not r_max > 0:
|
|
815
|
+
raise ValueError(f'r_max must be positive; got {r_max}.')
|
|
816
|
+
series = np.asarray(distances, dtype=np.float64)
|
|
817
|
+
if series.ndim > 2:
|
|
818
|
+
raise ValueError(
|
|
819
|
+
'distances must have shape (n_pairs, n_frames) or (n_frames,);'
|
|
820
|
+
f' got {series.ndim} dimensions.'
|
|
821
|
+
)
|
|
822
|
+
one_pair = series.ndim == 1
|
|
823
|
+
rows = series.reshape(1, -1) if one_pair else series
|
|
824
|
+
n_pairs, n_frames = rows.shape
|
|
825
|
+
# The bin count tolerates float64 round-off in r_max / bin_size, as
|
|
826
|
+
# every fixed-width grid of the package does, so a range of a whole
|
|
827
|
+
# number of bins ends exactly at r_max.
|
|
828
|
+
edges, _ = create_bin_edge_and_hist(bin_size, 0.0, r_max)
|
|
829
|
+
n_bins = edges.size - 1
|
|
830
|
+
if np.any(np.isnan(rows)) or np.any(rows < 0) or np.any(rows > edges[-1]):
|
|
831
|
+
raise ValueError(
|
|
832
|
+
f'distances must lie in [0, {edges[-1]}], the histogram grid.'
|
|
833
|
+
)
|
|
834
|
+
# Bin k holds edges[k] <= r < edges[k + 1]; a separation on the last edge
|
|
835
|
+
# goes into the last bin, as numpy.histogram does.
|
|
836
|
+
bins = np.searchsorted(edges, rows, side='right') - 1
|
|
837
|
+
bins = np.minimum(bins, n_bins - 1)
|
|
838
|
+
flat = bins + n_bins * np.arange(n_pairs)[:, None]
|
|
839
|
+
flat_counts = np.bincount(flat.ravel(), minlength=n_pairs * n_bins)
|
|
840
|
+
counts = flat_counts.reshape(n_pairs, n_bins).astype(np.int64)
|
|
841
|
+
if one_pair:
|
|
842
|
+
return counts[0], edges
|
|
843
|
+
return counts, edges
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def pair_separation_density(
|
|
847
|
+
counts: npt.ArrayLike,
|
|
848
|
+
edges: npt.ArrayLike,
|
|
849
|
+
) -> npt.NDArray[np.float64]:
|
|
850
|
+
"""
|
|
851
|
+
Estimate the probability density of a pair's separation vector.
|
|
852
|
+
|
|
853
|
+
Parameters
|
|
854
|
+
----------
|
|
855
|
+
counts : array_like
|
|
856
|
+
Histogram counts from :func:`pair_distance_histograms`, shape
|
|
857
|
+
``(..., n_bins)``, one row per pair; every row must have a positive
|
|
858
|
+
total.
|
|
859
|
+
edges : array_like
|
|
860
|
+
The bin edges, shape ``(n_bins + 1,)``, non-negative and strictly
|
|
861
|
+
increasing, in the caller's length unit.
|
|
862
|
+
|
|
863
|
+
Returns
|
|
864
|
+
-------
|
|
865
|
+
numpy.ndarray
|
|
866
|
+
:math:`\\bar P(r)`, the same shape as `counts`, ``float64``, in units
|
|
867
|
+
of one over the length unit cubed. For each row,
|
|
868
|
+
``sum(density * shell_volume)`` is 1.
|
|
869
|
+
|
|
870
|
+
Raises
|
|
871
|
+
------
|
|
872
|
+
ValueError
|
|
873
|
+
If `edges` is not a strictly increasing non-negative vector of length
|
|
874
|
+
``n_bins + 1``, or if a row of `counts` is all zero.
|
|
875
|
+
|
|
876
|
+
Notes
|
|
877
|
+
-----
|
|
878
|
+
Let :math:`\\vec R` be the separation vector of one pair of monomers and
|
|
879
|
+
:math:`P(\\vec R)` its probability density over the chain's
|
|
880
|
+
configurations, :math:`\\int P\\, d^3R = 1`. The scalar separation
|
|
881
|
+
:math:`R = |\\vec R|` has density :math:`P(R) = 4\\pi R^2 \\bar P(R)`,
|
|
882
|
+
where :math:`\\bar P(R)` is the average of :math:`P(\\vec R)` over the
|
|
883
|
+
directions of :math:`\\vec R`; the factor :math:`4\\pi R^2` is the
|
|
884
|
+
Jacobian of spherical coordinates, nothing else. With :math:`H_k` frames
|
|
885
|
+
out of :math:`N_f` in the shell between :math:`R_k` and :math:`R_{k+1}`,
|
|
886
|
+
whose volume is :math:`\\Delta V_k = \\tfrac{4\\pi}{3}(R_{k+1}^3 -
|
|
887
|
+
R_k^3)`,
|
|
888
|
+
|
|
889
|
+
.. math::
|
|
890
|
+
|
|
891
|
+
\\bar P_k = \\frac{H_k}{N_f\\, \\Delta V_k},
|
|
892
|
+
\\qquad \\sum_k \\bar P_k\\, \\Delta V_k = 1 .
|
|
893
|
+
|
|
894
|
+
:math:`-k_B T \\ln \\bar P(R)` is the free energy of the pair at
|
|
895
|
+
separation :math:`R`, up to a constant; for an ideal chain of :math:`n`
|
|
896
|
+
Kuhn segments of length :math:`b`, :math:`P(\\vec R)` is the Gaussian
|
|
897
|
+
:math:`(3 / 2\\pi n b^2)^{3/2} \\exp(-3R^2 / 2 n b^2)` (Rubinstein and
|
|
898
|
+
Colby, 2003, Chapter 2), a reference the estimate can be compared with
|
|
899
|
+
directly, since it carries no free constant. The estimate itself assumes
|
|
900
|
+
no chain model and no geometry: the accessible volume of a confinement
|
|
901
|
+
shapes :math:`\\bar P`, not its definition. The exact shell volume is
|
|
902
|
+
used rather than :math:`4\\pi R_c^2 \\Delta r` at the bin center, which
|
|
903
|
+
is 25% too small in the first bin. Time and space are
|
|
904
|
+
:math:`O(n_\\mathrm{pairs} n_\\mathrm{bins})`.
|
|
905
|
+
|
|
906
|
+
Examples
|
|
907
|
+
--------
|
|
908
|
+
Four frames all in the shell between 2 and 3: the density is one over
|
|
909
|
+
that shell's volume, :math:`\\tfrac{4\\pi}{3}(27 - 8)`.
|
|
910
|
+
|
|
911
|
+
>>> import numpy as np
|
|
912
|
+
>>> from polyphys.analyze.clusters import pair_separation_density
|
|
913
|
+
>>> density = pair_separation_density([0, 0, 4], [0.0, 1.0, 2.0, 3.0])
|
|
914
|
+
>>> np.round(density, 5)
|
|
915
|
+
array([0. , 0. , 0.01256])
|
|
916
|
+
>>> shells = 4 * np.pi / 3 * np.diff(np.array([0.0, 1.0, 2.0, 3.0])**3)
|
|
917
|
+
>>> float(np.sum(density * shells))
|
|
918
|
+
1.0
|
|
919
|
+
|
|
920
|
+
.. versionadded:: 0.6.0
|
|
921
|
+
"""
|
|
922
|
+
hist = np.asarray(counts, dtype=np.float64)
|
|
923
|
+
bounds = np.asarray(edges, dtype=np.float64)
|
|
924
|
+
if bounds.ndim != 1 or bounds.shape[0] != hist.shape[-1] + 1:
|
|
925
|
+
raise ValueError(
|
|
926
|
+
'edges must have length n_bins + 1 ='
|
|
927
|
+
f' {hist.shape[-1] + 1}; got shape {bounds.shape}.'
|
|
928
|
+
)
|
|
929
|
+
if bounds[0] < 0 or np.any(np.diff(bounds) <= 0):
|
|
930
|
+
raise ValueError(
|
|
931
|
+
'edges must be non-negative and strictly increasing.'
|
|
932
|
+
)
|
|
933
|
+
n_frames = hist.sum(axis=-1, keepdims=True)
|
|
934
|
+
if np.any(n_frames <= 0):
|
|
935
|
+
raise ValueError('every row of counts must have a positive total.')
|
|
936
|
+
shell_volume = 4.0 * np.pi / 3.0 * np.diff(bounds**3)
|
|
937
|
+
return np.asarray(hist / (n_frames * shell_volume), dtype=np.float64)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def enforce_single_patch_contact(
|
|
941
|
+
direct_contacts: npt.ArrayLike,
|
|
942
|
+
) -> npt.NDArray[np.bool_]:
|
|
943
|
+
"""
|
|
944
|
+
Keep at most one monomer per patch: the lowest-index one.
|
|
945
|
+
|
|
946
|
+
Parameters
|
|
947
|
+
----------
|
|
948
|
+
direct_contacts : array_like
|
|
949
|
+
The monomer-patch contact matrix, shape ``(n_monomers, n_patches)``,
|
|
950
|
+
boolean or 0/1: ``[i, j]`` true when monomer ``i`` touches patch
|
|
951
|
+
``j``.
|
|
952
|
+
|
|
953
|
+
Returns
|
|
954
|
+
-------
|
|
955
|
+
numpy.ndarray
|
|
956
|
+
The same shape, ``bool``, with every column reduced to its first true
|
|
957
|
+
entry; columns without a contact stay empty.
|
|
958
|
+
|
|
959
|
+
Raises
|
|
960
|
+
------
|
|
961
|
+
ValueError
|
|
962
|
+
If `direct_contacts` is not two-dimensional.
|
|
963
|
+
|
|
964
|
+
Notes
|
|
965
|
+
-----
|
|
966
|
+
A patch is a single binding site and can hold one monomer, but a
|
|
967
|
+
distance cutoff occasionally marks two or three adjacent monomers as its
|
|
968
|
+
contacts. The project rule keeps the one with the lowest backbone index
|
|
969
|
+
and treats the rest as artifacts. Applying the rule twice changes
|
|
970
|
+
nothing. Time and space are :math:`O(n_\\mathrm{monomers}
|
|
971
|
+
n_\\mathrm{patches})`.
|
|
972
|
+
|
|
973
|
+
Examples
|
|
974
|
+
--------
|
|
975
|
+
Patch 0 touches monomers 1 and 2; only monomer 1 is kept.
|
|
976
|
+
|
|
977
|
+
>>> import numpy as np
|
|
978
|
+
>>> from polyphys.analyze.clusters import enforce_single_patch_contact
|
|
979
|
+
>>> contacts = np.array([[0, 1],
|
|
980
|
+
... [1, 0],
|
|
981
|
+
... [1, 0]])
|
|
982
|
+
>>> enforce_single_patch_contact(contacts).astype(int)
|
|
983
|
+
array([[0, 1],
|
|
984
|
+
[1, 0],
|
|
985
|
+
[0, 0]])
|
|
986
|
+
|
|
987
|
+
.. versionadded:: 0.6.0
|
|
988
|
+
"""
|
|
989
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
990
|
+
n_monomers = contacts.shape[0]
|
|
991
|
+
first = np.argmax(contacts, axis=0)
|
|
992
|
+
engaged = contacts.any(axis=0)
|
|
993
|
+
rows = np.arange(n_monomers)[:, None]
|
|
994
|
+
return np.asarray((rows == first[None, :]) & engaged[None, :],
|
|
995
|
+
dtype=np.bool_)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def monomer_binder_contacts(
|
|
999
|
+
direct_contacts: npt.ArrayLike,
|
|
1000
|
+
patches_per_binder: int,
|
|
1001
|
+
) -> npt.NDArray[np.bool_]:
|
|
1002
|
+
"""
|
|
1003
|
+
Collapse the patches of each binder into one column.
|
|
1004
|
+
|
|
1005
|
+
Parameters
|
|
1006
|
+
----------
|
|
1007
|
+
direct_contacts : array_like
|
|
1008
|
+
The monomer-patch contact matrix, shape ``(n_monomers, n_patches)``,
|
|
1009
|
+
boolean or 0/1, with the patches of binder ``k`` in columns
|
|
1010
|
+
``k * patches_per_binder`` to ``(k + 1) * patches_per_binder - 1``.
|
|
1011
|
+
patches_per_binder : int
|
|
1012
|
+
The number of patches per binder; positive and dividing
|
|
1013
|
+
``n_patches``.
|
|
1014
|
+
|
|
1015
|
+
Returns
|
|
1016
|
+
-------
|
|
1017
|
+
numpy.ndarray
|
|
1018
|
+
The monomer-binder contact matrix, shape ``(n_monomers, n_binders)``,
|
|
1019
|
+
``bool``: ``[i, k]`` true when monomer ``i`` touches any patch of
|
|
1020
|
+
binder ``k``.
|
|
1021
|
+
|
|
1022
|
+
Raises
|
|
1023
|
+
------
|
|
1024
|
+
ValueError
|
|
1025
|
+
If `direct_contacts` is not two-dimensional, or if
|
|
1026
|
+
`patches_per_binder` is not positive or does not divide the number of
|
|
1027
|
+
columns.
|
|
1028
|
+
|
|
1029
|
+
Notes
|
|
1030
|
+
-----
|
|
1031
|
+
Time and space are :math:`O(n_\\mathrm{monomers} n_\\mathrm{patches})`.
|
|
1032
|
+
|
|
1033
|
+
Examples
|
|
1034
|
+
--------
|
|
1035
|
+
>>> import numpy as np
|
|
1036
|
+
>>> from polyphys.analyze.clusters import monomer_binder_contacts
|
|
1037
|
+
>>> contacts = np.array([[1, 0, 0, 0],
|
|
1038
|
+
... [0, 1, 0, 0],
|
|
1039
|
+
... [0, 0, 0, 1]])
|
|
1040
|
+
>>> monomer_binder_contacts(contacts, 2).astype(int)
|
|
1041
|
+
array([[1, 0],
|
|
1042
|
+
[1, 0],
|
|
1043
|
+
[0, 1]])
|
|
1044
|
+
|
|
1045
|
+
.. versionadded:: 0.6.0
|
|
1046
|
+
"""
|
|
1047
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
1048
|
+
n_monomers, n_patches = contacts.shape
|
|
1049
|
+
if patches_per_binder < 1:
|
|
1050
|
+
raise ValueError(
|
|
1051
|
+
f'patches_per_binder must be positive; got {patches_per_binder}.'
|
|
1052
|
+
)
|
|
1053
|
+
if n_patches % patches_per_binder != 0:
|
|
1054
|
+
raise ValueError(
|
|
1055
|
+
f'the number of columns, {n_patches}, must be a multiple of'
|
|
1056
|
+
f' patches_per_binder, {patches_per_binder}.'
|
|
1057
|
+
)
|
|
1058
|
+
n_binders = n_patches // patches_per_binder
|
|
1059
|
+
grouped = contacts.reshape(n_monomers, n_binders, patches_per_binder)
|
|
1060
|
+
return np.asarray(grouped.any(axis=2), dtype=np.bool_)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def split_binders(
|
|
1064
|
+
direct_contacts: npt.ArrayLike,
|
|
1065
|
+
) -> tuple[npt.NDArray[np.bool_], npt.NDArray[np.bool_]]:
|
|
1066
|
+
"""
|
|
1067
|
+
Separate the dangling binders from the bridging ones.
|
|
1068
|
+
|
|
1069
|
+
Parameters
|
|
1070
|
+
----------
|
|
1071
|
+
direct_contacts : array_like
|
|
1072
|
+
The monomer-binder contact matrix, shape ``(n_monomers, n_binders)``,
|
|
1073
|
+
boolean or 0/1, as :func:`monomer_binder_contacts` returns it.
|
|
1074
|
+
|
|
1075
|
+
Returns
|
|
1076
|
+
-------
|
|
1077
|
+
dangling : numpy.ndarray
|
|
1078
|
+
The same shape, ``bool``: the columns that touch exactly one monomer,
|
|
1079
|
+
every other column cleared.
|
|
1080
|
+
bridging : numpy.ndarray
|
|
1081
|
+
The same shape, ``bool``: the columns that touch exactly two
|
|
1082
|
+
monomers, every other column cleared.
|
|
1083
|
+
|
|
1084
|
+
Raises
|
|
1085
|
+
------
|
|
1086
|
+
ValueError
|
|
1087
|
+
If `direct_contacts` is not two-dimensional.
|
|
1088
|
+
|
|
1089
|
+
Notes
|
|
1090
|
+
-----
|
|
1091
|
+
A binder touching no monomer is free and appears in neither output; one
|
|
1092
|
+
touching three or more monomers, which a two-patch binder cannot do once
|
|
1093
|
+
:func:`enforce_single_patch_contact` has been applied, appears in
|
|
1094
|
+
neither either. Time and space are :math:`O(n_\\mathrm{monomers}
|
|
1095
|
+
n_\\mathrm{binders})`.
|
|
1096
|
+
|
|
1097
|
+
Examples
|
|
1098
|
+
--------
|
|
1099
|
+
>>> import numpy as np
|
|
1100
|
+
>>> from polyphys.analyze.clusters import split_binders
|
|
1101
|
+
>>> contacts = np.array([[1, 1, 0],
|
|
1102
|
+
... [0, 1, 0],
|
|
1103
|
+
... [0, 0, 0]])
|
|
1104
|
+
>>> dangling, bridging = split_binders(contacts)
|
|
1105
|
+
>>> dangling.astype(int)
|
|
1106
|
+
array([[1, 0, 0],
|
|
1107
|
+
[0, 0, 0],
|
|
1108
|
+
[0, 0, 0]])
|
|
1109
|
+
>>> bridging.astype(int)
|
|
1110
|
+
array([[0, 1, 0],
|
|
1111
|
+
[0, 1, 0],
|
|
1112
|
+
[0, 0, 0]])
|
|
1113
|
+
|
|
1114
|
+
.. versionadded:: 0.6.0
|
|
1115
|
+
"""
|
|
1116
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
1117
|
+
n_monomers_bound = contacts.sum(axis=0)
|
|
1118
|
+
dangling = contacts & (n_monomers_bound == 1)[None, :]
|
|
1119
|
+
bridging = contacts & (n_monomers_bound == 2)[None, :]
|
|
1120
|
+
return (np.asarray(dangling, dtype=np.bool_),
|
|
1121
|
+
np.asarray(bridging, dtype=np.bool_))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def binder_contacts(
|
|
1125
|
+
direct_contacts: npt.ArrayLike,
|
|
1126
|
+
topology: Topology,
|
|
1127
|
+
) -> npt.NDArray[np.bool_]:
|
|
1128
|
+
"""
|
|
1129
|
+
Link binders that touch the same or neighboring monomers.
|
|
1130
|
+
|
|
1131
|
+
Parameters
|
|
1132
|
+
----------
|
|
1133
|
+
direct_contacts : array_like
|
|
1134
|
+
The monomer-binder contact matrix, shape ``(n_monomers, n_binders)``,
|
|
1135
|
+
boolean or 0/1, with the monomers in backbone order along the rows.
|
|
1136
|
+
topology : {'linear', 'ring'}
|
|
1137
|
+
Whether the chain is open or closed; on a ring the first and last
|
|
1138
|
+
monomers are neighbors.
|
|
1139
|
+
|
|
1140
|
+
Returns
|
|
1141
|
+
-------
|
|
1142
|
+
numpy.ndarray
|
|
1143
|
+
The binder-binder direct-contact matrix, shape
|
|
1144
|
+
``(n_binders, n_binders)``, ``bool``: ``[k, l]`` true when binders
|
|
1145
|
+
``k`` and ``l`` touch the same monomer or two monomers adjacent along
|
|
1146
|
+
the backbone. Symmetric, with a true diagonal for every binder, bound
|
|
1147
|
+
or not, so :func:`count_clusters` sees every binder as a cluster of
|
|
1148
|
+
at least size 1.
|
|
1149
|
+
|
|
1150
|
+
Raises
|
|
1151
|
+
------
|
|
1152
|
+
ValueError
|
|
1153
|
+
If `direct_contacts` is not two-dimensional or `topology` is invalid.
|
|
1154
|
+
|
|
1155
|
+
Notes
|
|
1156
|
+
-----
|
|
1157
|
+
With :math:`M` the monomer-binder matrix and :math:`N_i = M_i \\lor
|
|
1158
|
+
M_{i+1}` the matrix of the monomer pairs ``(i, i + 1)`` that are
|
|
1159
|
+
neighbors, the result is :math:`M^\\top M > 0 \\lor N^\\top N > 0`
|
|
1160
|
+
plus the identity: the first term links binders sharing a monomer, the
|
|
1161
|
+
second links binders on neighboring monomers. Time is
|
|
1162
|
+
:math:`O(n_\\mathrm{monomers} n_\\mathrm{binders}^2)` from the two
|
|
1163
|
+
products; space is :math:`O(n_\\mathrm{monomers} n_\\mathrm{binders} +
|
|
1164
|
+
n_\\mathrm{binders}^2)`.
|
|
1165
|
+
|
|
1166
|
+
Examples
|
|
1167
|
+
--------
|
|
1168
|
+
Binder 0 on monomer 0 and binder 1 on monomer 3 are linked on a ring of
|
|
1169
|
+
four monomers, where 0 and 3 are neighbors, but not on a linear chain:
|
|
1170
|
+
|
|
1171
|
+
>>> import numpy as np
|
|
1172
|
+
>>> from polyphys.analyze.clusters import binder_contacts
|
|
1173
|
+
>>> contacts = np.array([[1, 0],
|
|
1174
|
+
... [0, 0],
|
|
1175
|
+
... [0, 0],
|
|
1176
|
+
... [0, 1]])
|
|
1177
|
+
>>> binder_contacts(contacts, 'ring').astype(int)
|
|
1178
|
+
array([[1, 1],
|
|
1179
|
+
[1, 1]])
|
|
1180
|
+
>>> binder_contacts(contacts, 'linear').astype(int)
|
|
1181
|
+
array([[1, 0],
|
|
1182
|
+
[0, 1]])
|
|
1183
|
+
|
|
1184
|
+
.. versionadded:: 0.6.0
|
|
1185
|
+
"""
|
|
1186
|
+
_check_topology(topology)
|
|
1187
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
1188
|
+
n_binders = contacts.shape[1]
|
|
1189
|
+
if topology == 'ring':
|
|
1190
|
+
neighbors = contacts | np.roll(contacts, -1, axis=0)
|
|
1191
|
+
else:
|
|
1192
|
+
neighbors = contacts[:-1] | contacts[1:]
|
|
1193
|
+
weights = contacts.astype(np.int64)
|
|
1194
|
+
shared = weights.T @ weights > 0
|
|
1195
|
+
adjacent_weights = neighbors.astype(np.int64)
|
|
1196
|
+
adjacent = adjacent_weights.T @ adjacent_weights > 0
|
|
1197
|
+
linked = shared | adjacent | np.eye(n_binders, dtype=np.bool_)
|
|
1198
|
+
return np.asarray(linked, dtype=np.bool_)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
@dataclass(frozen=True, eq=False)
|
|
1202
|
+
class FrameBinding:
|
|
1203
|
+
"""
|
|
1204
|
+
The binding statistics of two-patch binders in one frame.
|
|
1205
|
+
|
|
1206
|
+
Attributes
|
|
1207
|
+
----------
|
|
1208
|
+
n_patch_monomer_contacts : int
|
|
1209
|
+
Direct contacts between patches and monomers, before the single-patch
|
|
1210
|
+
rule; a patch touching two monomers contributes two.
|
|
1211
|
+
n_patches_engaged : int
|
|
1212
|
+
Patches touching at least one monomer.
|
|
1213
|
+
n_patches_free : int
|
|
1214
|
+
Patches touching none; ``n_patches_engaged + n_patches_free`` is the
|
|
1215
|
+
number of patches.
|
|
1216
|
+
n_binders_free : int
|
|
1217
|
+
Binders with both patches free.
|
|
1218
|
+
n_binders_dangling : int
|
|
1219
|
+
Binders with exactly one patch engaged.
|
|
1220
|
+
n_binders_bridging : int
|
|
1221
|
+
Binders with both patches engaged. The three binder counts sum to the
|
|
1222
|
+
number of binders.
|
|
1223
|
+
n_pairs_cis : int
|
|
1224
|
+
Distinct monomer pairs bridged at a genomic distance of at least 1 and
|
|
1225
|
+
at most ``cis_threshold`` bonds.
|
|
1226
|
+
n_pairs_trans : int
|
|
1227
|
+
Distinct monomer pairs bridged at more than ``cis_threshold`` bonds.
|
|
1228
|
+
bridged_pairs : numpy.ndarray
|
|
1229
|
+
Shape ``(n_bridged_pairs, 3)``, ``int64``: for each distinct bridged
|
|
1230
|
+
pair, the two monomer indices, lower first, and their genomic
|
|
1231
|
+
distance in bonds. ``n_pairs_cis + n_pairs_trans`` is its number of
|
|
1232
|
+
rows.
|
|
1233
|
+
|
|
1234
|
+
Notes
|
|
1235
|
+
-----
|
|
1236
|
+
The pair counts are per distinct monomer pair, so two binders bridging
|
|
1237
|
+
the same pair count once, and a binder whose two patches hold the same
|
|
1238
|
+
monomer is bridging but bridges no pair.
|
|
1239
|
+
|
|
1240
|
+
Examples
|
|
1241
|
+
--------
|
|
1242
|
+
>>> from polyphys.analyze.clusters import binding_statistics
|
|
1243
|
+
>>> frame = binding_statistics([[1, 0], [0, 1]], 'linear',
|
|
1244
|
+
... cis_threshold=1)
|
|
1245
|
+
>>> frame.n_binders_bridging, frame.n_pairs_cis
|
|
1246
|
+
(1, 1)
|
|
1247
|
+
|
|
1248
|
+
.. versionadded:: 0.6.0
|
|
1249
|
+
"""
|
|
1250
|
+
|
|
1251
|
+
n_patch_monomer_contacts: int
|
|
1252
|
+
n_patches_engaged: int
|
|
1253
|
+
n_patches_free: int
|
|
1254
|
+
n_binders_free: int
|
|
1255
|
+
n_binders_dangling: int
|
|
1256
|
+
n_binders_bridging: int
|
|
1257
|
+
n_pairs_cis: int
|
|
1258
|
+
n_pairs_trans: int
|
|
1259
|
+
bridged_pairs: npt.NDArray[np.int64]
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def binding_statistics(
|
|
1263
|
+
direct_contacts: npt.ArrayLike,
|
|
1264
|
+
topology: Topology,
|
|
1265
|
+
cis_threshold: int,
|
|
1266
|
+
) -> FrameBinding:
|
|
1267
|
+
"""
|
|
1268
|
+
Count free, dangling, bridging, cis, and trans binders in one frame.
|
|
1269
|
+
|
|
1270
|
+
Parameters
|
|
1271
|
+
----------
|
|
1272
|
+
direct_contacts : array_like
|
|
1273
|
+
The monomer-patch contact matrix, shape ``(n_monomers, n_patches)``,
|
|
1274
|
+
boolean or 0/1, with the monomers in backbone order along the rows
|
|
1275
|
+
and the two patches of binder ``k`` in columns ``2k`` and ``2k + 1``.
|
|
1276
|
+
topology : {'linear', 'ring'}
|
|
1277
|
+
Whether the chain is open or closed.
|
|
1278
|
+
cis_threshold : int
|
|
1279
|
+
The largest genomic distance, in bonds, of a cis bridge;
|
|
1280
|
+
non-negative. A project constant.
|
|
1281
|
+
|
|
1282
|
+
Returns
|
|
1283
|
+
-------
|
|
1284
|
+
FrameBinding
|
|
1285
|
+
The counts and the bridged pairs; see its attributes.
|
|
1286
|
+
|
|
1287
|
+
Raises
|
|
1288
|
+
------
|
|
1289
|
+
ValueError
|
|
1290
|
+
If `direct_contacts` is not two-dimensional or has an odd number of
|
|
1291
|
+
columns, if `topology` is invalid, or if `cis_threshold` is negative.
|
|
1292
|
+
|
|
1293
|
+
Notes
|
|
1294
|
+
-----
|
|
1295
|
+
The patch and binder counts use the contacts as given, so a patch
|
|
1296
|
+
marked on two monomers still counts as one engaged patch and its binder
|
|
1297
|
+
as one dangling or bridging binder. The bridged pairs use the contacts
|
|
1298
|
+
after :func:`enforce_single_patch_contact`, so each bridging binder
|
|
1299
|
+
joins exactly two monomers; the pairs are then made distinct, their
|
|
1300
|
+
genomic distance is taken from :func:`genomic_distance`, and a pair of
|
|
1301
|
+
distance 0 (both patches on one monomer) is dropped.
|
|
1302
|
+
|
|
1303
|
+
Time is :math:`O(n_\\mathrm{monomers} n_\\mathrm{patches} +
|
|
1304
|
+
n_\\mathrm{binders} \\log n_\\mathrm{binders})`; space is
|
|
1305
|
+
:math:`O(n_\\mathrm{monomers} n_\\mathrm{patches})`.
|
|
1306
|
+
|
|
1307
|
+
Examples
|
|
1308
|
+
--------
|
|
1309
|
+
A ring of six monomers and four binders. Binder 0 bridges monomers 0 and
|
|
1310
|
+
2; binder 1 has patch 2 on monomers 3 and 4 (an artifact) and patch 3
|
|
1311
|
+
free, so it dangles; binder 2 is free; binder 3 bridges monomers 5 and
|
|
1312
|
+
0, neighbors on the ring. With ``cis_threshold=1`` the second bridge is
|
|
1313
|
+
cis and the first, at 2 bonds, is trans.
|
|
1314
|
+
|
|
1315
|
+
>>> import numpy as np
|
|
1316
|
+
>>> from polyphys.analyze.clusters import binding_statistics
|
|
1317
|
+
>>> contacts = np.zeros((6, 8), dtype=int)
|
|
1318
|
+
>>> contacts[0, 0] = contacts[2, 1] = 1
|
|
1319
|
+
>>> contacts[3, 2] = contacts[4, 2] = 1
|
|
1320
|
+
>>> contacts[5, 6] = contacts[0, 7] = 1
|
|
1321
|
+
>>> frame = binding_statistics(contacts, 'ring', cis_threshold=1)
|
|
1322
|
+
>>> (frame.n_patch_monomer_contacts, frame.n_patches_engaged,
|
|
1323
|
+
... frame.n_patches_free)
|
|
1324
|
+
(6, 5, 3)
|
|
1325
|
+
>>> (frame.n_binders_free, frame.n_binders_dangling,
|
|
1326
|
+
... frame.n_binders_bridging)
|
|
1327
|
+
(1, 1, 2)
|
|
1328
|
+
>>> frame.n_pairs_cis, frame.n_pairs_trans
|
|
1329
|
+
(1, 1)
|
|
1330
|
+
>>> frame.bridged_pairs
|
|
1331
|
+
array([[0, 2, 2],
|
|
1332
|
+
[0, 5, 1]])
|
|
1333
|
+
|
|
1334
|
+
.. versionadded:: 0.6.0
|
|
1335
|
+
"""
|
|
1336
|
+
_check_topology(topology)
|
|
1337
|
+
if cis_threshold < 0:
|
|
1338
|
+
raise ValueError(
|
|
1339
|
+
f'cis_threshold must be non-negative; got {cis_threshold}.'
|
|
1340
|
+
)
|
|
1341
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
1342
|
+
n_monomers, n_patches = contacts.shape
|
|
1343
|
+
if n_patches % 2 != 0:
|
|
1344
|
+
raise ValueError(
|
|
1345
|
+
'direct_contacts must have two columns per binder; got'
|
|
1346
|
+
f' {n_patches} columns.'
|
|
1347
|
+
)
|
|
1348
|
+
n_binders = n_patches // 2
|
|
1349
|
+
per_patch = contacts.sum(axis=0)
|
|
1350
|
+
engaged = per_patch > 0
|
|
1351
|
+
n_patches_engaged = int(np.count_nonzero(engaged))
|
|
1352
|
+
first_engaged = engaged[0::2]
|
|
1353
|
+
second_engaged = engaged[1::2]
|
|
1354
|
+
n_binders_free = int(np.count_nonzero(~first_engaged & ~second_engaged))
|
|
1355
|
+
n_binders_bridging = int(
|
|
1356
|
+
np.count_nonzero(first_engaged & second_engaged)
|
|
1357
|
+
)
|
|
1358
|
+
# Bridged pairs: one monomer per patch, then the two patches of each
|
|
1359
|
+
# bridging binder.
|
|
1360
|
+
single = enforce_single_patch_contact(contacts)
|
|
1361
|
+
monomer_of_patch = np.argmax(single, axis=0)
|
|
1362
|
+
bridging = first_engaged & second_engaged
|
|
1363
|
+
pairs: npt.NDArray[np.int64] = np.column_stack(
|
|
1364
|
+
(monomer_of_patch[0::2][bridging], monomer_of_patch[1::2][bridging])
|
|
1365
|
+
)
|
|
1366
|
+
pairs = np.sort(pairs, axis=1)
|
|
1367
|
+
pairs = pairs[pairs[:, 0] != pairs[:, 1]]
|
|
1368
|
+
pairs = np.unique(pairs, axis=0).reshape(-1, 2)
|
|
1369
|
+
distance = genomic_distance(
|
|
1370
|
+
pairs[:, 0], pairs[:, 1], topology, n_monomers
|
|
1371
|
+
)
|
|
1372
|
+
bridged_pairs = np.column_stack((pairs, distance)).astype(np.int64)
|
|
1373
|
+
n_pairs_cis = int(np.count_nonzero(distance <= cis_threshold))
|
|
1374
|
+
return FrameBinding(
|
|
1375
|
+
n_patch_monomer_contacts=int(per_patch.sum()),
|
|
1376
|
+
n_patches_engaged=n_patches_engaged,
|
|
1377
|
+
n_patches_free=n_patches - n_patches_engaged,
|
|
1378
|
+
n_binders_free=n_binders_free,
|
|
1379
|
+
n_binders_dangling=n_binders - n_binders_free - n_binders_bridging,
|
|
1380
|
+
n_binders_bridging=n_binders_bridging,
|
|
1381
|
+
n_pairs_cis=n_pairs_cis,
|
|
1382
|
+
n_pairs_trans=int(distance.shape[0]) - n_pairs_cis,
|
|
1383
|
+
bridged_pairs=bridged_pairs,
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
@dataclass
|
|
1388
|
+
class BindingStats:
|
|
1389
|
+
"""
|
|
1390
|
+
Binding statistics accumulated over frames, with the loop-length
|
|
1391
|
+
histogram.
|
|
1392
|
+
|
|
1393
|
+
Parameters
|
|
1394
|
+
----------
|
|
1395
|
+
n_monomers : int
|
|
1396
|
+
The number of monomers along the backbone; positive.
|
|
1397
|
+
topology : {'linear', 'ring'}
|
|
1398
|
+
Whether the chain is open or closed.
|
|
1399
|
+
cis_threshold : int
|
|
1400
|
+
The largest genomic distance, in bonds, of a cis bridge;
|
|
1401
|
+
non-negative.
|
|
1402
|
+
|
|
1403
|
+
Attributes
|
|
1404
|
+
----------
|
|
1405
|
+
frames : list of FrameBinding
|
|
1406
|
+
The frames appended so far, in order.
|
|
1407
|
+
loop_length_histogram : numpy.ndarray
|
|
1408
|
+
``int64``: ``[d]`` is the number of distinct bridged pairs over all
|
|
1409
|
+
frames at a genomic distance of ``d`` bonds. Its length is
|
|
1410
|
+
``n_monomers`` on a linear chain, where the largest distance is
|
|
1411
|
+
``n_monomers - 1``, and ``n_monomers // 2 + 1`` on a ring, where it
|
|
1412
|
+
is ``n_monomers // 2``. ``[0]`` is always 0.
|
|
1413
|
+
|
|
1414
|
+
Notes
|
|
1415
|
+
-----
|
|
1416
|
+
Building the object costs :math:`O(n_\\mathrm{monomers})` space for the
|
|
1417
|
+
histogram; each :meth:`append` costs :math:`O(n_\\mathrm{bridged\\
|
|
1418
|
+
pairs})`.
|
|
1419
|
+
|
|
1420
|
+
Examples
|
|
1421
|
+
--------
|
|
1422
|
+
>>> import numpy as np
|
|
1423
|
+
>>> from polyphys.analyze.clusters import BindingStats
|
|
1424
|
+
>>> stats = BindingStats(n_monomers=4, topology='linear',
|
|
1425
|
+
... cis_threshold=1)
|
|
1426
|
+
>>> stats.update([[1, 0], [0, 1], [0, 0], [0, 0]])
|
|
1427
|
+
>>> stats.update([[1, 0], [0, 0], [0, 0], [0, 1]])
|
|
1428
|
+
>>> stats.n_frames
|
|
1429
|
+
2
|
|
1430
|
+
>>> stats.as_dict()['n_pairs_cis']
|
|
1431
|
+
array([1, 0])
|
|
1432
|
+
>>> stats.loop_length_histogram
|
|
1433
|
+
array([0, 1, 0, 1])
|
|
1434
|
+
|
|
1435
|
+
.. versionadded:: 0.6.0
|
|
1436
|
+
"""
|
|
1437
|
+
|
|
1438
|
+
n_monomers: int
|
|
1439
|
+
topology: Topology
|
|
1440
|
+
cis_threshold: int
|
|
1441
|
+
frames: list[FrameBinding] = field(default_factory=list)
|
|
1442
|
+
loop_length_histogram: npt.NDArray[np.int64] = field(init=False)
|
|
1443
|
+
|
|
1444
|
+
def __post_init__(self) -> None:
|
|
1445
|
+
_check_topology(self.topology)
|
|
1446
|
+
if self.n_monomers < 1:
|
|
1447
|
+
raise ValueError(
|
|
1448
|
+
f'n_monomers must be positive; got {self.n_monomers}.'
|
|
1449
|
+
)
|
|
1450
|
+
if self.cis_threshold < 0:
|
|
1451
|
+
raise ValueError(
|
|
1452
|
+
f'cis_threshold must be non-negative; got'
|
|
1453
|
+
f' {self.cis_threshold}.'
|
|
1454
|
+
)
|
|
1455
|
+
if self.topology == 'ring':
|
|
1456
|
+
n_lengths = self.n_monomers // 2 + 1
|
|
1457
|
+
else:
|
|
1458
|
+
n_lengths = self.n_monomers
|
|
1459
|
+
self.loop_length_histogram = np.zeros(n_lengths, dtype=np.int64)
|
|
1460
|
+
|
|
1461
|
+
@property
|
|
1462
|
+
def n_frames(self) -> int:
|
|
1463
|
+
"""The number of frames appended so far."""
|
|
1464
|
+
return len(self.frames)
|
|
1465
|
+
|
|
1466
|
+
def append(self, frame: FrameBinding) -> None:
|
|
1467
|
+
"""
|
|
1468
|
+
Add one frame's statistics and its bridged pairs to the histogram.
|
|
1469
|
+
|
|
1470
|
+
Parameters
|
|
1471
|
+
----------
|
|
1472
|
+
frame : FrameBinding
|
|
1473
|
+
As :func:`binding_statistics` returns it, computed with this
|
|
1474
|
+
object's `topology` and `cis_threshold` for a chain of
|
|
1475
|
+
`n_monomers`.
|
|
1476
|
+
|
|
1477
|
+
Raises
|
|
1478
|
+
------
|
|
1479
|
+
ValueError
|
|
1480
|
+
If a bridged pair's genomic distance does not fit the histogram,
|
|
1481
|
+
which means the frame came from a longer chain or another
|
|
1482
|
+
topology.
|
|
1483
|
+
"""
|
|
1484
|
+
distance = frame.bridged_pairs[:, 2]
|
|
1485
|
+
if distance.size and distance.max() >= self.loop_length_histogram.size:
|
|
1486
|
+
raise ValueError(
|
|
1487
|
+
'a bridged pair has a genomic distance beyond the chain;'
|
|
1488
|
+
' the frame does not match n_monomers and topology.'
|
|
1489
|
+
)
|
|
1490
|
+
np.add.at(self.loop_length_histogram, distance, 1)
|
|
1491
|
+
self.frames.append(frame)
|
|
1492
|
+
|
|
1493
|
+
def update(self, direct_contacts: npt.ArrayLike) -> None:
|
|
1494
|
+
"""
|
|
1495
|
+
Compute one frame's statistics and append them.
|
|
1496
|
+
|
|
1497
|
+
Parameters
|
|
1498
|
+
----------
|
|
1499
|
+
direct_contacts : array_like
|
|
1500
|
+
The monomer-patch contact matrix of the frame, shape
|
|
1501
|
+
``(n_monomers, n_patches)``, as :func:`binding_statistics` takes
|
|
1502
|
+
it.
|
|
1503
|
+
|
|
1504
|
+
Raises
|
|
1505
|
+
------
|
|
1506
|
+
ValueError
|
|
1507
|
+
As :func:`binding_statistics`, or if the matrix has not
|
|
1508
|
+
`n_monomers` rows.
|
|
1509
|
+
"""
|
|
1510
|
+
contacts = _as_contacts(direct_contacts, 'direct_contacts')
|
|
1511
|
+
if contacts.shape[0] != self.n_monomers:
|
|
1512
|
+
raise ValueError(
|
|
1513
|
+
f'direct_contacts must have n_monomers = {self.n_monomers}'
|
|
1514
|
+
f' rows; got {contacts.shape[0]}.'
|
|
1515
|
+
)
|
|
1516
|
+
self.append(
|
|
1517
|
+
binding_statistics(contacts, self.topology, self.cis_threshold)
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
def as_dict(self) -> dict[str, npt.NDArray[np.int64]]:
|
|
1521
|
+
"""
|
|
1522
|
+
Return the eight counts as time series.
|
|
1523
|
+
|
|
1524
|
+
Returns
|
|
1525
|
+
-------
|
|
1526
|
+
dict of numpy.ndarray
|
|
1527
|
+
One ``int64`` array of shape ``(n_frames,)`` per count, keyed by
|
|
1528
|
+
the :class:`FrameBinding` attribute name; `bridged_pairs` is not
|
|
1529
|
+
included.
|
|
1530
|
+
"""
|
|
1531
|
+
return {
|
|
1532
|
+
name: np.array(
|
|
1533
|
+
[getattr(frame, name) for frame in self.frames],
|
|
1534
|
+
dtype=np.int64,
|
|
1535
|
+
)
|
|
1536
|
+
for name in _BINDING_COUNTS
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def threshold_sweep(
|
|
1541
|
+
distances: npt.ArrayLike,
|
|
1542
|
+
cutoffs: Sequence[float] | npt.ArrayLike,
|
|
1543
|
+
cis_thresholds: Sequence[int],
|
|
1544
|
+
topology: Topology,
|
|
1545
|
+
*,
|
|
1546
|
+
inclusive: bool,
|
|
1547
|
+
) -> tuple[pd.DataFrame, npt.NDArray[np.float64]]:
|
|
1548
|
+
"""
|
|
1549
|
+
Recount the binding statistics of saved distance matrices at every
|
|
1550
|
+
cutoff and cis threshold of a grid.
|
|
1551
|
+
|
|
1552
|
+
Parameters
|
|
1553
|
+
----------
|
|
1554
|
+
distances : array_like
|
|
1555
|
+
The monomer-to-patch distance matrices of one whole, shape
|
|
1556
|
+
``(n_frames, n_monomers, n_patches)``, in the caller's length unit,
|
|
1557
|
+
with the monomers in backbone order along the second axis and the
|
|
1558
|
+
two patches of binder ``k`` at ``2k`` and ``2k + 1`` along the
|
|
1559
|
+
third; the ``distances`` that
|
|
1560
|
+
:class:`~polyphys.probe.binders.BinderStatistics` stores.
|
|
1561
|
+
cutoffs : sequence of float
|
|
1562
|
+
The contact distances :math:`r_c` to sweep, in the same unit, each
|
|
1563
|
+
positive; non-empty. Swept in the order given.
|
|
1564
|
+
cis_thresholds : sequence of int
|
|
1565
|
+
The largest genomic distances, in bonds, of a cis bridge to sweep;
|
|
1566
|
+
each non-negative, non-empty. Swept in the order given, inside each
|
|
1567
|
+
cutoff.
|
|
1568
|
+
topology : {'linear', 'ring'}
|
|
1569
|
+
Whether the chain is open or closed.
|
|
1570
|
+
inclusive : bool
|
|
1571
|
+
Count a pair at exactly the cutoff as a contact (``dist <= cutoff``)
|
|
1572
|
+
or not (``dist < cutoff``), as :func:`find_direct_contacts`. Keyword
|
|
1573
|
+
only, with no default, because the comparison decides whether the
|
|
1574
|
+
sweep reproduces stored counts: a sweep agrees with the series that
|
|
1575
|
+
:class:`~polyphys.probe.binders.BinderStatistics` saved only at the
|
|
1576
|
+
spec's cutoff, cis threshold, and comparison.
|
|
1577
|
+
|
|
1578
|
+
Returns
|
|
1579
|
+
-------
|
|
1580
|
+
counts : pandas.DataFrame
|
|
1581
|
+
One row per ``(cutoff, cis_threshold)`` pair, cutoffs outer and cis
|
|
1582
|
+
thresholds inner, in the order given, with no row repeated. Columns:
|
|
1583
|
+
``cutoff`` (float64), ``cis_threshold`` (int64), and the eight
|
|
1584
|
+
counts of :class:`FrameBinding`, each the mean over the frames
|
|
1585
|
+
(float64), under the attribute names ``n_patch_monomer_contacts``,
|
|
1586
|
+
``n_patches_engaged``, ``n_patches_free``, ``n_binders_free``,
|
|
1587
|
+
``n_binders_dangling``, ``n_binders_bridging``, ``n_pairs_cis``, and
|
|
1588
|
+
``n_pairs_trans``.
|
|
1589
|
+
loop_length_histogram : numpy.ndarray
|
|
1590
|
+
Shape ``(n_rows, n_lengths)``, ``float64``, one row per row of
|
|
1591
|
+
`counts`: ``[k, d]`` is the mean over the frames of the number of
|
|
1592
|
+
distinct bridged pairs at a genomic distance of ``d`` bonds at the
|
|
1593
|
+
cutoff of row ``k``. ``n_lengths`` is ``n_monomers`` on a linear
|
|
1594
|
+
chain and ``n_monomers // 2 + 1`` on a ring, as
|
|
1595
|
+
:class:`BindingStats`; ``[:, 0]`` is always 0. The cis threshold
|
|
1596
|
+
does not enter the histogram, so the rows of one cutoff are equal.
|
|
1597
|
+
|
|
1598
|
+
Raises
|
|
1599
|
+
------
|
|
1600
|
+
ValueError
|
|
1601
|
+
If `distances` is not three-dimensional or has no frames, if
|
|
1602
|
+
`cutoffs` or `cis_thresholds` is empty, if a cutoff is not positive
|
|
1603
|
+
or a cis threshold is negative or not a whole number, if the number
|
|
1604
|
+
of patches is odd, or
|
|
1605
|
+
if `topology` is invalid.
|
|
1606
|
+
|
|
1607
|
+
Notes
|
|
1608
|
+
-----
|
|
1609
|
+
This is the research code's choice-of-threshold sweep on the saved
|
|
1610
|
+
``distMatTMonHnsPatch`` matrices, with its two tables, the
|
|
1611
|
+
frame-averaged counts and the frame-averaged loop-length histogram,
|
|
1612
|
+
returned side by side rather than written with repeated rows. The
|
|
1613
|
+
research sweep compared strictly, while the H-NS study specs count with
|
|
1614
|
+
``inclusive=True`` at 0.685; the strict sweep's row at 0.685 therefore
|
|
1615
|
+
omits the pairs at exactly the cutoff and does not match the stored
|
|
1616
|
+
series. The research grid, ``numpy.arange(0.535, 0.685 + 0.01, 0.01)``,
|
|
1617
|
+
also ran one step past its end to 0.695; build a grid with a count,
|
|
1618
|
+
``0.535 + 0.01 * numpy.arange(16)``, to stop at 0.685.
|
|
1619
|
+
|
|
1620
|
+
Every count and the histogram are means over the frames, which treats
|
|
1621
|
+
the frames as exchangeable; they are not uncertainties. An error bar on
|
|
1622
|
+
a swept count needs the per-frame series of :class:`BindingStats` and
|
|
1623
|
+
an autocorrelation-aware estimate such as
|
|
1624
|
+
:func:`polyphys.analyze.stats.block_average`.
|
|
1625
|
+
|
|
1626
|
+
The contacts and the bridged pairs of a frame depend on the cutoff only;
|
|
1627
|
+
the cis threshold splits the bridged pairs into cis and trans. Each
|
|
1628
|
+
frame is therefore counted once per cutoff, with
|
|
1629
|
+
:func:`find_direct_contacts` and :func:`binding_statistics`, and its
|
|
1630
|
+
bridged pairs are split once per cis threshold. With :math:`n_c`
|
|
1631
|
+
cutoffs, :math:`n_t` cis thresholds, :math:`n_f` frames,
|
|
1632
|
+
:math:`N_m` monomers, :math:`N_p` patches, and :math:`N_b = N_p / 2`
|
|
1633
|
+
binders, time is :math:`O(n_c n_f (N_m N_p + N_b \\log N_b) + n_c n_t
|
|
1634
|
+
n_f N_b)` and space, beyond the input, is :math:`O(N_m N_p + n_c n_t
|
|
1635
|
+
n_\\mathrm{lengths})`.
|
|
1636
|
+
|
|
1637
|
+
Examples
|
|
1638
|
+
--------
|
|
1639
|
+
A linear chain of three monomers and one binder, in two frames. In the
|
|
1640
|
+
first frame, patch 0 sits 0.5 from monomer 0 and patch 1 sits 0.6 from
|
|
1641
|
+
monomer 2; in the second, patch 1 is far from every monomer. Below 0.6
|
|
1642
|
+
the binder dangles in both frames; at 0.65 it bridges monomers 0 and 2,
|
|
1643
|
+
two bonds apart, in the first frame, a trans bridge for a cis threshold
|
|
1644
|
+
of 1.
|
|
1645
|
+
|
|
1646
|
+
>>> import numpy as np
|
|
1647
|
+
>>> from polyphys.analyze.clusters import threshold_sweep
|
|
1648
|
+
>>> distances = np.full((2, 3, 2), 2.0)
|
|
1649
|
+
>>> distances[0, 0, 0] = distances[1, 0, 0] = 0.5
|
|
1650
|
+
>>> distances[0, 2, 1] = 0.6
|
|
1651
|
+
>>> counts, loops = threshold_sweep(
|
|
1652
|
+
... distances, [0.55, 0.65], [1], 'linear', inclusive=True)
|
|
1653
|
+
>>> counts[['cutoff', 'n_binders_dangling', 'n_binders_bridging',
|
|
1654
|
+
... 'n_pairs_trans']]
|
|
1655
|
+
cutoff n_binders_dangling n_binders_bridging n_pairs_trans
|
|
1656
|
+
0 0.55 1.0 0.0 0.0
|
|
1657
|
+
1 0.65 0.5 0.5 0.5
|
|
1658
|
+
>>> loops
|
|
1659
|
+
array([[0. , 0. , 0. ],
|
|
1660
|
+
[0. , 0. , 0.5]])
|
|
1661
|
+
|
|
1662
|
+
.. versionadded:: 0.7.0
|
|
1663
|
+
.. versionchanged:: 0.9.0
|
|
1664
|
+
Reject a cis threshold that is not a whole number instead of
|
|
1665
|
+
truncating it.
|
|
1666
|
+
"""
|
|
1667
|
+
_check_topology(topology)
|
|
1668
|
+
matrices = np.asarray(distances, dtype=np.float64)
|
|
1669
|
+
if matrices.ndim != 3:
|
|
1670
|
+
raise ValueError(
|
|
1671
|
+
'distances must have shape (n_frames, n_monomers, n_patches);'
|
|
1672
|
+
f' got {matrices.ndim} dimension(s).'
|
|
1673
|
+
)
|
|
1674
|
+
n_frames, n_monomers, n_patches = matrices.shape
|
|
1675
|
+
if n_frames == 0:
|
|
1676
|
+
raise ValueError('distances must hold at least one frame.')
|
|
1677
|
+
if n_patches % 2 != 0:
|
|
1678
|
+
raise ValueError(
|
|
1679
|
+
'distances must have two patches per binder; got'
|
|
1680
|
+
f' {n_patches} patches.'
|
|
1681
|
+
)
|
|
1682
|
+
cutoff_grid = np.asarray(cutoffs, dtype=np.float64).reshape(-1)
|
|
1683
|
+
if cutoff_grid.size == 0:
|
|
1684
|
+
raise ValueError('cutoffs must not be empty.')
|
|
1685
|
+
if not np.all(cutoff_grid > 0):
|
|
1686
|
+
raise ValueError(
|
|
1687
|
+
f'every cutoff must be positive; got {cutoff_grid.tolist()}.'
|
|
1688
|
+
)
|
|
1689
|
+
cis_values = np.asarray(cis_thresholds).reshape(-1)
|
|
1690
|
+
if cis_values.size == 0:
|
|
1691
|
+
raise ValueError('cis_thresholds must not be empty.')
|
|
1692
|
+
if not np.all(np.mod(cis_values, 1) == 0):
|
|
1693
|
+
raise ValueError(
|
|
1694
|
+
'every cis threshold must be a whole number of bonds; got'
|
|
1695
|
+
f' {cis_values.tolist()}.'
|
|
1696
|
+
)
|
|
1697
|
+
cis_grid = cis_values.astype(np.int64)
|
|
1698
|
+
if not np.all(cis_grid >= 0):
|
|
1699
|
+
raise ValueError(
|
|
1700
|
+
'every cis threshold must be non-negative; got'
|
|
1701
|
+
f' {cis_grid.tolist()}.'
|
|
1702
|
+
)
|
|
1703
|
+
if topology == 'ring':
|
|
1704
|
+
n_lengths = n_monomers // 2 + 1
|
|
1705
|
+
else:
|
|
1706
|
+
n_lengths = n_monomers
|
|
1707
|
+
n_rows = cutoff_grid.size * cis_grid.size
|
|
1708
|
+
means = np.zeros((n_rows, len(_BINDING_COUNTS)), dtype=np.float64)
|
|
1709
|
+
histogram = np.zeros((n_rows, n_lengths), dtype=np.float64)
|
|
1710
|
+
# The six counts that do not depend on the cis threshold, then the
|
|
1711
|
+
# cis and trans pair counts, in the order of _BINDING_COUNTS.
|
|
1712
|
+
n_fixed = len(_BINDING_COUNTS) - 2
|
|
1713
|
+
for i, cutoff in enumerate(cutoff_grid):
|
|
1714
|
+
fixed = np.zeros(n_fixed, dtype=np.int64)
|
|
1715
|
+
n_cis = np.zeros(cis_grid.size, dtype=np.int64)
|
|
1716
|
+
n_pairs = 0
|
|
1717
|
+
loops = np.zeros(n_lengths, dtype=np.int64)
|
|
1718
|
+
for matrix in matrices:
|
|
1719
|
+
contacts = find_direct_contacts(matrix, cutoff, inclusive)
|
|
1720
|
+
# Any cis threshold gives the same contacts and bridged pairs;
|
|
1721
|
+
# 0 makes every pair trans, and the split is redone below.
|
|
1722
|
+
frame = binding_statistics(contacts, topology, 0)
|
|
1723
|
+
fixed += [getattr(frame, name)
|
|
1724
|
+
for name in _BINDING_COUNTS[:n_fixed]]
|
|
1725
|
+
distance = frame.bridged_pairs[:, 2]
|
|
1726
|
+
n_pairs += distance.size
|
|
1727
|
+
n_cis += np.count_nonzero(
|
|
1728
|
+
distance[None, :] <= cis_grid[:, None], axis=1)
|
|
1729
|
+
loops += np.bincount(distance, minlength=n_lengths)
|
|
1730
|
+
rows = slice(i * cis_grid.size, (i + 1) * cis_grid.size)
|
|
1731
|
+
means[rows, :n_fixed] = fixed / n_frames
|
|
1732
|
+
means[rows, n_fixed] = n_cis / n_frames
|
|
1733
|
+
means[rows, n_fixed + 1] = (n_pairs - n_cis) / n_frames
|
|
1734
|
+
histogram[rows] = loops / n_frames
|
|
1735
|
+
counts = pd.DataFrame(means, columns=list(_BINDING_COUNTS))
|
|
1736
|
+
counts.insert(0, 'cis_threshold', np.tile(cis_grid, cutoff_grid.size))
|
|
1737
|
+
counts.insert(0, 'cutoff', np.repeat(cutoff_grid, cis_grid.size))
|
|
1738
|
+
return counts, histogram
|