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,711 @@
1
+ """
2
+ ==========================================================
3
+ :mod:`polyphys.probe.binders`
4
+ ==========================================================
5
+
6
+ The :mod:`polyphys.probe.binders` module counts, in every frame of a
7
+ trajectory, how the two-patch binders of a study bind its polymer chain:
8
+ how many patches touch a monomer, how many binders are free, dangling by
9
+ one patch, or bridging two monomers, how many bridged pairs are cis or
10
+ trans, and the histogram of the bridged pairs' genomic distances, the loop
11
+ lengths. :class:`BinderStatistics` is an MDAnalysis
12
+ :class:`~MDAnalysis.analysis.base.AnalysisBase`, so it runs over a slice of
13
+ frames in serial or, split across workers, in parallel, and stores one
14
+ array per count in its ``results``.
15
+
16
+ The research code measured the same eight counts in the H-NS probe of each
17
+ study: the distances between every monomer and every patch, with no
18
+ minimum image, the contacts below a cutoff, and the counts of its
19
+ ``hns_binding``. Here the distances take the minimum image along the
20
+ directions the study's geometry makes periodic, the cutoff and the
21
+ comparison are the study's :class:`~polyphys.spec.BinderSpec`,
22
+ and the counting is :func:`~polyphys.analyze.clusters.binding_statistics`.
23
+
24
+ Contents
25
+ ========
26
+
27
+ - :class:`BinderStatistics`, the per-frame binding analysis.
28
+
29
+ Dependencies
30
+ ============
31
+ - Python standard library: `math`, `pathlib`, `typing`.
32
+ - `NumPy <https://numpy.org/>`_.
33
+ - `MDAnalysis <https://docs.mdanalysis.org/>`_ 2.8 or later, through the
34
+ ``mda`` extra: :class:`~MDAnalysis.analysis.base.AnalysisBase` and
35
+ :class:`~MDAnalysis.analysis.results.ResultsGroup`.
36
+ - :mod:`polyphys.analyze.clusters`:
37
+ :func:`~polyphys.analyze.clusters.find_direct_contacts`,
38
+ :func:`~polyphys.analyze.clusters.binding_statistics`, and
39
+ :class:`~polyphys.analyze.clusters.BindingStats`.
40
+ - :mod:`polyphys.spec`:
41
+ :data:`~polyphys.spec.BINDER_SERIES` and
42
+ :class:`~polyphys.spec.ProbeSpec`.
43
+ - :mod:`polyphys.probe.core`: ``_ProbeAnalysis``, the base whose ``run``
44
+ closes the pool of the ``multiprocessing`` backend, and
45
+ ``_compute_part``, which computes each part of a split run on its own
46
+ copy of the analysis.
47
+
48
+ Notation
49
+ ========
50
+ :math:`N_m`, :math:`N_p`, :math:`N_b = N_p / 2`
51
+ The numbers of monomers, patches, and binders; the two patches of
52
+ binder :math:`k` are the patches :math:`2k` and :math:`2k + 1` in
53
+ atom-ID order.
54
+ :math:`r_{ij}`
55
+ The distance between monomer :math:`i` and patch :math:`j`, in the
56
+ dump's length unit, the monomer diameter in the studies; along a
57
+ periodic direction of box length :math:`L`, the component is reduced
58
+ to :math:`[-L/2, L/2)` first (the minimum image).
59
+ :math:`r_\\mathrm{thres}`
60
+ The binding threshold, `cutoff`: monomer :math:`i` and patch :math:`j`
61
+ are in contact when :math:`r_{ij} \\le r_\\mathrm{thres}` (`inclusive`)
62
+ or :math:`r_{ij} < r_\\mathrm{thres}`.
63
+ :math:`d`
64
+ The genomic distance of a bridged pair, in bonds along the backbone,
65
+ the shorter way round on a ring; a bridge is cis when
66
+ :math:`d \\le` `cis_threshold` and trans otherwise.
67
+
68
+ Examples
69
+ ========
70
+ A linear chain of four beads one unit apart and one binder whose two
71
+ patches sit 0.3 above beads 0 and 2, in a one-frame universe:
72
+
73
+ >>> import MDAnalysis as mda
74
+ >>> import numpy as np
75
+ >>> universe = mda.Universe.empty(6, trajectory=True)
76
+ >>> universe.atoms.positions = np.array(
77
+ ... [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0],
78
+ ... [0.0, 0.3, 0.0], [2.0, 0.3, 0.0]])
79
+ >>> analysis = BinderStatistics(
80
+ ... universe.atoms[:4], universe.atoms[4:], cutoff=0.5,
81
+ ... topology='linear', cis_threshold=1).run()
82
+ >>> analysis.results['n_binders_bridging'], analysis.results['n_pairs_trans']
83
+ (array([1]), array([1]))
84
+ >>> analysis.results['loop_length_histogram']
85
+ array([[0, 0, 1, 0]])
86
+
87
+ .. versionadded:: 0.7.0
88
+ """
89
+ import math
90
+ from pathlib import Path
91
+ from typing import Any, Mapping, Self, cast, get_args
92
+
93
+ import numpy as np
94
+ import numpy.typing as npt
95
+ from MDAnalysis.analysis.results import ResultsGroup
96
+ from MDAnalysis.core.groups import AtomGroup
97
+ from MDAnalysis.core.universe import Universe
98
+
99
+ from ..analyze.clusters import (
100
+ BindingStats,
101
+ Topology,
102
+ binding_statistics,
103
+ find_direct_contacts,
104
+ )
105
+ from ..spec import BINDER_SERIES, ProbeSpec
106
+ from ..manage.types import EntityT, GeometryT, Pathish, TopologyT
107
+ from .core import _ProbeAnalysis, _compute_part
108
+
109
+ __all__ = [
110
+ 'BinderStatistics',
111
+ 'DISTANCES_KEY',
112
+ 'LOOP_KEY',
113
+ ]
114
+
115
+ #: The result key of the per-frame loop-length histogram.
116
+ LOOP_KEY = 'loop_length_histogram'
117
+
118
+ #: The result key of the per-frame monomer-to-patch distance matrices, when
119
+ #: they are stored.
120
+ DISTANCES_KEY = 'distances'
121
+
122
+ # The directions that each geometry makes periodic: every one in a periodic
123
+ # cube, the axis only in a cylinder (LAMMPS boundary ``ff ff pp``).
124
+ _PERIODIC: Mapping[GeometryT, tuple[bool, bool, bool]] = {
125
+ 'cubic': (True, True, True),
126
+ 'cylindrical': (False, False, True),
127
+ }
128
+
129
+ _BACKENDS = ('serial', 'multiprocessing', 'dask')
130
+
131
+ # The number of patches per binder that binding_statistics counts.
132
+ _TWO_PATCHES = 2
133
+
134
+
135
+ def _pair_distances(
136
+ reference: npt.NDArray[np.float64],
137
+ configuration: npt.NDArray[np.float64],
138
+ box_lengths: npt.NDArray[np.float64],
139
+ periodic: tuple[bool, bool, bool],
140
+ ) -> npt.NDArray[np.float64]:
141
+ """
142
+ The distance between every row of `reference` and every row of
143
+ `configuration`, shape ``(n_reference, n_configuration)``, with the
144
+ minimum image along each periodic direction of `box_lengths`.
145
+ """
146
+ diff = reference[:, None, :] - configuration[None, :, :]
147
+ for axis in np.flatnonzero(periodic):
148
+ length = box_lengths[axis]
149
+ diff[..., axis] -= length * np.round(diff[..., axis] / length)
150
+ return np.asarray(np.sqrt(np.einsum('ijk,ijk->ij', diff, diff)),
151
+ dtype=np.float64)
152
+
153
+
154
+ class BinderStatistics(_ProbeAnalysis):
155
+ """
156
+ The binding of two-patch binders to one polymer chain, counted in every
157
+ frame.
158
+
159
+ .. versionadded:: 0.7.0
160
+ .. versionchanged:: 1.0.0
161
+ `cis_threshold` has no default.
162
+
163
+ Parameters
164
+ ----------
165
+ chain : :class:`~MDAnalysis.core.groups.AtomGroup`
166
+ The chain, at least two monomers, in atom-ID order: the order along
167
+ the backbone, which ``Universe.select_atoms`` gives.
168
+ patches : :class:`~MDAnalysis.core.groups.AtomGroup`
169
+ The binders' patches, in atom-ID order, from the same universe as
170
+ `chain`: the two patches of binder :math:`k` at positions
171
+ :math:`2k` and :math:`2k + 1`, so their number is a positive
172
+ multiple of `patches_per_binder`. The binder cores are not needed.
173
+ cutoff : float
174
+ The binding threshold :math:`r_\\mathrm{thres}`, in the dump's
175
+ length unit; finite and positive.
176
+ topology : :py:obj:`~.TopologyT`
177
+ ``'linear'`` or ``'ring'``, whether the chain is open or closed,
178
+ which sets the genomic distance of a bridged pair and the length of
179
+ the loop-length histogram.
180
+ cis_threshold : int
181
+ The largest genomic distance, in bonds, of a cis bridge;
182
+ non-negative, with no default.
183
+ inclusive : bool, default True
184
+ Whether a pair at exactly `cutoff` is a contact
185
+ (:math:`r \\le r_\\mathrm{thres}`) or not
186
+ (:math:`r < r_\\mathrm{thres}`).
187
+ patches_per_binder : int, default 2
188
+ The number of patches per binder; only ``2`` is accepted, which
189
+ :func:`~polyphys.analyze.clusters.binding_statistics` counts.
190
+ periodic : tuple of bool, default (False, False, False)
191
+ Whether the minimum image is taken along ``x``, ``y``, and ``z``,
192
+ with the box lengths of each frame. The research probe took none;
193
+ :meth:`from_spec` sets every direction in a cube and ``z`` only in
194
+ a cylinder.
195
+ store_distances : bool, default False
196
+ Whether the monomer-to-patch distance matrix of every frame is
197
+ kept in ``results`` and written by :meth:`save`.
198
+ chain_label, patch_label, binder_label : :py:obj:`~.EntityT`
199
+ The species labels that name the files :meth:`save` writes;
200
+ ``'Mon'``, ``'HnsPatch'``, and ``'HnsCore'`` by default, the H-NS
201
+ studies' names.
202
+ verbose : bool, default False
203
+ Whether to show a progress bar during
204
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`.
205
+
206
+ Attributes
207
+ ----------
208
+ chain : :class:`~MDAnalysis.core.groups.AtomGroup`
209
+ The chain.
210
+ patches : :class:`~MDAnalysis.core.groups.AtomGroup`
211
+ The patches.
212
+ cutoff : float
213
+ The binding threshold.
214
+ topology : :py:obj:`~.TopologyT`
215
+ The chain's topology.
216
+ inclusive : bool
217
+ Whether a pair at exactly `cutoff` is a contact.
218
+ cis_threshold : int
219
+ The largest genomic distance of a cis bridge, in bonds.
220
+ patches_per_binder : int
221
+ The number of patches per binder, ``2``.
222
+ periodic : tuple of bool
223
+ The directions along which the minimum image is taken.
224
+ store_distances : bool
225
+ Whether the distance matrices are kept.
226
+ chain_label : :py:obj:`~.EntityT`
227
+ The species label of the chain in the saved files.
228
+ patch_label : :py:obj:`~.EntityT`
229
+ The species label of the patches in the saved files.
230
+ binder_label : :py:obj:`~.EntityT`
231
+ The species label of the binders in the saved files.
232
+ n_lengths : int
233
+ The length of a loop-length histogram row: ``n_monomers // 2 + 1``
234
+ on a ring, ``n_monomers`` on a linear chain, as
235
+ :class:`~polyphys.analyze.clusters.BindingStats` sizes it.
236
+ results : :class:`~MDAnalysis.analysis.results.Results`
237
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`: one int64
238
+ array of shape ``(n_frames,)`` per attribute of
239
+ :class:`~polyphys.analyze.clusters.FrameBinding` in
240
+ :data:`~polyphys.spec.BINDER_SERIES`, keyed by that name;
241
+ ``'loop_length_histogram'``, int64 of shape ``(n_frames, n_lengths)``,
242
+ whose row ``[t, d]`` is the number of distinct bridged pairs at
243
+ :math:`d` bonds in frame ``t``, with column 0 always 0; and, with
244
+ `store_distances`, ``'distances'``, float64 of shape ``(n_frames,
245
+ n_monomers, n_patches)``.
246
+ n_frames : int
247
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the number of
248
+ frames analyzed, from :class:`~MDAnalysis.analysis.base.AnalysisBase`.
249
+ frames, times : numpy.ndarray
250
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the indices
251
+ and times of the frames analyzed, shape ``(n_frames,)``.
252
+
253
+ Raises
254
+ ------
255
+ ValueError
256
+ If `chain` has fewer than two atoms; if `patches` is empty, is not from
257
+ the universe of `chain`, or has a number of atoms that is not a
258
+ multiple of `patches_per_binder`; if `cutoff` is not a finite positive
259
+ number; if `topology` is not ``'linear'`` or ``'ring'``; if
260
+ `cis_threshold` is negative; if `patches_per_binder` is not ``2``; if
261
+ `periodic` is not three booleans; or if a label is empty. During
262
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, if a periodic
263
+ direction is asked for and the frame carries no box.
264
+
265
+ Notes
266
+ -----
267
+ Per frame, the analysis forms :math:`r_{ij}` for every monomer and
268
+ patch, with the minimum image along the periodic directions, marks the
269
+ contacts with :func:`~polyphys.analyze.clusters.find_direct_contacts`,
270
+ and counts them with
271
+ :func:`~polyphys.analyze.clusters.binding_statistics`, whose rules hold
272
+ here: the patch and binder counts use the contacts as marked, so a
273
+ patch within the cutoff of two adjacent monomers is one engaged patch;
274
+ the bridged pairs use one monomer per patch, the lowest-index one, so
275
+ each bridging binder joins exactly two monomers; and cis and trans
276
+ count distinct monomer pairs. The histogram row is the bincount of the
277
+ bridged pairs' genomic distances.
278
+
279
+ The binder model is the bivalent patchy protein of Brackley et al.
280
+ [1]_, with a patch at each pole of a repulsive core, that the H-NS
281
+ studies follow [2]_; the dangling, cis, and trans modes and the cis
282
+ threshold of two intervening monomers are those of Joyeux and Vreede
283
+ [3]_, whose cis bridge joins sites "separated by at most two DNA
284
+ beads", a genomic distance of at most 3 bonds: `cis_threshold` of 3,
285
+ which the H-NS studies state. A patch that sits between two
286
+ consecutive monomers can touch both, which Brackley [4]_ notes as the
287
+ one way a single patch reaches two beads; the single-patch rule
288
+ resolves it.
289
+
290
+ The MDAnalysis contract: ``_prepare`` allocates the arrays for the
291
+ frames of the run, ``_single_frame`` writes the current frame's
292
+ row, and ``_conclude`` does nothing. The class is marked
293
+ parallelizable, with ``serial``, ``multiprocessing``, and ``dask``
294
+ backends; each worker fills the arrays of its own frames, which
295
+ ``_get_aggregator`` concatenates along the frame axis
296
+ (``ResultsGroup.ndarray_hstack`` for the eight series,
297
+ ``ResultsGroup.ndarray_vstack`` for the histogram and the distances).
298
+
299
+ Cost per frame: :math:`O(N_m N_p)` time for the distances and the
300
+ contacts, plus :math:`O(N_b \\log N_b)` to make the bridged pairs
301
+ distinct, and :math:`O(N_m N_p)` space for the distance and contact
302
+ matrices. Over a run of :math:`F` frames, :math:`O(F)` space per
303
+ series, :math:`O(F N_m / 2)` for the histogram, and, with
304
+ `store_distances`, :math:`8 F N_m N_p` bytes for the float64
305
+ distances, held in memory until :meth:`save`, as the research code
306
+ held its list of matrices. Positions come from MDAnalysis as float32,
307
+ so each distance carries a relative round-off of about :math:`10^{-7}`
308
+ before it is compared with the cutoff.
309
+
310
+ References
311
+ ----------
312
+ .. [1] C. A. Brackley, S. Taylor, A. Papantonis, P. R. Cook, and D.
313
+ Marenduzzo, "Nonspecific bridging-induced attraction drives
314
+ clustering of DNA-binding proteins and genome organization", Proc.
315
+ Natl. Acad. Sci. U.S.A. 110, E3605-E3611 (2013).
316
+ https://doi.org/10.1073/pnas.1302950110
317
+ .. [2] Y. Jung, A. Sadeghi, and B.-Y. Ha, "Modeling the compaction of
318
+ bacterial chromosomes by biomolecular crowding and the cross-linking
319
+ protein H-NS", Sci. Rep. 14, 139 (2024).
320
+ https://doi.org/10.1038/s41598-023-50355-2
321
+ .. [3] M. Joyeux and J. Vreede, "A Model of H-NS Mediated Compaction of
322
+ Bacterial DNA", Biophys. J. 104, 1615-1622 (2013).
323
+ https://doi.org/10.1016/j.bpj.2013.02.043
324
+ .. [4] C. A. Brackley, "Polymer compaction and bridging-induced
325
+ clustering of protein-inspired patchy particles", J. Phys.: Condens.
326
+ Matter 32, 314002 (2020). https://doi.org/10.1088/1361-648X/ab7f6c
327
+
328
+ Examples
329
+ --------
330
+ A ring of six beads and two binders over one frame: binder 0 bridges
331
+ beads 0 and 2, two bonds apart; binder 1 dangles from bead 4 with its
332
+ other patch far away.
333
+
334
+ >>> import MDAnalysis as mda
335
+ >>> import numpy as np
336
+ >>> chain = np.column_stack([np.arange(6.0), np.zeros(6), np.zeros(6)])
337
+ >>> patches = np.array([[0.0, 0.3, 0.0], [2.0, 0.3, 0.0],
338
+ ... [4.0, 0.3, 0.0], [9.0, 9.0, 9.0]])
339
+ >>> universe = mda.Universe.empty(10, trajectory=True)
340
+ >>> universe.atoms.positions = np.vstack([chain, patches])
341
+ >>> analysis = BinderStatistics(
342
+ ... universe.atoms[:6], universe.atoms[6:], cutoff=0.5,
343
+ ... topology='ring', cis_threshold=4).run()
344
+ >>> [int(analysis.results[key][0]) for key in BINDER_SERIES]
345
+ [3, 1, 3, 0, 1, 1, 1, 0]
346
+ >>> analysis.results['loop_length_histogram']
347
+ array([[0, 0, 1, 0]])
348
+
349
+ The eight values are, in the order of
350
+ :data:`~polyphys.spec.BINDER_SERIES`: three patch-monomer
351
+ contacts, one free and three engaged patches, no free binder, one
352
+ bridging and one dangling binder, one cis pair and no trans pair.
353
+
354
+ With the minimum image along ``x`` in a box of length 10, a patch at
355
+ ``x = 9.8`` touches bead 0:
356
+
357
+ >>> universe.trajectory.ts.dimensions = [10.0, 10.0, 10.0, 90, 90, 90]
358
+ >>> universe.atoms[9].position = [9.8, 0.0, 0.0]
359
+ >>> wrapped = BinderStatistics(
360
+ ... universe.atoms[:6], universe.atoms[6:], cutoff=0.5,
361
+ ... topology='ring', cis_threshold=4,
362
+ ... periodic=(True, False, False)).run()
363
+ >>> int(wrapped.results['n_binders_bridging'][0])
364
+ 2
365
+ """
366
+
367
+ _analysis_algorithm_is_parallelizable = True
368
+
369
+ def __init__(
370
+ self,
371
+ chain: AtomGroup,
372
+ patches: AtomGroup,
373
+ *,
374
+ cutoff: float,
375
+ topology: TopologyT,
376
+ cis_threshold: int,
377
+ inclusive: bool = True,
378
+ patches_per_binder: int = _TWO_PATCHES,
379
+ periodic: tuple[bool, bool, bool] = (False, False, False),
380
+ store_distances: bool = False,
381
+ chain_label: EntityT = 'Mon',
382
+ patch_label: EntityT = 'HnsPatch',
383
+ binder_label: EntityT = 'HnsCore',
384
+ verbose: bool = False,
385
+ ) -> None:
386
+ super().__init__(chain.universe.trajectory, verbose=verbose)
387
+ if chain.n_atoms < 2:
388
+ raise ValueError(
389
+ f"'chain' must hold at least two monomers, not "
390
+ f"{chain.n_atoms}.")
391
+ if patches.universe is not chain.universe:
392
+ raise ValueError(
393
+ "'patches' must come from the universe of 'chain'.")
394
+ if patches_per_binder != _TWO_PATCHES:
395
+ raise ValueError(
396
+ f"'patches_per_binder' must be {_TWO_PATCHES}, not "
397
+ f"{patches_per_binder!r}: binding_statistics counts "
398
+ "two-patch binders only.")
399
+ if patches.n_atoms < 1 or patches.n_atoms % patches_per_binder:
400
+ raise ValueError(
401
+ f"'patches' must hold a positive multiple of "
402
+ f"{patches_per_binder} atoms, not {patches.n_atoms}.")
403
+ if not (isinstance(cutoff, (int, float))
404
+ and not isinstance(cutoff, bool)
405
+ and math.isfinite(cutoff) and cutoff > 0):
406
+ raise ValueError(
407
+ f"'cutoff' must be a finite positive number, not "
408
+ f"{cutoff!r}.")
409
+ if topology not in get_args(Topology):
410
+ raise ValueError(
411
+ f"'topology' must be one of {list(get_args(Topology))}, "
412
+ f"not {topology!r}.")
413
+ if cis_threshold < 0:
414
+ raise ValueError(
415
+ f"'cis_threshold' must be non-negative, not "
416
+ f"{cis_threshold!r}.")
417
+ periodic = tuple(periodic) # type: ignore[assignment]
418
+ if len(periodic) != 3 \
419
+ or any(not isinstance(flag, (bool, np.bool_))
420
+ for flag in periodic):
421
+ raise ValueError(
422
+ f"'periodic' must be three booleans, not {periodic!r}.")
423
+ for name, label in (('chain_label', chain_label),
424
+ ('patch_label', patch_label),
425
+ ('binder_label', binder_label)):
426
+ if not (isinstance(label, str) and label):
427
+ raise ValueError(
428
+ f"'{name}' must be a non-empty species label, not "
429
+ f"{label!r}.")
430
+ self.chain = chain
431
+ self.patches = patches
432
+ self.cutoff = float(cutoff)
433
+ self.topology = topology
434
+ self.inclusive = bool(inclusive)
435
+ self.cis_threshold = int(cis_threshold)
436
+ self.patches_per_binder = patches_per_binder
437
+ self.periodic = cast(tuple[bool, bool, bool],
438
+ tuple(bool(flag) for flag in periodic))
439
+ self.store_distances = bool(store_distances)
440
+ self.chain_label = chain_label
441
+ self.patch_label = patch_label
442
+ self.binder_label = binder_label
443
+ self.n_lengths = BindingStats(
444
+ chain.n_atoms, cast(Topology, topology),
445
+ self.cis_threshold).loop_length_histogram.size
446
+
447
+ @classmethod
448
+ def from_spec(
449
+ cls,
450
+ universe: Universe,
451
+ spec: ProbeSpec,
452
+ *,
453
+ verbose: bool = False,
454
+ ) -> Self:
455
+ """
456
+ Build the analysis of a study's binders from its probe spec.
457
+
458
+ The chain is ``universe.select_atoms(spec.selections[spec.chain])``
459
+ and the patches
460
+ ``universe.select_atoms(spec.selections[spec.binders.patches])``;
461
+ the cutoff, the comparison, the cis threshold, the patches per
462
+ binder, whether the distances are stored, and the labels are the
463
+ spec's :class:`~polyphys.spec.BinderSpec`; the topology
464
+ is the project's; and the minimum image is taken along every
465
+ direction in a cube and along ``z`` only in a cylinder.
466
+
467
+ .. versionadded:: 0.7.0
468
+
469
+ Parameters
470
+ ----------
471
+ universe : :class:`~MDAnalysis.core.universe.Universe`
472
+ The trajectory, as :func:`~polyphys.probe.core.make_universe`
473
+ opens it.
474
+ spec : :class:`~polyphys.spec.ProbeSpec`
475
+ The study's probe spec, with `binders` set.
476
+ verbose : bool, default False
477
+ Whether to show a progress bar during
478
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`.
479
+
480
+ Returns
481
+ -------
482
+ BinderStatistics
483
+ The analysis, not yet run.
484
+
485
+ Raises
486
+ ------
487
+ ValueError
488
+ If the spec has no `binders`, or if the selections pick fewer
489
+ than two monomers or a number of patches that is not a positive
490
+ multiple of the patches per binder.
491
+
492
+ Examples
493
+ --------
494
+ >>> import MDAnalysis as mda
495
+ >>> import numpy as np
496
+ >>> from polyphys.tests.synthetic import RingCubBinders
497
+ >>> from polyphys.spec import (
498
+ ... BinderSpec, ProbeSpec, ProjectSpec)
499
+ >>> project = ProjectSpec(
500
+ ... parser=RingCubBinders, group='nucleoid', divisor=0.01,
501
+ ... crowder_attr='phi_bulk_c',
502
+ ... physical_attrs=('space', 'nmon', 'nhns'),
503
+ ... species_sizes={'Mon': 'dmon', 'Hns': 'dhns'},
504
+ ... tseries_properties=(
505
+ ... 'nBoundTHnsPatch', 'nFreeTHnsPatch', 'nEngagedTHnsPatch',
506
+ ... 'nFreeTHnsCore', 'nBridgeTHnsCore', 'nDangleTHnsCore',
507
+ ... 'nCisTHnsCore', 'nTransTHnsCore', 'gyrTMon'))
508
+ >>> spec = ProbeSpec(
509
+ ... project, {'Mon': 'type 1', 'HnsPatch': 'type 2'}, 'Mon',
510
+ ... ('gyrT',), unwrap=True,
511
+ ... binders=BinderSpec('HnsPatch', 'HnsCore', cutoff=0.685,
512
+ ... cis_threshold=3))
513
+ >>> universe = mda.Universe.empty(6, trajectory=True)
514
+ >>> universe.add_TopologyAttr('types', ['1'] * 4 + ['2'] * 2)
515
+ >>> analysis = BinderStatistics.from_spec(universe, spec)
516
+ >>> analysis.chain.n_atoms, analysis.patches.n_atoms
517
+ (4, 2)
518
+ >>> analysis.topology, analysis.periodic, analysis.cutoff
519
+ ('ring', (True, True, True), 0.685)
520
+ """
521
+ if spec.binders is None:
522
+ raise ValueError(
523
+ f"{spec.name}: the probe spec has no 'binders'.")
524
+ binders = spec.binders
525
+ chain = universe.select_atoms(spec.selections[spec.chain])
526
+ patches = universe.select_atoms(spec.selections[binders.patches])
527
+ return cls(chain, patches, cutoff=binders.cutoff,
528
+ topology=spec.project.topology,
529
+ inclusive=binders.inclusive,
530
+ cis_threshold=binders.cis_threshold,
531
+ patches_per_binder=binders.patches_per_binder,
532
+ periodic=_PERIODIC[spec.geometry],
533
+ store_distances=binders.store_distances,
534
+ chain_label=spec.chain, patch_label=binders.patches,
535
+ binder_label=binders.binders, verbose=verbose)
536
+
537
+ @classmethod
538
+ def get_supported_backends(cls) -> tuple[str, ...]:
539
+ """
540
+ The backends :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`
541
+ accepts: ``'serial'``, ``'multiprocessing'``, and ``'dask'``.
542
+
543
+ Examples
544
+ --------
545
+ >>> BinderStatistics.get_supported_backends()
546
+ ('serial', 'multiprocessing', 'dask')
547
+ """
548
+ return _BACKENDS
549
+
550
+ @property
551
+ def keys(self) -> tuple[str, ...]:
552
+ """
553
+ The keys of ``results``, in the order :meth:`save` writes them:
554
+ the eight series of
555
+ :data:`~polyphys.spec.BINDER_SERIES`, the loop-length
556
+ histogram, and, with `store_distances`, the distances.
557
+
558
+ Examples
559
+ --------
560
+ >>> import MDAnalysis as mda
561
+ >>> universe = mda.Universe.empty(4, trajectory=True)
562
+ >>> analysis = BinderStatistics(
563
+ ... universe.atoms[:2], universe.atoms[2:], cutoff=0.5,
564
+ ... topology='linear', cis_threshold=3)
565
+ >>> analysis.keys[-2:]
566
+ ('n_pairs_trans', 'loop_length_histogram')
567
+ """
568
+ keys = (*BINDER_SERIES, LOOP_KEY)
569
+ if self.store_distances:
570
+ keys = (*keys, DISTANCES_KEY)
571
+ return keys
572
+
573
+ def _compute(
574
+ self, indexed_frames: np.ndarray, verbose: Any = None, *,
575
+ progressbar_kwargs: Any = None,
576
+ ) -> Any:
577
+ """
578
+ Compute one part of the run on a copy of this analysis, so that
579
+ serial parts do not overwrite each other.
580
+ """
581
+ return _compute_part(self, indexed_frames, verbose,
582
+ progressbar_kwargs=progressbar_kwargs)
583
+
584
+ def _get_aggregator(self) -> ResultsGroup:
585
+ """
586
+ Concatenate each worker's arrays along the frame axis.
587
+ """
588
+ return ResultsGroup(lookup={
589
+ key: (ResultsGroup.ndarray_hstack if key in BINDER_SERIES
590
+ else ResultsGroup.ndarray_vstack)
591
+ for key in self.keys})
592
+
593
+ def _prepare(self) -> None:
594
+ """
595
+ Allocate the arrays for the frames of this run or worker: int64
596
+ series and histogram rows, float64 distances.
597
+ """
598
+ for attr in BINDER_SERIES:
599
+ self.results[attr] = np.empty(self.n_frames, dtype=np.int64)
600
+ self.results[LOOP_KEY] = np.zeros(
601
+ (self.n_frames, self.n_lengths), dtype=np.int64)
602
+ if self.store_distances:
603
+ self.results[DISTANCES_KEY] = np.empty(
604
+ (self.n_frames, self.chain.n_atoms, self.patches.n_atoms),
605
+ dtype=np.float64)
606
+
607
+ def _single_frame(self) -> None:
608
+ """
609
+ Form the distances of the current frame, mark the contacts, count
610
+ them, and store the counts and the histogram row for this frame.
611
+ """
612
+ if any(self.periodic):
613
+ dimensions = self._ts.dimensions
614
+ if dimensions is None:
615
+ raise ValueError(
616
+ f"frame {self._ts.frame} carries no box, which the "
617
+ f"periodic directions {self.periodic} need.")
618
+ box_lengths = np.asarray(dimensions[:3], dtype=np.float64)
619
+ else:
620
+ box_lengths = np.zeros(3)
621
+ distances = _pair_distances(
622
+ np.asarray(self.chain.positions, dtype=np.float64),
623
+ np.asarray(self.patches.positions, dtype=np.float64),
624
+ box_lengths, self.periodic)
625
+ contacts = find_direct_contacts(distances, self.cutoff,
626
+ inclusive=self.inclusive)
627
+ frame = binding_statistics(contacts, cast(Topology, self.topology),
628
+ self.cis_threshold)
629
+ index = self._frame_index
630
+ for attr in BINDER_SERIES:
631
+ self.results[attr][index] = getattr(frame, attr)
632
+ self.results[LOOP_KEY][index] = np.bincount(
633
+ frame.bridged_pairs[:, 2], minlength=self.n_lengths)
634
+ if self.store_distances:
635
+ self.results[DISTANCES_KEY][index] = distances
636
+
637
+ def save(self, save_to: Pathish, name: str) -> list[Path]:
638
+ """
639
+ Save each array as ``<name>-<series>.npy``, under the research
640
+ code's names.
641
+
642
+ .. versionadded:: 0.7.0
643
+
644
+ Parameters
645
+ ----------
646
+ save_to : str or pathlib.Path
647
+ An existing directory.
648
+ name : str
649
+ The artifact's name, ``<parser.name>-<parser.group>`` in the
650
+ studies, such as ``'N200...ens1-nucleoid'``.
651
+
652
+ Returns
653
+ -------
654
+ list of pathlib.Path
655
+ The files written, in the order of :attr:`keys`: the eight
656
+ series as ``<stem><patch_label>`` or ``<stem><binder_label>``
657
+ (``nBoundTHnsPatch``, ``nFreeTHnsPatch``, ``nEngagedTHnsPatch``,
658
+ ``nFreeTHnsCore``, ``nBridgeTHnsCore``, ``nDangleTHnsCore``,
659
+ ``nCisTHnsCore``, ``nTransTHnsCore`` with the default labels),
660
+ the histogram as ``loopLengthHistT<chain_label>``, and, with
661
+ `store_distances`, the distances as
662
+ ``distMatT<chain_label><patch_label>``.
663
+
664
+ Raises
665
+ ------
666
+ RuntimeError
667
+ If :meth:`~MDAnalysis.analysis.base.AnalysisBase.run` has not been
668
+ called.
669
+
670
+ Notes
671
+ -----
672
+ The research code summed the loop-length histogram over the frames
673
+ into ``loopLengthHist<chain_label>``; the per-frame rows saved here
674
+ sum to it along the frame axis.
675
+
676
+ Examples
677
+ --------
678
+ >>> import tempfile
679
+ >>> import MDAnalysis as mda
680
+ >>> import numpy as np
681
+ >>> universe = mda.Universe.empty(4, trajectory=True)
682
+ >>> universe.atoms.positions = np.array(
683
+ ... [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0],
684
+ ... [0.0, 0.3, 0.0], [5.0, 5.0, 5.0]])
685
+ >>> analysis = BinderStatistics(
686
+ ... universe.atoms[:2], universe.atoms[2:], cutoff=0.5,
687
+ ... topology='linear', cis_threshold=3).run()
688
+ >>> with tempfile.TemporaryDirectory() as tmp:
689
+ ... paths = analysis.save(tmp, 'N2ens1-nucleoid')
690
+ ... names = [path.name for path in paths]
691
+ ... dangling = np.load(paths[5]).tolist()
692
+ >>> names[0]
693
+ 'N2ens1-nucleoid-nBoundTHnsPatch.npy'
694
+ >>> names[5], dangling
695
+ ('N2ens1-nucleoid-nDangleTHnsCore.npy', [1])
696
+ >>> names[8]
697
+ 'N2ens1-nucleoid-loopLengthHistTMon.npy'
698
+ """
699
+ if any(key not in self.results for key in self.keys):
700
+ raise RuntimeError("Call run() before save().")
701
+ labels = {'patches': self.patch_label, 'binders': self.binder_label}
702
+ stems = {attr: f"{stem}{labels[which]}"
703
+ for attr, (stem, which) in BINDER_SERIES.items()}
704
+ stems[LOOP_KEY] = f"loopLengthHistT{self.chain_label}"
705
+ stems[DISTANCES_KEY] = f"distMatT{self.chain_label}{self.patch_label}"
706
+ paths = []
707
+ for key in self.keys:
708
+ path = Path(save_to) / f"{name}-{stems[key]}.npy"
709
+ np.save(path, self.results[key])
710
+ paths.append(path)
711
+ return paths