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.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
# SFI/inference/underdamped.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import jax.numpy as jnp
|
|
7
|
+
|
|
8
|
+
from SFI.bases.constants import constant_array
|
|
9
|
+
from SFI.inference.base import BaseLangevinInference
|
|
10
|
+
from SFI.integrate.api import integrate
|
|
11
|
+
from SFI.integrate.integrand import (
|
|
12
|
+
ConstOperand,
|
|
13
|
+
ExprOperand,
|
|
14
|
+
Integrand,
|
|
15
|
+
Term,
|
|
16
|
+
TimeOperand,
|
|
17
|
+
)
|
|
18
|
+
from SFI.integrate.timeops import TimeOp, stream, timeop, velocity
|
|
19
|
+
from SFI.statefunc import PSF, SF, Basis
|
|
20
|
+
from SFI.utils.maths import sqrtm_psd
|
|
21
|
+
|
|
22
|
+
from .result import InferenceResultSF
|
|
23
|
+
from .sparse import SparseScorer
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------- #
|
|
29
|
+
# ULI linear force-inference presets
|
|
30
|
+
# ---------------------------------------------------------------------------- #
|
|
31
|
+
# The full (M_mode, G_mode, diffusion_method) surface is combinatorial and hard
|
|
32
|
+
# for users to navigate. The choice that actually matters is the diffusion
|
|
33
|
+
# estimator (``noisy`` vs ``WeakNoise``), keyed on measurement noise; the broadly
|
|
34
|
+
# robust kinematics/Gram settings are ``M_mode='symmetric'`` and
|
|
35
|
+
# ``G_mode='trapeze'`` (the ULI Gram is returned transposed — grid assessments on
|
|
36
|
+
# the VdP limit cycle and a 2-D noise-driven OU both place it at/near the per-cell
|
|
37
|
+
# oracle, ~1.07-1.08x, with a better in-band worst case). We expose two named
|
|
38
|
+
# presets and an ``auto`` switch.
|
|
39
|
+
_FORCE_LINEAR_PRESETS: dict[str, tuple[str, str, str]] = {
|
|
40
|
+
# broadly applicable default — flat across measurement noise in its band
|
|
41
|
+
"robust": ("symmetric", "trapeze", "noisy"),
|
|
42
|
+
# sharper refinement for verified noise-free data (coarse-dt, low-noise)
|
|
43
|
+
"clean": ("symmetric", "trapeze", "WeakNoise"),
|
|
44
|
+
# SFI v1.0 reproductions (explicit only; never selected by 'auto')
|
|
45
|
+
"legacy-clean-v1.0": ("early", "rectangle", "MSD"),
|
|
46
|
+
"legacy-noisy-v1.0": ("symmetric", "rectangle", "noisy"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_force_linear_preset(
|
|
51
|
+
preset: str,
|
|
52
|
+
*,
|
|
53
|
+
M_mode: str | None = None,
|
|
54
|
+
G_mode: str | None = None,
|
|
55
|
+
diffusion_method: str | None = None,
|
|
56
|
+
noise_detected: bool | None = None,
|
|
57
|
+
) -> tuple[tuple[str, str, str], str]:
|
|
58
|
+
"""Resolve a force-inference ``preset`` to a concrete mode triple.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
preset : {'auto', 'robust', 'clean'}
|
|
63
|
+
``'robust'`` and ``'clean'`` map directly to fixed mode triples (see
|
|
64
|
+
``_FORCE_LINEAR_PRESETS``). ``'auto'`` selects ``'robust'`` (the
|
|
65
|
+
``noisy`` diffusion estimator) when measurement noise is present and
|
|
66
|
+
``'clean'`` (``WeakNoise``) otherwise.
|
|
67
|
+
M_mode, G_mode, diffusion_method : str, optional
|
|
68
|
+
Power-user overrides. Any non-``None`` value replaces the preset's
|
|
69
|
+
choice on that axis (so explicit callers are unaffected by the preset).
|
|
70
|
+
noise_detected : bool, optional
|
|
71
|
+
Measurement-noise indicator for ``preset='auto'`` (``Lambda_trace > 0``).
|
|
72
|
+
``True`` -> ``robust``/``noisy``; ``False`` -> ``clean``/``WeakNoise``.
|
|
73
|
+
``None`` (indicator unavailable) falls back to the safe ``robust`` path.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
(M_mode, G_mode, diffusion_method) : tuple of str
|
|
78
|
+
The resolved mode triple.
|
|
79
|
+
resolved_preset : str
|
|
80
|
+
The concrete base preset used (``'robust'`` or ``'clean'``) — useful for
|
|
81
|
+
metadata/logging when ``preset='auto'``.
|
|
82
|
+
"""
|
|
83
|
+
if preset == "auto":
|
|
84
|
+
base = "clean" if noise_detected is False else "robust"
|
|
85
|
+
elif preset in _FORCE_LINEAR_PRESETS:
|
|
86
|
+
base = preset
|
|
87
|
+
else:
|
|
88
|
+
choices = ", ".join(repr(p) for p in ("auto", *_FORCE_LINEAR_PRESETS))
|
|
89
|
+
raise ValueError(f"unknown preset {preset!r}; choose from {choices}")
|
|
90
|
+
|
|
91
|
+
M0, G0, D0 = _FORCE_LINEAR_PRESETS[base]
|
|
92
|
+
resolved = (
|
|
93
|
+
M0 if M_mode is None else M_mode,
|
|
94
|
+
G0 if G_mode is None else G_mode,
|
|
95
|
+
D0 if diffusion_method is None else diffusion_method,
|
|
96
|
+
)
|
|
97
|
+
return resolved, base
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UnderdampedLangevinInference(BaseLangevinInference):
|
|
101
|
+
r"""
|
|
102
|
+
Stochastic Force Inference concrete class for underdamped systems (velocities unobserved).
|
|
103
|
+
|
|
104
|
+
Core equations (Ito convention):
|
|
105
|
+
dx/dt = v
|
|
106
|
+
dv/dt = F(x, v) + sqrt(2 D(x, v)) dW_t
|
|
107
|
+
|
|
108
|
+
Only x(t) is observed; v(t) is reconstructed from finite differences ("secant velocities").
|
|
109
|
+
|
|
110
|
+
.. physics:: Underdamped Langevin SDE
|
|
111
|
+
:label: underdamped-langevin-sde
|
|
112
|
+
:category: Dynamical equations
|
|
113
|
+
|
|
114
|
+
.. math::
|
|
115
|
+
|
|
116
|
+
\frac{\mathrm{d}x}{\mathrm{d}t} = v, \qquad
|
|
117
|
+
\frac{\mathrm{d}v}{\mathrm{d}t}
|
|
118
|
+
= F(x,v) + \sqrt{2\,D(x,v)}\;\mathrm{d}W_t
|
|
119
|
+
|
|
120
|
+
Only positions :math:`x(t)` are observed; velocities are
|
|
121
|
+
reconstructed from finite differences.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# ------------------------------ public API ------------------------------ #
|
|
125
|
+
|
|
126
|
+
def infer_force_linear(
|
|
127
|
+
self,
|
|
128
|
+
basis: Basis,
|
|
129
|
+
*,
|
|
130
|
+
preset: str = "auto",
|
|
131
|
+
M_mode: str | None = None,
|
|
132
|
+
G_mode: str | None = None,
|
|
133
|
+
diffusion_method: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Infer F(x, v) as a linear combination of basis functions.
|
|
137
|
+
|
|
138
|
+
The estimator is configured by a ``preset`` that consolidates the
|
|
139
|
+
``(M_mode, G_mode, diffusion_method)`` surface into two broadly-validated
|
|
140
|
+
choices, with an ``auto`` switch keyed on measurement noise. Power users
|
|
141
|
+
can still pass any of the three modes explicitly to override the preset.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
preset : {'auto', 'robust', 'clean', 'legacy-clean-v1.0', 'legacy-noisy-v1.0'}, default 'auto'
|
|
146
|
+
* ``'robust'`` — ``symmetric`` / ``trapeze`` / ``noisy``. The
|
|
147
|
+
broadly applicable default: flat across measurement noise within
|
|
148
|
+
its feasible band and never catastrophic.
|
|
149
|
+
* ``'clean'`` — ``symmetric`` / ``trapeze`` / ``WeakNoise``. A
|
|
150
|
+
sharper refinement for verified noise-free data (helps mainly in
|
|
151
|
+
the coarse-dt, low-noise corner); fragile under measurement noise.
|
|
152
|
+
* ``'auto'`` — pick ``'robust'`` when measurement noise is detected and
|
|
153
|
+
``'clean'`` otherwise, on the sign of the localization-noise estimate
|
|
154
|
+
``Lambda_trace`` (``>0`` -> noise present). ``Lambda_trace`` is
|
|
155
|
+
reused from a prior :meth:`compute_diffusion_constant` call or
|
|
156
|
+
estimated inline. Validated on the ``vdp_uli_10x`` benchmark: the
|
|
157
|
+
zero-crossing tracks the ``noisy``<->``WeakNoise`` transition with no
|
|
158
|
+
harmful misroutes.
|
|
159
|
+
* ``'legacy-clean-v1.0'`` — ``early`` / ``rectangle`` / ``MSD``.
|
|
160
|
+
Reproduces the published SFI v1.0 clean-data convention
|
|
161
|
+
(explicit only; never selected by ``'auto'``).
|
|
162
|
+
* ``'legacy-noisy-v1.0'`` — ``symmetric`` / ``rectangle`` /
|
|
163
|
+
``noisy``. Reproduces the SFI v1.0 noisy-data convention
|
|
164
|
+
(explicit only).
|
|
165
|
+
basis : Basis
|
|
166
|
+
Rank-1 basis producing per-particle vectors.
|
|
167
|
+
Must accept v=... keyword in evaluation.
|
|
168
|
+
M_mode : str, optional
|
|
169
|
+
Override the preset's kinematics / moments convention:
|
|
170
|
+
'symmetric' | 'early' | 'anticipated'. ``None`` uses the preset.
|
|
171
|
+
G_mode : str, optional
|
|
172
|
+
Override the preset's Gram construction mode: 'rectangle' | 'trapeze'
|
|
173
|
+
| 'shift' | 'doubleshift' (each returned transposed). ``None`` uses
|
|
174
|
+
the preset.
|
|
175
|
+
diffusion_method : str, optional
|
|
176
|
+
Override the preset's instantaneous diffusion estimator used in the
|
|
177
|
+
ULI correction term: 'noisy' | 'WeakNoise' | 'MSD'. ``None`` uses the
|
|
178
|
+
preset (and re-enables the ``auto`` measurement-noise switch).
|
|
179
|
+
"""
|
|
180
|
+
# Legacy alias: M_mode='auto' historically meant 'symmetric'.
|
|
181
|
+
if M_mode == "auto":
|
|
182
|
+
M_mode = "symmetric"
|
|
183
|
+
|
|
184
|
+
# Resolve the mode triple from the preset. preset='auto' selects the
|
|
185
|
+
# diffusion estimator on the sign of Lambda_trace, which is set by the
|
|
186
|
+
# required prior compute_diffusion_constant() call (the force inference
|
|
187
|
+
# also needs its A_inv normalization). If absent, fall back to the safe
|
|
188
|
+
# 'robust' branch; the missing-diffusion error surfaces downstream.
|
|
189
|
+
noise_detected: bool | None = None
|
|
190
|
+
if preset == "auto" and diffusion_method is None:
|
|
191
|
+
lam = getattr(self, "Lambda_trace", None)
|
|
192
|
+
noise_detected = None if lam is None else (lam > 0)
|
|
193
|
+
(M_mode, G_mode, diffusion_method), resolved_preset = _resolve_force_linear_preset(
|
|
194
|
+
preset,
|
|
195
|
+
M_mode=M_mode,
|
|
196
|
+
G_mode=G_mode,
|
|
197
|
+
diffusion_method=diffusion_method,
|
|
198
|
+
noise_detected=noise_detected,
|
|
199
|
+
)
|
|
200
|
+
self.metadata["force_preset"] = preset
|
|
201
|
+
if preset == "auto":
|
|
202
|
+
logger.info(
|
|
203
|
+
"Auto force preset -> %s (M_mode=%s, G_mode=%s, diffusion_method=%s; "
|
|
204
|
+
"Lambda_trace sign: %s)",
|
|
205
|
+
resolved_preset, M_mode, G_mode, diffusion_method,
|
|
206
|
+
None if noise_detected is None else ("+" if noise_detected else "-"),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._validate_basis(basis, expected_rank=1, label="force basis")
|
|
210
|
+
|
|
211
|
+
self.force_basis = basis
|
|
212
|
+
self.__force_M_mode__ = M_mode
|
|
213
|
+
self.__force_G_mode__ = G_mode
|
|
214
|
+
self.__force_diffusion_method__ = diffusion_method
|
|
215
|
+
self.metadata["force_M_mode"] = M_mode
|
|
216
|
+
self.metadata["force_G_mode"] = G_mode
|
|
217
|
+
self.metadata["force_diffusion_method"] = diffusion_method
|
|
218
|
+
|
|
219
|
+
if hasattr(self, "force_G_full"):
|
|
220
|
+
raise RuntimeError("Force has already been inferred on this object - create a new instance to re-infer.")
|
|
221
|
+
|
|
222
|
+
# Structural arrays are exposed only for this evaluation and never
|
|
223
|
+
# persisted on the dataset (see ``_structural_scope``).
|
|
224
|
+
with self._structural_scope(self.force_basis):
|
|
225
|
+
self.force_G_full = self._force_G_matrix()
|
|
226
|
+
self.force_moments = self._force_moments()
|
|
227
|
+
|
|
228
|
+
# Solve and expose a scorer for further model selection.
|
|
229
|
+
self.force_scorer = SparseScorer(M=self.force_moments, G=self.force_G_full)
|
|
230
|
+
self._update_force_coefficients(self.force_scorer.total_C)
|
|
231
|
+
self.metadata["force_method"] = "linear"
|
|
232
|
+
|
|
233
|
+
def infer_diffusion_linear(
|
|
234
|
+
self,
|
|
235
|
+
basis: Basis,
|
|
236
|
+
*,
|
|
237
|
+
M_mode: str = "noisy",
|
|
238
|
+
G_mode: str = "rectangle",
|
|
239
|
+
) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Fit D(x, v) as a linear combination of basis functions.
|
|
242
|
+
"""
|
|
243
|
+
if M_mode == "auto":
|
|
244
|
+
M_mode = "noisy"
|
|
245
|
+
|
|
246
|
+
self.diffusion_basis = basis
|
|
247
|
+
self.__diffusion_M_mode__ = M_mode
|
|
248
|
+
self.__diffusion_G_mode__ = G_mode
|
|
249
|
+
self.metadata["diffusion_M_mode"] = M_mode
|
|
250
|
+
self.metadata["diffusion_G_mode"] = G_mode
|
|
251
|
+
|
|
252
|
+
if hasattr(self, "diffusion_moments"):
|
|
253
|
+
raise RuntimeError(
|
|
254
|
+
"Diffusion has already been inferred on this object - create a new instance to re-infer."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
with self._structural_scope(self.diffusion_basis):
|
|
258
|
+
self.diffusion_G_full = self._diffusion_G_matrix()
|
|
259
|
+
self.diffusion_moments = self._diffusion_moments()
|
|
260
|
+
|
|
261
|
+
self.diffusion_scorer = SparseScorer(M=self.diffusion_moments, G=self.diffusion_G_full)
|
|
262
|
+
self._update_diffusion_coefficients(self.diffusion_scorer.total_C)
|
|
263
|
+
|
|
264
|
+
def _estimate_lambda_trace(self) -> float:
|
|
265
|
+
"""Estimate the localization (measurement) noise Λ and cache its trace.
|
|
266
|
+
|
|
267
|
+
Sets ``self.Lambda`` (the ULI 'Lambda' instantaneous estimator,
|
|
268
|
+
time-averaged) and ``self.Lambda_trace = tr(Λ)`` and returns the trace.
|
|
269
|
+
``Lambda_trace > 0`` indicates measurement noise dominates the
|
|
270
|
+
velocity-increment autocorrelation; ``< 0`` indicates force persistence
|
|
271
|
+
dominates (clean dynamics). Shared by :meth:`compute_diffusion_constant`
|
|
272
|
+
and the ``preset='auto'`` switch in :meth:`infer_force_linear`.
|
|
273
|
+
"""
|
|
274
|
+
L_op = self.get_diffusion_timeop("Lambda")
|
|
275
|
+
L_prog = Integrand(times=[L_op], terms=[Term(eq="imn->imn", ops=(L_op.alias,))])
|
|
276
|
+
# Diffusion/noise are per-point quantities: combine per-point, not dt-weighted.
|
|
277
|
+
self.Lambda = integrate(
|
|
278
|
+
self.data, L_prog, reduce="mean", weight_by_dt=False,
|
|
279
|
+
chunk_target_bytes=self._chunk_target_bytes,
|
|
280
|
+
)
|
|
281
|
+
self.Lambda_trace = float(jnp.trace(self.Lambda))
|
|
282
|
+
logger.info("Measurement noise trace: %s", self.Lambda_trace)
|
|
283
|
+
return self.Lambda_trace
|
|
284
|
+
|
|
285
|
+
def compute_diffusion_constant(self, method: str = "auto") -> None:
|
|
286
|
+
"""
|
|
287
|
+
Underdamped constant diffusion inference (ULI estimators).
|
|
288
|
+
|
|
289
|
+
1) Estimate measurement noise Λ with method "Lambda".
|
|
290
|
+
2) If method == 'auto': choose 'noisy' if tr(Λ)>0 else 'WeakNoise'.
|
|
291
|
+
3) Average chosen instantaneous estimator to obtain D̄.
|
|
292
|
+
4) Set A = 2 D̄ and derive A_inv, sqrtA, sqrtA_inv.
|
|
293
|
+
"""
|
|
294
|
+
if hasattr(self, "diffusion_average"):
|
|
295
|
+
raise RuntimeError("Diffusion already computed; create a new inference object to recompute.")
|
|
296
|
+
|
|
297
|
+
# 1) measurement noise Λ
|
|
298
|
+
self._estimate_lambda_trace()
|
|
299
|
+
|
|
300
|
+
# 2) select instantaneous estimator
|
|
301
|
+
if method == "auto":
|
|
302
|
+
method = "noisy" if self.Lambda_trace > 0 else "WeakNoise"
|
|
303
|
+
self.metadata["diffusion_constant_method"] = method
|
|
304
|
+
|
|
305
|
+
# 3) time-average instantaneous diffusion
|
|
306
|
+
D_op = self.get_diffusion_timeop(method)
|
|
307
|
+
D_prog = Integrand(times=[D_op], terms=[Term(eq="imn->imn", ops=(D_op.alias,))])
|
|
308
|
+
self.diffusion_average = integrate(
|
|
309
|
+
self.data, D_prog, reduce="mean", weight_by_dt=False,
|
|
310
|
+
chunk_target_bytes=self._chunk_target_bytes,
|
|
311
|
+
)
|
|
312
|
+
self.diffusion_inferred = constant_array(self.diffusion_average)
|
|
313
|
+
|
|
314
|
+
# 4) normalization matrices
|
|
315
|
+
self.A = 2.0 * self.diffusion_average
|
|
316
|
+
self.A_inv = jnp.linalg.inv(self.A)
|
|
317
|
+
self.sqrtA = sqrtm_psd(self.A)
|
|
318
|
+
self.sqrtA_inv = jnp.linalg.inv(self.sqrtA)
|
|
319
|
+
|
|
320
|
+
def simulate_bootstrapped_trajectory(self, key, oversampling: int = 1, simulate: bool = True, dataset: int = 0):
|
|
321
|
+
"""
|
|
322
|
+
Simulate an underdamped Langevin trajectory using the inferred force and diffusion.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
key: JAX random key.
|
|
326
|
+
oversampling: number of integration substeps between saved frames.
|
|
327
|
+
simulate: if True, runs a simulation initialized from the first row of data;
|
|
328
|
+
if False, returns an uninitialized process object.
|
|
329
|
+
dataset: which experiment of a pooled fit to reproduce. The inferred model is
|
|
330
|
+
collapsed to this condition via :meth:`~SFI.statefunc.StateExpr.specialize`
|
|
331
|
+
(per-dataset parameters folded at ``dataset``); the resulting process is a
|
|
332
|
+
standalone single-condition model that does not read ``dataset_index``.
|
|
333
|
+
Defaults to 0.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
If simulate=True: (TrajectoryCollection, UnderdampedProcess)
|
|
337
|
+
Else: UnderdampedProcess
|
|
338
|
+
"""
|
|
339
|
+
from SFI.langevin import UnderdampedProcess
|
|
340
|
+
|
|
341
|
+
if not hasattr(self, "force_inferred"):
|
|
342
|
+
raise RuntimeError("Force not inferred. Call infer_force_linear() (or nonlinear later) first.")
|
|
343
|
+
|
|
344
|
+
# Collapse a (possibly pooled) fit to the chosen experiment: per-dataset
|
|
345
|
+
# parameters are folded at ``dataset`` and the reserved ``dataset_index``
|
|
346
|
+
# disappears, so the simulated model is standalone (no dataset concept).
|
|
347
|
+
force_k = self.force_inferred.specialize(dataset=int(dataset))
|
|
348
|
+
diff_k = self.diffusion_inferred.specialize(dataset=int(dataset))
|
|
349
|
+
bootstrapped_process = UnderdampedProcess(force_k._psf, diff_k._psf)
|
|
350
|
+
bootstrapped_process.set_params(theta_F=force_k.params, theta_D=diff_k.params)
|
|
351
|
+
|
|
352
|
+
if not simulate:
|
|
353
|
+
# Just return the process, leave extras and state uninitialized
|
|
354
|
+
return bootstrapped_process
|
|
355
|
+
|
|
356
|
+
ds = self.data.datasets[int(dataset)]
|
|
357
|
+
bootstrapped_process.set_extras(extras_global=ds.extras_global, extras_local=ds.extras_local)
|
|
358
|
+
# Only the particle count is framework-supplied; the specialized
|
|
359
|
+
# force does not read ``dataset_index``.
|
|
360
|
+
bootstrapped_process._reserved_overrides = {
|
|
361
|
+
"particle_index": jnp.arange(int(ds.N)),
|
|
362
|
+
}
|
|
363
|
+
# Initialize from first available row; velocity from dX/dt as requested.
|
|
364
|
+
from SFI.trajectory import TrajectoryCollection
|
|
365
|
+
|
|
366
|
+
start_config = TrajectoryCollection.from_dataset(ds).peek_row(require={"X", "dX", "dt"})
|
|
367
|
+
x0 = start_config["X"]
|
|
368
|
+
v0 = start_config["dX"] / start_config["dt"]
|
|
369
|
+
|
|
370
|
+
bootstrapped_process.initialize(x0, v0=v0)
|
|
371
|
+
|
|
372
|
+
data_bootstrap = bootstrapped_process.simulate(
|
|
373
|
+
dt=start_config["dt"],
|
|
374
|
+
Nsteps=ds.T,
|
|
375
|
+
key=key,
|
|
376
|
+
prerun=0,
|
|
377
|
+
oversampling=oversampling,
|
|
378
|
+
)
|
|
379
|
+
return data_bootstrap, bootstrapped_process
|
|
380
|
+
|
|
381
|
+
# ------------------------------------------------------------------ #
|
|
382
|
+
# Parametric — underdamped parametric windowed inference
|
|
383
|
+
# ------------------------------------------------------------------ #
|
|
384
|
+
|
|
385
|
+
def infer_force(
|
|
386
|
+
self,
|
|
387
|
+
F,
|
|
388
|
+
theta0=None,
|
|
389
|
+
*,
|
|
390
|
+
D=None,
|
|
391
|
+
Lambda=None,
|
|
392
|
+
integrator: str = "rk4",
|
|
393
|
+
n_substeps: int = 1,
|
|
394
|
+
inner: str = "auto",
|
|
395
|
+
eiv="auto",
|
|
396
|
+
max_outer: int = 5,
|
|
397
|
+
inner_maxiter: int = 80,
|
|
398
|
+
) -> None:
|
|
399
|
+
r"""Infer force F(x,v) with the minimal parametric estimator.
|
|
400
|
+
|
|
401
|
+
Built on ``SFI.inference.parametric_core``: the unobserved velocity
|
|
402
|
+
is resolved by shooting through a single RK4 phase-space step, the
|
|
403
|
+
3-point residual covariance is pentadiagonal (bandwidth 2), and the
|
|
404
|
+
parameters are found by direct Gauss–Newton (linear-in-θ ``Basis``,
|
|
405
|
+
with the skip-trick errors-in-variables instrument) or
|
|
406
|
+
frozen-precision L-BFGS (nonlinear-in-θ ``PSF``). ``(D, Λ)`` are
|
|
407
|
+
profiled natively: moment-estimator init, then one windowed
|
|
408
|
+
conditional-NLL refinement at the fitted θ.
|
|
409
|
+
|
|
410
|
+
.. physics:: Parametric windowed force inference (underdamped)
|
|
411
|
+
:label: parametric-force-underdamped
|
|
412
|
+
:category: Inference
|
|
413
|
+
|
|
414
|
+
The phase-space dynamics
|
|
415
|
+
:math:`\dot{x}=v,\;\mathrm{d}v = F(x,v)\mathrm{d}t
|
|
416
|
+
+ \sqrt{2D}\,\mathrm{d}W` are factored into deterministic
|
|
417
|
+
flow + noise. Three-point shooting residuals follow a
|
|
418
|
+
pentadiagonal Gaussian whose local precision weights the
|
|
419
|
+
Gauss–Newton normal equations; the process noise enters at
|
|
420
|
+
:math:`\Delta t^3`. Under measurement noise the left factor
|
|
421
|
+
is the η-clean *skip* instrument built from the lagged clean
|
|
422
|
+
position pair (``eiv=True``) — consistent where the naive
|
|
423
|
+
MLE is velocity-EIV-biased.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
F : PSF (``needs_v=True``) or Basis
|
|
428
|
+
Parametric force model. A ``Basis`` is converted to a PSF
|
|
429
|
+
internally (coefficients initialised to zero) and PASTIS
|
|
430
|
+
sparsification is enabled; it runs the fast direct-GN path.
|
|
431
|
+
theta0 : dict, array, or None
|
|
432
|
+
Initial force parameters (default: zeros).
|
|
433
|
+
D : array (d, d), optional
|
|
434
|
+
Fixed diffusion matrix. If both ``D`` and ``Lambda``
|
|
435
|
+
are given, noise profiling is skipped entirely (fast path).
|
|
436
|
+
Lambda : array ``(d, d)``, optional
|
|
437
|
+
Fixed measurement-noise covariance.
|
|
438
|
+
integrator : {"rk4", "euler"}
|
|
439
|
+
Flow predictor (default ``"rk4"``, a single 4th-order step).
|
|
440
|
+
A single ``"euler"`` step cannot carry the force into the
|
|
441
|
+
position update, so the skip-trick is auto-disabled (with a
|
|
442
|
+
warning) for ``integrator="euler", n_substeps=1``.
|
|
443
|
+
n_substeps : int
|
|
444
|
+
Integrator micro-steps per observation interval (default 1 —
|
|
445
|
+
the single-step minimal estimator).
|
|
446
|
+
inner : {"auto", "gn", "lbfgs"}
|
|
447
|
+
Inner solver. ``"auto"`` → direct Gauss–Newton for a linear
|
|
448
|
+
``Basis``, L-BFGS for a ``PSF``.
|
|
449
|
+
eiv : {"auto", True, False, float}
|
|
450
|
+
Measurement-noise errors-in-variables instrument. ``"auto"``
|
|
451
|
+
(default) → ``True`` for all models (interacting models use
|
|
452
|
+
the same N-body flow for the instrument as for the residual);
|
|
453
|
+
``True`` forces the η-clean skip instrument; ``False`` is the
|
|
454
|
+
plain MLE; a float in ``[0, 1]`` blends. GN path only.
|
|
455
|
+
max_outer : int
|
|
456
|
+
Outer Gauss–Newton / IRLS iterations (default 5).
|
|
457
|
+
inner_maxiter : int
|
|
458
|
+
Inner L-BFGS iterations per outer step on the PSF path
|
|
459
|
+
(default 80; raise for large nonlinear families, e.g. NNs).
|
|
460
|
+
|
|
461
|
+
Updates
|
|
462
|
+
-------
|
|
463
|
+
Sets standard ``force_*`` attributes: ``force_psf``,
|
|
464
|
+
``force_G``, ``force_G_pinv``, ``force_coefficients_full``,
|
|
465
|
+
``force_coefficients``, ``force_support``, ``force_moments``,
|
|
466
|
+
``force_inferred``, ``diffusion_average``, ``A``, ``A_inv``,
|
|
467
|
+
``Lambda``.
|
|
468
|
+
|
|
469
|
+
When ``F`` is a ``Basis``, additionally sets
|
|
470
|
+
``force_basis``, ``force_G_full``, and ``force_scorer``
|
|
471
|
+
so that ``sparsify_force()`` can be called afterwards.
|
|
472
|
+
|
|
473
|
+
See Also
|
|
474
|
+
--------
|
|
475
|
+
:ref:`parametric-concept` : Mathematical foundations.
|
|
476
|
+
:ref:`parametric-algorithm` : Detailed algorithm description.
|
|
477
|
+
"""
|
|
478
|
+
import time as _time
|
|
479
|
+
|
|
480
|
+
from SFI.inference.parametric_core.solve import _as_psf, solve_force_ud
|
|
481
|
+
from SFI.statefunc import SF
|
|
482
|
+
from SFI.statefunc.basis import Basis
|
|
483
|
+
|
|
484
|
+
from .result import InferenceResultSF
|
|
485
|
+
|
|
486
|
+
if hasattr(self, "force_inferred"):
|
|
487
|
+
raise RuntimeError("Force inference has already been run on this object.")
|
|
488
|
+
|
|
489
|
+
if integrator not in ("rk4", "euler"):
|
|
490
|
+
raise ValueError(f"integrator must be 'rk4' or 'euler', got {integrator!r}")
|
|
491
|
+
|
|
492
|
+
# ── Accept Basis → convert to PSF, enable PASTIS ──
|
|
493
|
+
basis_mode = isinstance(F, Basis)
|
|
494
|
+
if basis_mode:
|
|
495
|
+
self.force_basis = F
|
|
496
|
+
elif not (hasattr(F, "flatten_params") and hasattr(F, "unflatten_params")):
|
|
497
|
+
raise TypeError("F must be a PSF or a Basis. Got %s." % type(F).__name__)
|
|
498
|
+
F_psf = _as_psf(F)
|
|
499
|
+
|
|
500
|
+
# Structural arrays (interaction lists, etc.) are exposed only for this
|
|
501
|
+
# parametric solve and never persisted (see ``_structural_scope``).
|
|
502
|
+
with self._structural_scope(F_psf):
|
|
503
|
+
dt_val = float(self.data.peek_row(require={"dt"})["dt"])
|
|
504
|
+
|
|
505
|
+
logger.info(
|
|
506
|
+
"[uli_parametric] UD minimal parametric solve (n_params=%d, dt=%.4g, %s·n%d, basis_mode=%s)...",
|
|
507
|
+
int(F_psf.template.size), dt_val, integrator, n_substeps, basis_mode,
|
|
508
|
+
)
|
|
509
|
+
t0 = _time.perf_counter()
|
|
510
|
+
|
|
511
|
+
res = solve_force_ud(
|
|
512
|
+
self.data, F, theta0=theta0, D=D, Lambda=Lambda,
|
|
513
|
+
integrator=integrator, n_substeps=n_substeps,
|
|
514
|
+
inner=inner, eiv=eiv, max_outer=max_outer,
|
|
515
|
+
inner_maxiter=inner_maxiter,
|
|
516
|
+
)
|
|
517
|
+
t_elapsed = _time.perf_counter() - t0
|
|
518
|
+
|
|
519
|
+
# ── Standard force attributes ──
|
|
520
|
+
theta_flat = res.theta
|
|
521
|
+
G = res.G
|
|
522
|
+
|
|
523
|
+
self.force_psf = F_psf
|
|
524
|
+
self.diffusion_average = res.D
|
|
525
|
+
# Callable constant-D field, so the full result surface (incl.
|
|
526
|
+
# simulate_bootstrapped_trajectory) works without a separate
|
|
527
|
+
# compute_diffusion_constant() call; a later infer_diffusion()
|
|
528
|
+
# overwrites it with the state-dependent fit.
|
|
529
|
+
self.diffusion_inferred = constant_array(res.D)
|
|
530
|
+
self.A = 2 * res.D
|
|
531
|
+
self.A_inv = jnp.linalg.inv(self.A)
|
|
532
|
+
self.Lambda = res.Lambda
|
|
533
|
+
self.Lambda_trace = float(jnp.trace(res.Lambda))
|
|
534
|
+
|
|
535
|
+
self.force_G = G
|
|
536
|
+
# Parameter covariance from the solver: the sandwich G⁻¹HG⁻ᵀ on the
|
|
537
|
+
# IV (eiv) path, the inverse information on the symmetric path.
|
|
538
|
+
G_inv = None
|
|
539
|
+
if bool(jnp.all(jnp.isfinite(res.theta_cov))):
|
|
540
|
+
G_inv = res.theta_cov
|
|
541
|
+
self.force_G_pinv = G_inv
|
|
542
|
+
|
|
543
|
+
self.force_coefficients_full = theta_flat
|
|
544
|
+
self.force_coefficients = theta_flat
|
|
545
|
+
self.force_support = jnp.arange(theta_flat.size)
|
|
546
|
+
self.force_moments = G @ theta_flat
|
|
547
|
+
|
|
548
|
+
# ── PASTIS plumbing (Basis mode only) ──
|
|
549
|
+
if basis_mode:
|
|
550
|
+
from SFI.inference.sparse import SparseScorer
|
|
551
|
+
|
|
552
|
+
self.force_G_full = G
|
|
553
|
+
self.force_scorer = SparseScorer(
|
|
554
|
+
M=self.force_moments,
|
|
555
|
+
G=self.force_G_full,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
sf_F = SF(F_psf, F_psf.unflatten_params(theta_flat))
|
|
559
|
+
beta = float(jnp.trace(res.Lambda) / (jnp.trace(res.D) * dt_val + 1e-30))
|
|
560
|
+
|
|
561
|
+
meta_F = dict(
|
|
562
|
+
kind="force",
|
|
563
|
+
inference="parametric",
|
|
564
|
+
n_params=int(theta_flat.size),
|
|
565
|
+
beta=beta,
|
|
566
|
+
A_inv=jnp.asarray(self.A_inv),
|
|
567
|
+
)
|
|
568
|
+
self.force_inferred = InferenceResultSF(
|
|
569
|
+
sf_F,
|
|
570
|
+
param_cov=G_inv,
|
|
571
|
+
meta=meta_F,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
self.metadata["force_method"] = "parametric"
|
|
575
|
+
self.metadata["force_parametric_info"] = {
|
|
576
|
+
**res.info,
|
|
577
|
+
"integrator": integrator,
|
|
578
|
+
"beta": beta,
|
|
579
|
+
"D_matrix": res.D,
|
|
580
|
+
"Lambda": res.Lambda,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
logger.info(
|
|
584
|
+
"[uli_parametric] Done in %.1f s. Λ=%s, D=%s, β=%.3f",
|
|
585
|
+
t_elapsed,
|
|
586
|
+
jnp.diag(res.Lambda),
|
|
587
|
+
jnp.diag(res.D),
|
|
588
|
+
beta,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# ── State-dependent diffusion regression ──
|
|
592
|
+
|
|
593
|
+
def infer_diffusion(
|
|
594
|
+
self,
|
|
595
|
+
basis=None,
|
|
596
|
+
*,
|
|
597
|
+
theta_D0=None,
|
|
598
|
+
integrator: str = "rk4",
|
|
599
|
+
n_substeps: int = 1,
|
|
600
|
+
maxiter: int = 100,
|
|
601
|
+
) -> None:
|
|
602
|
+
r"""Infer state- and velocity-dependent diffusion D(x, v).
|
|
603
|
+
|
|
604
|
+
Requires a prior parametric :meth:`infer_force` call. Holds the
|
|
605
|
+
fitted force fixed and minimises the pentadiagonal windowed
|
|
606
|
+
conditional NLL over the diffusion parameters, with ``D(x, v;
|
|
607
|
+
θ_D)`` evaluated at the shooting velocity.
|
|
608
|
+
|
|
609
|
+
.. physics:: State-dependent diffusion inference (underdamped)
|
|
610
|
+
:label: parametric-diffusion-underdamped
|
|
611
|
+
:category: Inference
|
|
612
|
+
|
|
613
|
+
With the force :math:`\hat F(x,v)` held fixed, the
|
|
614
|
+
state-dependent diffusion :math:`D(x,v;\theta_D)` is optimised
|
|
615
|
+
by minimising the windowed conditional NLL on pentadiagonal
|
|
616
|
+
(bandwidth-2) covariance windows; :math:`\Lambda` from the
|
|
617
|
+
force inference is held fixed.
|
|
618
|
+
|
|
619
|
+
Parameters
|
|
620
|
+
----------
|
|
621
|
+
basis : Basis (rank 2), PSF, or None
|
|
622
|
+
Diffusion model. A rank-2 ``Basis`` gives the linear
|
|
623
|
+
parameterisation ``D = Σ_j θ_j d_j``; a ``PSF`` (optionally
|
|
624
|
+
``needs_v=True``) is used directly. When ``None`` (default),
|
|
625
|
+
``symmetric_matrix_basis(d)`` — all constant symmetric
|
|
626
|
+
``d×d`` diffusion matrices.
|
|
627
|
+
theta_D0 : dict, array, or None
|
|
628
|
+
Initial diffusion parameters (default zeros).
|
|
629
|
+
integrator : {"rk4", "euler"}
|
|
630
|
+
Flow predictor (default ``"rk4"``, matching :meth:`infer_force`).
|
|
631
|
+
n_substeps : int
|
|
632
|
+
Integrator micro-steps per Δt (default 1).
|
|
633
|
+
maxiter : int
|
|
634
|
+
L-BFGS maximum iterations (default 100).
|
|
635
|
+
|
|
636
|
+
Updates
|
|
637
|
+
-------
|
|
638
|
+
Sets ``diffusion_inferred``, ``diffusion_coefficients`` (and
|
|
639
|
+
``diffusion_basis`` when ``basis`` is a ``Basis``), plus metadata.
|
|
640
|
+
|
|
641
|
+
See Also
|
|
642
|
+
--------
|
|
643
|
+
:ref:`parametric-algorithm` : Full algorithm description.
|
|
644
|
+
"""
|
|
645
|
+
from SFI.bases import symmetric_matrix_basis
|
|
646
|
+
from SFI.inference.parametric_core.solve import _as_psf, solve_diffusion_ud
|
|
647
|
+
|
|
648
|
+
if not hasattr(self, "force_inferred"):
|
|
649
|
+
raise RuntimeError("infer_diffusion() requires a prior infer_force() call.")
|
|
650
|
+
if self.metadata.get("force_method") not in ("parametric", "parametric_core"):
|
|
651
|
+
raise RuntimeError(
|
|
652
|
+
"infer_diffusion() requires parametric force inference (call infer_force(), not infer_force_linear)."
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
from SFI.statefunc import SF
|
|
656
|
+
from SFI.statefunc.basis import Basis
|
|
657
|
+
|
|
658
|
+
d = self.data.datasets[0].X.shape[-1]
|
|
659
|
+
if basis is None:
|
|
660
|
+
basis = symmetric_matrix_basis(d)
|
|
661
|
+
if isinstance(basis, Basis):
|
|
662
|
+
self._validate_basis(basis, expected_rank=2, label="diffusion basis")
|
|
663
|
+
self.diffusion_basis = basis
|
|
664
|
+
D_psf = _as_psf(basis)
|
|
665
|
+
Lambda = getattr(self, "Lambda", None)
|
|
666
|
+
if Lambda is None:
|
|
667
|
+
Lambda = jnp.zeros((d, d))
|
|
668
|
+
|
|
669
|
+
res = solve_diffusion_ud(
|
|
670
|
+
self.data, self.force_psf, self.force_coefficients_full, D_psf,
|
|
671
|
+
Lambda=Lambda, theta_D0=theta_D0,
|
|
672
|
+
n_substeps=n_substeps, integrator=integrator, maxiter=maxiter,
|
|
673
|
+
)
|
|
674
|
+
theta_D = res.theta_D
|
|
675
|
+
self.diffusion_coefficients = theta_D
|
|
676
|
+
self.diffusion_coefficients_full = theta_D
|
|
677
|
+
sf_D = SF(D_psf, D_psf.unflatten_params(theta_D))
|
|
678
|
+
meta_D = dict(
|
|
679
|
+
kind="diffusion",
|
|
680
|
+
inference="parametric",
|
|
681
|
+
n_params=int(D_psf.template.size),
|
|
682
|
+
nll=res.info["nll"],
|
|
683
|
+
)
|
|
684
|
+
self.diffusion_inferred = InferenceResultSF(
|
|
685
|
+
sf_D,
|
|
686
|
+
param_cov=None,
|
|
687
|
+
meta=meta_D,
|
|
688
|
+
)
|
|
689
|
+
self.metadata["diffusion_method"] = "parametric"
|
|
690
|
+
self.metadata["diffusion_parametric_info"] = dict(res.info)
|
|
691
|
+
|
|
692
|
+
def _force_G_matrix(self) -> jnp.ndarray:
|
|
693
|
+
if not hasattr(self, "A_inv"):
|
|
694
|
+
raise RuntimeError("A_inv not available. Compute diffusion first.")
|
|
695
|
+
b_left = self.force_basis
|
|
696
|
+
b_right = self.force_basis @ self.A_inv
|
|
697
|
+
return self.__G_matrix__(b_left, b_right, self.__force_G_mode__, "ima,imb->iab")
|
|
698
|
+
|
|
699
|
+
def _force_moments(self) -> jnp.ndarray:
|
|
700
|
+
r"""
|
|
701
|
+
ULI force moments expressed via Integrand.
|
|
702
|
+
|
|
703
|
+
With reconstructed kinematics (x̂, v̂, â):
|
|
704
|
+
|
|
705
|
+
M_a = < â · A_inv · b_a(x̂, v̂) > + w * < - D_inst : (A_inv · ∂_v b_a) >
|
|
706
|
+
|
|
707
|
+
where:
|
|
708
|
+
- b_a is the vector basis (i, dim, a)
|
|
709
|
+
- ∂_v b_a is the velocity-gradient basis (i, dim, dim, a)
|
|
710
|
+
- w = (1 + 2 l)/3 with l=1 (symmetric), l=0 (early), l=-1/2 (anticipated)
|
|
711
|
+
and the anticipated mode uses w=0 (no gradient correction).
|
|
712
|
+
|
|
713
|
+
.. physics:: Underdamped force moments (ULI)
|
|
714
|
+
:label: force-moments-underdamped
|
|
715
|
+
:category: Inference
|
|
716
|
+
|
|
717
|
+
.. math::
|
|
718
|
+
|
|
719
|
+
M_a = \bigl\langle \hat a_t \cdot A^{-1} \cdot b_a(\hat x_t, \hat v_t)
|
|
720
|
+
\bigr\rangle
|
|
721
|
+
\;+\; w \,\bigl\langle -D_{\text{inst}} : (A^{-1}\cdot\partial_v b_a)
|
|
722
|
+
\bigr\rangle
|
|
723
|
+
|
|
724
|
+
where :math:`w = (1+2\ell)/3`, with :math:`\ell=1` (symmetric),
|
|
725
|
+
:math:`\ell=0` (early), :math:`\ell=-\tfrac{1}{2}` (anticipated).
|
|
726
|
+
|
|
727
|
+
Contractions
|
|
728
|
+
------------
|
|
729
|
+
- phi term:
|
|
730
|
+
eq='im,mn,ina->ia' with ops (â, A_inv, b)
|
|
731
|
+
|
|
732
|
+
- correction term:
|
|
733
|
+
eq='imn,no,imoa->ia' with ops (D_inst, A_inv, ∂_v b)
|
|
734
|
+
(the third axis of ∂_v b is the derivative index; we label it 'o' above).
|
|
735
|
+
"""
|
|
736
|
+
if not hasattr(self, "A_inv"):
|
|
737
|
+
raise RuntimeError("A_inv not available. Compute diffusion first.")
|
|
738
|
+
|
|
739
|
+
mode = getattr(self, "__force_M_mode__", "symmetric")
|
|
740
|
+
if mode not in ("symmetric", "early", "anticipated"):
|
|
741
|
+
raise KeyError(f"Unknown __force_M_mode__: {mode}")
|
|
742
|
+
|
|
743
|
+
x_hat = self._get_x_hat(M_mode=mode)
|
|
744
|
+
v_hat = self._get_v_hat(M_mode=mode)
|
|
745
|
+
a_hat = self._get_a_hat(M_mode=mode)
|
|
746
|
+
|
|
747
|
+
# Main moment term: < â · A_inv · b >
|
|
748
|
+
A = ConstOperand(self.A_inv, alias="A")
|
|
749
|
+
a_op = TimeOperand(a_hat, alias="a")
|
|
750
|
+
B = ExprOperand(expr=self.force_basis, x=x_hat, v=v_hat, alias="B")
|
|
751
|
+
|
|
752
|
+
prog_phi = Integrand(
|
|
753
|
+
exprs=[B],
|
|
754
|
+
times=[a_op],
|
|
755
|
+
consts=[A],
|
|
756
|
+
terms=[Term(eq="im,mn,ina->ia", ops=("a", "A", "B"))],
|
|
757
|
+
)
|
|
758
|
+
phi_moments = integrate(self.data, prog_phi, reduce="sum", chunk_target_bytes=self._chunk_target_bytes)
|
|
759
|
+
self._phi_moments = phi_moments
|
|
760
|
+
# Determine w prefactor
|
|
761
|
+
if mode == "symmetric":
|
|
762
|
+
lam = 1.0
|
|
763
|
+
elif mode == "early":
|
|
764
|
+
lam = 0.0
|
|
765
|
+
else: # anticipated
|
|
766
|
+
lam = -0.5
|
|
767
|
+
w_pref = (1.0 + 2.0 * lam) / 3.0
|
|
768
|
+
|
|
769
|
+
# In anticipated mode, w_pref==0 and we skip the correction term.
|
|
770
|
+
if w_pref == 0.0:
|
|
771
|
+
return phi_moments
|
|
772
|
+
|
|
773
|
+
logger.debug("Computing ULI correction term (D_inst × grad_v basis).")
|
|
774
|
+
|
|
775
|
+
D_op = self.get_diffusion_timeop(getattr(self, "__force_diffusion_method__", "noisy"))
|
|
776
|
+
# For pair-dispatched bases (pdepth>=1), the Ito correction needs
|
|
777
|
+
# the same-particle velocity Jacobian ∂f_i/∂v_i (not cross ∂f_i/∂v_j).
|
|
778
|
+
_same = getattr(self.force_basis, "pdepth", 0) >= 1
|
|
779
|
+
Gv = ExprOperand(
|
|
780
|
+
expr=self.force_basis.d_v(same_particle=_same),
|
|
781
|
+
x=x_hat,
|
|
782
|
+
v=v_hat,
|
|
783
|
+
alias="Gv",
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
prog_w = Integrand(
|
|
787
|
+
exprs=[Gv],
|
|
788
|
+
times=[D_op],
|
|
789
|
+
consts=[A],
|
|
790
|
+
terms=[
|
|
791
|
+
Term(
|
|
792
|
+
eq="imn,no,imoa->ia",
|
|
793
|
+
ops=(D_op.alias, "A", "Gv"),
|
|
794
|
+
scale=-float(w_pref),
|
|
795
|
+
)
|
|
796
|
+
],
|
|
797
|
+
)
|
|
798
|
+
w_moments = integrate(self.data, prog_w, reduce="sum", chunk_target_bytes=self._chunk_target_bytes)
|
|
799
|
+
self._w_moments = w_moments
|
|
800
|
+
|
|
801
|
+
return phi_moments + w_moments
|
|
802
|
+
|
|
803
|
+
def _diffusion_G_matrix(self) -> jnp.ndarray:
|
|
804
|
+
# Diffusion is per-point: weight the projection per-point (weight_by_dt=False).
|
|
805
|
+
return self.__G_matrix__(
|
|
806
|
+
self.diffusion_basis,
|
|
807
|
+
self.diffusion_basis,
|
|
808
|
+
self.__diffusion_G_mode__,
|
|
809
|
+
"imna,imnb->iab",
|
|
810
|
+
weight_by_dt=False,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def _diffusion_moments(self) -> jnp.ndarray:
|
|
814
|
+
"""
|
|
815
|
+
ULI diffusion linear moments.
|
|
816
|
+
|
|
817
|
+
For each method, define kinematics (X_t, V_t) and use the corresponding instantaneous
|
|
818
|
+
diffusion estimator D_inst(t):
|
|
819
|
+
|
|
820
|
+
noisy:
|
|
821
|
+
X_t = 0.25*(X + X_minus + X_plus + X_plusplus)
|
|
822
|
+
V_t = (4 dX + dX_plus + dX_minus) / (6 dt)
|
|
823
|
+
MSD:
|
|
824
|
+
X_t = (X + X_minus + X_plus)/3
|
|
825
|
+
V_t = (dX + dX_minus)/(2 dt)
|
|
826
|
+
WeakNoise:
|
|
827
|
+
X_t = (X + X_plus)/2
|
|
828
|
+
V_t = dX/dt
|
|
829
|
+
|
|
830
|
+
Then:
|
|
831
|
+
M_a = < B_a(X_t, V_t) : D_inst(t) >
|
|
832
|
+
= sum_t sum_i einsum('imna,imn->ia', B, D_inst)
|
|
833
|
+
"""
|
|
834
|
+
method = getattr(self, "__diffusion_M_mode__", None)
|
|
835
|
+
if method is None:
|
|
836
|
+
raise RuntimeError("Missing __diffusion_M_mode__. Call infer_diffusion_linear() first.")
|
|
837
|
+
|
|
838
|
+
if method == "noisy":
|
|
839
|
+
x_hat = _X_noisy_uli
|
|
840
|
+
v_hat = _V_noisy_uli
|
|
841
|
+
elif method == "MSD":
|
|
842
|
+
# Reuse the symmetric ULI reconstruction
|
|
843
|
+
x_hat = _X_sym_uli
|
|
844
|
+
v_hat = _V_sym_uli
|
|
845
|
+
elif method == "WeakNoise":
|
|
846
|
+
x_hat = _X_weaknoise_uli
|
|
847
|
+
v_hat = velocity("dX", "dt")
|
|
848
|
+
else:
|
|
849
|
+
raise KeyError(f"Unknown underdamped diffusion moment method: {method!r}")
|
|
850
|
+
|
|
851
|
+
logger.debug("Computing diffusion linear moments (method=%s).", method)
|
|
852
|
+
|
|
853
|
+
# Instantaneous diffusion estimator matching the method string
|
|
854
|
+
D_op = self.get_diffusion_timeop(method)
|
|
855
|
+
|
|
856
|
+
# Evaluate diffusion basis on (x_hat, v_hat)
|
|
857
|
+
B = ExprOperand(expr=self.diffusion_basis, x=x_hat, v=v_hat, alias="B")
|
|
858
|
+
|
|
859
|
+
prog = Integrand(
|
|
860
|
+
exprs=[B],
|
|
861
|
+
times=[D_op],
|
|
862
|
+
terms=[Term(eq="imna,imn->ia", ops=("B", D_op.alias))],
|
|
863
|
+
)
|
|
864
|
+
# Per-point (weight_by_dt=False): each increment's diffusion estimate counts equally.
|
|
865
|
+
return integrate(self.data, prog, reduce="sum", weight_by_dt=False,
|
|
866
|
+
chunk_target_bytes=self._chunk_target_bytes)
|
|
867
|
+
|
|
868
|
+
def _update_force_inferred(self) -> None:
|
|
869
|
+
"""
|
|
870
|
+
Materialize fitted force as SF and wrap in InferenceResultSF.
|
|
871
|
+
"""
|
|
872
|
+
P: PSF = self.force_basis.to_psf()
|
|
873
|
+
theta = {"coeff": jnp.asarray(self.force_coefficients_full)}
|
|
874
|
+
sf = SF(P, theta)
|
|
875
|
+
|
|
876
|
+
meta = dict(
|
|
877
|
+
kind="force",
|
|
878
|
+
modes=dict(
|
|
879
|
+
M=getattr(self, "__force_M_mode__", None),
|
|
880
|
+
G=getattr(self, "__force_G_mode__", None),
|
|
881
|
+
),
|
|
882
|
+
diffusion_method=getattr(self, "__force_diffusion_method__", None),
|
|
883
|
+
A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
|
|
884
|
+
basis_features=int(getattr(self.force_basis, "n_features", 0)),
|
|
885
|
+
basis_labels=getattr(self.force_basis, "labels", None),
|
|
886
|
+
)
|
|
887
|
+
cov = getattr(self, "force_coefficients_covariance", None)
|
|
888
|
+
self.force_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
|
|
889
|
+
|
|
890
|
+
def _update_diffusion_inferred(self) -> None:
|
|
891
|
+
"""
|
|
892
|
+
Materialize fitted diffusion as SF and wrap in InferenceResultSF.
|
|
893
|
+
"""
|
|
894
|
+
if self.diffusion_basis is None:
|
|
895
|
+
raise RuntimeError("_update_diffusion_inferred called before diffusion was fitted.")
|
|
896
|
+
P: PSF = self.diffusion_basis.to_psf()
|
|
897
|
+
theta = {"coeff": jnp.asarray(self.diffusion_coefficients_full)}
|
|
898
|
+
sf = SF(P, theta)
|
|
899
|
+
|
|
900
|
+
meta = dict(
|
|
901
|
+
kind="diffusion",
|
|
902
|
+
modes=dict(
|
|
903
|
+
M=getattr(self, "__diffusion_M_mode__", None),
|
|
904
|
+
G=getattr(self, "__diffusion_G_mode__", None),
|
|
905
|
+
),
|
|
906
|
+
A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
|
|
907
|
+
basis_features=int(getattr(self.diffusion_basis, "n_features", 0)),
|
|
908
|
+
basis_labels=getattr(self.diffusion_basis, "labels", None),
|
|
909
|
+
)
|
|
910
|
+
cov = getattr(self, "diffusion_coefficients_cov", None)
|
|
911
|
+
self.diffusion_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
|
|
912
|
+
|
|
913
|
+
# ------------------------------ shared helpers ------------------------------ #
|
|
914
|
+
|
|
915
|
+
def __G_matrix__(
|
|
916
|
+
self,
|
|
917
|
+
b_left: callable,
|
|
918
|
+
b_right: callable,
|
|
919
|
+
G_mode: str,
|
|
920
|
+
einsum_string: str,
|
|
921
|
+
subsampling: int = 1,
|
|
922
|
+
weight_by_dt: bool = True,
|
|
923
|
+
) -> jnp.ndarray:
|
|
924
|
+
"""
|
|
925
|
+
ULI Gram matrix.
|
|
926
|
+
|
|
927
|
+
Uses secant velocities from raw displacements:
|
|
928
|
+
V(t) = dX/dt
|
|
929
|
+
V_minus(t)= dX_minus/dt
|
|
930
|
+
V_plus(t) = dX_plus/dt
|
|
931
|
+
|
|
932
|
+
Modes — the returned Gram is the transpose Gᵀ of the tabulated outer
|
|
933
|
+
product. The averaging is asymmetric in the left/right factors, and
|
|
934
|
+
grid assessments (VdP, OU) show Gᵀ is the better-conditioned regressor,
|
|
935
|
+
so it is the canonical form:
|
|
936
|
+
|
|
937
|
+
rectangle: < bL(X, V) ⊗ bR(X, V) >
|
|
938
|
+
shift: < bL(X, V) ⊗ bR(X_minus, V_minus) >
|
|
939
|
+
trapeze: < bL(X, V) ⊗ 0.5*(bR(X, V) + bR(X_minus, V_minus)) >
|
|
940
|
+
doubleshift: < bL(X_plus, V_plus) ⊗ bR(X_minus, V_minus) >
|
|
941
|
+
"""
|
|
942
|
+
logger.debug(
|
|
943
|
+
"Computing ULI G matrix (mode=%s) with einsum: %s",
|
|
944
|
+
G_mode,
|
|
945
|
+
einsum_string,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# Rectangle program
|
|
949
|
+
BL = ExprOperand(expr=b_left, x=stream("X"), v=velocity("dX", "dt"), alias="BL")
|
|
950
|
+
BR0 = ExprOperand(expr=b_right, x=stream("X"), v=velocity("dX", "dt"), alias="BR0")
|
|
951
|
+
prog_rect = Integrand(exprs=[BL, BR0], terms=[Term(eq=einsum_string, ops=("BL", "BR0"))])
|
|
952
|
+
|
|
953
|
+
if G_mode == "rectangle":
|
|
954
|
+
prog = prog_rect
|
|
955
|
+
|
|
956
|
+
elif G_mode in ("trapeze", "shift", "doubleshift"):
|
|
957
|
+
BRm = ExprOperand(
|
|
958
|
+
expr=b_right,
|
|
959
|
+
x=stream("X_minus"),
|
|
960
|
+
v=velocity("dX_minus", "dt"),
|
|
961
|
+
alias="BRm",
|
|
962
|
+
)
|
|
963
|
+
prog_shift = Integrand(exprs=[BL, BRm], terms=[Term(eq=einsum_string, ops=("BL", "BRm"))])
|
|
964
|
+
|
|
965
|
+
if G_mode == "shift":
|
|
966
|
+
prog = prog_shift
|
|
967
|
+
elif G_mode == "trapeze":
|
|
968
|
+
prog = 0.5 * (prog_rect + prog_shift)
|
|
969
|
+
else: # doubleshift
|
|
970
|
+
BLp = ExprOperand(
|
|
971
|
+
expr=b_left,
|
|
972
|
+
x=stream("X_plus"),
|
|
973
|
+
v=velocity("dX_plus", "dt"),
|
|
974
|
+
alias="BLp",
|
|
975
|
+
)
|
|
976
|
+
prog = Integrand(exprs=[BLp, BRm], terms=[Term(eq=einsum_string, ops=("BLp", "BRm"))])
|
|
977
|
+
|
|
978
|
+
else:
|
|
979
|
+
raise KeyError(f"Wrong G_mode argument for ULI: {G_mode}")
|
|
980
|
+
|
|
981
|
+
# The ULI Gram is returned transposed (Gᵀ): the averaging is asymmetric
|
|
982
|
+
# in the (left, right) factors and Gᵀ is the better-conditioned regressor.
|
|
983
|
+
G = integrate(
|
|
984
|
+
self.data,
|
|
985
|
+
prog,
|
|
986
|
+
reduce="sum",
|
|
987
|
+
reduce_over_particles=True,
|
|
988
|
+
subsampling=subsampling,
|
|
989
|
+
weight_by_dt=weight_by_dt,
|
|
990
|
+
chunk_target_bytes=self._chunk_target_bytes,
|
|
991
|
+
)
|
|
992
|
+
# ``integrate`` returns the scalar ``0.0`` when the plan has no chunks,
|
|
993
|
+
# i.e. the collection yielded no valid frames for this integrand. Feeding
|
|
994
|
+
# that scalar to ``swapaxes`` below would raise a cryptic
|
|
995
|
+
# "index -2 is out of bounds for axis 0 with size 0". Surface the real
|
|
996
|
+
# cause instead.
|
|
997
|
+
if jnp.ndim(G) < 2:
|
|
998
|
+
raise ValueError(
|
|
999
|
+
f"ULI Gram integration (G_mode={G_mode!r}) found no valid frames: "
|
|
1000
|
+
"the collection produced zero usable rows for this basis, so the "
|
|
1001
|
+
"Gram collapsed to a scalar instead of an (i, a, b) array. Common "
|
|
1002
|
+
"causes: the trajectory is too short for the requested stencil "
|
|
1003
|
+
"(secant velocities need a neighbouring frame), or — for an "
|
|
1004
|
+
"interaction/structural basis — the collection is missing the "
|
|
1005
|
+
"reserved structural extras the basis needs (frequent with "
|
|
1006
|
+
"bootstrapped collections built without those extras). Verify the "
|
|
1007
|
+
"collection has valid frames and carries the extras the basis requires."
|
|
1008
|
+
)
|
|
1009
|
+
return jnp.swapaxes(G, -1, -2)
|
|
1010
|
+
|
|
1011
|
+
# ------------------------------ diffusion timeops ------------------------------ #
|
|
1012
|
+
|
|
1013
|
+
def _build_diffusion_timeoperands(self) -> None:
|
|
1014
|
+
if hasattr(self, "_diff_ops"):
|
|
1015
|
+
return
|
|
1016
|
+
self._diff_ops = {
|
|
1017
|
+
"MSD": TimeOperand(_D_msd_uli, alias="D_msd"),
|
|
1018
|
+
"WeakNoise": TimeOperand(_D_weaknoise_uli, alias="D_weaknoise"),
|
|
1019
|
+
"noisy": TimeOperand(_D_noisy_uli, alias="D_noisy"),
|
|
1020
|
+
"Lambda": TimeOperand(_Lambda_uli, alias="Lambda"),
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
def get_diffusion_timeop(self, method: str) -> TimeOperand:
|
|
1024
|
+
self._build_diffusion_timeoperands()
|
|
1025
|
+
try:
|
|
1026
|
+
return self._diff_ops[method] # type: ignore[attr-defined]
|
|
1027
|
+
except KeyError as e:
|
|
1028
|
+
raise KeyError(f"Unknown underdamped diffusion estimator method: {method}") from e
|
|
1029
|
+
|
|
1030
|
+
# ------------------------------ kinematics selectors ------------------------------ #
|
|
1031
|
+
|
|
1032
|
+
def _build_kinematics_timeoperands(self) -> None:
|
|
1033
|
+
if hasattr(self, "_kin_ops"):
|
|
1034
|
+
return
|
|
1035
|
+
self._kin_ops = {
|
|
1036
|
+
"x": {
|
|
1037
|
+
"symmetric": _X_sym_uli,
|
|
1038
|
+
"early": _X_early_uli,
|
|
1039
|
+
"anticipated": _X_anticipated_uli,
|
|
1040
|
+
},
|
|
1041
|
+
"v": {
|
|
1042
|
+
"symmetric": _V_sym_uli,
|
|
1043
|
+
"early": _V_early_uli,
|
|
1044
|
+
"anticipated": _V_anticipated_uli,
|
|
1045
|
+
},
|
|
1046
|
+
"a": {
|
|
1047
|
+
"symmetric": _A_sym_uli,
|
|
1048
|
+
"early": _A_sym_uli,
|
|
1049
|
+
"anticipated": _A_anticipated_uli,
|
|
1050
|
+
},
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
def _get_x_hat(self, *, M_mode: str, at: str = "t") -> TimeOp:
|
|
1054
|
+
if at != "t":
|
|
1055
|
+
raise NotImplementedError("x_hat shifting not implemented yet (only at='t').")
|
|
1056
|
+
self._build_kinematics_timeoperands()
|
|
1057
|
+
return self._kin_ops["x"][M_mode] # type: ignore[index]
|
|
1058
|
+
|
|
1059
|
+
def _get_v_hat(self, *, M_mode: str, at: str = "t") -> TimeOp:
|
|
1060
|
+
if at != "t":
|
|
1061
|
+
raise NotImplementedError("v_hat shifting not implemented yet (only at='t').")
|
|
1062
|
+
self._build_kinematics_timeoperands()
|
|
1063
|
+
return self._kin_ops["v"][M_mode] # type: ignore[index]
|
|
1064
|
+
|
|
1065
|
+
def _get_a_hat(self, *, M_mode: str, at: str = "t") -> TimeOp:
|
|
1066
|
+
if at != "t":
|
|
1067
|
+
raise NotImplementedError("a_hat shifting not implemented yet (only at='t').")
|
|
1068
|
+
self._build_kinematics_timeoperands()
|
|
1069
|
+
return self._kin_ops["a"][M_mode] # type: ignore[index]
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
# -------------------- Underdamped (ULI) kinematics as TimeOps -------------------- #
|
|
1073
|
+
# .. physics:: ULI kinematic reconstructions
|
|
1074
|
+
# :label: uli-kinematics
|
|
1075
|
+
# :category: Kinematics
|
|
1076
|
+
#
|
|
1077
|
+
# The underdamped inference reconstructs (x̂, v̂, â) from observed positions
|
|
1078
|
+
# via finite differences. Three modes:
|
|
1079
|
+
#
|
|
1080
|
+
# Symmetric: x̂ = (x_{t-1}+x_t+x_{t+1})/3, v̂ = (dX+dX⁻)/(2dt), â = (dX−dX⁻)/dt²
|
|
1081
|
+
# Early: x̂ = x_t, v̂ = dX⁻/dt
|
|
1082
|
+
# Anticipated: x̂ = (x_t+x_{t+1}+x_{t+2})/3, â = (dX⁺−dX)/dt²
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@timeop(name="X_sym_uli")
|
|
1086
|
+
def _X_sym_uli(**streams):
|
|
1087
|
+
r"""
|
|
1088
|
+
.. physics:: Symmetric ULI kinematic reconstruction
|
|
1089
|
+
:label: uli-kinematics-symmetric
|
|
1090
|
+
:category: Kinematics
|
|
1091
|
+
|
|
1092
|
+
.. math::
|
|
1093
|
+
|
|
1094
|
+
\\hat x(t) = \\tfrac{1}{3}\\bigl[x_{t-1}+x_t+x_{t+1}\\bigr],
|
|
1095
|
+
\\quad
|
|
1096
|
+
\\hat v(t) = \\frac{\\mathrm{d}X_t+\\mathrm{d}X_{t-1}}{2\\,\\mathrm{d}t},
|
|
1097
|
+
\\quad
|
|
1098
|
+
\\hat a(t) = \\frac{\\mathrm{d}X_t - \\mathrm{d}X_{t-1}}{\\mathrm{d}t^2}
|
|
1099
|
+
"""
|
|
1100
|
+
# x̂(t) = (x(t-1) + x(t) + x(t+1)) / 3
|
|
1101
|
+
return (streams["X"] + streams["X_minus"] + streams["X_plus"]) / 3.0
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
_X_sym_uli._requires = frozenset({"X", "X_minus", "X_plus"}) # type: ignore[attr-defined]
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@timeop(name="X_early_uli")
|
|
1108
|
+
def _X_early_uli(**streams):
|
|
1109
|
+
# x̂(t) = x(t)
|
|
1110
|
+
return streams["X"]
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
_X_early_uli._requires = frozenset({"X"}) # type: ignore[attr-defined]
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
@timeop(name="X_anticipated_uli")
|
|
1117
|
+
def _X_anticipated_uli(**streams):
|
|
1118
|
+
# x̂(t) = (x(t) + x(t+1) + x(t+2)) / 3
|
|
1119
|
+
return (streams["X"] + streams["X_plus"] + streams["X_plusplus"]) / 3.0
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
_X_anticipated_uli._requires = frozenset({"X", "X_plus", "X_plusplus"}) # type: ignore[attr-defined]
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@timeop(name="V_sym_uli")
|
|
1126
|
+
def _V_sym_uli(**streams):
|
|
1127
|
+
# v̂(t) = (dX(t) + dX_minus(t)) / (2 dt)
|
|
1128
|
+
dt = streams["dt"][..., None, None]
|
|
1129
|
+
return 0.5 * (streams["dX"] + streams["dX_minus"]) / dt
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
_V_sym_uli._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@timeop(name="V_early_uli")
|
|
1136
|
+
def _V_early_uli(**streams):
|
|
1137
|
+
# v̂(t) = dX_minus(t) / dt
|
|
1138
|
+
dt = streams["dt"][..., None, None]
|
|
1139
|
+
return streams["dX_minus"] / dt
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
_V_early_uli._requires = frozenset({"dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
@timeop(name="V_anticipated_uli")
|
|
1146
|
+
def _V_anticipated_uli(**streams):
|
|
1147
|
+
# v̂(t) = dX_minus(t) / dt
|
|
1148
|
+
dt = streams["dt"][..., None, None]
|
|
1149
|
+
return streams["dX_minus"] / dt
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
_V_anticipated_uli._requires = frozenset({"dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@timeop(name="A_sym_uli")
|
|
1156
|
+
def _A_sym_uli(**streams):
|
|
1157
|
+
# â(t) = (dX(t) - dX_minus(t)) / dt^2
|
|
1158
|
+
dt = streams["dt"][..., None, None]
|
|
1159
|
+
return (streams["dX"] - streams["dX_minus"]) / (dt**2)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
_A_sym_uli._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
@timeop(name="A_anticipated_uli")
|
|
1166
|
+
def _A_anticipated_uli(**streams):
|
|
1167
|
+
# â(t) = (dX_plus(t) - dX(t)) / dt^2
|
|
1168
|
+
dt = streams["dt"][..., None, None]
|
|
1169
|
+
return (streams["dX_plus"] - streams["dX"]) / (dt**2)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
_A_anticipated_uli._requires = frozenset({"dX_plus", "dX", "dt"}) # type: ignore[attr-defined]
|
|
1173
|
+
|
|
1174
|
+
# -------------------- Underdamped (ULI) diffusion kinematics as TimeOps -------------------- #
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
@timeop(name="X_noisy_uli")
|
|
1178
|
+
def _X_noisy_uli(**streams):
|
|
1179
|
+
# X_t (noisy diffusion): 0.25*(X + X_minus + X_plus + X_plusplus)
|
|
1180
|
+
return 0.25 * (streams["X"] + streams["X_minus"] + streams["X_plus"] + streams["X_plusplus"])
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
_X_noisy_uli._requires = frozenset({"X", "X_minus", "X_plus", "X_plusplus"}) # type: ignore[attr-defined]
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
@timeop(name="V_noisy_uli")
|
|
1187
|
+
def _V_noisy_uli(**streams):
|
|
1188
|
+
# V_t (noisy diffusion): (4 dX + dX_plus + dX_minus) / (6 dt)
|
|
1189
|
+
dt = streams["dt"][..., None, None]
|
|
1190
|
+
return (4.0 * streams["dX"] + streams["dX_plus"] + streams["dX_minus"]) / (6.0 * dt)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
_V_noisy_uli._requires = frozenset({"dX", "dX_plus", "dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
@timeop(name="X_weaknoise_uli")
|
|
1197
|
+
def _X_weaknoise_uli(**streams):
|
|
1198
|
+
# X_t (WeakNoise diffusion): 0.5*(X + X_plus)
|
|
1199
|
+
return 0.5 * (streams["X"] + streams["X_plus"])
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
_X_weaknoise_uli._requires = frozenset({"X", "X_plus"}) # type: ignore[attr-defined]
|
|
1203
|
+
|
|
1204
|
+
# -------------------- Underdamped (ULI) diffusion estimators as TimeOps -------------------- #
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _symmetrize_imn(M: jnp.ndarray) -> jnp.ndarray:
|
|
1208
|
+
return 0.5 * (M + jnp.swapaxes(M, -1, -2))
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@timeop(name="D_msd_uli")
|
|
1212
|
+
def _D_msd_uli(**streams):
|
|
1213
|
+
r"""
|
|
1214
|
+
ULI 'MSD' instantaneous diffusion estimator:
|
|
1215
|
+
D = (3/4) * ( (dX - dX_minus) ⊗ (dX - dX_minus) ) / dt^3
|
|
1216
|
+
Returns (N, d, d).
|
|
1217
|
+
|
|
1218
|
+
.. physics:: MSD diffusion estimator (underdamped)
|
|
1219
|
+
:label: D-msd-underdamped
|
|
1220
|
+
:category: Estimator
|
|
1221
|
+
|
|
1222
|
+
.. math::
|
|
1223
|
+
|
|
1224
|
+
\hat D_{\text{MSD}}^{\text{ULI}}(t)
|
|
1225
|
+
= \frac{3}{4}\,
|
|
1226
|
+
\frac{(\mathrm{d}X_t - \mathrm{d}X_{t-1})
|
|
1227
|
+
\otimes (\mathrm{d}X_t - \mathrm{d}X_{t-1})}
|
|
1228
|
+
{\mathrm{d}t^3}
|
|
1229
|
+
"""
|
|
1230
|
+
dX = streams["dX"]
|
|
1231
|
+
dXm = streams["dX_minus"]
|
|
1232
|
+
ddX = dX - dXm
|
|
1233
|
+
dt = streams["dt"][..., None, None]
|
|
1234
|
+
return 0.75 * jnp.einsum("im,in->imn", ddX, ddX) / (dt**3)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
_D_msd_uli._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
@timeop(name="D_weaknoise_uli")
|
|
1241
|
+
def _D_weaknoise_uli(**streams):
|
|
1242
|
+
r"""
|
|
1243
|
+
ULI 'WeakNoise' estimator:
|
|
1244
|
+
D = (1/2) * ( (2 dX - dX_minus - dX_plus) ⊗ (2 dX - dX_minus - dX_plus) ) / dt^3
|
|
1245
|
+
Returns (N, d, d).
|
|
1246
|
+
|
|
1247
|
+
.. physics:: Weak-noise diffusion estimator (underdamped)
|
|
1248
|
+
:label: D-weaknoise-underdamped
|
|
1249
|
+
:category: Estimator
|
|
1250
|
+
|
|
1251
|
+
.. math::
|
|
1252
|
+
|
|
1253
|
+
\hat D_{\text{WN}}^{\text{ULI}}(t)
|
|
1254
|
+
= \frac{1}{2}\,
|
|
1255
|
+
\frac{(2\,\mathrm{d}X_t - \mathrm{d}X_{t-1} - \mathrm{d}X_{t+1})
|
|
1256
|
+
\otimes (\cdots)}{\mathrm{d}t^3}
|
|
1257
|
+
"""
|
|
1258
|
+
dX = streams["dX"]
|
|
1259
|
+
dXm = streams["dX_minus"]
|
|
1260
|
+
dXp = streams["dX_plus"]
|
|
1261
|
+
u = 2.0 * dX - dXm - dXp
|
|
1262
|
+
dt = streams["dt"][..., None, None]
|
|
1263
|
+
return 0.5 * jnp.einsum("im,in->imn", u, u) / (dt**3)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
_D_weaknoise_uli._requires = frozenset({"dX", "dX_minus", "dX_plus", "dt"}) # type: ignore[attr-defined]
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
@timeop(name="D_noisy_uli")
|
|
1270
|
+
def _D_noisy_uli(**streams):
|
|
1271
|
+
r"""
|
|
1272
|
+
ULI 'noisy' diffusion estimator:
|
|
1273
|
+
D = (3 / (11 dt^3)) * sym( -a + b + c - 3d + e + f )
|
|
1274
|
+
with:
|
|
1275
|
+
a = dX ⊗ dX
|
|
1276
|
+
b = dX_minus ⊗ dX_minus
|
|
1277
|
+
c = dX_plus ⊗ dX_plus
|
|
1278
|
+
d = dX_plus ⊗ dX_minus
|
|
1279
|
+
e = dX ⊗ dX_plus
|
|
1280
|
+
f = dX ⊗ dX_minus
|
|
1281
|
+
Returns (N, d, d).
|
|
1282
|
+
|
|
1283
|
+
.. physics:: Noisy diffusion estimator (underdamped)
|
|
1284
|
+
:label: D-noisy-underdamped
|
|
1285
|
+
:category: Estimator
|
|
1286
|
+
|
|
1287
|
+
.. math::
|
|
1288
|
+
|
|
1289
|
+
\hat D_{\text{noisy}}^{\text{ULI}}(t)
|
|
1290
|
+
= \frac{3}{11\,\mathrm{d}t^3}\,\operatorname{sym}\!
|
|
1291
|
+
\bigl[-a + b + c - 3d + e + f\bigr]
|
|
1292
|
+
|
|
1293
|
+
where :math:`a = \mathrm{d}X_t\otimes\mathrm{d}X_t`,
|
|
1294
|
+
:math:`b = \mathrm{d}X_{t-1}\otimes\mathrm{d}X_{t-1}`, etc.
|
|
1295
|
+
Optimally handles both signal and localization noise.
|
|
1296
|
+
"""
|
|
1297
|
+
dX = streams["dX"]
|
|
1298
|
+
dXm = streams["dX_minus"]
|
|
1299
|
+
dXp = streams["dX_plus"]
|
|
1300
|
+
|
|
1301
|
+
a = jnp.einsum("im,in->imn", dX, dX)
|
|
1302
|
+
b = jnp.einsum("im,in->imn", dXm, dXm)
|
|
1303
|
+
c = jnp.einsum("im,in->imn", dXp, dXp)
|
|
1304
|
+
d = jnp.einsum("im,in->imn", dXp, dXm)
|
|
1305
|
+
e = jnp.einsum("im,in->imn", dX, dXp)
|
|
1306
|
+
f = jnp.einsum("im,in->imn", dX, dXm)
|
|
1307
|
+
|
|
1308
|
+
M = (-1.0) * a + (1.0) * b + (1.0) * c + (-3.0) * d + (1.0) * e + (1.0) * f
|
|
1309
|
+
M = _symmetrize_imn(M)
|
|
1310
|
+
|
|
1311
|
+
dt = streams["dt"][..., None, None]
|
|
1312
|
+
return (3.0 / 11.0) * M / (dt**3)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
_D_noisy_uli._requires = frozenset({"dX", "dX_minus", "dX_plus", "dt"}) # type: ignore[attr-defined]
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
@timeop(name="Lambda_uli")
|
|
1319
|
+
def _Lambda_uli(**streams):
|
|
1320
|
+
r"""
|
|
1321
|
+
ULI 'Lambda' instantaneous measurement-noise estimator:
|
|
1322
|
+
Λ = sym( 10a + b + c + 8d - 10e - 10f ) / 44
|
|
1323
|
+
with the same (a..f) definitions as in _D_noisy_uli.
|
|
1324
|
+
Returns (N, d, d).
|
|
1325
|
+
|
|
1326
|
+
.. physics:: Measurement noise estimator (underdamped)
|
|
1327
|
+
:label: Lambda-underdamped
|
|
1328
|
+
:category: Estimator
|
|
1329
|
+
|
|
1330
|
+
.. math::
|
|
1331
|
+
|
|
1332
|
+
\hat\Lambda^{\text{ULI}}
|
|
1333
|
+
= \frac{1}{44}\,\operatorname{sym}\!
|
|
1334
|
+
\bigl[10a + b + c + 8d - 10e - 10f\bigr]
|
|
1335
|
+
|
|
1336
|
+
Same displacement products :math:`(a\ldots f)` as the noisy
|
|
1337
|
+
diffusion estimator. Extracts localization noise for
|
|
1338
|
+
underdamped systems.
|
|
1339
|
+
"""
|
|
1340
|
+
dX = streams["dX"]
|
|
1341
|
+
dXm = streams["dX_minus"]
|
|
1342
|
+
dXp = streams["dX_plus"]
|
|
1343
|
+
|
|
1344
|
+
a = jnp.einsum("im,in->imn", dX, dX)
|
|
1345
|
+
b = jnp.einsum("im,in->imn", dXm, dXm)
|
|
1346
|
+
c = jnp.einsum("im,in->imn", dXp, dXp)
|
|
1347
|
+
d = jnp.einsum("im,in->imn", dXp, dXm)
|
|
1348
|
+
e = jnp.einsum("im,in->imn", dX, dXp)
|
|
1349
|
+
f = jnp.einsum("im,in->imn", dX, dXm)
|
|
1350
|
+
|
|
1351
|
+
M = (10.0) * a + (1.0) * b + (1.0) * c + (8.0) * d + (-10.0) * e + (-10.0) * f
|
|
1352
|
+
return _symmetrize_imn(M) / 44.0
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
_Lambda_uli._requires = frozenset({"dX", "dX_minus", "dX_plus"}) # type: ignore[attr-defined]
|