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,1214 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import jax.numpy as jnp
6
+
7
+ from SFI.bases.constants import constant_array
8
+ from SFI.inference.base import BaseLangevinInference
9
+ from SFI.integrate.api import integrate
10
+ from SFI.integrate.integrand import (
11
+ ConstOperand,
12
+ ExprOperand,
13
+ Integrand,
14
+ Term,
15
+ TimeOperand,
16
+ )
17
+ from SFI.integrate.timeops import stream, timeop, velocity
18
+ from SFI.statefunc import PSF, SF, Basis
19
+ from SFI.utils.maths import sqrtm_psd
20
+
21
+ from .result import InferenceResultSF # fitted SF with param_cov/meta
22
+ from .sparse import SparseScorer
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ---------------------------------------------------------------------------- #
28
+ # OLI linear force-inference presets
29
+ # ---------------------------------------------------------------------------- #
30
+ # A single ``preset`` keyword bundles the (M_mode, G_mode) convention surface,
31
+ # mirroring the underdamped engine. ``'auto'`` keys off measurement noise; the
32
+ # ``legacy-*`` presets reproduce published SFI v1.0 conventions.
33
+ _FORCE_LINEAR_PRESETS: dict[str, tuple[str, str]] = {
34
+ # noise-robust default: Stratonovich moments + noise-decorrelating Gram
35
+ "robust": ("Strato", "shift"),
36
+ # sharper for verified clean / fine-sampled data
37
+ "clean": ("Ito", "trapeze"),
38
+ # Kramers–Moyal: plain Itô finite-difference moments, rectangle Gram
39
+ "KM": ("Ito", "rectangle"),
40
+ # SFI v1.0 (2020 PRX): Stratonovich moments, rectangle Gram
41
+ "legacy-sfi-v1.0": ("Strato", "rectangle"),
42
+ }
43
+
44
+
45
+ def _resolve_force_linear_preset(
46
+ preset: str,
47
+ *,
48
+ M_mode: str | None = None,
49
+ G_mode: str | None = None,
50
+ noise_detected: bool | None = None,
51
+ ) -> tuple[str, str, str]:
52
+ """Resolve an overdamped force ``preset`` to a concrete ``(M_mode, G_mode)``.
53
+
54
+ ``'auto'`` selects ``'robust'`` when measurement noise is detected and
55
+ ``'clean'`` otherwise. Any non-``None`` ``M_mode`` / ``G_mode`` (other than
56
+ the legacy ``M_mode='auto'`` synonym) overrides the preset on that axis.
57
+ Returns ``(M_mode, G_mode, resolved_preset)``.
58
+ """
59
+ if preset == "auto":
60
+ if noise_detected is None:
61
+ raise RuntimeError(
62
+ "preset='auto' needs the measurement-noise estimate; call "
63
+ "compute_diffusion_constant() first, or pass an explicit preset."
64
+ )
65
+ base = "robust" if noise_detected else "clean"
66
+ elif preset in _FORCE_LINEAR_PRESETS:
67
+ base = preset
68
+ else:
69
+ choices = ", ".join(repr(p) for p in ("auto", *_FORCE_LINEAR_PRESETS))
70
+ raise ValueError(f"unknown preset {preset!r}; choose from {choices}")
71
+ M0, G0 = _FORCE_LINEAR_PRESETS[base]
72
+ M = M0 if M_mode in (None, "auto") else M_mode
73
+ G = G0 if G_mode is None else G_mode
74
+ return M, G, base
75
+
76
+
77
+ class OverdampedLangevinInference(BaseLangevinInference):
78
+ r"""Stochastic Force Inference concrete class for overdamped systems
79
+
80
+ This class provides tools for inferring force (drift) and
81
+ diffusion tensors from stochastic trajectory data based on overdamped
82
+ Langevin dynamics. It supports both linear and nonlinear basis
83
+ function methods.
84
+
85
+ Core Equation
86
+ ~~~~~~~~~~~~~
87
+ The dynamics are described by the 1st order autonomous stochastic
88
+ differential equation (SDE)::
89
+
90
+ dx/dt = F(x) + sqrt(2D(x)) dxi(t)
91
+
92
+ where:
93
+
94
+ - ``F(x)`` is the Ito drift (force) term.
95
+ - ``D(x)`` is the diffusion tensor, evaluated in the Ito convention.
96
+ - ``dxi(t)`` is Gaussian white noise.
97
+
98
+ .. physics:: Overdamped Langevin SDE
99
+ :label: overdamped-langevin-sde
100
+ :category: Dynamical equations
101
+
102
+ .. math::
103
+
104
+ \frac{\mathrm{d}x}{\mathrm{d}t}
105
+ = F(x) + \sqrt{2\,D(x)}\;\mathrm{d}\xi(t)
106
+
107
+ :math:`F(x)` is the Itô drift, :math:`D(x)` the diffusion tensor
108
+ (Itô convention), and :math:`\mathrm{d}\xi` is Gaussian white noise.
109
+
110
+ Here x is a 2D array of shape Nparticles x dimension. All particles
111
+ are assumed to have identical properties.
112
+
113
+ This class provides tools to approximate F(x) and D(x) from a time series x(t) formatted as TrajectoryCollection.
114
+
115
+ Note that the `Ito` and `Strato` variants of the force inference
116
+ routines do NOT refer to the convention in which the SDE is
117
+ expressed (which is always Ito), but to the way stochastic
118
+ integrals are performed to compute parameters.
119
+
120
+ Key Features
121
+ ~~~~~~~~~~~~
122
+ - Force Inference:
123
+ - Linear combination of basis functions (`infer_force_linear`).
124
+ - Parametric families (`infer_force` with a `Basis` or `PSF`).
125
+ - Diffusion Inference:
126
+ - Constant diffusion estimation (`compute_diffusion_constant`).
127
+ - State-dependent diffusion with basis functions (`infer_diffusion_linear` and `infer_diffusion`).
128
+ - Sparsification:
129
+ - Force sparsification for linear inference `sparsify_force`, implementing PASTIS and other information criteria.
130
+ - Error Estimation:
131
+ - Normalized mean-squared error (MSE) prediction for both force and diffusion.
132
+ - Comparison Tools for method benchmarking:
133
+ - Evaluate inferred fields against known exact models (`compare_to_exact`).
134
+ - Simulation:
135
+ - Generate trajectories using inferred fields (`simulate_bootstrapped_trajectory`).
136
+
137
+ Workflow
138
+ ~~~~~~~~
139
+ 1. Initialize with `TrajectoryCollection` containing the trajectory.
140
+ 2. Use the `infer_*` methods to infer force and diffusion fields.
141
+ 3. Optionally compute error estimates and/or compare with exact data for validation.
142
+
143
+ Indices Convention
144
+ ~~~~~~~~~~~~~~~~~~
145
+ The code uses jnp.einsum for array manipulation, with a consistent index naming scheme for clarity:
146
+ - `t` : Time index, = 0..Ntimesteps-1
147
+ - `a, b, c...` : Basis function indices, = 0..Nfunctions - 1.
148
+ - `m, n, o...` : Spatial indices, = 0..dim-1.
149
+ - `i, j...` : Particle indices.
150
+ We also use these indices as shortcuts for array shapes. For
151
+ instance `basis_linear : im -> iam` reads `basis_linear has input
152
+ a jnp.array of shape (Nparticles,dim) and outputs a jnp.array of
153
+ shape (Nparticles,Nfunctions,dim)`.
154
+
155
+ Logging
156
+ ~~~~~~~
157
+ Progress messages use Python ``logging``. Enable with
158
+ ``SFI.enable_logging()`` or ``logging.getLogger('SFI').setLevel(logging.INFO)``.
159
+
160
+ Example
161
+ ~~~~~~~
162
+ Fully documented examples in the "examples" folder: Lorenz model, ActiveBrownianParticles, Ornstein-Uhlenbeck...
163
+
164
+ """
165
+
166
+ def compute_diffusion_constant(self, method: str = "auto") -> None:
167
+ """Estimate a constant (spatially uniform) diffusion matrix.
168
+
169
+ Parameters
170
+ ----------
171
+ method : {"auto", "noisy", "WeakNoise", "MSD"}
172
+ Estimator to use. ``"noisy"`` is the noise-robust
173
+ Vestergaard–Blainey–Flyvbjerg estimator. ``"auto"`` selects
174
+ ``"noisy"`` when the measurement-noise trace Tr(Λ) > 0
175
+ (localization noise detected), and ``"WeakNoise"`` otherwise.
176
+
177
+ Updates
178
+ -------
179
+ Sets ``diffusion_average``, ``diffusion_inferred``, ``A``,
180
+ ``A_inv``, ``sqrtA``, ``sqrtA_inv``, ``Lambda``,
181
+ ``Lambda_trace``, and ``metadata["diffusion_constant_method"]``.
182
+ """
183
+
184
+ if hasattr(self, "diffusion_average"):
185
+ raise RuntimeError("Diffusion already computed; create a new inference object to recompute.")
186
+
187
+ # 1) measurement noise Λ
188
+ L_op = self.get_diffusion_timeop("Lambda")
189
+ L_prog = Integrand(times=[L_op], terms=[Term(eq="imn->imn", ops=(L_op.alias,))])
190
+ # Diffusion/noise are per-point quantities: each increment's estimate has
191
+ # constant relative variance regardless of dt, so combine them per-point
192
+ # (weight_by_dt=False), not dt-weighted (which is the force convention).
193
+ self.Lambda = integrate(self.data, L_prog, reduce="mean", weight_by_dt=False,
194
+ chunk_target_bytes=self._chunk_target_bytes)
195
+ self.Lambda_trace = float(jnp.trace(self.Lambda))
196
+ logger.info("Measurement noise trace: %s", self.Lambda_trace)
197
+
198
+ # 2) select instantaneous estimator
199
+ if method == "auto":
200
+ method = "noisy" if self.Lambda_trace > 0 else "WeakNoise"
201
+ self.metadata["diffusion_constant_method"] = method
202
+
203
+ # 3) time-average instantaneous diffusion
204
+ D_op = self.get_diffusion_timeop(method)
205
+ D_prog = Integrand(times=[D_op], terms=[Term(eq="imn->imn", ops=(D_op.alias,))])
206
+ self.diffusion_average = integrate(
207
+ self.data, D_prog, reduce="mean", weight_by_dt=False,
208
+ chunk_target_bytes=self._chunk_target_bytes,
209
+ )
210
+ self.diffusion_inferred = constant_array(self.diffusion_average)
211
+
212
+ # 4) normalization matrices
213
+ self.A = 2.0 * self.diffusion_average
214
+ self.A_inv = jnp.linalg.inv(self.A)
215
+ self.sqrtA = sqrtm_psd(self.A)
216
+ self.sqrtA_inv = jnp.linalg.inv(self.sqrtA)
217
+
218
+ def infer_force_linear(
219
+ self,
220
+ basis: Basis,
221
+ *,
222
+ preset: str = "auto",
223
+ M_mode: str | None = None,
224
+ G_mode: str | None = None,
225
+ ):
226
+ r"""Infer the force field as a linear combination of basis functions (linear regression).
227
+
228
+ .. physics:: Linear force regression (overdamped)
229
+ :label: linear-force-regression-overdamped
230
+ :category: Inference
231
+
232
+ .. math::
233
+
234
+ \hat F(x) = \sum_a C_a\, b_a(x)
235
+ \qquad\text{where}\qquad
236
+ G\,C = M
237
+
238
+ :math:`G_{ab} = \langle b_a(x_t)\, b_b(x_t) \rangle` is the Gram
239
+ matrix, :math:`M_a = \langle v_t \cdot A^{-1} \cdot b_a \rangle` are
240
+ the force moments, and :math:`A = 2\bar D`.
241
+
242
+ This method computes the force field coefficients (`self.force_coefficients`) using
243
+ the provided Basis object. The force field is represented as:
244
+
245
+ inferred_force(x) = sum_a basis_linear(x)[:,a] * force_coefficients[a]
246
+
247
+ These coefficients are computed by solving a linear system::
248
+
249
+ G . force_coefficients = force_moments
250
+
251
+ and the different options account for the manner to compute G
252
+ and force_moments. In its simplest form::
253
+
254
+ G_ab = < b_a(xt) b_b(xt) > [G_mode = 'rectangle']
255
+ force_moments[a] = < dX[t]/dt b_a(xt) > [mode = 'Ito' ]
256
+
257
+ but this is rarely the best choice of parameters.
258
+
259
+ Args:
260
+
261
+ basis: Basis
262
+ The fitting functions, encoded as a single callable Basis
263
+ object (see SFI.statefunc submodule for doc and SFI.bases for examples).
264
+
265
+ preset (str):
266
+ Single-keyword convention bundle (mirrors the underdamped
267
+ engine), default ``'auto'``. ``'auto'`` picks ``'robust'``
268
+ when measurement noise is detected (Tr(Λ) > 0) and ``'clean'``
269
+ otherwise. Presets: ``'robust'`` (Stratonovich moments +
270
+ ``'shift'`` Gram, noise-robust); ``'clean'`` (Itô +
271
+ ``'trapeze'``, clean / fine-sampled data); ``'KM'``
272
+ (Kramers–Moyal: Itô + ``'rectangle'``); ``'legacy-sfi-v1.0'``
273
+ (Stratonovich + ``'rectangle'``, the published SFI v1.0
274
+ convention).
275
+
276
+ M_mode (str, optional):
277
+ Override the preset's moment convention: ``'Ito'``,
278
+ ``'Ito-shift'``, or ``'Strato'``. ``None`` (default) uses the
279
+ preset.
280
+
281
+ G_mode (str, optional):
282
+ Override the preset's Gram normalization: ``'rectangle'`` (2020
283
+ PRX), ``'trapeze'`` (2024 Amiri et al. PRR), or ``'shift'``
284
+ (``<b_a(xt) b_b(x_t+dt)>``, decorrelates measurement noise).
285
+ ``None`` (default) uses the preset.
286
+
287
+ Outputs:
288
+ Updates the following attributes:
289
+ - self.force_scorer: SparseScorer for model selection.
290
+ - self.force_coefficients: The inferred coefficients for the basis functions.
291
+ - self.force_inferred: Callable function representing the inferred force field.
292
+ - self.force_G: The normalization matrix used in the inference process.
293
+
294
+ """
295
+ noise = self.Lambda_trace > 0.0 if hasattr(self, "Lambda_trace") else None
296
+ M_mode, G_mode, _resolved_preset = _resolve_force_linear_preset(
297
+ preset, M_mode=M_mode, G_mode=G_mode, noise_detected=noise,
298
+ )
299
+ logger.info(
300
+ "Force inference: preset=%s -> M_mode=%s, G_mode=%s (Lambda trace: %s)",
301
+ _resolved_preset, M_mode, G_mode, getattr(self, "Lambda_trace", None),
302
+ )
303
+
304
+ self._validate_basis(basis, expected_rank=1, label="force basis")
305
+
306
+ self.__force_M_mode__ = M_mode
307
+ self.__force_G_mode__ = G_mode
308
+ self.force_basis = basis
309
+ self.metadata["force_preset"] = _resolved_preset
310
+ self.metadata["force_M_mode"] = M_mode
311
+ self.metadata["force_G_mode"] = G_mode
312
+
313
+ if hasattr(self, "force_G_full"):
314
+ raise RuntimeError("Force has already been inferred on this object - create a new instance to re-infer.")
315
+
316
+ # Structural (CSR/stencil) arrays are exposed only for this evaluation and
317
+ # never persisted on the dataset (see ``_structural_scope``).
318
+ with self._structural_scope(self.force_basis):
319
+ self.force_G_full = self._force_G_matrix()
320
+ self.force_moments = self._force_moments()
321
+
322
+ self.force_scorer = SparseScorer(M=self.force_moments, G=self.force_G_full)
323
+ self._update_force_coefficients(self.force_scorer.total_C)
324
+ self.metadata["force_method"] = "linear"
325
+
326
+ def infer_diffusion_linear(
327
+ self,
328
+ basis: Basis = None,
329
+ *,
330
+ M_mode: str = "auto",
331
+ G_mode: str = "rectangle",
332
+ ) -> None:
333
+ """
334
+ Fit the diffusion field as a linear combination of basis functions.
335
+
336
+ This method computes the coefficients of the diffusion tensor field (`self.diffusion_coefficients`) using
337
+ the provided basis functions. The diffusion tensor is represented as:
338
+
339
+ diffusion_inferred(x, mask) = sum_a basis_linear(x, mask)[:,a] * diffusion_coefficients[a]
340
+
341
+ Args:
342
+ basis (Basis with rank = 2 or None): the fitting functions. When ``None``
343
+ (default), ``symmetric_matrix_basis(d)`` is used, spanning all constant
344
+ symmetric ``d×d`` diffusion matrices. Requires a prior
345
+ ``compute_diffusion_constant()`` call to determine ``d``.
346
+
347
+ M_mode (str):
348
+ The method used for local diffusion tensor estimation and moments computation.
349
+ See _diffusion_estimator documentation for additional information.
350
+
351
+ G_mode (str):
352
+ The method used to compute the normalization matrix `G`.
353
+ Not investigated extensively yet for diffusion inference.
354
+
355
+ Updates:
356
+ self.diffusion_coefficients: The inferred coefficients for the diffusion basis functions.
357
+ self.diffusion_inferred: Callable representing the inferred diffusion tensor field.
358
+ self.diffusion_G: The normalization matrix used in the inference process.
359
+
360
+ Note:
361
+ This inferred tensor field is not guaranteed to be nonnegative.
362
+ """
363
+ if basis is None:
364
+ if not hasattr(self, "diffusion_average"):
365
+ raise RuntimeError(
366
+ "infer_diffusion_linear() with no basis requires a prior "
367
+ "compute_diffusion_constant() call to determine spatial dimension."
368
+ )
369
+ from SFI.bases import symmetric_matrix_basis
370
+ basis = symmetric_matrix_basis(self.diffusion_average.shape[0])
371
+
372
+ if M_mode == "auto":
373
+ if self.Lambda_trace > 0.0:
374
+ M_mode = "noisy"
375
+ G_mode = "rectangle"
376
+ else:
377
+ M_mode = "WeakNoise"
378
+ G_mode = "rectangle"
379
+ logger.info(
380
+ "Auto-selecting diffusion inference: M_mode %s, G_mode %s (Lambda trace: %s)",
381
+ M_mode,
382
+ G_mode,
383
+ self.Lambda_trace,
384
+ )
385
+
386
+ self._validate_basis(basis, expected_rank=2, label="diffusion basis")
387
+
388
+ self.diffusion_basis = basis
389
+ self.__diffusion_M_mode__ = M_mode
390
+ self.__diffusion_G_mode__ = G_mode
391
+ self.metadata["diffusion_M_mode"] = M_mode
392
+ self.metadata["diffusion_G_mode"] = G_mode
393
+
394
+ if hasattr(self, "diffusion_moments"):
395
+ raise RuntimeError(
396
+ "Diffusion has already been inferred on this object - create a new instance to re-infer."
397
+ )
398
+
399
+ with self._structural_scope(self.diffusion_basis):
400
+ self.diffusion_G_full = self._diffusion_G_matrix()
401
+ self.diffusion_moments = self._diffusion_moments()
402
+
403
+ self.diffusion_scorer = SparseScorer(M=self.diffusion_moments, G=self.diffusion_G_full)
404
+ self._update_diffusion_coefficients(self.diffusion_scorer.total_C)
405
+
406
+ def simulate_bootstrapped_trajectory(self, key, oversampling=1, simulate=True, dataset=0):
407
+ """
408
+ Simulate an overdamped Langevin trajectory with the inferred force and diffusion fields.
409
+
410
+ This function generates a trajectory using the inferred force field and diffusion tensor inferred
411
+ from the input data, matching the original time series and initial conditions.
412
+
413
+ Args:
414
+ key: JAX random key for generating noise in the simulation.
415
+ oversampling (int, optional): Factor for oversampling (i.e. number of intermediate simulated
416
+ points between two recorded points). Defaults to 1.
417
+ simulate: if True, performs the simulation with the first data point as initial position;
418
+ if False, returns an uninitialized object which can be simulated with flexible
419
+ initial position and parameters.
420
+ dataset (int, optional): Which experiment of a pooled fit to reproduce. The inferred
421
+ model is collapsed to this condition via
422
+ :meth:`~SFI.statefunc.StateExpr.specialize` (folding ``per_dataset_scalar`` /
423
+ ``dataset_indicator`` at ``dataset``); the resulting process is a standalone
424
+ single-condition model that does not read ``dataset_index``. Defaults to 0.
425
+
426
+ Returns:
427
+ OverdampedProcess: Simulated Langevin process object.
428
+ """
429
+ from SFI.langevin import OverdampedProcess
430
+
431
+ # Collapse a (possibly pooled) fit to the chosen experiment: per-dataset
432
+ # parameters are folded at ``dataset`` and the reserved ``dataset_index``
433
+ # disappears, so the simulated model is standalone (no dataset concept).
434
+ force_k = self.force_inferred.specialize(dataset=int(dataset))
435
+ diff_k = self.diffusion_inferred.specialize(dataset=int(dataset))
436
+ bootstrapped_process = OverdampedProcess(force_k._psf, diff_k._psf)
437
+ bootstrapped_process.set_params(theta_F=force_k.params, theta_D=diff_k.params)
438
+ ds = self.data.datasets[int(dataset)]
439
+ bootstrapped_process.set_extras(extras_global=ds.extras_global, extras_local=ds.extras_local)
440
+ # Only the particle count is framework-supplied; the specialized
441
+ # force does not read ``dataset_index``.
442
+ bootstrapped_process._reserved_overrides = {
443
+ "particle_index": jnp.arange(int(ds.N)),
444
+ }
445
+
446
+ if simulate:
447
+ from SFI.trajectory import TrajectoryCollection
448
+
449
+ start_config = TrajectoryCollection.from_dataset(ds).peek_row(require={"X", "dt"})
450
+ x0 = jnp.asarray(start_config["X"])
451
+ if not jnp.all(jnp.isfinite(x0)):
452
+ x0 = self._find_finite_x0()
453
+ bootstrapped_process.initialize(x0)
454
+ data_bootstrap = bootstrapped_process.simulate(
455
+ dt=start_config["dt"],
456
+ Nsteps=ds.T,
457
+ key=key,
458
+ prerun=0,
459
+ oversampling=oversampling,
460
+ )
461
+ return data_bootstrap, bootstrapped_process
462
+ return bootstrapped_process
463
+
464
+ # ── Parametric (windowed) force inference ──
465
+
466
+ def infer_force(
467
+ self,
468
+ F,
469
+ theta0=None,
470
+ *,
471
+ D=None,
472
+ Lambda=None,
473
+ integrator: str = "rk4",
474
+ n_substeps: int = 1,
475
+ inner: str = "auto",
476
+ eiv="auto",
477
+ max_outer: int = 5,
478
+ inner_maxiter: int = 80,
479
+ extra_radius: int = 1,
480
+ ) -> None:
481
+ r"""Infer the force field with the minimal parametric estimator.
482
+
483
+ Built on ``SFI.inference.parametric_core``: a single RK4 flow step
484
+ per observation interval defines the residual, the residual
485
+ covariance gives a windowed-precision NLL, and the parameters are
486
+ found by direct Gauss–Newton (linear-in-θ ``Basis``, with the
487
+ skip-trick errors-in-variables instrument) or frozen-precision
488
+ L-BFGS (nonlinear-in-θ ``PSF``). ``(D, Λ)`` are profiled
489
+ natively: moment-estimator init, then one windowed
490
+ conditional-NLL refinement at the fitted θ.
491
+
492
+ .. physics:: Parametric windowed force inference (overdamped)
493
+ :label: parametric-force-overdamped
494
+ :category: Inference
495
+
496
+ The observed positions follow
497
+ :math:`y_t = x_t + \eta_t` where
498
+ :math:`\eta \sim \mathcal{N}(0, \Lambda)`. The
499
+ deterministic flow
500
+ :math:`\Phi(x;\theta) = z(\Delta t) - x`
501
+ (one RK4 step by default) defines the residual
502
+ :math:`r_t = y_{t+1} - y_t - \Phi(y_t;\theta)`.
503
+ Residuals follow a banded Gaussian whose local precision
504
+ weights the Gauss–Newton normal equations; under
505
+ measurement noise the left factor is replaced by the
506
+ η-clean *skip* instrument
507
+ :math:`\psi_{\rm inst} = \partial\Phi/\partial\theta`
508
+ evaluated at the lagged clean point (``eiv=True``),
509
+ giving a consistent estimating equation.
510
+
511
+ Parameters
512
+ ----------
513
+ F : PSF or Basis
514
+ Parametric drift model. A ``Basis`` is converted to a PSF
515
+ internally (coefficients initialised to zero) and PASTIS
516
+ sparsification is enabled; it runs the fast direct-GN path.
517
+ A ``PSF`` (possibly nonlinear in θ) runs the L-BFGS path.
518
+ theta0 : dict, array, or None
519
+ Initial drift parameters (default: zeros).
520
+ D : array (d, d), optional
521
+ Fixed diffusion matrix. If both ``D`` and ``Lambda``
522
+ are given, noise profiling is skipped entirely (fast path).
523
+ Lambda : array (d, d), optional
524
+ Fixed measurement-noise covariance.
525
+ integrator : {"rk4", "euler"}
526
+ Flow predictor (default ``"rk4"``, a single 4th-order step).
527
+ n_substeps : int
528
+ Integrator micro-steps per observation interval (default 1 —
529
+ the single-step minimal estimator).
530
+ inner : {"auto", "gn", "lbfgs"}
531
+ Inner solver. ``"auto"`` → direct Gauss–Newton for a linear
532
+ ``Basis``, L-BFGS for a ``PSF``.
533
+ eiv : {"auto", True, False, float}
534
+ Measurement-noise errors-in-variables instrument. ``"auto"``
535
+ (default) → ``True`` for all models (interacting models use
536
+ the same N-body flow for the instrument as for the residual);
537
+ ``True`` forces the η-clean skip instrument (consistent under
538
+ noise); ``False`` is the plain MLE; a float in ``[0, 1]``
539
+ blends. Active on the GN path only.
540
+ max_outer : int
541
+ Outer Gauss–Newton / IRLS iterations (default 5).
542
+ inner_maxiter : int
543
+ Inner L-BFGS iterations per outer step on the PSF path
544
+ (default 80; raise for large nonlinear families, e.g. NNs).
545
+ extra_radius : int
546
+ Precision-window padding beyond the covariance bandwidth
547
+ (default 1). Raise to 2–3 in the noise-dominated regime
548
+ β = Tr(Λ)/Tr(2DΔt) ≫ 1, where the windowed precision
549
+ decays slowly and the default window under-resolves it.
550
+
551
+ Updates
552
+ -------
553
+ Sets standard ``force_*`` attributes:
554
+ ``force_inferred``, ``force_psf``, ``force_G``,
555
+ ``force_G_pinv``, ``force_coefficients_full``,
556
+ ``force_coefficients``, ``force_support``,
557
+ ``force_moments``.
558
+
559
+ Also sets ``diffusion_average``, ``A``, ``A_inv``, ``Lambda``
560
+ from the profiled ``(D, Λ)``.
561
+
562
+ When ``F`` is a ``Basis``, additionally sets
563
+ ``force_basis``, ``force_G_full``, and ``force_scorer``
564
+ so that ``sparsify_force()`` can be called afterwards.
565
+
566
+ See Also
567
+ --------
568
+ :ref:`parametric-concept` : Mathematical foundations.
569
+ :ref:`parametric-algorithm` : Detailed algorithm description.
570
+ """
571
+ import time as _time
572
+
573
+ from SFI.inference.parametric_core.solve import _as_psf, solve_force_od
574
+
575
+ if hasattr(self, "force_inferred"):
576
+ raise RuntimeError("Force inference has already been run on this object.")
577
+
578
+ if integrator not in ("rk4", "euler"):
579
+ raise ValueError(f"integrator must be 'rk4' or 'euler', got {integrator!r}")
580
+
581
+ # ── Accept Basis → convert to PSF, enable PASTIS ──
582
+ basis_mode = isinstance(F, Basis)
583
+ if basis_mode:
584
+ self.force_basis = F
585
+ elif not (hasattr(F, "flatten_params") and hasattr(F, "unflatten_params")):
586
+ raise TypeError("F must be a PSF or a Basis. Got %s." % type(F).__name__)
587
+ F_psf = _as_psf(F)
588
+
589
+ with self._structural_scope(F_psf):
590
+ dt_val = float(self.data.peek_row(require={"dt"})["dt"])
591
+
592
+ logger.info(
593
+ "[infer_force] OD minimal parametric solve (n_params=%d, dt=%.4g, %s·n%d, basis_mode=%s)...",
594
+ int(F_psf.template.size), dt_val, integrator, n_substeps, basis_mode,
595
+ )
596
+ t0 = _time.perf_counter()
597
+
598
+ res = solve_force_od(
599
+ self.data, F, theta0=theta0, D=D, Lambda=Lambda,
600
+ integrator=integrator, n_substeps=n_substeps,
601
+ inner=inner, eiv=eiv, max_outer=max_outer,
602
+ inner_maxiter=inner_maxiter, extra_radius=extra_radius,
603
+ )
604
+ t_elapsed = _time.perf_counter() - t0
605
+
606
+ # ── Store results (standard force_* attributes) ──
607
+ theta_flat = res.theta
608
+ G = res.G
609
+
610
+ self.force_psf = F_psf
611
+ self.force_G = G
612
+ # Parameter covariance from the solver: the sandwich G⁻¹HG⁻ᵀ on the
613
+ # IV (eiv) path, the inverse information on the symmetric path.
614
+ G_inv = None
615
+ if bool(jnp.all(jnp.isfinite(res.theta_cov))):
616
+ G_inv = res.theta_cov
617
+ self.force_G_pinv = G_inv
618
+
619
+ self.force_coefficients_full = theta_flat
620
+ self.force_coefficients = theta_flat
621
+ self.force_support = jnp.arange(theta_flat.size)
622
+ self.force_moments = G @ theta_flat
623
+
624
+ # ── PASTIS plumbing (Basis mode only) ──
625
+ if basis_mode:
626
+ from SFI.inference.sparse import SparseScorer
627
+
628
+ self.force_G_full = G
629
+ self.force_scorer = SparseScorer(
630
+ M=self.force_moments,
631
+ G=self.force_G_full,
632
+ )
633
+
634
+ self.diffusion_average = res.D
635
+ # Callable constant-D field, so the full result surface (incl.
636
+ # simulate_bootstrapped_trajectory) works without a separate
637
+ # compute_diffusion_constant() call; a later infer_diffusion()
638
+ # overwrites it with the state-dependent fit.
639
+ self.diffusion_inferred = constant_array(res.D)
640
+ self.A = 2.0 * res.D
641
+ self.A_inv = jnp.linalg.inv(self.A)
642
+ self.Lambda = res.Lambda
643
+ self.Lambda_trace = float(jnp.trace(res.Lambda))
644
+
645
+ beta = float(jnp.trace(res.Lambda) / (jnp.trace(res.D) * dt_val + 1e-30))
646
+
647
+ sf_F = SF(F_psf, F_psf.unflatten_params(theta_flat))
648
+ meta_F = dict(
649
+ kind="force",
650
+ inference="parametric",
651
+ n_params=int(F_psf.template.size),
652
+ beta=beta,
653
+ A_inv=jnp.asarray(self.A_inv),
654
+ )
655
+ self.force_inferred = InferenceResultSF(
656
+ sf_F,
657
+ param_cov=G_inv,
658
+ meta=meta_F,
659
+ )
660
+
661
+ self.metadata["force_method"] = "parametric"
662
+ self.metadata["force_parametric_info"] = {
663
+ **res.info,
664
+ "integrator": integrator,
665
+ "beta": beta,
666
+ "D_matrix": res.D,
667
+ "Lambda": res.Lambda,
668
+ }
669
+
670
+ logger.info(
671
+ "[infer_force] Done in %.1f s. Λ=%s, D=%s, β=%.3f",
672
+ t_elapsed,
673
+ jnp.diag(res.Lambda),
674
+ jnp.diag(res.D),
675
+ beta,
676
+ )
677
+
678
+ # ── State-dependent diffusion from parametric residuals ──
679
+
680
+ def infer_diffusion(
681
+ self,
682
+ basis=None,
683
+ *,
684
+ theta_D0=None,
685
+ integrator: str = "rk4",
686
+ n_substeps: int = 1,
687
+ maxiter: int = 100,
688
+ ) -> None:
689
+ r"""Infer state-dependent diffusion D(x) from parametric residuals.
690
+
691
+ Requires a prior parametric :meth:`infer_force` call. Holds the
692
+ fitted force fixed and minimises the windowed conditional NLL over
693
+ the diffusion parameters (the log-det term makes the diffusion
694
+ level identifiable), reusing the same flow residuals and integrate
695
+ engine as the force solve.
696
+
697
+ .. physics:: State-dependent diffusion inference (overdamped)
698
+ :label: parametric-diffusion-overdamped
699
+ :category: Inference
700
+
701
+ With the force :math:`\hat F` held fixed, the state-dependent
702
+ diffusion :math:`D(x;\theta_D)` is optimised by minimising the
703
+ windowed conditional negative log-likelihood; :math:`\Lambda`
704
+ from the force inference is held fixed. A rank-2 basis gives
705
+ :math:`D(x) = \sum_j (\theta_D)_j\, d_j(x)`; a PSF is evaluated
706
+ directly.
707
+
708
+ Parameters
709
+ ----------
710
+ basis : Basis (rank 2), PSF, or None
711
+ Diffusion model. A rank-2 ``Basis`` gives the linear
712
+ parameterisation; a ``PSF`` is used directly
713
+ (``D(x) = PSF(x; θ_D)``). When ``None`` (default),
714
+ ``symmetric_matrix_basis(d)`` — all constant symmetric
715
+ ``d×d`` diffusion matrices.
716
+ theta_D0 : dict, array, or None
717
+ Initial diffusion parameters (default zeros).
718
+ integrator : {"rk4", "euler"}
719
+ Flow predictor (default ``"rk4"``, matching :meth:`infer_force`).
720
+ n_substeps : int
721
+ Integrator micro-steps per Δt (default 1).
722
+ maxiter : int
723
+ L-BFGS maximum iterations (default 100).
724
+
725
+ Updates
726
+ -------
727
+ Sets ``diffusion_inferred``, ``diffusion_coefficients`` (and
728
+ ``diffusion_basis`` when ``basis`` is a ``Basis``), plus metadata.
729
+
730
+ See Also
731
+ --------
732
+ :ref:`parametric-algorithm` : Full algorithm description.
733
+ """
734
+ from SFI.bases import symmetric_matrix_basis
735
+ from SFI.inference.parametric_core.solve import _as_psf, solve_diffusion_od
736
+
737
+ if not hasattr(self, "force_inferred"):
738
+ raise RuntimeError("infer_diffusion() requires a prior infer_force() call.")
739
+ if self.metadata.get("force_method") not in ("parametric", "parametric_core"):
740
+ raise RuntimeError(
741
+ "infer_diffusion() requires parametric force inference (call infer_force(), not infer_force_linear)."
742
+ )
743
+
744
+ d = self.data.datasets[0].X.shape[-1]
745
+ if basis is None:
746
+ basis = symmetric_matrix_basis(d)
747
+ if isinstance(basis, Basis):
748
+ self._validate_basis(basis, expected_rank=2, label="diffusion basis")
749
+ self.diffusion_basis = basis
750
+ D_psf = _as_psf(basis)
751
+ Lambda = getattr(self, "Lambda", None)
752
+ if Lambda is None:
753
+ Lambda = jnp.zeros((d, d))
754
+
755
+ res = solve_diffusion_od(
756
+ self.data, self.force_psf, self.force_coefficients_full, D_psf,
757
+ Lambda=Lambda, theta_D0=theta_D0,
758
+ n_substeps=n_substeps, integrator=integrator, maxiter=maxiter,
759
+ )
760
+ theta_D = res.theta_D
761
+ self.diffusion_coefficients = theta_D
762
+ self.diffusion_coefficients_full = theta_D
763
+ sf_D = SF(D_psf, D_psf.unflatten_params(theta_D))
764
+ meta_D = dict(
765
+ kind="diffusion",
766
+ inference="parametric",
767
+ n_params=int(D_psf.template.size),
768
+ nll=res.info["nll"],
769
+ )
770
+ self.diffusion_inferred = InferenceResultSF(
771
+ sf_D,
772
+ param_cov=None,
773
+ meta=meta_D,
774
+ )
775
+ self.metadata["diffusion_method"] = "parametric"
776
+ self.metadata["diffusion_parametric_info"] = dict(res.info)
777
+
778
+
779
+ #################################################################
780
+ ################ BACKEND ############################
781
+ #################################################################
782
+
783
+ # Hooks required by BaseLangevinInference:
784
+ def _force_G_matrix(self) -> jnp.ndarray:
785
+ b_left = self.force_basis
786
+ b_right = self.force_basis @ self.A_inv
787
+ return self.__G_matrix__(b_left, b_right, self.__force_G_mode__, "ima,imb->iab")
788
+
789
+ def _force_moments(self):
790
+ r"""
791
+ Compute force moments ⟨ v · A_inv · b ⟩ with Ito / Ito-shift / Strato flavors.
792
+
793
+ .. physics:: Overdamped force moments (linear regression)
794
+ :label: force-moments-overdamped
795
+ :category: Inference
796
+
797
+ **Itô moments:**
798
+
799
+ .. math::
800
+
801
+ M_a = \bigl\langle v_t \cdot A^{-1} \cdot b_a(X_t) \bigr\rangle
802
+
803
+ **Stratonovich moments** (trapezoid + gradient correction):
804
+
805
+ .. math::
806
+
807
+ M_a^{\text{S}} = \tfrac{1}{2}\bigl\langle v_t \cdot A^{-1}
808
+ \cdot \bigl[b_a(X_t) + b_a(X_{t+1})\bigr] \bigr\rangle
809
+ \;-\; \bigl\langle D_{\text{inst}} : (A^{-1} \cdot \nabla_x b_a) \bigr\rangle
810
+
811
+ The force coefficients solve :math:`G \cdot C = M`
812
+ where :math:`G_{ab} = \langle b_a \cdot b_b \rangle` (with mode variants).
813
+
814
+ Contractions
815
+ ------------
816
+ - RHS (Ito or Ito-shift):
817
+ eq='im,mn,ina->ia' with ops (V, A, B)
818
+ shapes:
819
+ V: (N, m) from velocity(dX, dt)
820
+ A: (m, n) constant A_inv
821
+ B: (i, n, a) basis at X or X_minus, features last
822
+
823
+ - Stratonovich v∘b:
824
+ trapezoid average over X and X_plus, same rhs contraction on each, then 0.5*(...+...)
825
+
826
+ - Stratonovich gradient correction:
827
+ eq='imn,ioma,no->ia' with ops (D, G, A)
828
+ shapes:
829
+ D: (i, m, n) instantaneous diffusion (N, d, d) e.g. noisy
830
+ G: (i, o, m, a) basis.d_x()(X) shape (N, d_deriv, d_force, F)
831
+ A: (m, n) A_inv (d, d)
832
+
833
+ Masking
834
+ -------
835
+ Mask is applied by the integrator on the leading particle axis i,
836
+ and forwarded to state-expression calls via `mask_out`. The leaf
837
+ fill policy (`zerostop` by default) replaces masked entries with 0
838
+ via `jnp.where`, which naturally gives zero tangents for masked
839
+ entries without blocking the Jacobian for active entries.
840
+ """
841
+ if not hasattr(self, "A_inv"):
842
+ raise RuntimeError("A_inv not available. Compute diffusion first.")
843
+
844
+ # Common pieces
845
+ A = ConstOperand(self.A_inv, alias="A")
846
+ V = TimeOperand(velocity("dX", "dt"), alias="V")
847
+
848
+ mode = getattr(self, "__force_M_mode__", "Ito")
849
+ if mode not in ("Ito", "Ito-shift", "Strato"):
850
+ raise KeyError(f"Unknown __force_M_mode__: {mode}")
851
+
852
+ if mode in ("Ito", "Ito-shift"):
853
+ x_key = "X_minus" if mode == "Ito-shift" else "X"
854
+ B = ExprOperand(expr=self.force_basis, x=stream(x_key), alias="B")
855
+ prog = Integrand(
856
+ exprs=[B],
857
+ times=[V],
858
+ consts=[A],
859
+ terms=[Term(eq="im,mn,ina->ia", ops=("V", "A", "B"))],
860
+ )
861
+ logger.debug(
862
+ "Computing Ito-shift force coefficients."
863
+ if mode == "Ito-shift"
864
+ else "Computing Ito force coefficients."
865
+ )
866
+ return integrate(self.data, prog, reduce="sum", chunk_target_bytes=self._chunk_target_bytes)
867
+
868
+ # Stratonovich
869
+ logger.debug("Computing Strato force coefficients.")
870
+
871
+ # v ∘ b via trapezoid on basis
872
+ B0 = ExprOperand(expr=self.force_basis, x=stream("X"), alias="B0")
873
+ Bp = ExprOperand(expr=self.force_basis, x=stream("X_plus"), alias="Bp")
874
+ prog_B0 = Integrand(
875
+ exprs=[B0],
876
+ times=[V],
877
+ consts=[A],
878
+ terms=[Term(eq="im,mn,ina->ia", ops=("V", "A", "B0"))],
879
+ )
880
+ prog_Bp = Integrand(
881
+ exprs=[Bp],
882
+ times=[V],
883
+ consts=[A],
884
+ terms=[Term(eq="im,mn,ina->ia", ops=("V", "A", "Bp"))],
885
+ )
886
+ prog_strato_vb = 0.5 * (prog_B0 + prog_Bp)
887
+ self.force_v_moments = integrate(
888
+ self.data, prog_strato_vb, reduce="sum", chunk_target_bytes=self._chunk_target_bytes
889
+ )
890
+
891
+ logger.debug("Computing Strato gradient term.")
892
+
893
+ # Gradient correction: G = basis.d_x() with mask forwarded normally.
894
+ # The leaf's fill_policy='zerostop' uses jnp.where to zero masked
895
+ # entries, which preserves the Jacobian for active particles.
896
+ #
897
+ # For interacting bases (pdepth >= 1), the full cross-particle
898
+ # Jacobian d_x() has shape (N_out, N_in, d, d, F) which doesn't
899
+ # match the Strato einsum 'ioma'. We use same_particle=True to
900
+ # get the diagonal block (N, d, d, F) = (i, o, m, a) instead.
901
+ # This is physically correct: the Strato correction only needs
902
+ # ∂b_a(x_i)/∂x_i (same-particle gradient) IF the diffusion is
903
+ # diagonal on particle level (no noise correlations between particles).
904
+ _interacting = getattr(self.force_basis, "particles_input", False)
905
+ G = ExprOperand(
906
+ expr=self.force_basis.d_x(same_particle=_interacting),
907
+ x=stream("X"),
908
+ alias="G",
909
+ )
910
+ D = self.get_diffusion_timeop("noisy") # already a TimeOperand with alias
911
+
912
+ prog_grad = Integrand(
913
+ exprs=[G],
914
+ times=[D],
915
+ consts=[A],
916
+ terms=[Term(eq="imn,ioma,no->ia", ops=(D.alias, "G", "A"))],
917
+ )
918
+ self._force_D_grad_b_average = integrate(
919
+ self.data, prog_grad, reduce="sum", chunk_target_bytes=self._chunk_target_bytes
920
+ )
921
+
922
+ return self.force_v_moments - self._force_D_grad_b_average
923
+
924
+ def _diffusion_G_matrix(self) -> jnp.ndarray:
925
+ # Diffusion is a per-point quantity: weight the projection per-point
926
+ # (weight_by_dt=False), consistently with _diffusion_moments.
927
+ return self.__G_matrix__(
928
+ self.diffusion_basis,
929
+ self.diffusion_basis,
930
+ self.__diffusion_G_mode__,
931
+ "imna,imnb->iab",
932
+ weight_by_dt=False,
933
+ )
934
+
935
+ def _diffusion_moments(self) -> jnp.ndarray:
936
+ logger.debug("Computing diffusion linear moments.")
937
+ D_op = self.get_diffusion_timeop(self.__diffusion_M_mode__)
938
+ B = ExprOperand(expr=self.diffusion_basis, x=stream("X"), alias="B")
939
+ prog = Integrand(
940
+ exprs=[B],
941
+ times=[D_op],
942
+ terms=[Term(eq="imna,imn->ia", ops=("B", D_op.alias))],
943
+ )
944
+ # Per-point (weight_by_dt=False): each increment's diffusion estimate counts equally.
945
+ return integrate(self.data, prog, reduce="sum", weight_by_dt=False,
946
+ chunk_target_bytes=self._chunk_target_bytes)
947
+
948
+ def _update_force_inferred(self) -> None:
949
+ """
950
+ Materialize the fitted force as an SF and wrap it with param covariance.
951
+ Produces: self.force_inferred : InferenceResultSF
952
+ """
953
+ if hasattr(self, "force_basis"):
954
+ # Basis -> PSF is the supported path; tests already use .to_psf()
955
+ P = self.force_basis.to_psf()
956
+ theta = {"coeff": jnp.asarray(self.force_coefficients_full)}
957
+ elif hasattr(self, "force_psf"):
958
+ P = self.force_psf
959
+ theta = self.force_params_nonlinear
960
+
961
+ sf = SF(P, theta) # fixed-θ state function
962
+
963
+ meta = dict(
964
+ kind="force",
965
+ modes=dict(
966
+ M=getattr(self, "__force_M_mode__", None),
967
+ G=getattr(self, "__force_G_mode__", None),
968
+ ),
969
+ A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
970
+ basis_features=int(getattr(self.force_basis, "n_features", 0)),
971
+ basis_labels=getattr(self.force_basis, "labels", None),
972
+ )
973
+ cov = getattr(self, "force_coefficients_cov", None) # may be absent
974
+ self.force_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
975
+
976
+ def _update_diffusion_inferred(self) -> None:
977
+ """
978
+ Materialize the fitted diffusion tensor as an SF and wrap it.
979
+ Produces: self.diffusion_inferred : InferenceResultSF
980
+ """
981
+ if self.diffusion_basis is None:
982
+ raise RuntimeError("_update_diffusion_inferred called before diffusion was fitted.")
983
+ P: "PSF" = self.diffusion_basis.to_psf() # rank-2 PSF
984
+ theta = {"coeff": jnp.asarray(self.diffusion_coefficients_full)}
985
+ sf = SF(P, theta) # callable (x[, mask/extras]) -> (i, m, n)
986
+
987
+ meta = dict(
988
+ kind="diffusion",
989
+ mode=getattr(self, "__diffusion_M_mode__", None),
990
+ A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
991
+ basis_features=int(getattr(self.diffusion_basis, "n_features", 0)),
992
+ basis_labels=getattr(self.diffusion_basis, "labels", None),
993
+ )
994
+ cov = getattr(self, "diffusion_coefficients_cov", None) # may be absent
995
+ self.diffusion_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
996
+
997
+ def __G_matrix__(
998
+ self,
999
+ b_left: callable,
1000
+ b_right: callable,
1001
+ G_mode: str,
1002
+ einsum_string: str,
1003
+ subsampling: int = 1,
1004
+ weight_by_dt: bool = True,
1005
+ ) -> jnp.ndarray:
1006
+ """
1007
+ Compute Gram/normalization matrix G = < b_left ⊗ b_right > with chosen mode.
1008
+
1009
+ Arguments
1010
+ ---------
1011
+ b_left, b_right : stateexpr callables
1012
+ Each must follow the contract expr(x, v=..., mask=..., extras=..., params=...),
1013
+ producing arrays with features on the last axis. Particle axis is leading i.
1014
+ G_mode : {'rectangle','trapeze','shift'}
1015
+ rectangle: b_left(X_t) ⊗ b_right(X_t)
1016
+ trapeze: b_left(X_t) ⊗ 0.5 [ b_right(X_t) + b_right(X_{t+}) ]
1017
+ shift: b_left(X_t) ⊗ b_right(X_{t+})
1018
+ einsum_string : str
1019
+ Einstein string including the particle index in inputs (e.g. 'iam,ibn->iabmn').
1020
+ subsampling : int
1021
+ Use every `subsampling`-th time row.
1022
+
1023
+ Returns
1024
+ -------
1025
+ jnp.ndarray
1026
+ Time-averaged Gram matrix with particle axis reduced by the integrator.
1027
+ """
1028
+ logger.debug("Computing G matrix with einsum: %s", einsum_string)
1029
+
1030
+ BL = ExprOperand(expr=b_left, x=stream("X"), alias="BL")
1031
+ BR0 = ExprOperand(expr=b_right, x=stream("X"), alias="BR0")
1032
+ BRp = ExprOperand(expr=b_right, x=stream("X_plus"), alias="BRP")
1033
+
1034
+ if G_mode == "rectangle":
1035
+ prog = Integrand(exprs=[BL, BR0], terms=[Term(eq=einsum_string, ops=("BL", "BR0"))])
1036
+ elif G_mode == "trapeze":
1037
+ rect = Integrand(exprs=[BL, BR0], terms=[Term(eq=einsum_string, ops=("BL", "BR0"))])
1038
+ shift = Integrand(exprs=[BL, BRp], terms=[Term(eq=einsum_string, ops=("BL", "BRP"))])
1039
+ prog = 0.5 * (rect + shift)
1040
+ elif G_mode == "shift":
1041
+ prog = Integrand(exprs=[BL, BRp], terms=[Term(eq=einsum_string, ops=("BL", "BRP"))])
1042
+ else:
1043
+ raise KeyError("Wrong G_mode argument")
1044
+
1045
+ # Mask-aware reduction over particles; Teff mean over time
1046
+ return integrate(
1047
+ self.data,
1048
+ prog,
1049
+ reduce="sum",
1050
+ reduce_over_particles=True,
1051
+ subsampling=subsampling,
1052
+ weight_by_dt=weight_by_dt,
1053
+ chunk_target_bytes=self._chunk_target_bytes,
1054
+ )
1055
+
1056
+ def _build_diffusion_timeoperands(self):
1057
+ """
1058
+ Construct and cache TimeOperands for diffusion estimators.
1059
+ Safe to call multiple times; idempotent.
1060
+ """
1061
+ if hasattr(self, "_diff_ops"):
1062
+ return
1063
+ self._diff_ops = {
1064
+ "MSD": TimeOperand(_D_msd, alias="D_msd"),
1065
+ "noisy": TimeOperand(_D_noisy, alias="D_noisy"),
1066
+ "WeakNoise": TimeOperand(_D_weaknoise, alias="D_weaknoise"),
1067
+ "Lambda": TimeOperand(_Lambda, alias="Lambda"),
1068
+ }
1069
+
1070
+ def get_diffusion_timeop(self, method: str) -> TimeOperand:
1071
+ """
1072
+ Return the requested overdamped diffusion estimator as a TimeOperand.
1073
+ Output is (N, d, d). Required streams are declared on the wrapped TimeOp.
1074
+ """
1075
+ self._build_diffusion_timeoperands()
1076
+ try:
1077
+ return self._diff_ops[method] # type: ignore[attr-defined]
1078
+ except KeyError as e:
1079
+ raise KeyError(f"Unknown diffusion estimator method: {method}") from e
1080
+
1081
+
1082
+ # -------------------- Overdamped diffusion estimators as TimeOps --------------------
1083
+
1084
+
1085
+ @timeop(name="D_msd", batch_safe=True)
1086
+ def _D_msd(**streams):
1087
+ r"""
1088
+ MSD estimator (per particle): 0.5 * dX ⊗ (dX / dt)
1089
+ Returns (..., N, d, d) — batch-safe.
1090
+
1091
+ .. physics:: MSD diffusion estimator (overdamped)
1092
+ :label: D-msd-overdamped
1093
+ :category: Estimator
1094
+
1095
+ .. math::
1096
+
1097
+ \hat D_{\text{MSD}}(t)
1098
+ = \tfrac{1}{2}\,\mathrm{d}X_t \otimes
1099
+ \frac{\mathrm{d}X_t}{\mathrm{d}t}
1100
+
1101
+ Simplest estimator; biased by measurement noise.
1102
+ """
1103
+ dX = streams["dX"]
1104
+ dt = streams["dt"]
1105
+ while dt.ndim < dX.ndim:
1106
+ dt = dt[..., jnp.newaxis]
1107
+ v = dX / dt
1108
+ return 0.5 * jnp.einsum("...m,...n->...mn", dX, v)
1109
+
1110
+
1111
+ _D_msd._requires = frozenset({"dX", "dt"}) # type: ignore[attr-defined]
1112
+
1113
+
1114
+ @timeop(name="D_noisy", batch_safe=True)
1115
+ def _D_noisy(**streams):
1116
+ r"""
1117
+ Noisy diffusion estimator — Vestergaard–Blainey–Flyvbjerg (per particle):
1118
+ 1/4 [ dX⊗(dX/dt) + 2 dX⊗(dX^-/dt) + 2 dX^-⊗(dX/dt) + dX^-⊗(dX^-/dt) ].
1119
+ Returns (N, d, d).
1120
+
1121
+ .. physics:: Noisy (Vestergaard–Blainey–Flyvbjerg) diffusion estimator
1122
+ :label: D-noisy
1123
+ :category: Estimator
1124
+
1125
+ .. math::
1126
+
1127
+ \hat D_{\text{noisy}}(t) = \tfrac{1}{4}\bigl[
1128
+ \mathrm{d}X_t \otimes v_t
1129
+ + 2\,\mathrm{d}X_t \otimes v_{t-1}
1130
+ + 2\,\mathrm{d}X_{t-1} \otimes v_t
1131
+ + \mathrm{d}X_{t-1} \otimes v_{t-1}
1132
+ \bigr]
1133
+
1134
+ Two-point estimator robust to measurement noise
1135
+ (CL Vestergaard, PC Blainey, H Flyvbjerg - Physical Review E, 2014).
1136
+ """
1137
+ dX = streams["dX"]
1138
+ dXm = streams["dX_minus"]
1139
+ dt = streams["dt"]
1140
+ while dt.ndim < dX.ndim:
1141
+ dt = dt[..., jnp.newaxis]
1142
+ invdt = 1.0 / dt
1143
+ a = jnp.einsum("...m,...n->...mn", dX, dX * invdt)
1144
+ b = jnp.einsum("...m,...n->...mn", dX, dXm * invdt)
1145
+ c = jnp.einsum("...m,...n->...mn", dXm, dX * invdt)
1146
+ d = jnp.einsum("...m,...n->...mn", dXm, dXm * invdt)
1147
+ return 0.25 * (a + 2 * b + 2 * c + d)
1148
+
1149
+
1150
+ _D_noisy._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
1151
+
1152
+
1153
+ @timeop(name="D_weaknoise", batch_safe=True)
1154
+ def _D_weaknoise(**streams):
1155
+ r"""
1156
+ Weak-noise estimator (per particle):
1157
+ 1/4 * ( (dX - dX^-) ⊗ (dX/dt - dX^-/dt) ).
1158
+ Returns (N, d, d).
1159
+
1160
+ .. physics:: Weak-noise diffusion estimator (overdamped)
1161
+ :label: D-weaknoise-overdamped
1162
+ :category: Estimator
1163
+
1164
+ .. math::
1165
+
1166
+ \hat D_{\text{WN}}(t)
1167
+ = \tfrac{1}{4}\bigl(\mathrm{d}X_t - \mathrm{d}X_{t-1}\bigr)
1168
+ \otimes \bigl(v_t - v_{t-1}\bigr)
1169
+
1170
+ Uses successive-displacement differences; suitable when localization
1171
+ noise is negligible.
1172
+ """
1173
+ dX = streams["dX"]
1174
+ dXm = streams["dX_minus"]
1175
+ dt = streams["dt"]
1176
+ while dt.ndim < dX.ndim:
1177
+ dt = dt[..., jnp.newaxis]
1178
+ invdt = 1.0 / dt
1179
+ ddx = dX - dXm
1180
+ dv = dX * invdt - dXm * invdt
1181
+ return 0.25 * jnp.einsum("...m,...n->...mn", ddx, dv)
1182
+
1183
+
1184
+ _D_weaknoise._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
1185
+
1186
+
1187
+ @timeop(name="Lambda_meas_noise", batch_safe=True)
1188
+ def _Lambda(**streams):
1189
+ r"""
1190
+ Measurement-noise cross term (per particle):
1191
+ Λ_i = -0.5 [ dX_i ⊗ dX^-_i + dX^-_i ⊗ dX_i ].
1192
+ Returns (N, d, d). No 1/dt factor inside.
1193
+
1194
+ .. physics:: Measurement noise estimator (overdamped)
1195
+ :label: Lambda-overdamped
1196
+ :category: Estimator
1197
+
1198
+ .. math::
1199
+
1200
+ \hat\Lambda_i
1201
+ = -\,\tfrac{1}{2}\bigl[
1202
+ \mathrm{d}X_i \otimes \mathrm{d}X_{i-1}
1203
+ + \mathrm{d}X_{i-1} \otimes \mathrm{d}X_i
1204
+ \bigr]
1205
+
1206
+ Estimates localization / measurement noise from anti-correlation
1207
+ of successive increments.
1208
+ """
1209
+ dX = streams["dX"]
1210
+ dXm = streams["dX_minus"]
1211
+ return -0.5 * (jnp.einsum("...m,...n->...mn", dX, dXm) + jnp.einsum("...m,...n->...mn", dXm, dX))
1212
+
1213
+
1214
+ _Lambda._requires = frozenset({"dX", "dX_minus"}) # type: ignore[attr-defined]