polyphys 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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