StochasticForceInference 2.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 (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,621 @@
1
+ """Overdamped-vs-underdamped dynamics classifier.
2
+
3
+ Reads raw trajectory data and decides whether the dynamics are overdamped
4
+ (OD, first-order Langevin) or underdamped (UD, second-order / inertial),
5
+ robust to high localization noise and coarse sampling. Entry point:
6
+ :func:`classify_dynamics`.
7
+
8
+ The discriminator is the lag-resolved displacement covariance
9
+ ``C_k(dt) = <Delta x_t . Delta x_{t+k}>`` with ``Delta x_t = x_{t+1} - x_t``:
10
+
11
+ * White localization noise (variance ``sigma^2`` per axis) contributes ``+2
12
+ sigma^2`` to ``C0``, ``-sigma^2`` to ``C1`` and **exactly 0 to ``C_{k>=2}``**
13
+ — so lag >= 2 is measurement-noise-immune by construction.
14
+ * A force field cannot fake inertia: for OD the apparent kinetic energy
15
+ ``C0/dt^2`` diverges as ``2D/dt`` while the lag-2 velocity correlation stays
16
+ finite, so the noise-corrected ratio ``rho2 = C2 / (C0 + 2 C1)`` (the
17
+ combination ``C0 + 2 C1`` cancels white localization noise exactly) tends to
18
+ 0; for UD the displacement is ballistic (``Delta x ~ v dt``) so ``rho2`` rises
19
+ to a positive plateau. Scanning ``dt`` (via
20
+ :meth:`TrajectoryCollection.degrade`) removes the force confound; using lag 2
21
+ removes the noise.
22
+
23
+ The pipeline (see :func:`classify_dynamics`) layers a model-free
24
+ ``dt``-scaling test (:func:`_scaling_statistics`), a parametric covariance fit
25
+ (:func:`_fit_dynamics`) and an overdamped-fit residual-autocorrelation
26
+ cross-check (:func:`_cross_check`) into an OD / UD / inconclusive verdict.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from dataclasses import dataclass, field
33
+ from typing import Optional
34
+
35
+ import jax.numpy as jnp
36
+ import numpy as np
37
+ from scipy.optimize import least_squares
38
+
39
+ from SFI.diagnostics.report import _to_jsonable
40
+ from SFI.integrate import Integrand, Term, TimeOperand, integrate, timeop
41
+
42
+ # --------------------------------------------------------------------------- #
43
+ # Lag-0/1/2 displacement-covariance time-operators.
44
+ #
45
+ # Each returns a per-particle (..., N, d, d) tensor, mirroring the diffusion /
46
+ # measurement-noise estimators in SFI.inference.overdamped (``_Lambda`` etc.).
47
+ # The dataset stream layer supplies the increments:
48
+ # dX = X[t+1] - X[t] (offset 0..+1)
49
+ # dX_minus = X[t] - X[t-1] (offset -1..0)
50
+ # dX_plus = X[t+2] - X[t+1] (offset +1..+2)
51
+ # so <dX . dX_minus> is the lag-1 covariance and <dX_minus . dX_plus> is the
52
+ # lag-2 covariance (displacements one step apart).
53
+ # --------------------------------------------------------------------------- #
54
+
55
+
56
+ @timeop(name="dyn_cov_lag0", batch_safe=True)
57
+ def _cov_lag0(**streams):
58
+ """Lag-0 displacement covariance ``dX (x) dX``."""
59
+ dX = streams["dX"]
60
+ return _outer(dX, dX)
61
+
62
+
63
+ @timeop(name="dyn_cov_lag1", batch_safe=True)
64
+ def _cov_lag1(**streams):
65
+ """Symmetrised lag-1 covariance ``1/2 (dX (x) dX^- + dX^- (x) dX)``."""
66
+ dX = streams["dX"]
67
+ dXm = streams["dX_minus"]
68
+ return 0.5 * (_outer(dX, dXm) + _outer(dXm, dX))
69
+
70
+
71
+ @timeop(name="dyn_cov_lag2", batch_safe=True)
72
+ def _cov_lag2(**streams):
73
+ """Symmetrised lag-2 covariance ``1/2 (dX^- (x) dX^+ + dX^+ (x) dX^-)``."""
74
+ dXm = streams["dX_minus"]
75
+ dXp = streams["dX_plus"]
76
+ return 0.5 * (_outer(dXm, dXp) + _outer(dXp, dXm))
77
+
78
+
79
+ _cov_lag0._requires = frozenset({"dX"}) # type: ignore[attr-defined]
80
+ _cov_lag1._requires = frozenset({"dX", "dX_minus"}) # type: ignore[attr-defined]
81
+ _cov_lag2._requires = frozenset({"dX_minus", "dX_plus"}) # type: ignore[attr-defined]
82
+
83
+
84
+ def _outer(a, b):
85
+ """Outer product over the trailing spatial axis: ``a_m b_n``."""
86
+ return jnp.einsum("...m,...n->...mn", a, b)
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # Covariance backbone
91
+ # --------------------------------------------------------------------------- #
92
+ def _integrate_tensor(coll, op, alias):
93
+ """Time- and particle-averaged ``(d, d)`` value of a covariance time-op."""
94
+ operand = TimeOperand(op, alias=alias)
95
+ prog = Integrand(times=[operand], terms=[Term(eq="imn->imn", ops=(alias,))])
96
+ return np.asarray(integrate(coll, prog, reduce="mean"))
97
+
98
+
99
+ def _mean_dt(coll):
100
+ """Representative scalar step (uniform within a degraded collection)."""
101
+ dts = []
102
+ for ds in coll.datasets:
103
+ dt = getattr(ds, "dt", None)
104
+ if dt is None:
105
+ t = getattr(ds, "t", None)
106
+ if t is None:
107
+ continue
108
+ dt = np.diff(np.asarray(t))
109
+ dts.append(float(np.mean(np.asarray(dt, dtype=float))))
110
+ if not dts:
111
+ raise ValueError("Cannot determine dt: provide dt or t on the trajectories.")
112
+ return float(np.mean(dts))
113
+
114
+
115
+ def _n_eff(coll, require):
116
+ """Number of valid (masked) increment samples available for ``require``."""
117
+ n = 0
118
+ for ds in coll.datasets:
119
+ idx = ds.valid_indices(frozenset(require))
120
+ n += int(np.asarray(idx).size) * int(ds.N)
121
+ return n
122
+
123
+
124
+ def _increment_covariances(coll):
125
+ """Lag-0/1/2 displacement covariances of ``coll`` at its native ``dt``.
126
+
127
+ Parameters
128
+ ----------
129
+ coll : TrajectoryCollection
130
+ Trajectories sharing a (uniform) time step.
131
+
132
+ Returns
133
+ -------
134
+ dict
135
+ ``C0``, ``C1``, ``C2`` : ``(d, d)`` displacement covariance tensors;
136
+ ``dt`` : representative scalar step; ``n_eff`` : valid sample count.
137
+ """
138
+ C0 = _integrate_tensor(coll, _cov_lag0, "c0")
139
+ C1 = _integrate_tensor(coll, _cov_lag1, "c1")
140
+ C2 = _integrate_tensor(coll, _cov_lag2, "c2")
141
+ return {
142
+ "C0": C0,
143
+ "C1": C1,
144
+ "C2": C2,
145
+ "dt": _mean_dt(coll),
146
+ "n_eff": _n_eff(coll, {"dX_minus", "dX_plus"}),
147
+ }
148
+
149
+
150
+ def _covariances_vs_dt(coll, strides):
151
+ """Lag-0/1/2 covariances across a set of coarse-graining strides.
152
+
153
+ Each stride ``s`` coarse-grains the trajectories via
154
+ :meth:`TrajectoryCollection.degrade` (effective step ``s * dt``), then the
155
+ lag-0/1/2 covariances are recomputed. This is the dt-scan that separates
156
+ the overdamped force confound (vanishes as ``dt -> 0``) from genuine
157
+ inertia (saturates).
158
+
159
+ Parameters
160
+ ----------
161
+ coll : TrajectoryCollection
162
+ strides : sequence of int
163
+ Positive coarse-graining factors (``1`` keeps the native step).
164
+
165
+ Returns
166
+ -------
167
+ dict
168
+ ``strides``, ``dt`` : ``(S,)`` arrays; ``C0``, ``C1``, ``C2`` :
169
+ ``(S, d, d)`` covariance tensors; ``n_eff`` : ``(S,)`` sample counts.
170
+ """
171
+ strides = [int(s) for s in strides]
172
+ dts, C0s, C1s, C2s, n_effs = [], [], [], [], []
173
+ for s in strides:
174
+ sub = coll if s == 1 else coll.degrade(downsample=s)
175
+ cov = _increment_covariances(sub)
176
+ dts.append(cov["dt"])
177
+ C0s.append(cov["C0"])
178
+ C1s.append(cov["C1"])
179
+ C2s.append(cov["C2"])
180
+ n_effs.append(cov["n_eff"])
181
+ return {
182
+ "strides": np.asarray(strides),
183
+ "dt": np.asarray(dts),
184
+ "C0": np.stack(C0s),
185
+ "C1": np.stack(C1s),
186
+ "C2": np.stack(C2s),
187
+ "n_eff": np.asarray(n_effs),
188
+ }
189
+
190
+
191
+ # --------------------------------------------------------------------------- #
192
+ # Parametric forward model for the lag-0/1/2 increment covariances
193
+ # --------------------------------------------------------------------------- #
194
+ def _dynamics_model(h, sigma2, D, V, gamma):
195
+ r"""Predicted scalar (per-axis) lag-0/1/2 increment covariances.
196
+
197
+ Three physical contributions to ``c_k(h) = Cov(dx_t, dx_{t+k})`` for step
198
+ ``h``:
199
+
200
+ * **Localization noise** ``sigma^2``: ``+2 sigma^2`` at lag 0, ``-sigma^2``
201
+ at lag 1, ``0`` at lag 2 (white, dt-independent).
202
+ * **Overdamped diffusion** ``D``: ``+2 D h`` at lag 0, ``0`` elsewhere.
203
+ * **Inertia** — an Ornstein--Uhlenbeck velocity with variance ``V = <v^2>``
204
+ and relaxation rate ``gamma`` (so ``tau_v = 1/gamma``). Integrating
205
+ ``<v(0) v(t)> = V e^{-gamma|t|}`` over the sampling cells gives
206
+
207
+ .. math::
208
+
209
+ c_0^{inert} &= \tfrac{2V}{\gamma^2}(\gamma h - 1 + e^{-\gamma h}), \\
210
+ c_m^{inert} &= \tfrac{2V}{\gamma^2}(\cosh\gamma h - 1)\,
211
+ e^{-m\,\gamma h} \quad (m \ge 1).
212
+
213
+ As ``gamma h -> 0`` this is ballistic (``c_k -> V h^2``); the lag-2 term
214
+ decays as ``e^{-2 gamma h}``.
215
+
216
+ Parameters
217
+ ----------
218
+ h : float or array
219
+ Sampling step(s).
220
+ sigma2, D, V, gamma : float
221
+ Localization-noise variance, diffusion constant, velocity variance,
222
+ and velocity relaxation rate (``gamma > 0``).
223
+
224
+ Returns
225
+ -------
226
+ (c0, c1, c2) : arrays shaped like ``h``.
227
+ """
228
+ h = np.asarray(h, dtype=float)
229
+ gh = gamma * h
230
+ inv_g2 = 1.0 / (gamma * gamma)
231
+ em1 = -np.expm1(-gh) # = 1 - exp(-gamma h), in [0, 1]; overflow-free
232
+ # (cosh(gamma h) - 1) exp(-gamma h) = (1 - exp(-gamma h))^2 / 2, so:
233
+ g0 = 2.0 * inv_g2 * (gh + np.expm1(-gh)) # = (2/g^2)(gamma h - 1 + e^{-gamma h})
234
+ g1 = inv_g2 * em1 * em1
235
+ g2 = np.exp(-gh) * inv_g2 * em1 * em1
236
+ c0 = 2.0 * sigma2 + 2.0 * D * h + V * g0
237
+ c1 = -sigma2 + V * g1
238
+ c2 = V * g2 + np.zeros_like(h)
239
+ return c0, c1, c2
240
+
241
+
242
+ # --------------------------------------------------------------------------- #
243
+ # Parametric fit of {D, sigma^2, V, gamma} to the covariance scan
244
+ # --------------------------------------------------------------------------- #
245
+ def _scalar_lags(scan):
246
+ """Isotropic per-axis scalar lags ``c_k = Tr(C_k) / d`` from a scan."""
247
+ d = scan["C0"].shape[-1]
248
+ c0 = np.einsum("sii->s", scan["C0"]) / d
249
+ c1 = np.einsum("sii->s", scan["C1"]) / d
250
+ c2 = np.einsum("sii->s", scan["C2"]) / d
251
+ return c0, c1, c2
252
+
253
+
254
+ def _aicc(ssr, m, k):
255
+ """Small-sample-corrected AIC for a Gaussian least-squares fit."""
256
+ if m <= 0 or ssr <= 0:
257
+ ssr = max(ssr, 1e-300)
258
+ aic = m * np.log(ssr / m) + 2.0 * k
259
+ denom = m - k - 1
260
+ if denom > 0:
261
+ aic += 2.0 * k * (k + 1) / denom
262
+ return aic
263
+
264
+
265
+ def _lsq_stderr(res, m, n):
266
+ """Parameter standard errors from a ``least_squares`` result."""
267
+ J = np.asarray(res.jac, dtype=float)
268
+ JTJ = J.T @ J
269
+ try:
270
+ cov = np.linalg.inv(JTJ)
271
+ except np.linalg.LinAlgError:
272
+ cov = np.linalg.pinv(JTJ)
273
+ dof = max(m - n, 1)
274
+ s2 = 2.0 * res.cost / dof
275
+ return np.sqrt(np.clip(np.diag(cov) * s2, 0.0, None))
276
+
277
+
278
+ def _fit_dynamics(scan):
279
+ """Fit the diffusion + inertia + localization model to a covariance scan.
280
+
281
+ Fits the four-parameter model ``{sigma^2, D, V, gamma}`` and the nested
282
+ overdamped null ``V = 0`` (``{sigma^2, D}``), and compares them with the
283
+ small-sample-corrected AIC.
284
+
285
+ Returns
286
+ -------
287
+ dict
288
+ ``params`` (``sigma2, D, V, gamma``), ``tau_v = 1/gamma``, ``stderr``
289
+ (per parameter), ``V_z`` (``V / stderr_V``), ``aicc_full``,
290
+ ``aicc_od``, ``delta_aicc`` (``aicc_od - aicc_full``; ``> 0`` favours
291
+ the inertial model), and the raw ``ssr_full`` / ``ssr_od``.
292
+ """
293
+ h = np.asarray(scan["dt"], dtype=float)
294
+ c0, c1, c2 = _scalar_lags(scan)
295
+ n_eff = np.asarray(scan["n_eff"], dtype=float)
296
+ w = np.sqrt(np.clip(n_eff, 1.0, None))
297
+
298
+ data = np.concatenate([c0, c1, c2])
299
+ wvec = np.concatenate([w, w, w])
300
+ scale = max(float(np.median(np.abs(c0))), 1e-12)
301
+
302
+ def _residuals(sigma2, D, V, gamma):
303
+ m0, m1, m2 = _dynamics_model(h, sigma2, D, V, gamma)
304
+ model = np.concatenate([m0, m1, m2])
305
+ return wvec * (model - data) / scale
306
+
307
+ # --- initial guesses from the lag structure ---------------------------- #
308
+ gamma_lo = 1e-3 / float(h.max())
309
+ gamma_hi = 1e3 / float(h.min())
310
+ V0 = max(c2[0] / h[0] ** 2, 1e-6)
311
+ s20 = max(-c1[0] + V0 * h[0] ** 2, 1e-9)
312
+ g_init = min(max(1.0 / float(np.median(h)), gamma_lo * 1.01), gamma_hi * 0.99)
313
+ g0_tail = (2.0 / g_init**2) * (g_init * h[-1] - 1.0 + np.exp(-g_init * h[-1]))
314
+ D0 = max((c0[-1] - 2.0 * s20 - V0 * g0_tail) / (2.0 * h[-1]), 1e-6)
315
+
316
+ res_full = least_squares(
317
+ lambda p: _residuals(*p),
318
+ x0=[s20, D0, V0, g_init],
319
+ bounds=([0.0, 0.0, 0.0, gamma_lo], [np.inf, np.inf, np.inf, gamma_hi]),
320
+ method="trf",
321
+ max_nfev=5000,
322
+ )
323
+ sigma2, D, V, gamma = (float(x) for x in res_full.x)
324
+
325
+ res_od = least_squares(
326
+ lambda p: _residuals(p[0], p[1], 0.0, 1.0),
327
+ x0=[s20, D0],
328
+ bounds=([0.0, 0.0], [np.inf, np.inf]),
329
+ method="trf",
330
+ max_nfev=5000,
331
+ )
332
+
333
+ m = data.size
334
+ ssr_full = 2.0 * float(res_full.cost)
335
+ ssr_od = 2.0 * float(res_od.cost)
336
+ aicc_full = _aicc(ssr_full, m, 4)
337
+ aicc_od = _aicc(ssr_od, m, 2)
338
+ stderr = _lsq_stderr(res_full, m, 4)
339
+ V_stderr = float(stderr[2]) if stderr[2] > 0 else 0.0
340
+ V_z = V / V_stderr if V_stderr > 0 else np.inf
341
+
342
+ return {
343
+ "params": {"sigma2": sigma2, "D": D, "V": V, "gamma": gamma},
344
+ "tau_v": 1.0 / gamma,
345
+ "stderr": {"sigma2": float(stderr[0]), "D": float(stderr[1]), "V": V_stderr, "gamma": float(stderr[3])},
346
+ "V_z": V_z,
347
+ "ssr_full": ssr_full,
348
+ "ssr_od": ssr_od,
349
+ "aicc_full": aicc_full,
350
+ "aicc_od": aicc_od,
351
+ "delta_aicc": aicc_od - aicc_full,
352
+ }
353
+
354
+
355
+ # --------------------------------------------------------------------------- #
356
+ # Model-free scaling statistics (Layer 1) and verdict
357
+ # --------------------------------------------------------------------------- #
358
+ def _scaling_statistics(scan):
359
+ r"""Noise-immune lag-2 persistence and apparent-KE scaling across ``dt``.
360
+
361
+ The Vestergaard combination ``c0 + 2 c1`` cancels white localization noise
362
+ exactly (``2 sigma^2 + 2(-sigma^2) = 0``), leaving the noise-free variance.
363
+ From it:
364
+
365
+ * ``rho2 = c2 / (c0 + 2 c1)`` — noise-immune normalized lag-2 correlation;
366
+ ``-> 0`` for overdamped (a force only adds an ``O(h^2)`` piece to a
367
+ variance that grows like ``h``) and to a positive plateau for inertia.
368
+ * ``K = (c0 + 2 c1) / h^2`` — noise-corrected apparent kinetic energy;
369
+ log-log slope ``beta -> -1`` (overdamped, ``K ~ 2D/h``) vs ``-> 0``
370
+ (underdamped, ``K -> const``) at fine sampling.
371
+
372
+ Returns
373
+ -------
374
+ dict
375
+ ``rho2``, ``K`` : ``(S,)`` arrays; ``noise_free_var`` : ``(S,)``;
376
+ ``beta`` : log-log slope of ``K`` vs ``dt`` (OLS over positive ``K``).
377
+ """
378
+ h = np.asarray(scan["dt"], dtype=float)
379
+ c0, c1, c2 = _scalar_lags(scan)
380
+ noise_free_var = c0 + 2.0 * c1
381
+ with np.errstate(divide="ignore", invalid="ignore"):
382
+ rho2 = np.where(np.abs(noise_free_var) > 0, c2 / noise_free_var, 0.0)
383
+ K = noise_free_var / h**2
384
+ good = K > 0
385
+ beta = float(np.polyfit(np.log(h[good]), np.log(K[good]), 1)[0]) if good.sum() >= 2 else float("nan")
386
+ return {"rho2": rho2, "K": K, "noise_free_var": noise_free_var, "beta": beta}
387
+
388
+
389
+ #: Verdict thresholds (documented; the full report exposes the raw statistics).
390
+ _AICC_MARGIN = 2.0 # AICc gain required to prefer the inertial model
391
+ _V_Z_MIN = 3.0 # significance of the fitted velocity variance V
392
+ _RHO2_UD = 0.15 # lag-2 persistence (at finest dt) confirming resolved inertia
393
+ _RHO2_OD = 0.05 # lag-2 persistence below which the data looks overdamped
394
+
395
+
396
+ def _decide_verdict(fit, scaling, scan):
397
+ """Combine the parametric fit and the scaling test into a verdict.
398
+
399
+ Returns ``"UD"`` when the inertial model is decisively preferred *and* the
400
+ momentum is resolved (significant, persistent lag-2 correlation at the
401
+ finest step); ``"OD"`` when there is no inertial signal; ``"inconclusive"``
402
+ in the marginal band — typically coarse sampling (``gamma * dt >~ 1``),
403
+ where the data is on the boundary of being effectively overdamped.
404
+ """
405
+ h = np.asarray(scan["dt"], dtype=float)
406
+ rho2_fine = float(scaling["rho2"][int(np.argmin(h))])
407
+ ud_preferred = (fit["delta_aicc"] > _AICC_MARGIN) and (fit["V_z"] > _V_Z_MIN)
408
+
409
+ if ud_preferred and rho2_fine > _RHO2_UD:
410
+ return "UD"
411
+ if (not ud_preferred) or rho2_fine < _RHO2_OD:
412
+ return "OD"
413
+ return "inconclusive"
414
+
415
+
416
+ # --------------------------------------------------------------------------- #
417
+ # Layer 3: overdamped-fit residual-autocorrelation cross-check
418
+ # --------------------------------------------------------------------------- #
419
+ def _cross_check(data, *, force_order: int = 3):
420
+ """Corroborate the verdict via an overdamped fit's residual autocorrelation.
421
+
422
+ Fits a flexible (cubic by default) overdamped force and runs the existing
423
+ Ljung--Box residual-autocorrelation test from
424
+ :func:`SFI.diagnostics.assess`. An overdamped model cannot capture
425
+ momentum, so on underdamped data its residuals stay serially correlated and
426
+ the test fires. This is *corroboration only*: a low p-value flags that an
427
+ overdamped model leaves structure — usually inertia, but a force too
428
+ complex for the cubic basis would also trip it — so the verdict itself rests
429
+ on the noise-immune scaling test and the parametric fit, not on this.
430
+
431
+ Best-effort: returns ``ljung_box_pvalue = None`` (with an ``error``) if the
432
+ quick fit fails for any reason.
433
+ """
434
+ try:
435
+ from SFI.bases.constants import unit_vector_basis
436
+ from SFI.bases.monomials import monomials_up_to
437
+ from SFI.diagnostics.assess import assess
438
+ from SFI.inference.overdamped import OverdampedLangevinInference
439
+
440
+ d = int(data.datasets[0].d)
441
+ inf = OverdampedLangevinInference(data)
442
+ inf.compute_diffusion_constant(method="auto")
443
+ basis = monomials_up_to(order=force_order, dim=d, include_constant=True, include_x=True, include_v=False)
444
+ inf.infer_force_linear(basis * unit_vector_basis(d))
445
+ inf.compute_force_error()
446
+ report = assess(inf, level="standard")
447
+ lb = report.residuals.get("autocorr", {}).get("ljung_box", {})
448
+ return {"ljung_box_pvalue": lb.get("pvalue"), "ljung_box_stat": lb.get("statistic")}
449
+ except Exception as exc: # pragma: no cover - corroboration only
450
+ return {"ljung_box_pvalue": None, "error": repr(exc)}
451
+
452
+
453
+ # --------------------------------------------------------------------------- #
454
+ # Public report + entry point
455
+ # --------------------------------------------------------------------------- #
456
+ def _num_frames(ds):
457
+ T = getattr(ds, "T", None)
458
+ if T is None:
459
+ T = np.asarray(ds.X).shape[0]
460
+ return int(T)
461
+
462
+
463
+ def _default_strides(coll, max_strides=6, min_samples=200):
464
+ """Geometric strides ``1, 2, 4, ...`` capped so the coarsest keeps stats."""
465
+ T = min(_num_frames(ds) for ds in coll.datasets)
466
+ strides, s = [], 1
467
+ while len(strides) < max_strides and (T // s) >= min_samples:
468
+ strides.append(s)
469
+ s *= 2
470
+ return strides or [1]
471
+
472
+
473
+ @dataclass
474
+ class DynamicsOrderReport:
475
+ """Result of :func:`classify_dynamics`.
476
+
477
+ Attributes
478
+ ----------
479
+ verdict : str
480
+ ``"OD"``, ``"UD"`` or ``"inconclusive"``.
481
+ fit : dict
482
+ Parametric fit output (``params`` = ``sigma2, D, V, gamma``, ``tau_v``,
483
+ ``stderr``, ``V_z``, ``delta_aicc`` and the raw SSR / AICc values).
484
+ scaling : dict
485
+ Model-free statistics (``rho2``, ``K``, ``beta``).
486
+ scan : dict
487
+ Lag-0/1/2 covariances across the dt scan (``strides``, ``dt``, ``C0``,
488
+ ``C1``, ``C2``, ``n_eff``).
489
+ cross_check : dict or None
490
+ Overdamped-fit Ljung--Box result, or ``None`` if disabled.
491
+ meta : dict
492
+ Sampling summary (``d``, ``n_datasets``, ``strides``, ``dt_min/max``,
493
+ ``gamma_dt_min``).
494
+ """
495
+
496
+ verdict: str
497
+ fit: dict = field(default_factory=dict)
498
+ scaling: dict = field(default_factory=dict)
499
+ scan: dict = field(default_factory=dict)
500
+ cross_check: Optional[dict] = None
501
+ meta: dict = field(default_factory=dict)
502
+
503
+ def to_dict(self) -> dict:
504
+ """JSON-serialisable representation of the report."""
505
+ return _to_jsonable(
506
+ {
507
+ "verdict": self.verdict,
508
+ "fit": self.fit,
509
+ "scaling": self.scaling,
510
+ "scan": self.scan,
511
+ "cross_check": self.cross_check,
512
+ "meta": self.meta,
513
+ }
514
+ )
515
+
516
+ def to_json(self, indent: int = 2) -> str:
517
+ return json.dumps(self.to_dict(), indent=indent, default=str)
518
+
519
+ def print_summary(self) -> None:
520
+ """Print a human-readable summary of the classification."""
521
+ p = self.fit.get("params", {})
522
+ sc = self.scaling
523
+ meta = self.meta
524
+ print("\n=== SFI dynamics-order classification ===")
525
+ print(f"verdict : {self.verdict}")
526
+ if meta:
527
+ print(
528
+ f"sampling: d={meta.get('d', '?')}, "
529
+ f"dt in [{meta.get('dt_min', float('nan')):.4g}, "
530
+ f"{meta.get('dt_max', float('nan')):.4g}] over "
531
+ f"{len(meta.get('strides', []))} strides"
532
+ )
533
+ if p:
534
+ sigma = float(np.sqrt(max(p.get("sigma2", 0.0), 0.0)))
535
+ print("\n-- parametric fit (diffusion + inertia + localization) --")
536
+ print(f" tau_v = 1/gamma = {self.fit.get('tau_v', float('nan')):.4g} (momentum relaxation time)")
537
+ print(
538
+ f" <v^2> = {p.get('V', float('nan')):.4g} "
539
+ f"D = {p.get('D', float('nan')):.4g} sigma_loc = {sigma:.4g}"
540
+ )
541
+ print(
542
+ f" AICc(OD) - AICc(UD) = {self.fit.get('delta_aicc', float('nan')):+.2f} "
543
+ f"(>0 favours inertia) V/stderr = {self.fit.get('V_z', float('nan')):.2f}"
544
+ )
545
+ if sc:
546
+ rho2 = np.asarray(sc.get("rho2", []))
547
+ rfine = float(rho2[0]) if rho2.size else float("nan")
548
+ print("\n-- model-free scaling (noise-immune at lag>=2) --")
549
+ print(f" rho2(finest dt) = {rfine:.3f} (OD -> 0, UD -> positive)")
550
+ print(f" apparent-KE log-log slope = {sc.get('beta', float('nan')):+.2f} (OD -> -1, UD -> 0)")
551
+ if self.cross_check and self.cross_check.get("ljung_box_pvalue") is not None:
552
+ print("\n-- cross-check (OD-fit residual autocorrelation) --")
553
+ print(
554
+ f" Ljung-Box p = {self.cross_check['ljung_box_pvalue']:.2e} "
555
+ "(small => overdamped model leaves structure)"
556
+ )
557
+ if self.verdict == "inconclusive":
558
+ gdt = meta.get("gamma_dt_min")
559
+ tail = f" (gamma*dt_min ~ {gdt:.2f}); sample finer than tau_v to decide." if gdt else "."
560
+ print("\n Note: momentum only marginally resolved" + tail)
561
+ print()
562
+
563
+
564
+ def classify_dynamics(data, *, strides=None, cross_check: bool = True) -> DynamicsOrderReport:
565
+ """Classify trajectory data as overdamped, underdamped, or inconclusive.
566
+
567
+ Robust to high localization noise (lag >= 2 covariances are noise-immune)
568
+ and reports *inconclusive* at coarse sampling where the momentum is
569
+ unresolved (``gamma * dt >~ 1``), rather than guessing.
570
+
571
+ Parameters
572
+ ----------
573
+ data : TrajectoryCollection
574
+ Raw trajectories (positions). Velocities are *not* required — the test
575
+ reconstructs the dynamics order from position increments alone.
576
+ strides : sequence of int, optional
577
+ Coarse-graining factors for the dt scan. Default: a geometric set
578
+ ``1, 2, 4, ...`` capped so the coarsest still has enough samples.
579
+ cross_check : bool
580
+ Run the Layer-3 overdamped-fit residual-autocorrelation corroboration
581
+ (default ``True``).
582
+
583
+ Returns
584
+ -------
585
+ DynamicsOrderReport
586
+
587
+ Notes
588
+ -----
589
+ Assumes *white* localization noise; spatially uniform, isotropic pooling of
590
+ components. Strong memory (a generalized Langevin / viscoelastic bath) can
591
+ also produce velocity persistence without inertia — out of scope; the test
592
+ detects trajectory smoothness, which inertia produces.
593
+
594
+ Examples
595
+ --------
596
+ >>> report = classify_dynamics(collection) # doctest: +SKIP
597
+ >>> report.verdict # doctest: +SKIP
598
+ 'UD'
599
+ """
600
+ if strides is None:
601
+ strides = _default_strides(data)
602
+ strides = [int(s) for s in strides]
603
+
604
+ scan = _covariances_vs_dt(data, strides)
605
+ fit = _fit_dynamics(scan)
606
+ scaling = _scaling_statistics(scan)
607
+ verdict = _decide_verdict(fit, scaling, scan)
608
+ cross = _cross_check(data) if cross_check else None
609
+
610
+ dt = np.asarray(scan["dt"], dtype=float)
611
+ meta = {
612
+ "d": int(data.datasets[0].d),
613
+ "n_datasets": len(data.datasets),
614
+ "strides": [int(s) for s in scan["strides"]],
615
+ "dt_min": float(dt.min()),
616
+ "dt_max": float(dt.max()),
617
+ "gamma_dt_min": float(fit["params"]["gamma"] * dt.min()),
618
+ }
619
+ return DynamicsOrderReport(
620
+ verdict=verdict, fit=fit, scaling=scaling, scan=scan, cross_check=cross, meta=meta
621
+ )