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,583 @@
1
+ """
2
+ ==========================================================
3
+ :mod:`polyphys.probe.chain_size`
4
+ ==========================================================
5
+
6
+ The :mod:`polyphys.probe.chain_size` module measures the size and shape of
7
+ one polymer chain in every frame of a trajectory: its end-to-end distance,
8
+ its span along the cylinder axis, its transverse size, its radius of
9
+ gyration, and the asphericity, shape parameter, and principal axes of its
10
+ gyration tensor. :class:`ChainSize` is an MDAnalysis
11
+ :class:`~MDAnalysis.analysis.base.AnalysisBase`, so it runs over a slice of
12
+ frames in serial or, split across workers, in parallel, and stores one
13
+ array per observable in its ``results``.
14
+
15
+ The research code measured the same seven quantities in one loop over the
16
+ frames of each study's probe function, after shifting every position by
17
+ half the box. Every quantity here is invariant under such a shift, because
18
+ each one first recenters the chain on its center of geometry or of mass,
19
+ so the shift is dropped.
20
+
21
+ Contents
22
+ ========
23
+
24
+ - :class:`ChainSize`, the per-frame chain-size analysis.
25
+
26
+ Dependencies
27
+ ============
28
+ - Python standard library: `pathlib`, `typing`.
29
+ - `NumPy <https://numpy.org/>`_.
30
+ - `MDAnalysis <https://docs.mdanalysis.org/>`_ 2.8 or later, through the
31
+ ``mda`` extra: :class:`~MDAnalysis.analysis.base.AnalysisBase`,
32
+ :class:`~MDAnalysis.analysis.results.ResultsGroup`, and the shape methods
33
+ of :class:`~MDAnalysis.core.groups.AtomGroup`.
34
+ - :mod:`polyphys.analyze.measurer`: :func:`~polyphys.analyze.measurer.fsd`,
35
+ :func:`~polyphys.analyze.measurer.end_to_end`, and
36
+ :func:`~polyphys.analyze.measurer.transverse_size`.
37
+ - :mod:`polyphys.spec`:
38
+ :data:`~polyphys.spec.CHAIN_SIZE_OBSERVABLES` and
39
+ :class:`~polyphys.spec.ProbeSpec`.
40
+ - :mod:`polyphys.probe.core`: ``_ProbeAnalysis``, the base whose ``run``
41
+ closes the pool of the ``multiprocessing`` backend, and
42
+ ``_compute_part``, which computes each part of a split run on its own
43
+ copy of the analysis.
44
+
45
+ Notation
46
+ ========
47
+ :math:`N`
48
+ The number of beads in the chain, ``atomgroup.n_atoms``.
49
+ :math:`\\mathbf{r}_i`, :math:`m_i`
50
+ The position and mass of bead :math:`i`, in the dump's length and mass
51
+ units; the studies use the monomer diameter and mass.
52
+ :math:`\\mathbf{r}_\\mathrm{cg}`, :math:`\\mathbf{r}_\\mathrm{cm}`
53
+ The center of geometry :math:`\\sum_i \\mathbf{r}_i / N` and the center
54
+ of mass :math:`\\sum_i m_i \\mathbf{r}_i / \\sum_i m_i`.
55
+ :math:`\\mathbf{S}`, :math:`\\lambda_1 \\ge \\lambda_2 \\ge \\lambda_3`
56
+ The gyration tensor
57
+ :math:`S_{ab} = \\sum_i m_i (r_{i,a} - r_{\\mathrm{cm},a})
58
+ (r_{i,b} - r_{\\mathrm{cm},b}) / \\sum_i m_i`, and its eigenvalues,
59
+ which MDAnalysis's ``gyration_moments`` returns.
60
+
61
+ Examples
62
+ ========
63
+ A straight chain of four beads one unit apart, in a one-frame universe:
64
+
65
+ >>> import MDAnalysis as mda
66
+ >>> import numpy as np
67
+ >>> universe = mda.Universe.empty(4, trajectory=True)
68
+ >>> universe.add_TopologyAttr('masses', np.ones(4))
69
+ >>> universe.atoms.positions = np.array(
70
+ ... [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]])
71
+ >>> analysis = ChainSize(universe.atoms, ('rfloryT', 'asphericityT')).run()
72
+ >>> analysis.results['rfloryT'], analysis.results['asphericityT']
73
+ (array([3.]), array([1.]))
74
+
75
+ .. versionadded:: 0.7.0
76
+ """
77
+ from pathlib import Path
78
+ from typing import Any, Callable, Mapping, Self, Sequence
79
+
80
+ import numpy as np
81
+ from MDAnalysis.analysis.results import ResultsGroup
82
+ from MDAnalysis.core.groups import AtomGroup
83
+ from MDAnalysis.core.universe import Universe
84
+
85
+ from ..analyze import measurer
86
+ from ..spec import CHAIN_SIZE_OBSERVABLES, ProbeSpec
87
+ from ..manage.types import AxisT, EntityT, Pathish, PropertyT
88
+ from .core import _ProbeAnalysis, _compute_part
89
+
90
+ __all__ = [
91
+ 'ChainSize',
92
+ ]
93
+
94
+ # One frame's value of an observable: a function of the chain, the cylinder
95
+ # axis, and whether to unwrap the chain.
96
+ _Measure = Callable[[AtomGroup, AxisT, bool], Any]
97
+
98
+
99
+ def _trans_size(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
100
+ return measurer.transverse_size(group.positions, axis)
101
+
102
+
103
+ def _fsd(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
104
+ return measurer.fsd(group.positions, axis)
105
+
106
+
107
+ def _gyr(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
108
+ return group.radius_of_gyration()
109
+
110
+
111
+ def _rflory(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
112
+ return measurer.end_to_end(group.positions)
113
+
114
+
115
+ def _asphericity(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
116
+ return group.asphericity(wrap=False, unwrap=unwrap)
117
+
118
+
119
+ def _shape(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
120
+ return group.shape_parameter(wrap=False)
121
+
122
+
123
+ def _principal(group: AtomGroup, axis: AxisT, unwrap: bool) -> Any:
124
+ return group.principal_axes(wrap=False)
125
+
126
+
127
+ # The measure of each observable, keyed as CHAIN_SIZE_OBSERVABLES names them.
128
+ _MEASURES: Mapping[PropertyT, _Measure] = {
129
+ 'transSizeT': _trans_size,
130
+ 'fsdT': _fsd,
131
+ 'gyrT': _gyr,
132
+ 'rfloryT': _rflory,
133
+ 'asphericityT': _asphericity,
134
+ 'shapeT': _shape,
135
+ 'principalT': _principal,
136
+ }
137
+
138
+ # The shape of one frame's value, after the frame axis; a scalar otherwise.
139
+ _FRAME_SHAPES: Mapping[PropertyT, tuple[int, ...]] = {'principalT': (3, 3)}
140
+
141
+ _BACKENDS = ('serial', 'multiprocessing', 'dask')
142
+
143
+
144
+ def _chain_bonds(atomgroup: AtomGroup) -> np.ndarray:
145
+ """
146
+ The bonds within `atomgroup`, as pairs of positions in the group, shape
147
+ ``(n_bonds, 2)``; empty when the topology has no bonds. Time
148
+ :math:`O(N + B \\log N)` and space :math:`O(N + B)` for :math:`N` beads
149
+ and :math:`B` bonds.
150
+ """
151
+ try:
152
+ bonds = atomgroup.intra_bonds.indices
153
+ except AttributeError:
154
+ return np.empty((0, 2), dtype=np.intp)
155
+ order = np.argsort(atomgroup.indices)
156
+ local: np.ndarray = order[
157
+ np.searchsorted(atomgroup.indices, bonds, sorter=order)]
158
+ return local
159
+
160
+
161
+ class ChainSize(_ProbeAnalysis):
162
+ """
163
+ The size and shape of one polymer chain, measured in every frame.
164
+
165
+ .. versionadded:: 0.7.0
166
+
167
+ Parameters
168
+ ----------
169
+ atomgroup : :class:`~MDAnalysis.core.groups.AtomGroup`
170
+ The chain, at least two beads, in atom-ID order: the order along
171
+ the backbone, which ``Universe.select_atoms`` gives.
172
+ observables : sequence of :py:obj:`~.PropertyT`
173
+ The observables to measure, each a name in
174
+ :data:`~polyphys.spec.CHAIN_SIZE_OBSERVABLES`, in the
175
+ order ``results`` and :meth:`save` list them.
176
+ chain : :py:obj:`~.EntityT`, default 'Mon'
177
+ The species label of the chain, which names the files
178
+ :meth:`save` writes, ``<name>-<observable><chain>.npy``.
179
+ unwrap : bool, default False
180
+ Whether the chain is unwrapped across the periodic boundaries
181
+ before its gyration tensor is formed, for ``asphericityT``. The
182
+ research code unwrapped in a periodic cube and not in a cylinder.
183
+ Every other observable reads the positions as the dump gives them,
184
+ so the chain must be whole; see Notes.
185
+ axis : :py:obj:`~.AxisT`, default 2
186
+ The cylinder axis, ``0``, ``1``, or ``2`` for ``x``, ``y``, or
187
+ ``z``, along which ``fsdT`` spans and across which ``transSizeT``
188
+ spreads.
189
+ verbose : bool, default False
190
+ Whether to show a progress bar during
191
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`.
192
+
193
+ Attributes
194
+ ----------
195
+ atomgroup : :class:`~MDAnalysis.core.groups.AtomGroup`
196
+ The chain.
197
+ observables : tuple of :py:obj:`~.PropertyT`
198
+ The observables, in order.
199
+ chain : :py:obj:`~.EntityT`
200
+ The species label of the chain.
201
+ unwrap : bool
202
+ Whether the chain is unwrapped for ``asphericityT``.
203
+ axis : :py:obj:`~.AxisT`
204
+ The cylinder axis.
205
+ results : :class:`~MDAnalysis.analysis.results.Results`
206
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, one float64
207
+ array per observable, keyed by its name: shape ``(n_frames,)`` for
208
+ every scalar, ``(n_frames, 3, 3)`` for ``principalT``.
209
+ n_frames : int
210
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the number of
211
+ frames analyzed, from :class:`~MDAnalysis.analysis.base.AnalysisBase`.
212
+ frames : numpy.ndarray
213
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the indices
214
+ of the frames analyzed, shape ``(n_frames,)``.
215
+ times : numpy.ndarray
216
+ After :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, the times of
217
+ the frames analyzed, shape ``(n_frames,)``, in the trajectory's time
218
+ unit.
219
+
220
+ Raises
221
+ ------
222
+ ValueError
223
+ If `observables` is empty, repeats a name, or names an observable that
224
+ is not in :data:`~polyphys.spec.CHAIN_SIZE_OBSERVABLES`; if `chain` is
225
+ empty; if `axis` is not ``0``, ``1``, or ``2``; or if `atomgroup` has
226
+ fewer than two beads. During
227
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`, if a chain bond
228
+ spans half the frame's box or more along a direction, which means the
229
+ dump splits the chain across a periodic boundary; the message names the
230
+ frame.
231
+
232
+ Notes
233
+ -----
234
+ The observables, with the species label ``Mon`` as the studies name
235
+ them:
236
+
237
+ ``rfloryTMon``
238
+ :math:`|\\mathbf{r}_N - \\mathbf{r}_1|`, the end-to-end distance
239
+ (:func:`~polyphys.analyze.measurer.end_to_end`).
240
+ ``fsdTMon``
241
+ :math:`\\max_i z_i - \\min_i z_i` along `axis`, the span or Feret
242
+ diameter (:func:`~polyphys.analyze.measurer.fsd`).
243
+ ``transSizeTMon``
244
+ :math:`2 \\langle |\\mathbf{r}_{i,\\perp} -
245
+ \\mathbf{r}_{\\mathrm{cg},\\perp}| \\rangle_i`, twice the mean
246
+ distance from the center of geometry in the plane across `axis`
247
+ (:func:`~polyphys.analyze.measurer.transverse_size`).
248
+ ``gyrTMon``
249
+ :math:`R_g = \\sqrt{\\lambda_1 + \\lambda_2 + \\lambda_3}`, the
250
+ mass-weighted radius of gyration
251
+ (``AtomGroup.radius_of_gyration``).
252
+ ``asphericityTMon``
253
+ :math:`\\frac{3}{2} \\sum_k (\\lambda_k - \\bar\\lambda)^2 /
254
+ (\\sum_k \\lambda_k)^2`, from 0 for a spherically symmetric chain
255
+ to 1 for a straight one
256
+ (``AtomGroup.asphericity(wrap=False, unwrap=unwrap)``).
257
+ ``shapeTMon``
258
+ :math:`27 \\prod_k (\\lambda_k - \\bar\\lambda) /
259
+ (\\sum_k \\lambda_k)^3`, from :math:`-1/4` for a flat chain with two
260
+ equal eigenvalues and a zero one, such as a planar ring, through 0
261
+ for a spherically symmetric one, to 2 for a straight one
262
+ (``AtomGroup.shape_parameter(wrap=False)``).
263
+ ``principalTMon``
264
+ The :math:`3 \\times 3` matrix of principal axes of the moment of
265
+ inertia, one eigenvector per row, by decreasing eigenvalue
266
+ (``AtomGroup.principal_axes(wrap=False)``).
267
+
268
+ The asphericity and shape parameter follow Dima and Thirumalai [1]_,
269
+ as MDAnalysis implements them. ``shapeT`` and ``principalT`` read the
270
+ positions as the dump wraps them, whatever `unwrap` is, as the research
271
+ code did; `unwrap` reaches ``asphericityT`` only.
272
+
273
+ Whole molecules: every observable but an unwrapped ``asphericityT`` reads
274
+ the positions as the dump gives them, so the dump must hold the chain
275
+ whole, as the studies' dumps do. In each frame,
276
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run` checks that no bond
277
+ :math:`(i, j)` of the chain spans half the box or more along any direction
278
+ :math:`a`, :math:`|r_{i,a} - r_{j,a}| \\ge L_a / 2` for the frame's box
279
+ length :math:`L_a`, and raises on the first frame that fails. A bond is far
280
+ shorter than half the box (the studies' FENE bound :math:`R_0 = 1.5`,
281
+ ``docs/source/design.md``), so a bond that long has been split by the
282
+ boundary. The check is skipped when the frame carries no box or the
283
+ topology has no bonds, and when ``asphericityT`` with `unwrap` is the only
284
+ observable, since unwrapping makes that one whole itself.
285
+
286
+ No per-frame shift of the positions is applied: each observable
287
+ recenters the chain itself, so the research code's shift of every
288
+ position by half the box changed none of these values.
289
+
290
+ The MDAnalysis contract: ``_prepare`` allocates one float64 array
291
+ per observable for the frames of the run, ``_single_frame`` writes
292
+ the current frame's row, and ``_conclude`` does nothing. The class
293
+ is marked parallelizable, with ``serial``, ``multiprocessing``, and
294
+ ``dask`` backends; each worker fills the arrays of its own frames,
295
+ which ``_get_aggregator`` concatenates along the frame axis
296
+ (``ResultsGroup.ndarray_hstack`` for a scalar series,
297
+ ``ResultsGroup.ndarray_vstack`` for ``principalT``).
298
+
299
+ Cost per frame: :math:`O(N)` time for every observable, since each is a
300
+ sum over the beads followed by at most a :math:`3 \\times 3`
301
+ eigenproblem, plus the :math:`O(N + N_\\mathrm{bonds})` unwrap of the
302
+ chain when `unwrap` is set, and the :math:`O(N_\\mathrm{bonds})` check
303
+ that the chain is whole; :math:`O(N + N_\\mathrm{bonds})` extra space
304
+ for the recentered positions and the bond spans. Over a run,
305
+ :math:`O(F)` space per scalar series and :math:`O(9 F)` for
306
+ ``principalT``, for :math:`F` frames. Positions come
307
+ from MDAnalysis as float32, so each value carries a relative round-off
308
+ of about :math:`10^{-7}`.
309
+
310
+ References
311
+ ----------
312
+ .. [1] R. I. Dima and D. Thirumalai, "Asymmetry in the Shapes of Folded
313
+ and Denatured States of Proteins", J. Phys. Chem. B 108, 6564-6570
314
+ (2004). https://doi.org/10.1021/jp037128y
315
+
316
+ Examples
317
+ --------
318
+ Two frames of a three-bead chain, the second frame stretched along z:
319
+
320
+ >>> import MDAnalysis as mda
321
+ >>> import numpy as np
322
+ >>> frames = np.array([
323
+ ... [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
324
+ ... [[0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 2.0]]])
325
+ >>> universe = mda.Universe.empty(3, trajectory=True)
326
+ >>> universe.add_TopologyAttr('masses', np.ones(3))
327
+ >>> universe.load_new(frames, format='MEMORY')
328
+ <Universe with 3 atoms>
329
+ >>> analysis = ChainSize(universe.atoms, ('rfloryT', 'fsdT', 'gyrT'),
330
+ ... axis=2)
331
+ >>> analysis = analysis.run()
332
+ >>> np.round(analysis.results['rfloryT'], 6)
333
+ array([1.414214, 2. ])
334
+ >>> analysis.results['fsdT']
335
+ array([0., 2.])
336
+
337
+ The frames to analyze are chosen as in any MDAnalysis analysis:
338
+
339
+ >>> ChainSize(universe.atoms, ('fsdT',)).run(start=1).results['fsdT']
340
+ array([2.])
341
+ """
342
+
343
+ _analysis_algorithm_is_parallelizable = True
344
+
345
+ def __init__(
346
+ self,
347
+ atomgroup: AtomGroup,
348
+ observables: Sequence[PropertyT],
349
+ *,
350
+ chain: EntityT = 'Mon',
351
+ unwrap: bool = False,
352
+ axis: AxisT = 2,
353
+ verbose: bool = False,
354
+ ) -> None:
355
+ super().__init__(atomgroup.universe.trajectory, verbose=verbose)
356
+ observables = tuple(observables)
357
+ if not observables:
358
+ raise ValueError("'observables' must not be empty.")
359
+ unknown = [obs for obs in observables if obs not in _MEASURES]
360
+ if unknown:
361
+ raise ValueError(
362
+ f"'observables' names {unknown}, which are not in "
363
+ f"{list(CHAIN_SIZE_OBSERVABLES)}.")
364
+ if len(set(observables)) != len(observables):
365
+ raise ValueError(
366
+ f"'observables' repeats a name: {list(observables)}.")
367
+ if not (isinstance(chain, str) and chain):
368
+ raise ValueError(
369
+ f"'chain' must be a non-empty species label, not {chain!r}.")
370
+ if axis not in (0, 1, 2):
371
+ raise ValueError(f"'axis' must be 0, 1, or 2, not {axis!r}.")
372
+ if atomgroup.n_atoms < 2:
373
+ raise ValueError(
374
+ f"'atomgroup' must hold at least two beads, not "
375
+ f"{atomgroup.n_atoms}.")
376
+ self.atomgroup = atomgroup
377
+ self.observables = observables
378
+ self.chain = chain
379
+ self.unwrap = unwrap
380
+ self.axis = axis
381
+ # Every observable but an unwrapped asphericity reads the positions
382
+ # as the dump wraps them, so the chain must be whole in each frame.
383
+ self._check_whole = unwrap is False or any(
384
+ obs != 'asphericityT' for obs in observables)
385
+ self._bonds = _chain_bonds(atomgroup)
386
+
387
+ @classmethod
388
+ def from_spec(
389
+ cls,
390
+ universe: Universe,
391
+ spec: ProbeSpec,
392
+ *,
393
+ axis: AxisT = 2,
394
+ verbose: bool = False,
395
+ ) -> Self:
396
+ """
397
+ Build the analysis of a study's chain from its probe spec.
398
+
399
+ The chain is ``universe.select_atoms(spec.selections[spec.chain])``;
400
+ the observables, the chain label, and `unwrap` are the spec's.
401
+
402
+ .. versionadded:: 0.7.0
403
+
404
+ Parameters
405
+ ----------
406
+ universe : :class:`~MDAnalysis.core.universe.Universe`
407
+ The trajectory, as :func:`~polyphys.probe.core.make_universe`
408
+ opens it.
409
+ spec : :class:`~polyphys.spec.ProbeSpec`
410
+ The study's probe spec.
411
+ axis : :py:obj:`~.AxisT`, default 2
412
+ The cylinder axis.
413
+ verbose : bool, default False
414
+ Whether to show a progress bar during
415
+ :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`.
416
+
417
+ Returns
418
+ -------
419
+ ChainSize
420
+ The analysis, not yet run.
421
+
422
+ Raises
423
+ ------
424
+ ValueError
425
+ If the selection picks fewer than two beads.
426
+
427
+ Examples
428
+ --------
429
+ >>> import MDAnalysis as mda
430
+ >>> import numpy as np
431
+ >>> from polyphys.tests.synthetic import ChainCyl
432
+ >>> from polyphys.spec import ProbeSpec, ProjectSpec
433
+ >>> project = ProjectSpec(
434
+ ... parser=ChainCyl, group='bug', divisor=0.025,
435
+ ... crowder_attr='phi_bulk_c',
436
+ ... physical_attrs=('space', 'nmon'),
437
+ ... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
438
+ ... tseries_properties=('gyrTMon', 'rfloryTMon'))
439
+ >>> spec = ProbeSpec(project, {'Mon': 'resid 1'}, 'Mon',
440
+ ... ('rfloryT',), unwrap=False)
441
+ >>> universe = mda.Universe.empty(3, n_residues=1,
442
+ ... atom_resindex=[0, 0, 0],
443
+ ... trajectory=True)
444
+ >>> universe.add_TopologyAttr('resid', [1])
445
+ >>> universe.add_TopologyAttr('masses', np.ones(3))
446
+ >>> universe.atoms.positions = np.array(
447
+ ... [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]])
448
+ >>> analysis = ChainSize.from_spec(universe, spec)
449
+ >>> analysis.chain, analysis.atomgroup.n_atoms
450
+ ('Mon', 3)
451
+ >>> analysis.run().results['rfloryT']
452
+ array([2.])
453
+ """
454
+ chain = universe.select_atoms(spec.selections[spec.chain])
455
+ return cls(chain, spec.observables, chain=spec.chain,
456
+ unwrap=spec.unwrap, axis=axis, verbose=verbose)
457
+
458
+ @classmethod
459
+ def get_supported_backends(cls) -> tuple[str, ...]:
460
+ """
461
+ The backends :meth:`~MDAnalysis.analysis.base.AnalysisBase.run`
462
+ accepts: ``'serial'``, ``'multiprocessing'``, and ``'dask'``.
463
+
464
+ Examples
465
+ --------
466
+ >>> ChainSize.get_supported_backends()
467
+ ('serial', 'multiprocessing', 'dask')
468
+ """
469
+ return _BACKENDS
470
+
471
+ def _compute(
472
+ self, indexed_frames: np.ndarray, verbose: Any = None, *,
473
+ progressbar_kwargs: Any = None,
474
+ ) -> Any:
475
+ """
476
+ Compute one part of the run on a copy of this analysis, so that
477
+ serial parts do not overwrite each other.
478
+ """
479
+ return _compute_part(self, indexed_frames, verbose,
480
+ progressbar_kwargs=progressbar_kwargs)
481
+
482
+ def _get_aggregator(self) -> ResultsGroup:
483
+ """
484
+ Concatenate each worker's arrays along the frame axis.
485
+ """
486
+ return ResultsGroup(lookup={
487
+ obs: (ResultsGroup.ndarray_vstack if obs in _FRAME_SHAPES
488
+ else ResultsGroup.ndarray_hstack)
489
+ for obs in self.observables})
490
+
491
+ def _prepare(self) -> None:
492
+ """
493
+ Allocate one float64 array per observable for the frames of this
494
+ run or worker.
495
+ """
496
+ for obs in self.observables:
497
+ self.results[obs] = np.empty(
498
+ (self.n_frames, *_FRAME_SHAPES.get(obs, ())),
499
+ dtype=np.float64)
500
+
501
+ def _single_frame(self) -> None:
502
+ """
503
+ Check that the chain is whole, then measure every observable on the
504
+ current frame and store it in its array's row for this frame.
505
+ """
506
+ if self._check_whole:
507
+ self._raise_if_split()
508
+ for obs in self.observables:
509
+ self.results[obs][self._frame_index] = _MEASURES[obs](
510
+ self.atomgroup, self.axis, self.unwrap)
511
+
512
+ def _raise_if_split(self) -> None:
513
+ """
514
+ Raise if a chain bond spans half the frame's box or more along a
515
+ direction: the dump has split the chain across a periodic boundary.
516
+ """
517
+ dimensions = self._ts.dimensions
518
+ if dimensions is None or not len(self._bonds):
519
+ return
520
+ lengths = np.asarray(dimensions[:3], dtype=np.float64)
521
+ positions = self.atomgroup.positions.astype(np.float64)
522
+ spans = np.abs(positions[self._bonds[:, 0]]
523
+ - positions[self._bonds[:, 1]])
524
+ split = (lengths > 0) & (spans >= 0.5 * lengths)
525
+ if split.any():
526
+ directions = ', '.join(
527
+ 'xyz'[axis] for axis in np.flatnonzero(split.any(axis=0)))
528
+ raise ValueError(
529
+ f"frame {self._ts.frame}: "
530
+ f"{int(np.count_nonzero(split.any(axis=1)))} of "
531
+ f"{len(self._bonds)} chain bonds span half the box or more "
532
+ f"along {directions}, so the dump splits the chain across a "
533
+ f"periodic boundary; the chain-size observables need whole "
534
+ f"molecules.")
535
+
536
+ def save(self, save_to: Pathish, name: str) -> list[Path]:
537
+ """
538
+ Save each observable's array as ``<name>-<observable><chain>.npy``.
539
+
540
+ .. versionadded:: 0.7.0
541
+
542
+ Parameters
543
+ ----------
544
+ save_to : str or pathlib.Path
545
+ An existing directory.
546
+ name : str
547
+ The artifact's name, ``<parser.name>-<parser.group>`` in the
548
+ studies, such as ``'N200...ens1-bug'``.
549
+
550
+ Returns
551
+ -------
552
+ list of pathlib.Path
553
+ The files written, one per observable, in order.
554
+
555
+ Raises
556
+ ------
557
+ RuntimeError
558
+ If :meth:`~MDAnalysis.analysis.base.AnalysisBase.run` has not been
559
+ called.
560
+
561
+ Examples
562
+ --------
563
+ >>> import tempfile
564
+ >>> import MDAnalysis as mda
565
+ >>> import numpy as np
566
+ >>> universe = mda.Universe.empty(2, trajectory=True)
567
+ >>> universe.add_TopologyAttr('masses', np.ones(2))
568
+ >>> universe.atoms.positions = np.array([[0.0, 0.0, 0.0],
569
+ ... [0.0, 0.0, 3.0]])
570
+ >>> analysis = ChainSize(universe.atoms, ('rfloryT', 'fsdT')).run()
571
+ >>> with tempfile.TemporaryDirectory() as tmp:
572
+ ... paths = analysis.save(tmp, 'N2ens1-bug')
573
+ ... [path.name for path in paths], np.load(paths[1]).tolist()
574
+ (['N2ens1-bug-rfloryTMon.npy', 'N2ens1-bug-fsdTMon.npy'], [3.0])
575
+ """
576
+ if any(obs not in self.results for obs in self.observables):
577
+ raise RuntimeError("Call run() before save().")
578
+ paths = []
579
+ for obs in self.observables:
580
+ path = Path(save_to) / f"{name}-{obs}{self.chain}.npy"
581
+ np.save(path, self.results[obs])
582
+ paths.append(path)
583
+ return paths