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,732 @@
1
+ """
2
+ Tests for :mod:`polyphys.analyze.stats`.
3
+
4
+ Expected values come from three analytic results, derived here so that no
5
+ expected value depends on the code under test.
6
+
7
+ R1. For a stationary series with autocorrelation function ``rho(t)``, the
8
+ statistical inefficiency is ``s = sum_{t=-inf}^{inf} rho(t)``, twice the
9
+ integrated autocorrelation time (Wolff, Comput. Phys. Commun. 156, 143
10
+ (2004), Eq. (23) and (25)). For independent frames ``s = 1`` at every
11
+ block size.
12
+
13
+ R2. AR(1), ``x_t = phi x_{t-1} + e_t`` with unit-variance innovations and a
14
+ stationary start, has ``rho(t) = phi**|t|`` and ``var(x) = 1/(1-phi^2)``.
15
+ The variance of the mean of ``B`` consecutive values is
16
+ ``(var(x)/B) [1 + 2 sum_{t=1}^{B-1} (1 - t/B) phi**t]``, so the
17
+ inefficiency at block size ``B`` is the bracket, and its ``B -> inf``
18
+ limit is ``(1+phi)/(1-phi)`` by the geometric series in R1. Finite-``n``
19
+ corrections are of order ``s/n``, below 3e-4 for the sizes used here.
20
+
21
+ R3. A block-constant series, ``x_t = c_{t // L}`` with i.i.d. ``c_i`` and
22
+ ``L`` dividing ``n``: for ``B <= L`` every block mean is one of the
23
+ ``c_i`` and both ``ddof=1`` variances are sums over the same repeated
24
+ values, so exactly ``s(B) = B (n-1) / (n-B)``. For ``B >= L`` the block
25
+ means are means of ``B/L`` independent ``c_i``, so ``E[var_B] = var(c)
26
+ L/B`` and ``E[var_0] = var(c) (n-L)/(n-1)``, giving ``L (n-1)/(n-L)`` as
27
+ the ratio of expectations; the two forms agree at ``B = L``.
28
+
29
+ Statistical tolerances are five times the leading-order Gaussian error of a
30
+ variance estimated from ``m`` block means, ``sqrt(2/(m-1))`` relative (Lee et
31
+ al., Phys. Rev. E 83, 066706 (2011), Eq. (15)), checked only at levels with at
32
+ least 32 blocks, where that Gaussian approximation holds. With about one
33
+ hundred such comparisons in this file, the false-failure probability is below
34
+ 1e-4, and every input is seeded, so a run is deterministic.
35
+ """
36
+
37
+ import warnings
38
+
39
+ import numpy as np
40
+ import pytest
41
+ from hypothesis import assume, example, given, settings
42
+ from hypothesis import strategies as st
43
+ from hypothesis.extra.numpy import arrays
44
+ from numpy.testing import assert_allclose, assert_array_equal
45
+
46
+ from polyphys.analyze.measurer import sem
47
+ from polyphys.analyze.stats import (
48
+ BlockingResult,
49
+ block_average,
50
+ select_plateau,
51
+ sem_blocked,
52
+ statistical_inefficiency,
53
+ )
54
+
55
+ # Relative tolerance for results that differ only by float64 round-off over
56
+ # at most ~2 n additions: far above the 2.2e-16 epsilon, far below any
57
+ # statistical effect.
58
+ FLOAT64_RTOL = 1e-12
59
+ # Gaussian-error multiple and the smallest block count at which it is used;
60
+ # see the module docstring.
61
+ SIGMA = 5.0
62
+ MIN_BLOCKS_FOR_GAUSSIAN = 32
63
+
64
+
65
+ def ar1_inefficiency(phi: float, block_size: np.ndarray) -> np.ndarray:
66
+ """Return the AR(1) inefficiency at each block size, from R2."""
67
+ out = np.empty(len(block_size), dtype=np.float64)
68
+ for i, size in enumerate(block_size):
69
+ t = np.arange(1, size)
70
+ out[i] = 1.0 + 2.0 * np.sum((1.0 - t / size) * phi ** t)
71
+ return out
72
+
73
+
74
+ def block_constant_inefficiency(
75
+ period: int, n_frames: int, block_size: np.ndarray
76
+ ) -> np.ndarray:
77
+ """Return the block-constant inefficiency at each block size, from R3."""
78
+ effective = np.minimum(block_size, period)
79
+ return effective * (n_frames - 1) / (n_frames - effective)
80
+
81
+
82
+ def relative_gaussian_error(n_blocks: np.ndarray) -> np.ndarray:
83
+ """Return the relative error of a variance from ``n_blocks`` samples."""
84
+ return np.sqrt(2.0 / (n_blocks - 1.0))
85
+
86
+
87
+ @pytest.fixture
88
+ def make_ar1():
89
+ """Return a factory for a stationary AR(1) series with unit innovations."""
90
+
91
+ def _make(phi: float, n_frames: int, seed: int) -> np.ndarray:
92
+ rng = np.random.default_rng(seed)
93
+ noise = rng.standard_normal(n_frames)
94
+ x = np.empty(n_frames, dtype=np.float64)
95
+ x[0] = noise[0] / np.sqrt(1.0 - phi ** 2) # stationary start
96
+ for t in range(1, n_frames):
97
+ x[t] = phi * x[t - 1] + noise[t]
98
+ return x
99
+
100
+ return _make
101
+
102
+
103
+ @pytest.fixture
104
+ def make_block_constant():
105
+ """Return a factory for a series constant over blocks of ``period``."""
106
+
107
+ def _make(period: int, n_frames: int, seed: int) -> np.ndarray:
108
+ rng = np.random.default_rng(seed)
109
+ return np.repeat(rng.standard_normal(n_frames // period), period)
110
+
111
+ return _make
112
+
113
+
114
+ @pytest.fixture
115
+ def make_table():
116
+ """Return a factory for a hand-built :class:`BlockingResult`.
117
+
118
+ Only ``sem`` (and optionally ``sem_err``) carry the test's values; the
119
+ other fields are filled consistently with the given ``n_frames``.
120
+ """
121
+
122
+ def _make(
123
+ sem_values, n_frames: int, sem_err_values=None
124
+ ) -> BlockingResult:
125
+ sem_arr = np.asarray(sem_values, dtype=np.float64)
126
+ n_levels = len(sem_arr)
127
+ block_size = 2 ** np.arange(n_levels, dtype=np.int64)
128
+ n_blocks = n_frames // block_size
129
+ var = sem_arr ** 2 * n_blocks
130
+ if sem_err_values is None:
131
+ sem_err = sem_arr / np.sqrt(2.0 * (n_blocks - 1))
132
+ else:
133
+ sem_err = np.asarray(sem_err_values, dtype=np.float64)
134
+ inefficiency = block_size * var / var[0]
135
+ return BlockingResult(
136
+ n_frames=n_frames,
137
+ block_size=block_size,
138
+ n_blocks=n_blocks,
139
+ mean=np.zeros(n_levels),
140
+ var=var,
141
+ sem=sem_arr,
142
+ sem_err=sem_err,
143
+ statistical_inefficiency=inefficiency,
144
+ inefficiency_err=inefficiency * np.sqrt(2.0 / (n_blocks - 1)),
145
+ )
146
+
147
+ return _make
148
+
149
+
150
+ # --- the table ---------------------------------------------------------------
151
+
152
+
153
+ @pytest.mark.parametrize(
154
+ "n_frames,n_levels",
155
+ [
156
+ pytest.param(64, 5, id="power-of-two-64"),
157
+ pytest.param(100, 5, id="odd-levels-100"),
158
+ pytest.param(127, 5, id="just-below-128"),
159
+ pytest.param(128, 6, id="power-of-two-128"),
160
+ pytest.param(1000, 8, id="1000"),
161
+ ],
162
+ )
163
+ def test_block_average_level_layout(n_frames, n_levels):
164
+ """Levels have block sizes 1, 2, 4, ... and stop at min_blocks blocks."""
165
+ # n_levels is floor(log2(n_frames // 4)) + 1, worked by hand per case.
166
+ x = np.random.default_rng(0).standard_normal(n_frames)
167
+
168
+ table = block_average(x, min_blocks=4)
169
+
170
+ assert table.n_levels == n_levels
171
+ assert_array_equal(table.block_size, 2 ** np.arange(n_levels))
172
+ assert_array_equal(table.n_blocks, n_frames // table.block_size)
173
+ assert table.n_blocks[-1] >= 4
174
+ assert table.n_blocks[-1] // 2 < 4
175
+
176
+
177
+ def test_block_average_level_zero_is_naive_estimate():
178
+ """Level 0 reproduces the naive SEM and an inefficiency of exactly 1."""
179
+ x = np.random.default_rng(1).standard_normal(1000)
180
+
181
+ table = block_average(x)
182
+
183
+ assert table.sem[0] == pytest.approx(sem(x), rel=FLOAT64_RTOL)
184
+ # var / var is exactly 1 in IEEE arithmetic.
185
+ assert table.statistical_inefficiency[0] == pytest.approx(1.0, abs=0.0)
186
+
187
+
188
+ def test_block_average_preserves_mean_without_dropped_frames():
189
+ """With a power-of-two frame count the mean is the same at every level."""
190
+ x = np.random.default_rng(2).standard_normal(2 ** 10)
191
+
192
+ table = block_average(x)
193
+
194
+ assert_allclose(
195
+ table.mean, np.full(table.n_levels, x.mean()), rtol=FLOAT64_RTOL
196
+ )
197
+
198
+
199
+ @pytest.mark.parametrize(
200
+ "scale,offset",
201
+ [
202
+ pytest.param(-3.0, 7.0, id="negative-scale"),
203
+ pytest.param(0.5, 7.0, id="shrink"),
204
+ ],
205
+ )
206
+ def test_block_average_is_affine_equivariant(scale, offset):
207
+ """Scaling a series scales its SEMs; the inefficiency does not change."""
208
+ x = np.random.default_rng(3).standard_normal(2 ** 10)
209
+
210
+ base = block_average(x)
211
+ moved = block_average(scale * x + offset)
212
+
213
+ assert_allclose(moved.sem, abs(scale) * base.sem, rtol=FLOAT64_RTOL)
214
+ assert_allclose(
215
+ moved.statistical_inefficiency,
216
+ base.statistical_inefficiency,
217
+ rtol=FLOAT64_RTOL,
218
+ )
219
+
220
+
221
+ # --- known answers -----------------------------------------------------------
222
+
223
+
224
+ def test_block_average_iid_inefficiency_is_one_at_every_level():
225
+ """Independent frames give an inefficiency of 1 at every level (R1)."""
226
+ n_frames = 2 ** 16
227
+ x = np.random.default_rng(4).standard_normal(n_frames)
228
+
229
+ table = block_average(x)
230
+
231
+ checked = table.n_blocks >= MIN_BLOCKS_FOR_GAUSSIAN
232
+ sigma = relative_gaussian_error(table.n_blocks[checked])
233
+ pulls = (table.statistical_inefficiency[checked] - 1.0) / sigma
234
+ assert_allclose(pulls, np.zeros_like(pulls), rtol=0.0, atol=SIGMA)
235
+
236
+
237
+ @pytest.mark.parametrize(
238
+ "phi,seed",
239
+ [
240
+ pytest.param(-0.5, 5, id="anticorrelated"),
241
+ pytest.param(0.5, 6, id="phi-0.5"),
242
+ pytest.param(0.9, 7, id="phi-0.9"),
243
+ ],
244
+ )
245
+ def test_block_average_ar1_inefficiency_matches_analytic(
246
+ make_ar1, phi, seed
247
+ ):
248
+ """An AR(1) series gives the analytic inefficiency at each level (R2)."""
249
+ n_frames = 2 ** 16
250
+ x = make_ar1(phi, n_frames, seed)
251
+
252
+ table = block_average(x)
253
+
254
+ checked = table.n_blocks >= MIN_BLOCKS_FOR_GAUSSIAN
255
+ expected = ar1_inefficiency(phi, table.block_size[checked])
256
+ sigma = expected * relative_gaussian_error(table.n_blocks[checked])
257
+ pulls = (table.statistical_inefficiency[checked] - expected) / sigma
258
+ assert_allclose(pulls, np.zeros_like(pulls), rtol=0.0, atol=SIGMA)
259
+
260
+
261
+ @pytest.mark.parametrize(
262
+ "phi,seed,method",
263
+ [
264
+ pytest.param(0.5, 8, "lee", id="phi-0.5-lee"),
265
+ pytest.param(0.9, 9, "lee", id="phi-0.9-lee"),
266
+ pytest.param(0.5, 8, "sem_err", id="phi-0.5-sem_err"),
267
+ pytest.param(0.9, 9, "sem_err", id="phi-0.9-sem_err"),
268
+ ],
269
+ )
270
+ def test_sem_blocked_ar1_matches_analytic_error(make_ar1, phi, seed, method):
271
+ """The blocked SEM of an AR(1) series is the analytic error at the level
272
+ each rule selects, and a long series raises no warning (R2)."""
273
+ n_frames = 2 ** 16
274
+ x = make_ar1(phi, n_frames, seed)
275
+ variance = 1.0 / (1.0 - phi ** 2)
276
+
277
+ with warnings.catch_warnings():
278
+ warnings.simplefilter("error")
279
+ table = block_average(x)
280
+ level = select_plateau(table, method=method)
281
+ estimate = sem_blocked(x, method=method)
282
+
283
+ assert level >= 0
284
+ block_size = table.block_size[level]
285
+ expected = np.sqrt(
286
+ ar1_inefficiency(phi, np.array([block_size]))[0]
287
+ * variance / n_frames
288
+ )
289
+ tolerance = SIGMA * expected * relative_gaussian_error(
290
+ np.array([table.n_blocks[level]])
291
+ )[0] / 2.0 # the SEM is a square root, so half the relative error
292
+ assert estimate == pytest.approx(expected, abs=tolerance)
293
+
294
+
295
+ @pytest.mark.parametrize(
296
+ "period,seed",
297
+ [
298
+ pytest.param(8, 10, id="period-8"),
299
+ pytest.param(32, 11, id="period-32"),
300
+ ],
301
+ )
302
+ def test_block_average_block_constant_below_period_is_exact(
303
+ make_block_constant, period, seed
304
+ ):
305
+ """Below the period a block-constant series has an exact inefficiency
306
+ B (n-1)/(n-B) (R3)."""
307
+ n_frames = 2 ** 14
308
+ x = make_block_constant(period, n_frames, seed)
309
+
310
+ table = block_average(x)
311
+
312
+ below = table.block_size <= period
313
+ expected = block_constant_inefficiency(
314
+ period, n_frames, table.block_size[below]
315
+ )
316
+ assert_allclose(
317
+ table.statistical_inefficiency[below], expected, rtol=FLOAT64_RTOL
318
+ )
319
+
320
+
321
+ @pytest.mark.parametrize(
322
+ "period,seed",
323
+ [
324
+ pytest.param(8, 10, id="period-8"),
325
+ pytest.param(32, 11, id="period-32"),
326
+ ],
327
+ )
328
+ def test_block_average_block_constant_plateaus_at_period(
329
+ make_block_constant, period, seed
330
+ ):
331
+ """At and above the period the inefficiency plateaus at L (n-1)/(n-L)
332
+ within statistical error (R3)."""
333
+ n_frames = 2 ** 14
334
+ x = make_block_constant(period, n_frames, seed)
335
+
336
+ table = block_average(x)
337
+
338
+ checked = (table.block_size >= period) & (
339
+ table.n_blocks >= MIN_BLOCKS_FOR_GAUSSIAN
340
+ )
341
+ expected = block_constant_inefficiency(
342
+ period, n_frames, table.block_size[checked]
343
+ )
344
+ sigma = expected * relative_gaussian_error(table.n_blocks[checked])
345
+ pulls = (table.statistical_inefficiency[checked] - expected) / sigma
346
+ assert_allclose(pulls, np.zeros_like(pulls), rtol=0.0, atol=SIGMA)
347
+
348
+
349
+ # --- contracts: shapes, dtypes, independence of observables ------------------
350
+
351
+
352
+ def test_block_average_treats_trailing_axes_independently(make_ar1):
353
+ """Each observable of an n-D input gets the table of its own 1-D series."""
354
+ first = make_ar1(0.3, 2 ** 10, 12)
355
+ second = make_ar1(0.8, 2 ** 10, 13)
356
+ stacked = np.column_stack([first, second]).reshape(2 ** 10, 2, 1)
357
+
358
+ table = block_average(stacked)
359
+
360
+ assert_allclose(
361
+ table.sem[:, 0, 0], block_average(first).sem, rtol=FLOAT64_RTOL
362
+ )
363
+ assert_allclose(
364
+ table.statistical_inefficiency[:, 1, 0],
365
+ block_average(second).statistical_inefficiency,
366
+ rtol=FLOAT64_RTOL,
367
+ )
368
+
369
+
370
+ @pytest.mark.parametrize(
371
+ "trailing_shape",
372
+ [
373
+ pytest.param((), id="scalar-series"),
374
+ pytest.param((3,), id="vector-series"),
375
+ pytest.param((2, 3), id="matrix-series"),
376
+ ],
377
+ )
378
+ def test_block_average_shapes_follow_the_input(trailing_shape):
379
+ """Per-level arrays are (n_levels, *trailing); counts are (n_levels,)."""
380
+ n_frames = 256
381
+ x = np.random.default_rng(14).standard_normal((n_frames, *trailing_shape))
382
+
383
+ table = block_average(x)
384
+
385
+ assert table.observable_shape == trailing_shape
386
+ assert table.block_size.shape == (table.n_levels,)
387
+ assert table.n_blocks.shape == (table.n_levels,)
388
+ assert table.mean.shape == (table.n_levels, *trailing_shape)
389
+ assert table.sem.shape == (table.n_levels, *trailing_shape)
390
+ assert table.sem_err.shape == (table.n_levels, *trailing_shape)
391
+ assert table.statistical_inefficiency.shape == (
392
+ table.n_levels, *trailing_shape
393
+ )
394
+ assert table.inefficiency_err.shape == (table.n_levels, *trailing_shape)
395
+
396
+
397
+ def test_select_plateau_returns_int_for_scalar_series():
398
+ """A scalar series gives a plain int level and float wrapped estimates."""
399
+ x = np.random.default_rng(15).standard_normal(2 ** 12)
400
+
401
+ level = select_plateau(block_average(x), method="lee")
402
+
403
+ assert isinstance(level, int)
404
+ assert isinstance(sem_blocked(x, method="lee"), float)
405
+ assert isinstance(statistical_inefficiency(x, method="lee"), float)
406
+
407
+
408
+ def test_select_plateau_returns_array_per_observable():
409
+ """An n-D series gives one level and one estimate per observable."""
410
+ x = np.random.default_rng(16).standard_normal((2 ** 12, 2, 3))
411
+
412
+ level = select_plateau(block_average(x), method="lee")
413
+
414
+ assert level.shape == (2, 3)
415
+ assert level.dtype == np.int64
416
+ assert sem_blocked(x, method="lee").shape == (2, 3)
417
+ assert statistical_inefficiency(x, method="lee").shape == (2, 3)
418
+
419
+
420
+ @pytest.mark.parametrize(
421
+ "dtype",
422
+ [
423
+ pytest.param(np.float32, id="float32"),
424
+ pytest.param(np.int64, id="int64"),
425
+ pytest.param(np.float64, id="float64"),
426
+ ],
427
+ )
428
+ def test_block_average_output_dtypes(dtype):
429
+ """Statistics are float64 and counts int64 whatever the input dtype."""
430
+ x = np.random.default_rng(17).integers(0, 100, 512).astype(dtype)
431
+
432
+ table = block_average(x)
433
+
434
+ assert table.block_size.dtype == np.int64
435
+ assert table.n_blocks.dtype == np.int64
436
+ assert table.mean.dtype == np.float64
437
+ assert table.var.dtype == np.float64
438
+ assert table.sem.dtype == np.float64
439
+ assert table.sem_err.dtype == np.float64
440
+ assert table.statistical_inefficiency.dtype == np.float64
441
+ assert table.inefficiency_err.dtype == np.float64
442
+
443
+
444
+ def test_block_average_leaves_input_unchanged():
445
+ """The input array is not modified by the in-place blocking buffer."""
446
+ x = np.random.default_rng(18).standard_normal((512, 2))
447
+ before = x.copy()
448
+
449
+ block_average(x)
450
+
451
+ assert_array_equal(x, before)
452
+
453
+
454
+ # --- the selection rules on hand-built tables --------------------------------
455
+
456
+
457
+ @pytest.mark.parametrize(
458
+ "sem_ratios,n_frames,expected_level",
459
+ [
460
+ # n = 1024: a level qualifies when B**3 > 2048 r**4; with r = 1
461
+ # everywhere that is B >= 16, so the run from the top ends at k = 4.
462
+ pytest.param([1, 1, 1, 1, 1, 1, 1, 1], 1024, 4, id="flat-table"),
463
+ # n = 512, r = 0.5 for k <= 3: at k = 2, B**3 = 64 equals
464
+ # 2 * 512 * 0.5**4 = 64, so strict '>' stops the run at k = 3; a
465
+ # non-strict rule would continue to k = 2.
466
+ pytest.param([1, 0.5, 0.5, 0.5, 1, 1, 1], 512, 3, id="strict"),
467
+ # n = 4096: a level qualifies when B**3 > 8192 r**4, so with r = 1
468
+ # that is B >= 32. r = 10 at k = 5 breaks the run: k = 7, 6 qualify,
469
+ # k = 5 does not, so the rule stops at k = 6 although k = 5 would
470
+ # qualify with r = 1 and a smallest-anywhere rule would pick it.
471
+ pytest.param([1, 1, 1, 1, 1, 10, 1, 1], 4096, 6, id="broken-run"),
472
+ ],
473
+ )
474
+ def test_select_plateau_lee_scans_down_from_largest_block(
475
+ make_table, sem_ratios, n_frames, expected_level
476
+ ):
477
+ """The Lee rule takes the smallest block of the qualifying run that
478
+ includes the largest block, with a strict inequality."""
479
+ table = make_table(sem_ratios, n_frames)
480
+
481
+ level = select_plateau(table, method="lee")
482
+
483
+ assert level == expected_level
484
+
485
+
486
+ def test_select_plateau_lee_reports_no_plateau_when_top_level_fails(
487
+ make_table
488
+ ):
489
+ """A top level that fails the Lee inequality gives -1 with a warning."""
490
+ # n = 1024, k = 7: B**3 = 2**21 is not above 2048 * 10**4.
491
+ table = make_table([1, 1, 1, 1, 1, 1, 1, 10], 1024)
492
+
493
+ with pytest.warns(RuntimeWarning, match="No plateau found"):
494
+ level = select_plateau(table, method="lee")
495
+
496
+ assert level == -1
497
+
498
+
499
+ def test_select_plateau_lee_warns_when_fewer_than_fifty_blocks_remain(
500
+ make_table
501
+ ):
502
+ """Selecting a block size at or above n/50 raises Lee's reliability
503
+ warning while still returning the level."""
504
+ # n = 1024, r = 10 for k <= 4: the run from the top stops at k = 5,
505
+ # B = 32, and 50 * 32 >= 1024.
506
+ table = make_table([1, 10, 10, 10, 10, 1, 1, 1], 1024)
507
+
508
+ with pytest.warns(RuntimeWarning, match="not below n_frames / 50"):
509
+ level = select_plateau(table, method="lee")
510
+
511
+ assert level == 5
512
+
513
+
514
+ @pytest.mark.parametrize(
515
+ "sem_values,expected_level",
516
+ [
517
+ # n = 1024, sem_err 0.05 everywhere: levels 1 to 3 are flat at 10,
518
+ # so sem_err picks k = 1, with 512 blocks. The Lee run from the top
519
+ # stops at k = 5, B = 32, as in the test above, and 50 * 32 >= 1024.
520
+ pytest.param(
521
+ [1, 10, 10, 10, 10, 1, 1, 1], 1, id="lee-block-too-large"
522
+ ),
523
+ # n = 1024: sem_err picks k = 0, but the top level fails the Lee
524
+ # inequality, B**3 = 2**21 not above 2048 * 10**4, so the Lee rule
525
+ # finds no level at all.
526
+ pytest.param(
527
+ [1, 1, 1, 1, 1, 1, 1, 10], 0, id="lee-level-missing"
528
+ ),
529
+ ],
530
+ )
531
+ def test_select_plateau_sem_err_warns_when_the_lee_block_is_too_large(
532
+ make_table, sem_values, expected_level
533
+ ):
534
+ """Under sem_err, Lee's reliability warning follows the Lee block size,
535
+ not the selected one, and the selected level is still returned."""
536
+ table = make_table(sem_values, 1024, [0.05] * len(sem_values))
537
+
538
+ with pytest.warns(RuntimeWarning, match="not below n_frames / 50"):
539
+ level = select_plateau(table, method="sem_err")
540
+
541
+ assert level == expected_level
542
+
543
+
544
+ def test_select_plateau_sem_err_warns_on_a_too_short_ar1_run(make_ar1):
545
+ """AR(1) with phi = 0.99 over 2**14 frames, about 82 independent
546
+ samples, makes the sem_err rule warn that its plateau is unreliable."""
547
+ # s = 199 by R2, so n / s = 82. Lee's criterion n / B_opt > 50, with
548
+ # B_opt = (2 n s**2)**(1/3), needs n / s > 500.
549
+ table = block_average(make_ar1(0.99, 2 ** 14, 22))
550
+
551
+ with pytest.warns(RuntimeWarning, match="not below n_frames / 50"):
552
+ level = select_plateau(table, method="sem_err")
553
+
554
+ assert level >= 0
555
+
556
+
557
+ @pytest.mark.parametrize(
558
+ "sem_values,sem_err_values,expected_level",
559
+ [
560
+ # Level 3 is the first whose SEM equals the next two within 0.05.
561
+ pytest.param(
562
+ [1.0, 1.5, 1.9, 2.0, 2.0, 2.0, 2.0, 2.0],
563
+ [0.05] * 8,
564
+ 3,
565
+ id="first-flat-level",
566
+ ),
567
+ # Level 2 has a large error bar of its own, but the rule compares
568
+ # against the error bars of levels 3 and 4, which it fails.
569
+ pytest.param(
570
+ [1.0, 1.5, 1.9, 2.0, 2.0, 2.0, 2.0, 2.0],
571
+ [0.05, 0.05, 0.2, 0.05, 0.05, 0.05, 0.05, 0.05],
572
+ 3,
573
+ id="next-levels-error-bars",
574
+ ),
575
+ ],
576
+ )
577
+ def test_select_plateau_sem_err_takes_first_level_within_next_two_errors(
578
+ make_table, sem_values, sem_err_values, expected_level
579
+ ):
580
+ """The sem_err rule picks the first level whose SEM lies within the
581
+ error bar of each of the next two levels."""
582
+ table = make_table(sem_values, 1024, sem_err_values)
583
+
584
+ # At 1024 frames the table's block sizes fail the Lee et al. (2011)
585
+ # Eq. (14) check, which warns; the sem_err rule still picks a level.
586
+ with pytest.warns(RuntimeWarning, match="not below n_frames / 50"):
587
+ level = select_plateau(table, method="sem_err")
588
+
589
+ assert level == expected_level
590
+
591
+
592
+ @pytest.mark.parametrize(
593
+ "sem_values",
594
+ [
595
+ pytest.param([1.0, 2.0, 4.0, 8.0, 16.0], id="still-rising"),
596
+ pytest.param([1.0, 1.0], id="two-levels-only"),
597
+ ],
598
+ )
599
+ def test_select_plateau_sem_err_reports_no_plateau(make_table, sem_values):
600
+ """A table that never flattens, or has fewer than three levels, gives
601
+ -1 with a warning."""
602
+ table = make_table(sem_values, 1024, [0.01] * len(sem_values))
603
+
604
+ with pytest.warns(RuntimeWarning, match="No plateau found"):
605
+ level = select_plateau(table, method="sem_err")
606
+
607
+ assert level == -1
608
+
609
+
610
+ @pytest.mark.parametrize("method", ["lee", "sem_err"])
611
+ def test_wrappers_return_nan_for_a_drifting_series(method):
612
+ """A series with a linear drift has no plateau: the wrappers give NaN."""
613
+ x = np.arange(1024, dtype=np.float64)
614
+
615
+ with pytest.warns(RuntimeWarning, match="No plateau found"):
616
+ estimate = sem_blocked(x, method=method)
617
+
618
+ assert np.isnan(estimate)
619
+
620
+
621
+ @pytest.mark.parametrize("method", ["lee", "sem_err"])
622
+ def test_wrappers_return_the_selected_row_of_the_table(make_ar1, method):
623
+ """sem_blocked and statistical_inefficiency equal the table entries at
624
+ the level select_plateau chooses."""
625
+ x = make_ar1(0.7, 2 ** 14, 19)
626
+ table = block_average(x)
627
+ level = select_plateau(table, method=method)
628
+
629
+ blocked_sem = sem_blocked(x, method=method)
630
+ inefficiency = statistical_inefficiency(x, method=method)
631
+
632
+ assert blocked_sem == pytest.approx(table.sem[level], rel=FLOAT64_RTOL)
633
+ assert inefficiency == pytest.approx(
634
+ table.statistical_inefficiency[level], rel=FLOAT64_RTOL
635
+ )
636
+
637
+
638
+ # --- invalid input -----------------------------------------------------------
639
+
640
+
641
+ @pytest.mark.parametrize(
642
+ "x,min_blocks,match",
643
+ [
644
+ pytest.param(3.0, 4, "got a scalar", id="scalar"),
645
+ pytest.param([1.0, np.nan, 2.0, 3.0], 4, "finite", id="nan"),
646
+ pytest.param([1.0, np.inf, 2.0, 3.0], 4, "finite", id="inf"),
647
+ pytest.param([1.0, 2.0, 3.0], 4, "at least min_blocks", id="short"),
648
+ pytest.param([1.0, 2.0, 3.0, 4.0], 1, "at least 2", id="min-blocks"),
649
+ pytest.param(
650
+ np.column_stack([np.arange(8.0), np.ones(8)]),
651
+ 4,
652
+ "constant series",
653
+ id="constant-observable",
654
+ ),
655
+ ],
656
+ )
657
+ def test_block_average_rejects_invalid_input(x, min_blocks, match):
658
+ """Scalars, non-finite values, too few frames, a min_blocks below 2, and
659
+ a constant observable raise ValueError."""
660
+ with pytest.raises(ValueError, match=match):
661
+ block_average(x, min_blocks=min_blocks)
662
+
663
+
664
+ def test_select_plateau_rejects_unknown_method(make_table):
665
+ """An unknown method name raises ValueError naming the choices."""
666
+ table = make_table([1, 1, 1, 1, 1], 256)
667
+
668
+ with pytest.raises(ValueError, match="choose 'lee' or 'sem_err'"):
669
+ select_plateau(table, method="bogus")
670
+
671
+
672
+ def test_sem_blocked_rejects_unknown_method():
673
+ """The wrappers pass an unknown method name through as ValueError."""
674
+ x = np.random.default_rng(20).standard_normal(256)
675
+
676
+ with pytest.raises(ValueError, match="Unknown plateau method"):
677
+ sem_blocked(x, method="bogus")
678
+
679
+
680
+ # Generated series
681
+
682
+
683
+ @settings(max_examples=50, deadline=None)
684
+ @given(
685
+ x=arrays(np.float64, st.integers(min_value=8, max_value=512),
686
+ elements=st.floats(min_value=-1.0, max_value=1.0)),
687
+ scale=st.one_of(st.floats(min_value=-10.0, max_value=-0.1),
688
+ st.floats(min_value=0.1, max_value=10.0)),
689
+ offset=st.floats(min_value=-10.0, max_value=10.0),
690
+ )
691
+ # Every level-1 block mean is exactly 0, so the plain variance there is
692
+ # exactly 0 while the mapped one is round-off of about 4e-33.
693
+ @example(
694
+ x=np.array([0.1, -0.1, 0.3, -0.3, 0.7, -0.7, 0.9, -0.9]),
695
+ scale=3.0,
696
+ offset=0.1,
697
+ )
698
+ def test_block_average_is_affine_equivariant_on_generated_series(
699
+ x, scale, offset):
700
+ """For a x + b, every blocking level keeps its blocks, its mean becomes
701
+ a mean + b, its variance a**2 var, its standard error |a| sem, and its
702
+ inefficiency is unchanged: the linearity of the mean and the variance,
703
+ with no reference value."""
704
+ assume(np.ptp(x) > 0.1)
705
+
706
+ plain = block_average(x)
707
+ mapped = block_average(scale * x + offset)
708
+
709
+ assert_array_equal(mapped.block_size, plain.block_size)
710
+ assert_array_equal(mapped.n_blocks, plain.n_blocks)
711
+ # float64 round-off of a sum of at most 512 values of size about 20,
712
+ # whose spread is at least 0.01 times the offset: 1e-8 holds with room.
713
+ assert_allclose(mapped.mean, scale * plain.mean + offset,
714
+ rtol=1e-8, atol=1e-10)
715
+ # A level whose block means are all equal has a variance of exactly 0 in
716
+ # one series and of round-off in the other, which no rtol accepts. With
717
+ # eps = 2.2e-16 and |values| <= 20, a block mean carries at most one
718
+ # rounding per halving (at most 9 for 512 frames) and two from the map,
719
+ # so the error of each block mean, both series counted, is
720
+ # delta <= 1e-13. Over m blocks the variance then moves by at most
721
+ # 2 sqrt(2 var) delta + 2 delta**2 <= (rtol / 2) var + 4 delta**2 / rtol
722
+ # (AM-GM), with 4 delta**2 / rtol = 4e-18: atol 1e-17 for var.
723
+ # sem = sqrt(var / m) with m >= 4 moves by at most sqrt(1e-17 / 4),
724
+ # about 1.6e-9: atol 2e-9. The inefficiency B var / var_0 has B <= 256
725
+ # and, since the range exceeds 0.1 over at most 512 frames and
726
+ # |scale| >= 0.1, var_0 >= 0.1**2 / (2 * 511) * 0.1**2, about 1e-7, so
727
+ # it moves by at most 256 * 1e-17 / 9.8e-8, about 3e-8: atol 1e-7.
728
+ assert_allclose(mapped.var, scale**2 * plain.var, rtol=1e-8, atol=1e-17)
729
+ assert_allclose(mapped.sem, abs(scale) * plain.sem,
730
+ rtol=1e-8, atol=2e-9)
731
+ assert_allclose(mapped.statistical_inefficiency,
732
+ plain.statistical_inefficiency, rtol=1e-8, atol=1e-7)