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,708 @@
1
+ """
2
+ Tests for :mod:`polyphys.analyze.correlations`.
3
+
4
+ Every expected value is independent of the code under test. It comes from
5
+ one of these sources, named in the test that uses it:
6
+
7
+ A1. AR(1), ``x_t = phi x_{t-1} + e_t`` with ``|phi| < 1``, independent
8
+ innovations, and a stationary start: multiplying by ``x_{t-k}`` and
9
+ taking expectations gives ``gamma_k = phi gamma_{k-1}``, so the
10
+ autocorrelation is ``rho_k = phi**k``.
11
+
12
+ A2. The unit bond vectors of a regular planar polygon of ``N`` sides turn by
13
+ ``2 pi / N`` from one bond to the next, so two bonds ``k`` apart have
14
+ cosine ``cos(2 pi k / N)``.
15
+
16
+ A3. Every bond of a straight rod is parallel to every other, so the bond
17
+ cosine is 1 at every lag.
18
+
19
+ A4. A pure exponential ``A exp(-x/tau) + B`` sampled without noise is in the
20
+ model family, so least squares recovers its parameters exactly (to
21
+ round-off).
22
+
23
+ R1. Brute force: the defining sums, one lag at a time, in
24
+ :func:`_acf_by_definition` (the ``1/N`` autocovariance of the
25
+ NIST/SEMATECH e-Handbook, section 1.3.3.1) and
26
+ :func:`_bond_cosines_by_definition` (a double loop over bond pairs).
27
+
28
+ R2. Bartlett's variance as the e-Handbook states it and ``statsmodels``
29
+ computes it, ``(1 + 2 sum_{j=1}^{k-1} r_j^2) / N`` for ``k >= 2``,
30
+ ``1/N`` at ``k = 1``, and 0 at ``k = 0``, written out in
31
+ :func:`_bartlett_half_width`.
32
+
33
+ P. A property: shape, normalization, invariance under affine rescaling of
34
+ a series, rigid motion of a chain, relabeling of a ring, or
35
+ column-wise independence.
36
+
37
+ Random inputs come from ``numpy.random.default_rng(seed)`` with the seed
38
+ shown, so every example is reproducible. Float comparisons use
39
+ ``FLOAT64_ATOL`` where two computations differ only in the order of float64
40
+ operations; the one statistical tolerance is derived where it is used.
41
+ """
42
+
43
+ import logging
44
+
45
+ import numpy as np
46
+ import pytest
47
+ from numpy.testing import assert_allclose, assert_array_equal
48
+ from scipy.optimize import OptimizeWarning
49
+ from scipy.stats import norm
50
+
51
+ from polyphys.analyze.correlations import (
52
+ DECAY_MODELS,
53
+ AcfResult,
54
+ DecayFit,
55
+ acf,
56
+ bartlett_half_width,
57
+ bond_cosine_correlation,
58
+ bond_vectors,
59
+ fit_decay,
60
+ initial_guess,
61
+ )
62
+
63
+ # Absolute tolerance for results that differ only by float64 round-off over
64
+ # an FFT of a few thousand points: far above the 2.2e-16 epsilon, far below
65
+ # any value the tests compare.
66
+ FLOAT64_ATOL = 1e-10
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Reference computations
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _acf_by_definition(x: np.ndarray, nlags: int) -> np.ndarray:
75
+ """Return the ``1/N`` sample autocorrelation by its defining sum (R1)."""
76
+ n = len(x)
77
+ centered = x - x.mean()
78
+ autocov = np.array(
79
+ [np.sum(centered[: n - k] * centered[k:]) / n
80
+ for k in range(nlags + 1)]
81
+ )
82
+ return autocov / autocov[0]
83
+
84
+
85
+ def _bartlett_half_width(
86
+ values: np.ndarray, n: int, alpha: float
87
+ ) -> np.ndarray:
88
+ """Return Bartlett's half-widths for the given autocorrelations (R2)."""
89
+ z = norm.ppf(1.0 - alpha / 2.0)
90
+ out = np.empty(len(values))
91
+ for k in range(len(values)):
92
+ if k == 0:
93
+ var = 0.0
94
+ else:
95
+ var = (1.0 + 2.0 * np.sum(values[1:k] ** 2)) / n
96
+ out[k] = z * np.sqrt(var)
97
+ return out
98
+
99
+
100
+ def _bond_cosines_by_definition(
101
+ units: np.ndarray, topology: str
102
+ ) -> np.ndarray:
103
+ """Return the mean bond cosine at every lag by a double loop (R1)."""
104
+ n = len(units)
105
+ out = np.empty(n)
106
+ for k in range(n):
107
+ if topology == 'ring':
108
+ pairs = [units[i] @ units[(i + k) % n] for i in range(n)]
109
+ else:
110
+ pairs = [units[i] @ units[i + k] for i in range(n - k)]
111
+ out[k] = np.mean(pairs)
112
+ return out
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Fixtures
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ @pytest.fixture
121
+ def make_ar1():
122
+ """Return a factory for a stationary AR(1) series with unit innovations."""
123
+
124
+ def _make(phi: float, n_frames: int, seed: int) -> np.ndarray:
125
+ rng = np.random.default_rng(seed)
126
+ noise = rng.standard_normal(n_frames)
127
+ x = np.empty(n_frames, dtype=np.float64)
128
+ x[0] = noise[0] / np.sqrt(1.0 - phi ** 2) # stationary start
129
+ for t in range(1, n_frames):
130
+ x[t] = phi * x[t - 1] + noise[t]
131
+ return x
132
+
133
+ return _make
134
+
135
+
136
+ @pytest.fixture
137
+ def make_noise():
138
+ """Return a factory for i.i.d. standard normal series, ``(n,)`` or
139
+ ``(n, m)``."""
140
+
141
+ def _make(shape, seed: int) -> np.ndarray:
142
+ return np.random.default_rng(seed).standard_normal(shape)
143
+
144
+ return _make
145
+
146
+
147
+ @pytest.fixture
148
+ def make_polygon():
149
+ """Return a factory for the vertices of a regular planar polygon."""
150
+
151
+ def _make(n_sides: int, radius: float = 1.0) -> np.ndarray:
152
+ angles = 2.0 * np.pi * np.arange(n_sides) / n_sides
153
+ return radius * np.column_stack(
154
+ [np.cos(angles), np.sin(angles), np.zeros(n_sides)]
155
+ )
156
+
157
+ return _make
158
+
159
+
160
+ @pytest.fixture
161
+ def make_random_walk():
162
+ """Return a factory for a random walk of unit steps in three dimensions,
163
+ with any leading frames axis."""
164
+
165
+ def _make(n_atoms: int, seed: int, n_frames: int | None = None):
166
+ rng = np.random.default_rng(seed)
167
+ shape = (n_atoms, 3) if n_frames is None else (n_frames, n_atoms, 3)
168
+ steps = rng.standard_normal(shape)
169
+ steps /= np.linalg.norm(steps, axis=-1, keepdims=True)
170
+ return np.cumsum(steps, axis=-2)
171
+
172
+ return _make
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # acf
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ def test_acf_lag_zero_is_one_with_zero_band(make_noise):
181
+ """The autocorrelation at lag 0 is exactly 1 with a zero half-width,
182
+ and the result has ``nlags + 1`` rows (P)."""
183
+ x = make_noise(100, seed=1)
184
+ result = acf(x, nlags=5)
185
+ assert isinstance(result, AcfResult)
186
+ assert result.values.shape == (6,)
187
+ assert result.half_width is not None
188
+ assert result.half_width.shape == (6,)
189
+ assert result.values[0] == 1.0
190
+ assert result.half_width[0] == 0.0
191
+
192
+
193
+ def test_acf_matches_defining_sum(make_ar1):
194
+ """The FFT autocorrelation equals the ``1/N`` defining sum at every lag
195
+ on an odd, non-power-of-two length (R1)."""
196
+ x = make_ar1(0.5, n_frames=257, seed=2)
197
+ result = acf(x, nlags=40, alpha=None)
198
+ assert_allclose(
199
+ result.values, _acf_by_definition(x, 40), atol=FLOAT64_ATOL
200
+ )
201
+
202
+
203
+ def test_acf_of_ar1_is_phi_to_k_within_bartlett_band(make_ar1):
204
+ """The AR(1) autocorrelation ``phi**k`` lies inside Bartlett's band at
205
+ lags 1 to 10 (A1)."""
206
+ phi, n_frames, n_lags = 0.6, 2 ** 15, 10
207
+ # Bonferroni over the 10 lags: a 1 % family-wise level, so that the
208
+ # check holds for almost every seed rather than one chosen seed.
209
+ alpha = 0.01 / n_lags
210
+ x = make_ar1(phi, n_frames, seed=3)
211
+ result = acf(x, nlags=n_lags, alpha=alpha)
212
+ assert result.half_width is not None
213
+ lags = np.arange(1, n_lags + 1)
214
+ deviation = np.abs(result.values[1:] - phi ** lags)
215
+ assert np.all(deviation <= result.half_width[1:])
216
+
217
+
218
+ def test_acf_of_white_noise_is_within_root_n_band(make_noise):
219
+ """White noise has autocorrelations inside ``z / sqrt(N)`` at every lag
220
+ checked, the band the e-Handbook gives for randomness (P, R2)."""
221
+ n_frames, n_lags = 2 ** 14, 10
222
+ alpha = 0.01 / n_lags # Bonferroni over the lags, as above
223
+ x = make_noise(n_frames, seed=4)
224
+ result = acf(x, nlags=n_lags, alpha=alpha)
225
+ band = norm.ppf(1.0 - alpha / 2.0) / np.sqrt(n_frames)
226
+ assert np.all(np.abs(result.values[1:]) <= band)
227
+
228
+
229
+ def test_acf_band_is_bartlett_formula():
230
+ """The half-width at lag ``k`` is ``z sqrt((1 + 2 sum_{j<k} r_j^2)/N)``
231
+ with ``1/N`` at lag 1 and 0 at lag 0, on a six-value series (R2)."""
232
+ x = np.array([2.0, 4.0, 1.0, 5.0, 3.0, 6.0])
233
+ result = acf(x, nlags=4, alpha=0.1)
234
+ assert result.half_width is not None
235
+ expected = _bartlett_half_width(result.values, n=6, alpha=0.1)
236
+ assert_allclose(result.half_width, expected, atol=FLOAT64_ATOL)
237
+
238
+
239
+ def test_bartlett_half_width_rebuilds_the_band_of_acf(make_ar1):
240
+ """The band rebuilt from a stored autocorrelation, the frame count, and
241
+ the level equals the one ``acf`` computed with them."""
242
+ x = make_ar1(0.5, 256, 11)
243
+ result = acf(x, nlags=10, alpha=0.1)
244
+
245
+ rebuilt = bartlett_half_width(result.values, 256, alpha=0.1)
246
+
247
+ assert_allclose(rebuilt, result.half_width, atol=FLOAT64_ATOL)
248
+ assert rebuilt[0] == 0.0
249
+
250
+
251
+ def test_bartlett_half_width_treats_columns_independently(make_noise):
252
+ """Each column's band is the band of that column alone (R2)."""
253
+ x = make_noise((128, 2), seed=5)
254
+ values = acf(x, nlags=6, alpha=None).values
255
+
256
+ band = bartlett_half_width(values, 128, alpha=0.05)
257
+
258
+ for col in range(2):
259
+ assert_allclose(band[:, col],
260
+ _bartlett_half_width(values[:, col], 128, 0.05),
261
+ atol=FLOAT64_ATOL)
262
+
263
+
264
+ @pytest.mark.parametrize("values, n_frames, alpha, match", [
265
+ pytest.param(np.ones((2, 2, 2)), 8, 0.05, "one- or two-dimensional",
266
+ id="three-dimensional"),
267
+ pytest.param([1.0, 0.5], 1, 0.05, "at least 2", id="one-frame"),
268
+ pytest.param([1.0, 0.5], 8, 1.0, r"alpha must be in \(0, 1\)",
269
+ id="alpha-one"),
270
+ ])
271
+ def test_bartlett_half_width_rejects_bad_input(values, n_frames, alpha,
272
+ match):
273
+ """Bad shapes, frame counts, and levels raise ``ValueError``."""
274
+ with pytest.raises(ValueError, match=match):
275
+ bartlett_half_width(values, n_frames, alpha)
276
+
277
+
278
+ def test_acf_treats_columns_as_independent_series(make_noise):
279
+ """A ``(n, m)`` input gives, column by column, the result of ``m``
280
+ one-dimensional calls (P)."""
281
+ x = make_noise((200, 3), seed=5)
282
+ result = acf(x, nlags=7)
283
+ assert result.half_width is not None
284
+ assert result.values.shape == (8, 3)
285
+ for j in range(3):
286
+ single = acf(x[:, j], nlags=7)
287
+ assert single.half_width is not None
288
+ assert_allclose(result.values[:, j], single.values, atol=FLOAT64_ATOL)
289
+ assert_allclose(
290
+ result.half_width[:, j], single.half_width, atol=FLOAT64_ATOL
291
+ )
292
+
293
+
294
+ def test_acf_is_invariant_under_positive_affine_rescaling(make_noise):
295
+ """Scaling a series by a positive factor and shifting it leaves its
296
+ autocorrelation unchanged (P)."""
297
+ x = make_noise(300, seed=6)
298
+ reference = acf(x, nlags=10, alpha=None).values
299
+ rescaled = acf(3.5 * x - 20.0, nlags=10, alpha=None).values
300
+ assert_allclose(rescaled, reference, atol=FLOAT64_ATOL)
301
+
302
+
303
+ def test_acf_skips_band_when_alpha_is_none(make_noise):
304
+ """``alpha=None`` returns no half-width and records no level (P)."""
305
+ result = acf(make_noise(50, seed=7), nlags=3, alpha=None)
306
+ assert result.half_width is None
307
+ assert result.alpha is None
308
+
309
+
310
+ @pytest.mark.parametrize(
311
+ 'x, nlags, alpha, match',
312
+ [
313
+ pytest.param(np.ones(10), 2, 0.05, 'constant', id='constant-series'),
314
+ pytest.param(
315
+ np.arange(10.0), 10, 0.05, 'nlags must be', id='nlags-too-large'
316
+ ),
317
+ pytest.param(
318
+ np.arange(10.0), -1, 0.05, 'nlags must be', id='nlags-negative'
319
+ ),
320
+ pytest.param(
321
+ np.array([1.0, np.nan, 2.0]), 1, 0.05, 'finite', id='nan-value'
322
+ ),
323
+ pytest.param(
324
+ np.arange(10.0), 2, 1.0, 'alpha must be', id='alpha-one'
325
+ ),
326
+ pytest.param(
327
+ np.arange(10.0), 2, 0.0, 'alpha must be', id='alpha-zero'
328
+ ),
329
+ pytest.param(np.ones((2, 2, 2)), 1, 0.05, 'dimensional', id='3-d'),
330
+ pytest.param(np.array([1.0]), 0, 0.05, 'two frames', id='one-frame'),
331
+ ],
332
+ )
333
+ def test_acf_rejects_bad_input(x, nlags, alpha, match):
334
+ """A constant series, a lag outside ``[0, N-1]``, a non-finite value, a
335
+ level outside ``(0, 1)``, or a bad shape raises ``ValueError``."""
336
+ with pytest.raises(ValueError, match=match):
337
+ acf(x, nlags=nlags, alpha=alpha)
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # fit_decay and initial_guess
342
+ # ---------------------------------------------------------------------------
343
+
344
+
345
+ def test_fit_decay_recovers_pure_exponential():
346
+ """A noise-free ``exp(-x/tau)`` is recovered to 1e-10 from a start 50 %
347
+ off (A4)."""
348
+ x = np.arange(0.0, 40.0)
349
+ fit = fit_decay(x, np.exp(-x / 3.7), 'exp', p0=[5.55])
350
+ assert fit.converged
351
+ assert fit.params[0] == pytest.approx(3.7, rel=1e-10)
352
+
353
+
354
+ def test_fit_decay_recovers_stretched_amp_offset():
355
+ """A noise-free ``A exp(-(x/tau)^beta) + B`` is recovered to 1e-8 in
356
+ every parameter (A4)."""
357
+ x = np.arange(0.0, 80.0)
358
+ truth = np.array([5.0, 0.7, 2.0, 0.1])
359
+ y = DECAY_MODELS['stretched_amp_offset'].function(x, *truth)
360
+ fit = fit_decay(x, y, 'stretched_amp_offset', p0=[4.0, 1.0, 1.5, 0.0])
361
+ assert fit.converged
362
+ assert fit.names == ('tau', 'beta', 'amp', 'offset')
363
+ assert_allclose(fit.params, truth, rtol=1e-8)
364
+ assert_allclose(fit.evaluate(x), y, rtol=1e-8)
365
+
366
+
367
+ @pytest.mark.parametrize('model', list(DECAY_MODELS))
368
+ def test_fit_decay_returns_one_value_per_parameter(model):
369
+ """Every model's fit returns parameters, errors, a starting point, and a
370
+ covariance sized by its parameter count, in its parameter order (P)."""
371
+ x = np.arange(0.0, 30.0)
372
+ y = np.exp(-x / 6.0)
373
+ fit = fit_decay(x, y, model)
374
+ n_params = len(DECAY_MODELS[model].names)
375
+ assert isinstance(fit, DecayFit)
376
+ assert fit.model == model
377
+ assert fit.names == DECAY_MODELS[model].names
378
+ assert fit.params.shape == (n_params,)
379
+ assert fit.stderr.shape == (n_params,)
380
+ assert fit.p0.shape == (n_params,)
381
+ assert fit.pcov.shape == (n_params, n_params)
382
+
383
+
384
+ def test_fit_decay_reports_failure_without_raising(caplog):
385
+ """A fit the optimizer gives up on returns ``converged=False`` with NaN
386
+ parameters of the model's length and a logged warning (P)."""
387
+ x = np.arange(0.0, 80.0)
388
+ y = 2.0 * np.exp(-((x / 5.0) ** 0.7)) + 0.1
389
+ with caplog.at_level(logging.WARNING, logger='polyphys'):
390
+ fit = fit_decay(x, y, 'stretched_amp_offset', maxfev=1)
391
+ assert fit.converged is False
392
+ assert fit.params.shape == (4,)
393
+ assert np.all(np.isnan(fit.params))
394
+ assert np.all(np.isnan(fit.stderr))
395
+ assert np.all(np.isnan(fit.pcov))
396
+ assert np.all(np.isnan(fit.evaluate(x)))
397
+ assert 'did not converge' in caplog.text
398
+
399
+
400
+ def test_fit_decay_stderr_is_root_of_covariance_diagonal():
401
+ """The standard error of each parameter is the square root of the
402
+ covariance diagonal, not the variance itself (P)."""
403
+ x = np.arange(0.0, 60.0)
404
+ rng = np.random.default_rng(8)
405
+ y = np.exp(-x / 7.0) + 0.02 * rng.standard_normal(x.size)
406
+ fit = fit_decay(x, y, 'stretched_amp', p0=[7.0, 1.0, 1.0])
407
+ assert fit.converged
408
+ assert_allclose(fit.stderr ** 2, np.diag(fit.pcov), rtol=1e-12)
409
+ assert np.all(fit.stderr > 0.0)
410
+
411
+
412
+ def test_fit_decay_records_the_start_it_used():
413
+ """With ``p0=None`` the fit starts from :func:`initial_guess` and
414
+ records that start; an explicit ``p0`` is recorded as given (P)."""
415
+ x = np.arange(0.0, 40.0)
416
+ y = 2.0 * np.exp(-x / 4.0) + 0.5
417
+ default = fit_decay(x, y, 'stretched_amp_offset')
418
+ explicit = fit_decay(x, y, 'stretched_amp_offset', p0=[3.0, 1.0, 1.0, 0.0])
419
+ assert_array_equal(
420
+ default.p0, initial_guess(x, y, 'stretched_amp_offset')
421
+ )
422
+ assert_array_equal(explicit.p0, [3.0, 1.0, 1.0, 0.0])
423
+
424
+
425
+ def test_initial_guess_is_within_a_factor_of_two_and_fit_converges_from_it():
426
+ """On a noise-free curve every guess lies within a factor of 2 of the
427
+ truth, and the fit from it recovers the truth (A4)."""
428
+ x = np.arange(0.0, 100.0)
429
+ truth = np.array([5.0, 1.0, 2.0, 0.3])
430
+ y = DECAY_MODELS['stretched_amp_offset'].function(x, *truth)
431
+ guess = initial_guess(x, y, 'stretched_amp_offset')
432
+ ratio = guess / truth
433
+ assert np.all((ratio >= 0.5) & (ratio <= 2.0))
434
+ fit = fit_decay(x, y, 'stretched_amp_offset', p0=guess)
435
+ assert fit.converged
436
+ assert_allclose(fit.params, truth, rtol=1e-8)
437
+
438
+
439
+ @pytest.mark.parametrize(
440
+ 'model, expected',
441
+ [
442
+ pytest.param('exp', [4.0], id='exp'),
443
+ pytest.param('stretched', [4.0, 1.0], id='stretched'),
444
+ pytest.param('stretched_amp', [4.0, 1.0, 1.0], id='stretched-amp'),
445
+ ],
446
+ )
447
+ def test_initial_guess_reads_tau_at_one_over_e(model, expected):
448
+ """The decay-time guess is the first ``x`` at which a unit exponential
449
+ is at ``1/e``, which is ``tau`` itself when it is sampled, with ``beta``
450
+ 1 and amplitude 1 for a model without an offset (A4)."""
451
+ x = np.arange(0.0, 20.0)
452
+ guess = initial_guess(x, np.exp(-x / 4.0), model)
453
+ assert_allclose(guess, expected, atol=FLOAT64_ATOL)
454
+
455
+
456
+ def test_initial_guess_uses_unit_amplitude_for_flat_curve():
457
+ """A curve whose first value equals its tail gives an amplitude guess
458
+ of 1, not 0, so the fit can start (P)."""
459
+ x = np.arange(0.0, 20.0)
460
+ guess = initial_guess(x, np.full(20, 0.3), 'stretched_amp_offset')
461
+ assert guess[2] == 1.0
462
+
463
+
464
+ def test_initial_guess_rejects_empty_curve():
465
+ """An empty curve raises ``ValueError`` instead of indexing nothing."""
466
+ with pytest.raises(ValueError, match='empty'):
467
+ initial_guess([], [], 'exp')
468
+
469
+
470
+ def test_fit_decay_marks_unestimable_covariance_as_nan(caplog):
471
+ """A fit with as many points as parameters converges but gets NaN
472
+ standard errors and a logged warning, since SciPy cannot estimate the
473
+ covariance (P)."""
474
+ x = np.array([2.0])
475
+ y = np.exp(-x / 3.0)
476
+ with caplog.at_level(logging.WARNING, logger='polyphys'):
477
+ with pytest.warns(OptimizeWarning, match='Covariance'):
478
+ fit = fit_decay(x, y, 'exp', p0=[2.5])
479
+ assert fit.converged
480
+ assert fit.params[0] == pytest.approx(3.0, rel=1e-8)
481
+ assert np.all(np.isnan(fit.stderr))
482
+ assert 'covariance' in caplog.text
483
+
484
+
485
+ @pytest.mark.parametrize('method', [
486
+ pytest.param(None, id='lm'),
487
+ pytest.param('trf', id='trf'),
488
+ ])
489
+ def test_fit_decay_reports_non_finite_start_as_failed(method, caplog):
490
+ """A start where the model is not finite is a failed fit under every
491
+ method, reported without raising; ``(x / tau) ** beta`` is NaN for
492
+ ``tau < 0`` and ``x > 0`` (analytic)."""
493
+ x = np.linspace(0.0, 50.0, 501)
494
+ y = np.exp(-x / 5.0)
495
+ kwargs = {} if method is None else {'method': method}
496
+ with caplog.at_level(logging.WARNING, logger='polyphys'):
497
+ fit = fit_decay(x, y, 'stretched', p0=[-2.0, 0.5], **kwargs)
498
+ assert fit.converged is False
499
+ assert np.all(np.isnan(fit.params))
500
+ assert np.all(np.isnan(fit.stderr))
501
+ assert np.all(np.isnan(fit.pcov))
502
+ assert_array_equal(fit.p0, [-2.0, 0.5])
503
+ assert 'not finite' in caplog.text
504
+
505
+
506
+ def test_fit_decay_reports_unmoved_fit_without_covariance_as_failed(caplog):
507
+ """A fit that stays at its start with a non-finite covariance is not
508
+ converged: at ``tau = 1e300`` the model is exactly 1 and its gradient
509
+ underflows to 0, so the optimizer cannot move (analytic)."""
510
+ x = np.linspace(0.0, 50.0, 501)
511
+ y = np.exp(-x / 5.0)
512
+ with caplog.at_level(logging.WARNING, logger='polyphys'):
513
+ with pytest.warns(OptimizeWarning, match='Covariance'):
514
+ fit = fit_decay(x, y, 'exp', p0=[1e300])
515
+ assert fit.converged is False
516
+ assert np.all(np.isnan(fit.params))
517
+ assert 'did not move' in caplog.text
518
+
519
+
520
+ def test_fit_decay_converges_from_the_exact_start():
521
+ """A start at the exact parameters of noise-free data is converged, with
522
+ a finite covariance, although the optimizer does not move: the residual
523
+ is zero there (A4)."""
524
+ x = np.linspace(0.0, 50.0, 501)
525
+ y = np.exp(-x / 5.0)
526
+ fit = fit_decay(x, y, 'exp', p0=[5.0])
527
+ assert fit.converged
528
+ # float64 round-off in exp(-x / 5.0) and the zero-residual covariance
529
+ assert_allclose(fit.params, [5.0], rtol=1e-12)
530
+ assert np.all(np.isfinite(fit.stderr))
531
+
532
+
533
+ def test_initial_guess_never_returns_zero_tau():
534
+ """A curve already at ``1/e`` at ``x = 0`` gets the first positive ``x``
535
+ as its decay time, never 0 (P)."""
536
+ x = np.array([0.0, 0.5, 1.0, 1.5])
537
+ guess = initial_guess(x, np.full(4, 0.1), 'exp')
538
+ assert guess[0] == 0.5
539
+
540
+
541
+ @pytest.mark.parametrize(
542
+ 'kwargs, match',
543
+ [
544
+ pytest.param(
545
+ {'model': 'mono_exp'}, 'model must be one of', id='unknown-model'
546
+ ),
547
+ pytest.param(
548
+ {'model': 'exp', 'p0': [1.0, 2.0]}, 'p0 must have', id='p0-length'
549
+ ),
550
+ pytest.param(
551
+ {'model': 'exp', 'y': np.ones(5)}, 'same length', id='length'
552
+ ),
553
+ pytest.param(
554
+ {'model': 'exp', 'y': np.full(10, np.nan)}, 'finite', id='nan'
555
+ ),
556
+ pytest.param(
557
+ {'model': 'exp', 'x': np.ones((2, 5)), 'y': np.ones((2, 5))},
558
+ 'one-dimensional',
559
+ id='2-d',
560
+ ),
561
+ pytest.param(
562
+ {'model': 'stretched_amp_offset', 'x': np.arange(3.0),
563
+ 'y': np.ones(3)},
564
+ 'only 3 point',
565
+ id='too-few-points',
566
+ ),
567
+ ],
568
+ )
569
+ def test_fit_decay_rejects_bad_input(kwargs, match):
570
+ """An unknown model, a wrong-length ``p0``, mismatched or non-finite
571
+ curves, a 2-D curve, or fewer points than parameters raises
572
+ ``ValueError``."""
573
+ args = {'x': np.arange(10.0), 'y': np.exp(-np.arange(10.0))}
574
+ args.update(kwargs)
575
+ with pytest.raises(ValueError, match=match):
576
+ fit_decay(**args)
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # bond_vectors and bond_cosine_correlation
581
+ # ---------------------------------------------------------------------------
582
+
583
+
584
+ def test_bond_cosine_of_straight_rod_is_one():
585
+ """A straight rod has bond cosine 1 at every lag (A3)."""
586
+ rod = np.outer(np.arange(10.0), [0.3, -0.4, 1.2])
587
+ assert_allclose(
588
+ bond_cosine_correlation(rod, 'linear'), np.ones(9), atol=FLOAT64_ATOL
589
+ )
590
+
591
+
592
+ @pytest.mark.parametrize('n_sides', [pytest.param(12, id='12-gon'),
593
+ pytest.param(7, id='7-gon')])
594
+ def test_bond_cosine_of_regular_ring_is_cosine_of_turning_angle(
595
+ make_polygon, n_sides
596
+ ):
597
+ """A regular ring of ``N`` bonds has ``C(k) = cos(2 pi k / N)`` (A2)."""
598
+ lags = np.arange(n_sides)
599
+ result = bond_cosine_correlation(make_polygon(n_sides), 'ring')
600
+ assert_allclose(
601
+ result, np.cos(2.0 * np.pi * lags / n_sides), atol=FLOAT64_ATOL
602
+ )
603
+
604
+
605
+ @pytest.mark.parametrize('topology', ['linear', 'ring'])
606
+ def test_bond_cosine_matches_double_loop(make_random_walk, topology):
607
+ """The FFT bond cosine equals the mean over pairs by a double loop at
608
+ every lag, for both topologies (R1)."""
609
+ positions = make_random_walk(n_atoms=33, seed=9)
610
+ bonds = bond_vectors(positions, topology)
611
+ units = bonds / np.linalg.norm(bonds, axis=-1, keepdims=True)
612
+ assert_allclose(
613
+ bond_cosine_correlation(positions, topology),
614
+ _bond_cosines_by_definition(units, topology),
615
+ atol=FLOAT64_ATOL,
616
+ )
617
+
618
+
619
+ def test_bond_cosine_is_invariant_under_rigid_motion(make_random_walk):
620
+ """Rotating and translating a chain leaves its bond cosines unchanged
621
+ (P)."""
622
+ positions = make_random_walk(n_atoms=20, seed=10)
623
+ rng = np.random.default_rng(11)
624
+ rotation, _ = np.linalg.qr(rng.standard_normal((3, 3)))
625
+ moved = positions @ rotation.T + np.array([5.0, -2.0, 7.5])
626
+ assert_allclose(
627
+ bond_cosine_correlation(moved, 'linear'),
628
+ bond_cosine_correlation(positions, 'linear'),
629
+ atol=FLOAT64_ATOL,
630
+ )
631
+
632
+
633
+ def test_bond_cosine_keeps_leading_frames_axis(make_random_walk):
634
+ """A ``(n_frames, n_atoms, 3)`` input gives one row per frame, equal to
635
+ the single-frame results (P)."""
636
+ frames = make_random_walk(n_atoms=15, seed=12, n_frames=4)
637
+ result = bond_cosine_correlation(frames, 'ring')
638
+ assert result.shape == (4, 15)
639
+ for f in range(4):
640
+ assert_allclose(
641
+ result[f], bond_cosine_correlation(frames[f], 'ring'),
642
+ atol=FLOAT64_ATOL,
643
+ )
644
+
645
+
646
+ def test_bond_cosine_of_ring_is_symmetric_and_relabeling_invariant(
647
+ make_random_walk,
648
+ ):
649
+ """On a ring ``C(k) = C(N - k)``, and starting the labels at another
650
+ monomer gives the same curve (P)."""
651
+ positions = make_random_walk(n_atoms=21, seed=13)
652
+ result = bond_cosine_correlation(positions, 'ring')
653
+ assert_allclose(result[1:], result[1:][::-1], atol=FLOAT64_ATOL)
654
+ relabeled = bond_cosine_correlation(np.roll(positions, 5, axis=0), 'ring')
655
+ assert_allclose(relabeled, result, atol=FLOAT64_ATOL)
656
+
657
+
658
+ def test_bond_cosine_runs_on_linear_chain():
659
+ """A linear chain of four beads gives three lags without error, which
660
+ the research ``bond_info`` could not (P)."""
661
+ positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [1.0, 1, 0], [2.0, 1, 0]])
662
+ result = bond_cosine_correlation(positions, 'linear')
663
+ assert result.shape == (3,)
664
+ assert_allclose(result, [1.0, 0.0, 1.0], atol=FLOAT64_ATOL)
665
+
666
+
667
+ @pytest.mark.parametrize(
668
+ 'topology, expected',
669
+ [
670
+ pytest.param(
671
+ 'linear', [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]], id='linear'
672
+ ),
673
+ pytest.param(
674
+ 'ring',
675
+ [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]],
676
+ id='ring',
677
+ ),
678
+ ],
679
+ )
680
+ def test_bond_vectors_differences_along_chain(topology, expected):
681
+ """Bond ``i`` is ``r[i+1] - r[i]``, and a ring adds ``r[0] - r[-1]``
682
+ as its last bond (hand count on a unit square)."""
683
+ square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
684
+ assert_array_equal(bond_vectors(square, topology), expected)
685
+
686
+
687
+ @pytest.mark.parametrize(
688
+ 'positions, topology, match',
689
+ [
690
+ pytest.param(
691
+ np.array([[0.0, 0, 0], [0.0, 0, 0], [1.0, 0, 0]]),
692
+ 'linear',
693
+ 'positive length',
694
+ id='coincident-beads',
695
+ ),
696
+ pytest.param(np.eye(3), 'helix', "must be 'linear' or 'ring'",
697
+ id='bad-topology'),
698
+ pytest.param(np.arange(6.0), 'linear', 'dimension', id='1-d'),
699
+ pytest.param(np.eye(2), 'ring', 'at least 3 atoms', id='ring-of-two'),
700
+ pytest.param(np.ones((1, 3)), 'linear', 'at least 2 atoms',
701
+ id='one-atom'),
702
+ ],
703
+ )
704
+ def test_bond_cosine_rejects_bad_input(positions, topology, match):
705
+ """Coincident bonded beads, an unknown topology, a 1-D input, or a chain
706
+ too short for its topology raises ``ValueError``."""
707
+ with pytest.raises(ValueError, match=match):
708
+ bond_cosine_correlation(positions, topology)