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,995 @@
1
+ """
2
+ ==========================================================
3
+ :mod:`polyphys.analyze.correlations`
4
+ ==========================================================
5
+
6
+
7
+ The :mod:`polyphys.analyze.correlations` module measures how a signal is
8
+ correlated with itself: the autocorrelation function of a time series with
9
+ its confidence band, the decay model fitted to such a curve, and the cosine
10
+ correlation between the bond vectors of a polymer chain, from which a
11
+ persistence length follows. Every routine is a pure NumPy or SciPy kernel on
12
+ arrays; reading time series or frames, choosing the lags to fit, and
13
+ averaging over frames are the caller's work.
14
+
15
+ Notation
16
+ ========
17
+
18
+ ``x``
19
+ A time series, shape ``(n_frames,)`` or ``(n_frames, n_series)``, in the
20
+ caller's unit; the frames are equally spaced in time and run along axis
21
+ 0, and each column is an independent series.
22
+ ``nlags``
23
+ The largest lag :math:`k` at which the autocorrelation is reported, in
24
+ frames; a lag is the number of frames between the two values compared.
25
+ ``acf``
26
+ The sample autocorrelation function :math:`r_k`, the covariance of the
27
+ series with itself :math:`k` frames later divided by its variance:
28
+ :math:`r_0 = 1` and :math:`|r_k| \\le 1`.
29
+ ``half_width``
30
+ The half-width of the confidence interval around :math:`r_k` at level
31
+ :math:`1 - \\alpha`, from Bartlett's large-sample variance of
32
+ :math:`r_k`; the interval is ``acf - half_width`` to
33
+ ``acf + half_width``.
34
+ *decay model*
35
+ One of the forms of the stretched exponential (Kohlrausch-Williams-Watts)
36
+ function :math:`A \\exp[-(x / \\tau)^\\beta] + B` listed in
37
+ :data:`DECAY_MODELS`, with the *decay time* :math:`\\tau` in the unit of
38
+ ``x``, the *stretching exponent* :math:`\\beta` (1 for a pure
39
+ exponential), the *amplitude* :math:`A`, and the *offset* :math:`B`.
40
+ ``positions``
41
+ The coordinates of the monomers of one chain in chain order, shape
42
+ ``(..., n_atoms, n_dims)``, unwrapped (no periodic image breaks a bond);
43
+ any leading axes, such as frames, are carried through.
44
+ ``topology``
45
+ ``'linear'`` or ``'ring'``: whether the chain's last monomer is bonded
46
+ back to its first.
47
+ *bond vector*
48
+ :math:`\\mathbf{b}_i = \\mathbf{r}_{i+1} - \\mathbf{r}_i`, and on a ring
49
+ also :math:`\\mathbf{b}_{n-1} = \\mathbf{r}_0 - \\mathbf{r}_{n-1}`;
50
+ ``n_bonds`` is ``n_atoms - 1`` on a linear chain and ``n_atoms`` on a
51
+ ring. :math:`\\hat{\\mathbf{u}}_i` is the unit bond vector.
52
+ *bond cosine correlation*
53
+ :math:`C(k) = \\langle \\hat{\\mathbf{u}}_i \\cdot
54
+ \\hat{\\mathbf{u}}_{i+k} \\rangle`, the mean cosine of the angle between
55
+ two bonds :math:`k` bonds apart along the chain, averaged over the pairs
56
+ in one frame; :math:`C(0) = 1`.
57
+
58
+ Contents
59
+ ========
60
+
61
+ Autocorrelation:
62
+ :func:`acf` estimates :math:`r_k` for :math:`k = 0, \\dots,` ``nlags`` by
63
+ the fast Fourier transform, with Bartlett's confidence band, as an
64
+ :class:`AcfResult`; :func:`bartlett_half_width` is that band on its own,
65
+ for a stored :math:`r_k` at any level.
66
+
67
+ Decay fits:
68
+ :data:`DECAY_MODELS` names the decay models; :func:`initial_guess` reads a
69
+ starting point for each parameter off the data; :func:`fit_decay` fits a
70
+ model with :func:`scipy.optimize.curve_fit` and reports the parameters,
71
+ their standard errors, and whether the fit converged as a
72
+ :class:`DecayFit`, whose :meth:`DecayFit.evaluate` draws the fitted curve.
73
+
74
+ Bonds:
75
+ :func:`bond_vectors` differences the positions along the chain;
76
+ :func:`bond_cosine_correlation` averages the cosines between bond pairs at
77
+ every lag by the fast Fourier transform.
78
+
79
+ Conventions
80
+ ===========
81
+
82
+ - The autocorrelation uses the ``1/n_frames`` estimator of the
83
+ autocovariance at every lag, which keeps the sequence positive
84
+ semi-definite and is the default of ``statsmodels.tsa.stattools.acf``;
85
+ dividing by ``n_frames - k`` instead is unbiased per lag but can give
86
+ :math:`|r_k| > 1`.
87
+ - Bartlett's variance for lag :math:`k` sums the squared autocorrelations at
88
+ the lags below it, :math:`\\mathrm{var}(r_k) = (1 + 2 \\sum_{j=1}^{k-1}
89
+ r_j^2) / n_\\mathrm{frames}` for :math:`k \\ge 2`, with
90
+ :math:`\\mathrm{var}(r_1) = 1 / n_\\mathrm{frames}` and
91
+ :math:`\\mathrm{var}(r_0) = 0`; this is the convention of
92
+ ``statsmodels``, so a band computed here matches one computed there.
93
+ - Decay models are written in the decay time :math:`\\tau`, not the rate
94
+ :math:`\\omega = \\tau^{-\\beta}` of some research code; convert with
95
+ :math:`\\tau = \\omega^{-1/\\beta}`.
96
+ - A failed fit never raises: :attr:`DecayFit.converged` is false and the
97
+ parameters are NaN.
98
+ - On a ring every lag :math:`k` has ``n_bonds`` pairs, going round the ring,
99
+ so :math:`C(k) = C(n_\\mathrm{bonds} - k)` and the lags that carry
100
+ information are :math:`0 \\le k \\le n_\\mathrm{bonds} / 2`. On a linear
101
+ chain lag :math:`k` has ``n_bonds - k`` pairs.
102
+ - Complexities are stated per call in ``n_frames`` (:math:`n`),
103
+ ``n_series`` (:math:`m`), ``n_bonds`` (:math:`N`), the number of leading
104
+ frames of a positions array (:math:`F`), and ``n_points`` of a fit.
105
+
106
+ References
107
+ ==========
108
+
109
+ - M. S. Bartlett, "On the theoretical specification and sampling properties
110
+ of autocorrelated time-series", Supplement to the Journal of the Royal
111
+ Statistical Society 8, 27-41 (1946). https://doi.org/10.2307/2983611
112
+ - NIST/SEMATECH e-Handbook of Statistical Methods, section 1.3.3.1,
113
+ "Autocorrelation Plot".
114
+ https://www.itl.nist.gov/div898/handbook/eda/section3/autocopl.htm
115
+ - G. Williams and D. C. Watts, "Non-symmetrical dielectric relaxation
116
+ behavior arising from a simple empirical decay function", Trans. Faraday
117
+ Soc. 66, 80-85 (1970). https://doi.org/10.1039/TF9706600080
118
+ - SciPy, :func:`scipy.optimize.curve_fit`.
119
+ https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.curve_fit.html
120
+ - M. Rubinstein and R. H. Colby, Polymer Physics (Oxford University Press,
121
+ 2003), Chapter 2, on the bond correlation and the persistence length.
122
+ - MDAnalysis, :class:`MDAnalysis.analysis.polymer.PersistenceLength`, the
123
+ same bond autocorrelation and exponential fit for linear chains.
124
+ https://docs.mdanalysis.org/stable/documentation_pages/analysis/polymer.html
125
+ - MDAnalysis User Guide, "Determining the persistence length of a polymer".
126
+ https://userguide.mdanalysis.org/stable/examples/analysis/polymers_and_membranes/polymer.html
127
+
128
+ .. versionadded:: 0.6.0
129
+ """
130
+
131
+ import logging
132
+ from collections.abc import Callable, Mapping
133
+ from dataclasses import dataclass
134
+ from typing import Any, Literal
135
+
136
+ import numpy as np
137
+ import numpy.typing as npt
138
+ from scipy.optimize import curve_fit
139
+ from scipy.stats import norm
140
+
141
+ __all__ = [
142
+ 'AcfResult',
143
+ 'DECAY_MODELS',
144
+ 'DecayFit',
145
+ 'DecayModel',
146
+ 'Topology',
147
+ 'acf',
148
+ 'bartlett_half_width',
149
+ 'bond_cosine_correlation',
150
+ 'bond_vectors',
151
+ 'fit_decay',
152
+ 'initial_guess',
153
+ ]
154
+
155
+ logger = logging.getLogger(__name__)
156
+
157
+ Topology = Literal['linear', 'ring']
158
+ """The polymer topologies the bond vectors are defined for."""
159
+
160
+ FloatArray = npt.NDArray[np.float64]
161
+ ModelFunction = Callable[..., FloatArray]
162
+
163
+
164
+ def _check_topology(topology: str) -> None:
165
+ """Raise ``ValueError`` unless `topology` is ``'linear'`` or ``'ring'``."""
166
+ if topology not in ('linear', 'ring'):
167
+ raise ValueError(
168
+ f"topology must be 'linear' or 'ring'; got '{topology}'."
169
+ )
170
+
171
+
172
+ def _fft_length(n: int) -> int:
173
+ """Return the smallest power of two that is at least ``2 n - 1``."""
174
+ return 1 << (2 * n - 1).bit_length()
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Autocorrelation
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class AcfResult:
184
+ """
185
+ The sample autocorrelation of one or more time series.
186
+
187
+ .. versionadded:: 0.6.0
188
+
189
+ Attributes
190
+ ----------
191
+ values : numpy.ndarray
192
+ :math:`r_k` for lags ``0`` to ``nlags``, shape ``(nlags + 1,)`` for a
193
+ one-dimensional series or ``(nlags + 1, n_series)`` for columns.
194
+ ``values[0]`` is 1.
195
+ half_width : numpy.ndarray or None
196
+ The half-width of the confidence interval around each value at
197
+ level ``1 - alpha``, the same shape as `values`; ``half_width[0]``
198
+ is 0. ``None`` when no band was asked for.
199
+ alpha : float or None
200
+ The significance level of the band, or ``None``.
201
+ """
202
+
203
+ values: FloatArray
204
+ half_width: FloatArray | None
205
+ alpha: float | None
206
+
207
+
208
+ def acf(
209
+ x: npt.ArrayLike,
210
+ nlags: int,
211
+ alpha: float | None = 0.05,
212
+ ) -> AcfResult:
213
+ """
214
+ Estimate the autocorrelation function of a time series by the FFT.
215
+
216
+ Parameters
217
+ ----------
218
+ x : array_like
219
+ The time series, shape ``(n_frames,)`` or ``(n_frames, n_series)``,
220
+ with the frames along axis 0 and one series per column; every value
221
+ finite, and no series constant.
222
+ nlags : int
223
+ The largest lag, in frames, from 0 to ``n_frames - 1``.
224
+ alpha : float or None, default 0.05
225
+ The significance level of the confidence band, in ``(0, 1)``; 0.05
226
+ gives a 95 % band. ``None`` skips the band.
227
+
228
+ Returns
229
+ -------
230
+ AcfResult
231
+ The autocorrelation at lags 0 to `nlags` and its band.
232
+
233
+ Raises
234
+ ------
235
+ ValueError
236
+ If `x` is not one- or two-dimensional or has fewer than two frames,
237
+ holds a non-finite value, or has a constant series; if `nlags` is
238
+ outside ``[0, n_frames - 1]``; or if `alpha` is outside ``(0, 1)``.
239
+
240
+ Notes
241
+ -----
242
+ With :math:`\\bar{x}` the mean of the series, the autocovariance is
243
+ :math:`c_k = \\frac{1}{n} \\sum_{t=0}^{n-k-1} (x_t - \\bar{x})
244
+ (x_{t+k} - \\bar{x})` and :math:`r_k = c_k / c_0` (NIST/SEMATECH
245
+ e-Handbook, section 1.3.3.1). The sum over :math:`t` for every
246
+ :math:`k` is one linear correlation, computed as the inverse transform
247
+ of the squared magnitude of the series' Fourier transform after
248
+ zero-padding to at least :math:`2n - 1` points so that no wrap-around
249
+ enters (Wiener-Khinchin).
250
+
251
+ The band is Bartlett's (1946) large-sample approximation under the
252
+ hypothesis that the autocorrelation vanishes beyond lag :math:`k - 1`:
253
+ :math:`\\mathrm{var}(r_k) \\approx (1 + 2 \\sum_{j=1}^{k-1} r_j^2) / n`,
254
+ so the band widens with the lag, and the half-width is
255
+ :math:`z_{1-\\alpha/2} \\sqrt{\\mathrm{var}(r_k)}` with :math:`z` the
256
+ standard normal quantile. For a white-noise series it reduces to
257
+ :math:`\\pm z_{1-\\alpha/2} / \\sqrt{n}`. The approximation needs
258
+ :math:`n \\gg` `nlags`.
259
+
260
+ Time is :math:`O(m\\, n \\log n)` and space :math:`O(m\\, n)`, with
261
+ :math:`n` the frames and :math:`m` the series.
262
+
263
+ Examples
264
+ --------
265
+ The alternating series ``1, -1, 1, -1, ...`` of 8 frames has mean 0,
266
+ :math:`c_k = (-1)^k (8 - k) / 8`, and so :math:`r_1 = -7/8` and
267
+ :math:`r_2 = 6/8`:
268
+
269
+ >>> import numpy as np
270
+ >>> from polyphys.analyze.correlations import acf
271
+ >>> x = np.tile([1.0, -1.0], 4)
272
+ >>> result = acf(x, nlags=2)
273
+ >>> np.round(result.values, 6)
274
+ array([ 1. , -0.875, 0.75 ])
275
+
276
+ The 95 % band is 0 at lag 0, :math:`1.96 / \\sqrt{8}` at lag 1, and
277
+ wider at lag 2 because :math:`r_1^2` enters:
278
+
279
+ >>> np.round(result.half_width, 4)
280
+ array([0. , 0.693 , 1.1025])
281
+
282
+ .. versionadded:: 0.6.0
283
+ """
284
+ series = np.asarray(x, dtype=np.float64)
285
+ if series.ndim not in (1, 2):
286
+ raise ValueError(
287
+ f'x must be one- or two-dimensional; got {series.ndim}'
288
+ ' dimension(s).'
289
+ )
290
+ n_frames = series.shape[0]
291
+ if n_frames < 2:
292
+ raise ValueError(f'x must have at least two frames; got {n_frames}.')
293
+ if not np.all(np.isfinite(series)):
294
+ raise ValueError('x must be finite.')
295
+ if not 0 <= nlags < n_frames:
296
+ raise ValueError(
297
+ f'nlags must be between 0 and n_frames - 1 = {n_frames - 1};'
298
+ f' got {nlags}.'
299
+ )
300
+ if alpha is not None and not 0 < alpha < 1:
301
+ raise ValueError(f'alpha must be in (0, 1); got {alpha}.')
302
+ if np.any(np.all(series == series[0], axis=0)):
303
+ raise ValueError('x has a constant series; its acf is undefined.')
304
+
305
+ centered = series - series.mean(axis=0)
306
+ n_fft = _fft_length(n_frames)
307
+ spectrum = np.fft.rfft(centered, n=n_fft, axis=0)
308
+ autocov = np.fft.irfft(np.abs(spectrum) ** 2, n=n_fft, axis=0)
309
+ autocov = autocov[: nlags + 1] / n_frames
310
+ values: FloatArray = autocov / autocov[0]
311
+ values[0] = 1.0
312
+
313
+ half_width: FloatArray | None = None
314
+ if alpha is not None:
315
+ half_width = bartlett_half_width(values, n_frames, alpha)
316
+ return AcfResult(values=values, half_width=half_width, alpha=alpha)
317
+
318
+
319
+ def bartlett_half_width(
320
+ values: npt.ArrayLike,
321
+ n_frames: int,
322
+ alpha: float = 0.05,
323
+ ) -> FloatArray:
324
+ """
325
+ The half-width of Bartlett's confidence band around an autocorrelation.
326
+
327
+ Parameters
328
+ ----------
329
+ values : array_like
330
+ The sample autocorrelation :math:`r_k` for lags 0 to ``nlags``,
331
+ shape ``(nlags + 1,)`` or ``(nlags + 1, n_series)``, as
332
+ :func:`acf` returns it.
333
+ n_frames : int
334
+ The number of frames the autocorrelation was estimated from, at
335
+ least 2.
336
+ alpha : float, default 0.05
337
+ The significance level of the band, in ``(0, 1)``; 0.05 gives a 95 %
338
+ band.
339
+
340
+ Returns
341
+ -------
342
+ numpy.ndarray
343
+ The half-width at each lag, the same shape as `values`, ``float64``;
344
+ 0 at lag 0. The band is ``values - half_width`` to
345
+ ``values + half_width``.
346
+
347
+ Raises
348
+ ------
349
+ ValueError
350
+ If `values` is not one- or two-dimensional, `n_frames` is below 2,
351
+ or `alpha` is outside ``(0, 1)``.
352
+
353
+ Notes
354
+ -----
355
+ Bartlett's (1946) large-sample variance under the hypothesis that the
356
+ autocorrelation vanishes beyond lag :math:`k - 1` is
357
+ :math:`\\mathrm{var}(r_k) \\approx (1 + 2 \\sum_{j=1}^{k-1} r_j^2) / n`,
358
+ and the half-width is :math:`z_{1-\\alpha/2} \\sqrt{\\mathrm{var}(r_k)}`.
359
+ The band depends on the stored :math:`r_k`, `n_frames`, and `alpha`
360
+ only, so a pipeline need store the autocorrelation alone and draw the
361
+ band at any level later. Time and space are :math:`O(m\\, k)`, with
362
+ :math:`k` the lags and :math:`m` the series.
363
+
364
+ Examples
365
+ --------
366
+ The band of the alternating series of :func:`acf`'s example, rebuilt
367
+ from its autocorrelation:
368
+
369
+ >>> import numpy as np
370
+ >>> from polyphys.analyze.correlations import bartlett_half_width
371
+ >>> np.round(bartlett_half_width([1.0, -0.875, 0.75], 8), 4)
372
+ array([0. , 0.693 , 1.1025])
373
+
374
+ .. versionadded:: 0.6.0
375
+ """
376
+ acf_values = np.asarray(values, dtype=np.float64)
377
+ if acf_values.ndim not in (1, 2):
378
+ raise ValueError(
379
+ f'values must be one- or two-dimensional; got {acf_values.ndim}'
380
+ ' dimension(s).'
381
+ )
382
+ if n_frames < 2:
383
+ raise ValueError(f'n_frames must be at least 2; got {n_frames}.')
384
+ if not 0 < alpha < 1:
385
+ raise ValueError(f'alpha must be in (0, 1); got {alpha}.')
386
+ variance = np.full_like(acf_values, 1.0 / n_frames)
387
+ variance[0] = 0.0
388
+ if acf_values.shape[0] > 2:
389
+ variance[2:] *= 1.0 + 2.0 * np.cumsum(acf_values[1:-1] ** 2, axis=0)
390
+ z_score = float(norm.ppf(1.0 - alpha / 2.0))
391
+ half_width: FloatArray = z_score * np.sqrt(variance)
392
+ return half_width
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # Decay fits
397
+ # ---------------------------------------------------------------------------
398
+
399
+
400
+ def _exp(x: FloatArray, tau: float) -> FloatArray:
401
+ """:math:`\\exp(-x / \\tau)`."""
402
+ return np.asarray(np.exp(-x / tau), dtype=np.float64)
403
+
404
+
405
+ def _stretched(x: FloatArray, tau: float, beta: float) -> FloatArray:
406
+ """:math:`\\exp[-(x / \\tau)^\\beta]`."""
407
+ return np.asarray(np.exp(-((x / tau) ** beta)), dtype=np.float64)
408
+
409
+
410
+ def _stretched_amp(
411
+ x: FloatArray, tau: float, beta: float, amp: float
412
+ ) -> FloatArray:
413
+ """:math:`A \\exp[-(x / \\tau)^\\beta]`."""
414
+ return amp * _stretched(x, tau, beta)
415
+
416
+
417
+ def _stretched_amp_offset(
418
+ x: FloatArray, tau: float, beta: float, amp: float, offset: float
419
+ ) -> FloatArray:
420
+ """:math:`A \\exp[-(x / \\tau)^\\beta] + B`."""
421
+ return amp * _stretched(x, tau, beta) + offset
422
+
423
+
424
+ @dataclass(frozen=True)
425
+ class DecayModel:
426
+ """
427
+ A decay model: its function, parameter names, and formula.
428
+
429
+ .. versionadded:: 0.6.0
430
+
431
+ Attributes
432
+ ----------
433
+ function : callable
434
+ ``function(x, *params)``, vectorized over `x`.
435
+ names : tuple of str
436
+ The parameter names in the order `function` takes them, from
437
+ ``'tau'``, ``'beta'``, ``'amp'``, and ``'offset'``.
438
+ formula : str
439
+ The model as a formula, for labels.
440
+ """
441
+
442
+ function: ModelFunction
443
+ names: tuple[str, ...]
444
+ formula: str
445
+
446
+
447
+ DECAY_MODELS: Mapping[str, DecayModel] = {
448
+ 'exp': DecayModel(_exp, ('tau',), 'exp(-x/tau)'),
449
+ 'stretched': DecayModel(
450
+ _stretched, ('tau', 'beta'), 'exp(-(x/tau)^beta)'
451
+ ),
452
+ 'stretched_amp': DecayModel(
453
+ _stretched_amp, ('tau', 'beta', 'amp'), 'amp exp(-(x/tau)^beta)'
454
+ ),
455
+ 'stretched_amp_offset': DecayModel(
456
+ _stretched_amp_offset,
457
+ ('tau', 'beta', 'amp', 'offset'),
458
+ 'amp exp(-(x/tau)^beta) + offset',
459
+ ),
460
+ }
461
+ """
462
+ The decay models :func:`fit_decay` accepts, by name.
463
+
464
+ ``'exp'`` is the pure exponential :math:`\\exp(-x/\\tau)`; ``'stretched'``
465
+ the Kohlrausch-Williams-Watts form :math:`\\exp[-(x/\\tau)^\\beta]`;
466
+ ``'stretched_amp'`` scales it by an amplitude :math:`A`; and
467
+ ``'stretched_amp_offset'`` adds an offset :math:`B`. The parameters are
468
+ ordered ``tau``, ``beta``, ``amp``, ``offset``. :math:`\\tau` is in the
469
+ unit of ``x`` and positive; :math:`\\beta` is positive, 1 for a pure
470
+ exponential.
471
+
472
+ .. versionadded:: 0.6.0
473
+ """
474
+
475
+
476
+ def _get_model(model: str) -> DecayModel:
477
+ """Return the :class:`DecayModel` named `model` or raise ``ValueError``."""
478
+ try:
479
+ return DECAY_MODELS[model]
480
+ except KeyError:
481
+ names = ', '.join(repr(name) for name in DECAY_MODELS)
482
+ raise ValueError(
483
+ f"model must be one of {names}; got '{model}'."
484
+ ) from None
485
+
486
+
487
+ def _as_curve(
488
+ x: npt.ArrayLike, y: npt.ArrayLike
489
+ ) -> tuple[FloatArray, FloatArray]:
490
+ """Return `x` and `y` as finite one-dimensional arrays of one length."""
491
+ x_arr = np.asarray(x, dtype=np.float64)
492
+ y_arr = np.asarray(y, dtype=np.float64)
493
+ if x_arr.ndim != 1 or y_arr.ndim != 1:
494
+ raise ValueError(
495
+ 'x and y must be one-dimensional; got'
496
+ f' {x_arr.ndim} and {y_arr.ndim} dimension(s).'
497
+ )
498
+ if x_arr.shape != y_arr.shape:
499
+ raise ValueError(
500
+ f'x and y must have the same length; got {x_arr.size} and'
501
+ f' {y_arr.size}.'
502
+ )
503
+ if not (np.all(np.isfinite(x_arr)) and np.all(np.isfinite(y_arr))):
504
+ raise ValueError('x and y must be finite.')
505
+ return x_arr, y_arr
506
+
507
+
508
+ @dataclass(frozen=True)
509
+ class DecayFit:
510
+ """
511
+ The result of fitting a decay model to a curve.
512
+
513
+ .. versionadded:: 0.6.0
514
+
515
+ Attributes
516
+ ----------
517
+ model : str
518
+ The key of the model in :data:`DECAY_MODELS`.
519
+ names : tuple of str
520
+ The parameter names, in the order of `params`.
521
+ params : numpy.ndarray
522
+ The fitted parameters, shape ``(n_params,)``; all NaN when the fit
523
+ did not converge.
524
+ stderr : numpy.ndarray
525
+ The standard error of each parameter, the square root of the
526
+ diagonal of `pcov`; NaN where the covariance could not be estimated
527
+ or the fit did not converge.
528
+ pcov : numpy.ndarray
529
+ The estimated covariance of the parameters, shape
530
+ ``(n_params, n_params)``, as :func:`scipy.optimize.curve_fit`
531
+ returns it; all NaN when the fit did not converge.
532
+ p0 : numpy.ndarray
533
+ The starting point the fit used, shape ``(n_params,)``.
534
+ converged : bool
535
+ Whether :func:`scipy.optimize.curve_fit` returned a solution.
536
+ """
537
+
538
+ model: str
539
+ names: tuple[str, ...]
540
+ params: FloatArray
541
+ stderr: FloatArray
542
+ pcov: FloatArray
543
+ p0: FloatArray
544
+ converged: bool
545
+
546
+ def evaluate(self, x: npt.ArrayLike) -> FloatArray:
547
+ """
548
+ Evaluate the fitted model at `x`.
549
+
550
+ Parameters
551
+ ----------
552
+ x : array_like
553
+ Points in the unit of the fitted ``x``.
554
+
555
+ Returns
556
+ -------
557
+ numpy.ndarray
558
+ The model at `x`, the same shape; all NaN when the fit did not
559
+ converge.
560
+
561
+ Examples
562
+ --------
563
+ >>> import numpy as np
564
+ >>> from polyphys.analyze.correlations import fit_decay
565
+ >>> x = np.arange(0.0, 20.0)
566
+ >>> fit = fit_decay(x, np.exp(-x / 4.0), 'exp')
567
+ >>> np.round(fit.evaluate([0.0, 4.0]), 6)
568
+ array([1. , 0.367879])
569
+ """
570
+ return DECAY_MODELS[self.model].function(
571
+ np.asarray(x, dtype=np.float64), *self.params
572
+ )
573
+
574
+
575
+ def initial_guess(
576
+ x: npt.ArrayLike, y: npt.ArrayLike, model: str
577
+ ) -> FloatArray:
578
+ """
579
+ Read a starting point for each parameter of a decay model off the data.
580
+
581
+ Parameters
582
+ ----------
583
+ x : array_like
584
+ The abscissa, shape ``(n_points,)``, finite, increasing, and
585
+ non-negative, in the caller's unit (lags in frames, or time).
586
+ y : array_like
587
+ The curve at `x`, the same shape, finite.
588
+ model : str
589
+ A key of :data:`DECAY_MODELS`.
590
+
591
+ Returns
592
+ -------
593
+ numpy.ndarray
594
+ One value per parameter of the model, in its order.
595
+
596
+ Raises
597
+ ------
598
+ ValueError
599
+ If `model` is unknown, or `x` and `y` are not finite
600
+ one-dimensional arrays of one length.
601
+
602
+ Notes
603
+ -----
604
+ The offset :math:`B` is the mean of the last tenth of `y` (at least one
605
+ point); the amplitude :math:`A` is ``y[0] - B``, or 1 when that is 0;
606
+ the decay time :math:`\\tau` is the first `x` at which
607
+ :math:`(y - B) / A` is at or below :math:`1/e`, which is exact for a
608
+ pure exponential sampled at :math:`\\tau`, or the last `x` when the
609
+ curve never falls that far; and :math:`\\beta` is 1. A model without an
610
+ offset takes :math:`B = 0`; one without an amplitude takes
611
+ :math:`A = 1`. A :math:`\\tau` of 0 (the curve is already at
612
+ :math:`1/e` at ``x[0] = 0``) is replaced by the first positive `x`, or
613
+ 1.
614
+
615
+ Time and space are :math:`O(n_\\mathrm{points})`.
616
+
617
+ Examples
618
+ --------
619
+ A pure exponential of decay time 4 crosses :math:`1/e` at ``x = 4``:
620
+
621
+ >>> import numpy as np
622
+ >>> from polyphys.analyze.correlations import initial_guess
623
+ >>> x = np.arange(0.0, 20.0)
624
+ >>> initial_guess(x, np.exp(-x / 4.0), 'exp')
625
+ array([4.])
626
+
627
+ The offset is read off the tail, so it is a starting point, not the
628
+ answer:
629
+
630
+ >>> y = 2.0 * np.exp(-x / 4.0) + 0.5
631
+ >>> np.round(initial_guess(x, y, 'stretched_amp_offset'), 2)
632
+ array([4. , 1. , 1.98, 0.52])
633
+
634
+ .. versionadded:: 0.6.0
635
+ """
636
+ spec = _get_model(model)
637
+ x_arr, y_arr = _as_curve(x, y)
638
+ n_points = x_arr.size
639
+ if n_points == 0:
640
+ raise ValueError('x and y must not be empty.')
641
+ offset = 0.0
642
+ if 'offset' in spec.names:
643
+ offset = float(np.mean(y_arr[-max(1, n_points // 10):]))
644
+ amp = 1.0
645
+ if 'amp' in spec.names:
646
+ amp = float(y_arr[0] - offset)
647
+ if amp == 0.0:
648
+ amp = 1.0
649
+ decayed = (y_arr - offset) / amp
650
+ below = np.flatnonzero(decayed <= np.exp(-1.0))
651
+ tau = float(x_arr[below[0]]) if below.size else float(x_arr[-1])
652
+ if tau <= 0.0:
653
+ positive = np.flatnonzero(x_arr > 0.0)
654
+ tau = float(x_arr[positive[0]]) if positive.size else 1.0
655
+ guesses = {'tau': tau, 'beta': 1.0, 'amp': amp, 'offset': offset}
656
+ return np.array([guesses[name] for name in spec.names], dtype=np.float64)
657
+
658
+
659
+ def fit_decay(
660
+ x: npt.ArrayLike,
661
+ y: npt.ArrayLike,
662
+ model: str,
663
+ p0: npt.ArrayLike | None = None,
664
+ **kwargs: Any,
665
+ ) -> DecayFit:
666
+ """
667
+ Fit a decay model to a curve by nonlinear least squares.
668
+
669
+ Parameters
670
+ ----------
671
+ x : array_like
672
+ The abscissa, shape ``(n_points,)``, finite, in the caller's unit
673
+ (lags in frames, or time). Fit only the range meant for the model:
674
+ on an autocorrelation, the lags the model describes; on a ring's
675
+ bond correlation, lags up to ``n_bonds // 2``.
676
+ y : array_like
677
+ The curve at `x`, the same shape, finite.
678
+ model : str
679
+ A key of :data:`DECAY_MODELS`.
680
+ p0 : array_like or None, default None
681
+ The starting point, one value per parameter of the model, in its
682
+ order. ``None`` takes :func:`initial_guess`.
683
+ **kwargs
684
+ Passed to :func:`scipy.optimize.curve_fit`: ``bounds``, ``sigma``,
685
+ ``absolute_sigma``, ``maxfev``, ``method``, and the rest.
686
+
687
+ Returns
688
+ -------
689
+ DecayFit
690
+ The parameters, their standard errors, the covariance, the starting
691
+ point used, and whether the fit converged.
692
+
693
+ Raises
694
+ ------
695
+ ValueError
696
+ If `model` is unknown; `x` and `y` are not finite one-dimensional
697
+ arrays of one length; there are fewer points than parameters; or
698
+ `p0` has the wrong length. :func:`scipy.optimize.curve_fit` also
699
+ raises :exc:`ValueError` for an invalid option in `kwargs`, such as
700
+ an unknown ``method`` or a `p0` outside ``bounds``.
701
+
702
+ Notes
703
+ -----
704
+ :func:`scipy.optimize.curve_fit` minimizes the sum of squared residuals
705
+ from `p0`, by default with the Levenberg-Marquardt method (``'lm'``),
706
+ or with ``'trf'`` when ``bounds`` are given, as its documentation
707
+ states; ``method`` in `kwargs` chooses another. A nonlinear model can
708
+ have several local minima, so `p0` matters, and the one used is
709
+ recorded.
710
+ A fit fails when the model is not finite at the start, when the
711
+ optimizer stops without a solution, or when it does not move from the
712
+ start and the covariance cannot be estimated, which is how the
713
+ ``'lm'`` method signals a Jacobian that is zero or rank-deficient (the
714
+ :func:`scipy.optimize.curve_fit` documentation, under ``pcov``). A
715
+ failed fit is reported with ``converged=False``, NaN parameters, and a
716
+ warning in the :mod:`logging` log, not an exception; invalid inputs or
717
+ options still raise (see Raises). When the fit
718
+ moves but the covariance cannot be estimated (SciPy emits an
719
+ ``OptimizeWarning``), it is converged and the standard errors of the
720
+ affected parameters are NaN. A start at the exact solution of
721
+ noise-free data does not move either, but its covariance is finite, so
722
+ it is converged. The standard
723
+ error is the square root of the covariance diagonal, as in SciPy's
724
+ documentation.
725
+
726
+ The models are the stretched exponential of Williams and Watts (1970),
727
+ Trans. Faraday Soc. 66, 80, https://doi.org/10.1039/TF9706600080, with
728
+ :math:`\\beta = 1` for the pure exponential.
729
+
730
+ Time is :math:`O(n_\\mathrm{iter}\\, n_\\mathrm{points}\\,
731
+ n_\\mathrm{params})` and space :math:`O(n_\\mathrm{points}\\,
732
+ n_\\mathrm{params})`.
733
+
734
+ Examples
735
+ --------
736
+ >>> import numpy as np
737
+ >>> from polyphys.analyze.correlations import fit_decay
738
+ >>> x = np.arange(0.0, 40.0)
739
+ >>> y = 2.0 * np.exp(-((x / 5.0) ** 0.8)) + 0.3
740
+ >>> fit = fit_decay(x, y, 'stretched_amp_offset')
741
+ >>> fit.converged
742
+ True
743
+ >>> fit.names
744
+ ('tau', 'beta', 'amp', 'offset')
745
+ >>> np.round(fit.params, 6)
746
+ array([5. , 0.8, 2. , 0.3])
747
+
748
+ .. versionadded:: 0.6.0
749
+ .. versionchanged:: 0.9.0
750
+ A start where the model is not finite, and a fit that does not move
751
+ and has no finite covariance, are failed fits; ``method='trf'`` no
752
+ longer raises on a non-finite start.
753
+ """
754
+ spec = _get_model(model)
755
+ x_arr, y_arr = _as_curve(x, y)
756
+ n_params = len(spec.names)
757
+ if x_arr.size < n_params:
758
+ raise ValueError(
759
+ f"model '{model}' has {n_params} parameter(s) but x has only"
760
+ f' {x_arr.size} point(s).'
761
+ )
762
+ if p0 is None:
763
+ start = initial_guess(x_arr, y_arr, model)
764
+ else:
765
+ start = np.asarray(p0, dtype=np.float64)
766
+ if start.shape != (n_params,):
767
+ raise ValueError(
768
+ f"p0 must have one value per parameter of '{model}',"
769
+ f' {spec.names}; got shape {start.shape}.'
770
+ )
771
+ nan_params = np.full(n_params, np.nan)
772
+ failed = DecayFit(
773
+ model=model,
774
+ names=spec.names,
775
+ params=nan_params,
776
+ stderr=nan_params.copy(),
777
+ pcov=np.full((n_params, n_params), np.nan),
778
+ p0=start,
779
+ converged=False,
780
+ )
781
+ # A start where the model is not finite cannot be fitted: 'lm' stays
782
+ # there and 'trf' raises, so it is reported as a failed fit up front.
783
+ with np.errstate(invalid='ignore', divide='ignore', over='ignore'):
784
+ at_start = spec.function(x_arr, *start)
785
+ if not np.all(np.isfinite(at_start)):
786
+ logger.warning(
787
+ "fit_decay: '%s' is not finite at the start %s; the fit failed.",
788
+ model,
789
+ start,
790
+ )
791
+ return failed
792
+ try:
793
+ params, pcov = curve_fit(
794
+ spec.function, x_arr, y_arr, p0=start, **kwargs
795
+ )
796
+ except RuntimeError as error:
797
+ logger.warning("fit_decay: '%s' did not converge: %s", model, error)
798
+ return failed
799
+ params = np.asarray(params, dtype=np.float64)
800
+ pcov = np.asarray(pcov, dtype=np.float64)
801
+ diagonal = np.diag(pcov)
802
+ if np.array_equal(params, start) and not np.all(np.isfinite(pcov)):
803
+ logger.warning(
804
+ "fit_decay: '%s' did not move from the start %s and its"
805
+ ' covariance could not be estimated; the fit failed.',
806
+ model,
807
+ start,
808
+ )
809
+ return failed
810
+ stderr = np.where(np.isfinite(diagonal), np.sqrt(np.abs(diagonal)), np.nan)
811
+ if not np.all(np.isfinite(diagonal)):
812
+ logger.warning(
813
+ "fit_decay: '%s' converged but its covariance could not be"
814
+ ' estimated.',
815
+ model,
816
+ )
817
+ return DecayFit(
818
+ model=model,
819
+ names=spec.names,
820
+ params=params,
821
+ stderr=np.asarray(stderr, dtype=np.float64),
822
+ pcov=pcov,
823
+ p0=start,
824
+ converged=True,
825
+ )
826
+
827
+
828
+ # ---------------------------------------------------------------------------
829
+ # Bonds
830
+ # ---------------------------------------------------------------------------
831
+
832
+
833
+ def bond_vectors(
834
+ positions: npt.ArrayLike, topology: Topology
835
+ ) -> FloatArray:
836
+ """
837
+ Difference the positions of a chain into its bond vectors.
838
+
839
+ Parameters
840
+ ----------
841
+ positions : array_like
842
+ The monomer coordinates in chain order, shape
843
+ ``(..., n_atoms, n_dims)``, unwrapped, in the caller's length unit;
844
+ ``n_atoms`` at least 2 on a linear chain and 3 on a ring.
845
+ topology : {'linear', 'ring'}
846
+ Whether the last monomer is bonded back to the first.
847
+
848
+ Returns
849
+ -------
850
+ numpy.ndarray
851
+ The bond vectors :math:`\\mathbf{r}_{i+1} - \\mathbf{r}_i`, shape
852
+ ``(..., n_bonds, n_dims)``, with ``n_bonds = n_atoms - 1`` on a
853
+ linear chain and ``n_atoms`` on a ring, whose last bond is
854
+ :math:`\\mathbf{r}_0 - \\mathbf{r}_{n-1}`. Bond lengths are
855
+ ``numpy.linalg.norm(bonds, axis=-1)``.
856
+
857
+ Raises
858
+ ------
859
+ ValueError
860
+ If `topology` is unknown, `positions` has fewer than two dimensions,
861
+ or the chain is too short for its topology.
862
+
863
+ Notes
864
+ -----
865
+ Positions must be unwrapped: a periodic image jump inside a bond gives
866
+ a bond of the box length. Time and space are
867
+ :math:`O(F\\, n_\\mathrm{atoms}\\, n_\\mathrm{dims})`.
868
+
869
+ Examples
870
+ --------
871
+ >>> import numpy as np
872
+ >>> from polyphys.analyze.correlations import bond_vectors
873
+ >>> square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
874
+ >>> bond_vectors(square, 'linear')
875
+ array([[ 1., 0.],
876
+ [ 0., 1.],
877
+ [-1., 0.]])
878
+ >>> bond_vectors(square, 'ring')[-1]
879
+ array([ 0., -1.])
880
+
881
+ .. versionadded:: 0.6.0
882
+ """
883
+ _check_topology(topology)
884
+ coords = np.asarray(positions, dtype=np.float64)
885
+ if coords.ndim < 2:
886
+ raise ValueError(
887
+ 'positions must have shape (..., n_atoms, n_dims); got'
888
+ f' {coords.ndim} dimension(s).'
889
+ )
890
+ n_atoms = coords.shape[-2]
891
+ minimum = 2 if topology == 'linear' else 3
892
+ if n_atoms < minimum:
893
+ raise ValueError(
894
+ f"a '{topology}' chain needs at least {minimum} atoms; got"
895
+ f' {n_atoms}.'
896
+ )
897
+ if topology == 'linear':
898
+ return np.asarray(coords[..., 1:, :] - coords[..., :-1, :])
899
+ return np.asarray(np.roll(coords, -1, axis=-2) - coords)
900
+
901
+
902
+ def bond_cosine_correlation(
903
+ positions: npt.ArrayLike, topology: Topology
904
+ ) -> FloatArray:
905
+ """
906
+ Average the cosine between bond vectors at every lag along a chain.
907
+
908
+ Parameters
909
+ ----------
910
+ positions : array_like
911
+ The monomer coordinates in chain order, shape
912
+ ``(..., n_atoms, n_dims)``, unwrapped; no two bonded monomers
913
+ coincide.
914
+ topology : {'linear', 'ring'}
915
+ Whether the last monomer is bonded back to the first.
916
+
917
+ Returns
918
+ -------
919
+ numpy.ndarray
920
+ :math:`C(k)` for lags ``0`` to ``n_bonds - 1``, shape
921
+ ``(..., n_bonds)``, dimensionless in ``[-1, 1]`` with ``C(0) = 1``.
922
+ Leading axes are kept, so the average over frames is
923
+ ``result.mean(axis=0)`` for a ``(n_frames, n_atoms, n_dims)``
924
+ input.
925
+
926
+ Raises
927
+ ------
928
+ ValueError
929
+ If `topology` is unknown, `positions` has a bad shape or a chain too
930
+ short for its topology, or a bond has zero length.
931
+
932
+ Notes
933
+ -----
934
+ With :math:`\\hat{\\mathbf{u}}_i` the unit bond vectors, on a linear
935
+ chain :math:`C(k) = \\frac{1}{N - k} \\sum_{i=0}^{N-k-1}
936
+ \\hat{\\mathbf{u}}_i \\cdot \\hat{\\mathbf{u}}_{i+k}` over the
937
+ :math:`N - k` pairs at lag :math:`k`; on a ring :math:`C(k) =
938
+ \\frac{1}{N} \\sum_{i=0}^{N-1} \\hat{\\mathbf{u}}_i \\cdot
939
+ \\hat{\\mathbf{u}}_{(i+k) \\bmod N}` over all :math:`N` pairs, so that
940
+ the result does not depend on which monomer is labeled first and
941
+ :math:`C(k) = C(N - k)`. The sums over :math:`i` are linear (zero-padded)
942
+ or circular autocorrelations, computed by the FFT of each Cartesian
943
+ component and summed over components.
944
+
945
+ For a worm-like chain :math:`C(k) = \\exp(-k\\, b / \\ell_p)` with
946
+ :math:`b` the bond length and :math:`\\ell_p` the persistence length
947
+ (Rubinstein and Colby 2003, section 2.3), so fitting ``'exp'`` with
948
+ :func:`fit_decay` on lags up to ``n_bonds // 2`` for a ring gives
949
+ :math:`\\tau = \\ell_p / b`, and :math:`\\ell_p` is :math:`\\tau` times
950
+ the mean bond length. On a linear chain this is the quantity and the
951
+ fit of :class:`MDAnalysis.analysis.polymer.PersistenceLength`
952
+ (``results.bond_autocorrelation``, ``results.lb``, ``results.lp``),
953
+ which averages over chains and frames and does not treat rings.
954
+
955
+ Time is :math:`O(F\\, N \\log N)` and space :math:`O(F\\, N)` for
956
+ :math:`F` frames of :math:`N` bonds, against the :math:`O(F\\, N^2)`
957
+ of the pairwise matrix.
958
+
959
+ Examples
960
+ --------
961
+ The bonds of a square ring turn by a right angle each, so
962
+ :math:`C(k) = \\cos(\\pi k / 2)`:
963
+
964
+ >>> import numpy as np
965
+ >>> from polyphys.analyze.correlations import bond_cosine_correlation
966
+ >>> square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
967
+ >>> np.round(bond_cosine_correlation(square, 'ring'), 12)
968
+ array([ 1., 0., -1., 0.])
969
+
970
+ A straight rod has every bond parallel:
971
+
972
+ >>> rod = np.outer(np.arange(4.0), [1.0, 2.0, 2.0])
973
+ >>> np.round(bond_cosine_correlation(rod, 'linear'), 12)
974
+ array([1., 1., 1.])
975
+
976
+ .. versionadded:: 0.6.0
977
+ """
978
+ bonds = bond_vectors(positions, topology)
979
+ lengths = np.linalg.norm(bonds, axis=-1)
980
+ if np.any(lengths == 0.0):
981
+ raise ValueError('every bond must have a positive length.')
982
+ units = bonds / lengths[..., np.newaxis]
983
+ # Put the bond axis last so the FFT runs along it, per component.
984
+ components = np.swapaxes(units, -1, -2)
985
+ n_bonds = components.shape[-1]
986
+ if topology == 'linear':
987
+ n_fft = _fft_length(n_bonds)
988
+ pairs: FloatArray = np.arange(n_bonds, 0, -1, dtype=np.float64)
989
+ else:
990
+ n_fft = n_bonds
991
+ pairs = np.full(n_bonds, float(n_bonds))
992
+ spectrum = np.fft.rfft(components, n=n_fft, axis=-1)
993
+ power = np.sum(np.abs(spectrum) ** 2, axis=-2)
994
+ sums = np.fft.irfft(power, n=n_fft, axis=-1)[..., :n_bonds]
995
+ return np.asarray(sums / pairs, dtype=np.float64)