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,560 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Overdamped Langevin simulator (Euler–Maruyama / Heun) with post-run observables.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import jax.numpy as jnp
|
|
9
|
+
from jax import jit, vmap
|
|
10
|
+
|
|
11
|
+
from SFI.statefunc import PSF, SF
|
|
12
|
+
from SFI.utils.maths import as_default_float
|
|
13
|
+
|
|
14
|
+
from .base import Array, DiffusionKind, LangevinBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class OverdampedProcess(LangevinBase):
|
|
19
|
+
"""Overdamped Langevin simulator (Euler–Maruyama or stochastic Heun).
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
F : PSF | SF
|
|
24
|
+
Force model with `rank=vector`, `needs_v=False`, and `pdepth∈{0,1}`.
|
|
25
|
+
If a PSF is provided, bind parameters via :meth:`set_params` prior to
|
|
26
|
+
simulation.
|
|
27
|
+
D : float | Array | PSF | SF
|
|
28
|
+
Diffusion model: scalar σ (interpreted as σ·I), constant (d×d) matrix,
|
|
29
|
+
or a PSF/SF with `rank=matrix`, `pdepth∈{0,1}` compatible with `F`.
|
|
30
|
+
theta_F, theta_D : Optional[Array], optional
|
|
31
|
+
Parameter vectors for binding PSF → SF.
|
|
32
|
+
extras_global, extras_local : Optional[dict], optional
|
|
33
|
+
Frozen, time-independent extras passed to both ``F`` and ``D`` at
|
|
34
|
+
every call. Users should classify extras explicitly as:
|
|
35
|
+
|
|
36
|
+
- extras_global: system-wide objects (geometry, external field ...)
|
|
37
|
+
- extras_local: per-particle objects (species labels, radii, ...)
|
|
38
|
+
|
|
39
|
+
At runtime these are merged into a single ``extras`` mapping, with
|
|
40
|
+
local keys overriding global ones, and passed identically to both
|
|
41
|
+
models.
|
|
42
|
+
|
|
43
|
+
Notes
|
|
44
|
+
-----
|
|
45
|
+
This class does **not** insert particle axes. The shapes must match the
|
|
46
|
+
model contract:
|
|
47
|
+
|
|
48
|
+
- If ``F.pdepth == 0``, ``x0.shape == (d,)``.
|
|
49
|
+
- If ``F.pdepth == 1``, ``x0.shape == (P, d)``.
|
|
50
|
+
|
|
51
|
+
Observables
|
|
52
|
+
-----------
|
|
53
|
+
After the run (on recorded steps only), we compute:
|
|
54
|
+
|
|
55
|
+
- information ``I`` approx ``0.25 * sum_t <dx_t, D_inv(x_t) . F(x_t)>``
|
|
56
|
+
- entropy ``S`` approx ``sum_t <dx_t, D_inv(x_mid) . (F(x_t)+F(x_{t+dt}))/2>``
|
|
57
|
+
|
|
58
|
+
where ``dx_t = x_{t+dt} - x_t`` and ``x_mid = (x_{t+dt}+x_t)/2``.
|
|
59
|
+
We evaluate F(x) exactly once per recorded step and reuse it for both terms.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# cached diffusion inverse for constant-D case
|
|
63
|
+
_Dinv_const: Optional[Array] = None
|
|
64
|
+
# bound diffusion callable for state-dependent case (so we can evaluate D at x_t / x_mid)
|
|
65
|
+
_D_sf: Optional[SF] = None
|
|
66
|
+
|
|
67
|
+
# ------------------------------- API --------------------------------
|
|
68
|
+
def initialize(self, x0: Array) -> None:
|
|
69
|
+
"""Initialize the process state.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
x0 : Array
|
|
74
|
+
Initial position. Must satisfy:
|
|
75
|
+
|
|
76
|
+
- If ``F.pdepth == 0``: shape ``(d,)``
|
|
77
|
+
- If ``F.pdepth == 1``: shape ``(P, d)``
|
|
78
|
+
|
|
79
|
+
Side effects
|
|
80
|
+
------------
|
|
81
|
+
Binds PSF parameters (if any), validates model contracts, and prepares
|
|
82
|
+
diffusion shortcuts (constant vs state-dependent).
|
|
83
|
+
"""
|
|
84
|
+
# Basic contract inspection from the statefunc objects
|
|
85
|
+
self._normalize_basis_to_psf()
|
|
86
|
+
self._assert_force_contract(self.F)
|
|
87
|
+
|
|
88
|
+
# Canonicalize dtype: any user-provided array (float32, float64,
|
|
89
|
+
# numpy int, plain list, …) is cast to JAX's currently-active
|
|
90
|
+
# default float dtype. This is the single normalization point
|
|
91
|
+
# that makes lax.scan carry-in / carry-out dtypes always agree.
|
|
92
|
+
x0 = as_default_float(x0)
|
|
93
|
+
|
|
94
|
+
# Deduce (P, d) from x0
|
|
95
|
+
if x0.ndim == 1:
|
|
96
|
+
d = int(x0.shape[0])
|
|
97
|
+
P = None
|
|
98
|
+
elif x0.ndim == 2:
|
|
99
|
+
P = int(x0.shape[0])
|
|
100
|
+
d = int(x0.shape[1])
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError("x0 must have shape (d,) or (P, d).")
|
|
103
|
+
|
|
104
|
+
# Check against F.pdepth
|
|
105
|
+
f_pdepth = getattr(self.F, "pdepth", None)
|
|
106
|
+
if f_pdepth not in (0, 1):
|
|
107
|
+
raise ValueError("F.pdepth must be 0 or 1 for overdamped simulations.")
|
|
108
|
+
if f_pdepth == 0 and x0.ndim != 1:
|
|
109
|
+
if x0.shape[0] == 1 and x0.ndim == 2:
|
|
110
|
+
# Silently drop the first axis
|
|
111
|
+
x0 = x0[0]
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError("F expects no particle axis (pdepth=0): x0 must be (d,).")
|
|
114
|
+
if f_pdepth == 1 and x0.ndim != 2:
|
|
115
|
+
raise ValueError("F expects a particle axis (pdepth=1): x0 must be (P, d).")
|
|
116
|
+
|
|
117
|
+
# Bind F now, keeping the unbound object intact
|
|
118
|
+
self._F_sf = self._bind_force()
|
|
119
|
+
|
|
120
|
+
# Bind D early *if* it's a PSF/SF, so structural extras can be prepared once for both.
|
|
121
|
+
D_sf_for_extras: Optional[SF] = None
|
|
122
|
+
if isinstance(self.D, SF):
|
|
123
|
+
D_sf_for_extras = self.D
|
|
124
|
+
elif isinstance(self.D, PSF):
|
|
125
|
+
if self.theta_D is None:
|
|
126
|
+
raise ValueError("Diffusion PSF not bound: call set_params(theta_D=...).")
|
|
127
|
+
D_sf_for_extras = self.D.bind(self.theta_D) # type: ignore[attr-defined]
|
|
128
|
+
|
|
129
|
+
# Eagerly materialize any structural extras needed by bound expressions.
|
|
130
|
+
# This MUST happen before the first JIT-triggering evaluation.
|
|
131
|
+
self._invalidate_prepared_extras()
|
|
132
|
+
exprs = [self._F_sf] + ([D_sf_for_extras] if D_sf_for_extras is not None else [])
|
|
133
|
+
extras = self._prepare_model_extras(x_probe=x0, exprs=exprs)
|
|
134
|
+
|
|
135
|
+
# Validate output dimension of F on a cheap probe (shape-only).
|
|
136
|
+
# Use the merged process-level extras seen by both F and D.
|
|
137
|
+
f_out = self._F_sf(x0, extras=extras)
|
|
138
|
+
if f_out.shape != x0.shape:
|
|
139
|
+
raise ValueError(f"Force output shape {f_out.shape} does not match input shape {x0.shape}.")
|
|
140
|
+
|
|
141
|
+
# Prepare diffusion shortcuts (constant vs state-dependent) for the integrator
|
|
142
|
+
self._check_diffusion_contract(self.D, d=d, f_pdepth=f_pdepth)
|
|
143
|
+
self._setup_diffusion(d=d, with_v=False)
|
|
144
|
+
|
|
145
|
+
# Also prepare D or D^{-1} for post-run observables
|
|
146
|
+
from SFI.langevin.noise import NoiseModel
|
|
147
|
+
|
|
148
|
+
if isinstance(self.D, NoiseModel):
|
|
149
|
+
# Use the effective per-site D for observables approximation
|
|
150
|
+
extras = self._model_extras()
|
|
151
|
+
D_eff = self.D.effective_D_per_site(extras)
|
|
152
|
+
self._Dinv_const = jnp.linalg.pinv(D_eff)
|
|
153
|
+
self._D_sf = None
|
|
154
|
+
elif isinstance(self.D, (int, float)):
|
|
155
|
+
sigma = float(self.D)
|
|
156
|
+
self._Dinv_const = (1.0 / sigma) * jnp.eye(d) # pinv(σ I) = (1/σ) I
|
|
157
|
+
self._D_sf = None
|
|
158
|
+
elif isinstance(self.D, jnp.ndarray) and self.D.ndim == 2:
|
|
159
|
+
self._Dinv_const = jnp.linalg.pinv(self.D)
|
|
160
|
+
self._D_sf = None
|
|
161
|
+
elif isinstance(self.D, (PSF, SF)):
|
|
162
|
+
# Reuse the already-bound callable (if any)
|
|
163
|
+
if D_sf_for_extras is None:
|
|
164
|
+
# Defensive fallback; should not happen if the above binding logic ran
|
|
165
|
+
if isinstance(self.D, SF):
|
|
166
|
+
D_sf_for_extras = self.D
|
|
167
|
+
else:
|
|
168
|
+
if self.theta_D is None:
|
|
169
|
+
raise ValueError("Diffusion PSF not bound: call set_params(theta_D=...).")
|
|
170
|
+
D_sf_for_extras = self.D.bind(self.theta_D) # type: ignore[attr-defined]
|
|
171
|
+
|
|
172
|
+
self._D_sf = D_sf_for_extras
|
|
173
|
+
self._Dinv_const = None
|
|
174
|
+
else:
|
|
175
|
+
raise TypeError("D must be a float, (d×d) array, or a PSF/SF.")
|
|
176
|
+
|
|
177
|
+
# Persist runtime state/metadata basics
|
|
178
|
+
self._x = x0
|
|
179
|
+
num_particles = int(P) if P is not None else 1
|
|
180
|
+
self.metadata.clear()
|
|
181
|
+
self.metadata.update(
|
|
182
|
+
dict(
|
|
183
|
+
kind="overdamped",
|
|
184
|
+
dimension=d,
|
|
185
|
+
pdepth=int(f_pdepth),
|
|
186
|
+
num_particles=num_particles,
|
|
187
|
+
x0=x0,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def simulate(
|
|
192
|
+
self,
|
|
193
|
+
dt: float,
|
|
194
|
+
Nsteps: int,
|
|
195
|
+
key: Array,
|
|
196
|
+
*,
|
|
197
|
+
oversampling: int = 4,
|
|
198
|
+
prerun: int = 0,
|
|
199
|
+
jit_compile: bool = True,
|
|
200
|
+
compute_observables: bool = True,
|
|
201
|
+
method: str = "heun",
|
|
202
|
+
):
|
|
203
|
+
r"""
|
|
204
|
+
Integrate overdamped Langevin dynamics and return a
|
|
205
|
+
:class:`TrajectoryCollection` of positions.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
dt
|
|
210
|
+
Time step between recorded frames.
|
|
211
|
+
Nsteps
|
|
212
|
+
Number of recorded time steps.
|
|
213
|
+
key
|
|
214
|
+
PRNG key for the simulation.
|
|
215
|
+
oversampling
|
|
216
|
+
Number of integration substeps between recorded frames.
|
|
217
|
+
The effective substep size is ``dt / oversampling``.
|
|
218
|
+
Although all integrators have a consistent continuous limit, they
|
|
219
|
+
introduce short-range, algorithm-specific temporal correlations at
|
|
220
|
+
the scale of a single step. Downsampling by recording only every
|
|
221
|
+
``oversampling``-th substep ensures these artefacts never reach
|
|
222
|
+
the inference layer. The default of 4 is a safe minimum for
|
|
223
|
+
typical use; increase it when ``dt`` is large or the process
|
|
224
|
+
varies rapidly.
|
|
225
|
+
prerun
|
|
226
|
+
Number of recorded steps to discard as burn-in, using the same
|
|
227
|
+
``dt`` and ``oversampling``.
|
|
228
|
+
jit_compile
|
|
229
|
+
If True, JIT-compile the single-step integrator before scanning.
|
|
230
|
+
method
|
|
231
|
+
Integration scheme. ``"heun"`` (default) selects the stochastic
|
|
232
|
+
Heun predictor-corrector scheme, which achieves **weak order 2**
|
|
233
|
+
for constant (additive) diffusion — the dominant use case — at
|
|
234
|
+
the cost of two force evaluations per substep. For
|
|
235
|
+
state-dependent diffusion the Heun scheme still uses the
|
|
236
|
+
Itô-correct left-point noise evaluation, giving weak order 1 but
|
|
237
|
+
with better error constants than Euler–Maruyama. ``"euler"``
|
|
238
|
+
selects the classical Euler–Maruyama integrator (weak order 1).
|
|
239
|
+
compute_observables
|
|
240
|
+
If True, compute post-run information and entropy production
|
|
241
|
+
estimates on the recorded trajectory and store them in the
|
|
242
|
+
dataset metadata under the ``"observables"`` key.
|
|
243
|
+
|
|
244
|
+
.. physics:: Information functional & entropy production (overdamped)
|
|
245
|
+
:label: info-entropy-overdamped
|
|
246
|
+
:category: Observable
|
|
247
|
+
|
|
248
|
+
.. math::
|
|
249
|
+
|
|
250
|
+
I \approx \tfrac{1}{4}\sum_t
|
|
251
|
+
\mathrm{d}X_t^\top\, D^{-1}(x_t)\, F(x_t)
|
|
252
|
+
|
|
253
|
+
.. math::
|
|
254
|
+
|
|
255
|
+
S \approx \sum_t
|
|
256
|
+
\mathrm{d}X_t^\top\, D^{-1}(x_{\text{mid}})\,
|
|
257
|
+
\tfrac{1}{2}\bigl[F(x_t)+F(x_{t+1})\bigr]
|
|
258
|
+
|
|
259
|
+
:math:`I` estimates the information content; :math:`S` the
|
|
260
|
+
entropy production (time-reversal asymmetry).
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
TrajectoryCollection
|
|
265
|
+
A collection with a single dataset containing the positions.
|
|
266
|
+
The underlying dataset has:
|
|
267
|
+
|
|
268
|
+
- ``X`` of shape ``(Nsteps, d)`` or ``(Nsteps, P, d)``,
|
|
269
|
+
- metadata combining model info (kind, dimension, pdepth, etc.),
|
|
270
|
+
run info (dt, Nsteps, oversampling, prerun), and optional
|
|
271
|
+
observables.
|
|
272
|
+
"""
|
|
273
|
+
if self._F_sf is None:
|
|
274
|
+
raise RuntimeError("Call initialize(x0) before simulate().")
|
|
275
|
+
|
|
276
|
+
_valid_methods = ("euler", "heun")
|
|
277
|
+
if method not in _valid_methods:
|
|
278
|
+
raise ValueError(f"Unknown method {method!r}; expected one of {_valid_methods}.")
|
|
279
|
+
self._method = method
|
|
280
|
+
|
|
281
|
+
# Split extras into static values and per-frame schedules (the
|
|
282
|
+
# time-dependent ones: TimeSeriesExtra of length Nsteps, or f(t)
|
|
283
|
+
# callables materialized at the frame times).
|
|
284
|
+
static_extras, schedules, eg_out, el_out = self._materialize_step_extras(dt=dt, Nsteps=Nsteps)
|
|
285
|
+
|
|
286
|
+
step = self._make_step(static_extras)
|
|
287
|
+
if jit_compile:
|
|
288
|
+
step = jit(step)
|
|
289
|
+
|
|
290
|
+
traj, info = self._scan(
|
|
291
|
+
self._x,
|
|
292
|
+
step_fn=step,
|
|
293
|
+
dt=dt,
|
|
294
|
+
Nsteps=Nsteps,
|
|
295
|
+
oversampling=oversampling,
|
|
296
|
+
prerun=prerun,
|
|
297
|
+
key=key,
|
|
298
|
+
schedules=schedules or None,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Update final state for continuation
|
|
302
|
+
self._x = traj[-1]
|
|
303
|
+
|
|
304
|
+
# Run-level metadata snapshot
|
|
305
|
+
run_meta: Dict[str, Any] = dict(self.metadata)
|
|
306
|
+
run_meta.update(dict(dt=float(dt), integrator=method, **info))
|
|
307
|
+
if schedules:
|
|
308
|
+
run_meta["time_dependent_extras"] = sorted(schedules)
|
|
309
|
+
|
|
310
|
+
# Optional post-run observables (information/entropy)
|
|
311
|
+
if compute_observables:
|
|
312
|
+
X = traj
|
|
313
|
+
dX = X[1:] - X[:-1]
|
|
314
|
+
X_mid = 0.5 * (X[1:] + X[:-1])
|
|
315
|
+
|
|
316
|
+
extras = static_extras
|
|
317
|
+
|
|
318
|
+
# Frame-aligned extras: a[k] governs [X[k] -> X[k+1]] (zeroth-
|
|
319
|
+
# order hold), so the evaluation at X[k] pairs with the frame-k
|
|
320
|
+
# schedule slice.
|
|
321
|
+
if schedules:
|
|
322
|
+
|
|
323
|
+
def _ex_at(s):
|
|
324
|
+
return {**(extras or {}), **s}
|
|
325
|
+
|
|
326
|
+
F_all = vmap(
|
|
327
|
+
lambda x, s: self._F_sf(x, extras=_ex_at(s)) # type: ignore[misc]
|
|
328
|
+
)(X, schedules)
|
|
329
|
+
else:
|
|
330
|
+
F_all = vmap(
|
|
331
|
+
lambda x, _extras=extras: self._F_sf(x, extras=_extras) # type: ignore[misc]
|
|
332
|
+
)(X)
|
|
333
|
+
F_t, F_tp = F_all[:-1], F_all[1:]
|
|
334
|
+
F_avg = 0.5 * (F_t + F_tp)
|
|
335
|
+
|
|
336
|
+
if self._Dinv_const is not None:
|
|
337
|
+
I_terms = jnp.einsum("...m,...n,mn->", dX, F_t, self._Dinv_const)
|
|
338
|
+
S_terms = jnp.einsum("...m,...n,mn->", dX, F_avg, self._Dinv_const)
|
|
339
|
+
else:
|
|
340
|
+
if self._D_sf is None:
|
|
341
|
+
raise RuntimeError("State-dependent diffusion SF not initialized.")
|
|
342
|
+
|
|
343
|
+
if schedules:
|
|
344
|
+
sched_t = {k: v[:-1] for k, v in schedules.items()}
|
|
345
|
+
Dinv_t = vmap(
|
|
346
|
+
lambda x, s: jnp.linalg.pinv(self._D_sf(x, extras=_ex_at(s))) # type: ignore[misc]
|
|
347
|
+
)(X[:-1], sched_t)
|
|
348
|
+
Dinv_mid = vmap(
|
|
349
|
+
lambda x, s: jnp.linalg.pinv(self._D_sf(x, extras=_ex_at(s))) # type: ignore[misc]
|
|
350
|
+
)(X_mid, sched_t)
|
|
351
|
+
else:
|
|
352
|
+
Dinv_t = vmap(
|
|
353
|
+
lambda x, _extras=extras: jnp.linalg.pinv(self._D_sf(x, extras=_extras)) # type: ignore[misc]
|
|
354
|
+
)(X[:-1])
|
|
355
|
+
Dinv_mid = vmap(
|
|
356
|
+
lambda x, _extras=extras: jnp.linalg.pinv(self._D_sf(x, extras=_extras)) # type: ignore[misc]
|
|
357
|
+
)(X_mid)
|
|
358
|
+
|
|
359
|
+
I_terms = jnp.einsum("...m,...n,...mn->", dX, F_t, Dinv_t)
|
|
360
|
+
S_terms = jnp.einsum("...m,...n,...mn->", dX, F_avg, Dinv_mid)
|
|
361
|
+
|
|
362
|
+
observables = {
|
|
363
|
+
"information": float(0.25 * I_terms),
|
|
364
|
+
"entropy": float(S_terms),
|
|
365
|
+
}
|
|
366
|
+
run_meta["observables"] = observables
|
|
367
|
+
|
|
368
|
+
# Hand off to the base helper: positions → TrajectoryCollection
|
|
369
|
+
coll = self._traj_to_collection(
|
|
370
|
+
traj, dt=dt, meta=run_meta, extras_global_out=eg_out, extras_local_out=el_out
|
|
371
|
+
)
|
|
372
|
+
return coll
|
|
373
|
+
|
|
374
|
+
# ----------------------------- Internals -----------------------------
|
|
375
|
+
def _make_step(self, extras=None):
|
|
376
|
+
"""Dispatch to the selected integration scheme.
|
|
377
|
+
|
|
378
|
+
``extras`` is the merged *static* extras dict; per-frame scheduled
|
|
379
|
+
values arrive through the step's optional ``sched`` argument and
|
|
380
|
+
override it.
|
|
381
|
+
"""
|
|
382
|
+
method = getattr(self, "_method", "euler")
|
|
383
|
+
if extras is None:
|
|
384
|
+
extras = self._model_extras()
|
|
385
|
+
if method == "heun":
|
|
386
|
+
return self._make_heun_step(extras)
|
|
387
|
+
return self._make_euler_step(extras)
|
|
388
|
+
|
|
389
|
+
def _make_euler_step(self, extras=None):
|
|
390
|
+
r"""Create the Euler–Maruyama substep function (no observables inside).
|
|
391
|
+
|
|
392
|
+
.. physics:: Euler–Maruyama integrator (overdamped)
|
|
393
|
+
:label: euler-maruyama-overdamped
|
|
394
|
+
:category: Simulation
|
|
395
|
+
|
|
396
|
+
.. math::
|
|
397
|
+
|
|
398
|
+
x_{t+\mathrm{d}t}
|
|
399
|
+
= x_t + \mathrm{d}t\, F(x_t)
|
|
400
|
+
+ \sqrt{\mathrm{d}t}\, B(x_t)\,\xi_t
|
|
401
|
+
|
|
402
|
+
where :math:`B = \sqrt{2D}` and
|
|
403
|
+
:math:`\xi_t \sim \mathcal{N}(0, I)`.
|
|
404
|
+
"""
|
|
405
|
+
F = self._F_sf
|
|
406
|
+
kind = self._D_kind
|
|
407
|
+
Bc = self._B_const
|
|
408
|
+
Bf = self._B_fn
|
|
409
|
+
noise_model = self._noise_model
|
|
410
|
+
static_extras = extras
|
|
411
|
+
|
|
412
|
+
if F is None:
|
|
413
|
+
raise RuntimeError("Force not bound. Did you call initialize() after set_params()?")
|
|
414
|
+
|
|
415
|
+
def step(x: Array, ddt: float, key: Array, sched=None) -> Array:
|
|
416
|
+
"""Single Euler–Maruyama substep."""
|
|
417
|
+
ex = static_extras if sched is None else {**(static_extras or {}), **sched}
|
|
418
|
+
|
|
419
|
+
# Drift term
|
|
420
|
+
drift = F(x, extras=ex)
|
|
421
|
+
|
|
422
|
+
# Noise increment
|
|
423
|
+
if kind is DiffusionKind.NOISE_MODEL:
|
|
424
|
+
assert noise_model is not None
|
|
425
|
+
inc = noise_model.sample(key, x, ex)
|
|
426
|
+
elif kind is DiffusionKind.STATE_FUNC:
|
|
427
|
+
assert Bf is not None
|
|
428
|
+
xi = self._noise(key, x)
|
|
429
|
+
Bx = Bf(x, extras=ex)
|
|
430
|
+
inc = self._apply_B(Bx, xi, state_dependent=True)
|
|
431
|
+
else:
|
|
432
|
+
xi = self._noise(key, x)
|
|
433
|
+
inc = self._apply_B(Bc, xi, state_dependent=False)
|
|
434
|
+
|
|
435
|
+
return x + ddt * drift + jnp.sqrt(ddt) * inc
|
|
436
|
+
|
|
437
|
+
return step
|
|
438
|
+
|
|
439
|
+
def _make_heun_step(self, extras=None):
|
|
440
|
+
r"""Create the stochastic Heun (predictor-corrector) substep function.
|
|
441
|
+
|
|
442
|
+
.. physics:: Stochastic Heun integrator (overdamped)
|
|
443
|
+
:label: heun-overdamped
|
|
444
|
+
:category: Simulation
|
|
445
|
+
|
|
446
|
+
Predictor (Euler):
|
|
447
|
+
|
|
448
|
+
.. math::
|
|
449
|
+
|
|
450
|
+
\hat x = x_t + \mathrm{d}t\, F(x_t)
|
|
451
|
+
+ \sqrt{\mathrm{d}t}\, B(x_t)\,\xi_t
|
|
452
|
+
|
|
453
|
+
Corrector (trapezoidal drift):
|
|
454
|
+
|
|
455
|
+
.. math::
|
|
456
|
+
|
|
457
|
+
x_{t+\mathrm{d}t}
|
|
458
|
+
= x_t + \tfrac{1}{2}\mathrm{d}t\,[F(x_t) + F(\hat x)]
|
|
459
|
+
+ \sqrt{\mathrm{d}t}\, B(x_t)\,\xi_t
|
|
460
|
+
|
|
461
|
+
For constant (additive) diffusion this achieves
|
|
462
|
+
**weak order 2**. For state-dependent diffusion it uses the
|
|
463
|
+
left-point noise evaluation to preserve the Itô convention,
|
|
464
|
+
giving weak order 1 but with better error constants than
|
|
465
|
+
Euler–Maruyama. Costs two force evaluations per substep.
|
|
466
|
+
"""
|
|
467
|
+
F = self._F_sf
|
|
468
|
+
kind = self._D_kind
|
|
469
|
+
Bc = self._B_const
|
|
470
|
+
Bf = self._B_fn
|
|
471
|
+
noise_model = self._noise_model
|
|
472
|
+
static_extras = extras
|
|
473
|
+
|
|
474
|
+
if F is None:
|
|
475
|
+
raise RuntimeError("Force not bound. Did you call initialize() after set_params()?")
|
|
476
|
+
|
|
477
|
+
def step(x: Array, ddt: float, key: Array, sched=None) -> Array:
|
|
478
|
+
"""Single stochastic Heun substep."""
|
|
479
|
+
ex = static_extras if sched is None else {**(static_extras or {}), **sched}
|
|
480
|
+
|
|
481
|
+
drift = F(x, extras=ex)
|
|
482
|
+
|
|
483
|
+
# Noise increment (evaluated at left point for Itô correctness)
|
|
484
|
+
if kind is DiffusionKind.NOISE_MODEL:
|
|
485
|
+
assert noise_model is not None
|
|
486
|
+
noise_inc = jnp.sqrt(ddt) * noise_model.sample(key, x, ex)
|
|
487
|
+
elif kind is DiffusionKind.STATE_FUNC:
|
|
488
|
+
assert Bf is not None
|
|
489
|
+
xi = self._noise(key, x)
|
|
490
|
+
Bx = Bf(x, extras=ex)
|
|
491
|
+
noise_inc = jnp.sqrt(ddt) * self._apply_B(Bx, xi, state_dependent=True)
|
|
492
|
+
else:
|
|
493
|
+
xi = self._noise(key, x)
|
|
494
|
+
noise_inc = jnp.sqrt(ddt) * self._apply_B(Bc, xi, state_dependent=False)
|
|
495
|
+
|
|
496
|
+
# Euler predictor
|
|
497
|
+
x_pred = x + ddt * drift + noise_inc
|
|
498
|
+
|
|
499
|
+
# Corrector: trapezoidal average of drift, same noise realization
|
|
500
|
+
# (predictor and corrector share the frame's scheduled extras —
|
|
501
|
+
# the protocol is piecewise constant by definition).
|
|
502
|
+
drift_pred = F(x_pred, extras=ex)
|
|
503
|
+
return x + 0.5 * ddt * (drift + drift_pred) + noise_inc
|
|
504
|
+
|
|
505
|
+
return step
|
|
506
|
+
|
|
507
|
+
# --------------------------- Validations -----------------------------
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _assert_force_contract(F: PSF | SF) -> None:
|
|
510
|
+
"""Validate force has the right rank/dim flags for overdamped use."""
|
|
511
|
+
needs_v = getattr(F, "needs_v", False)
|
|
512
|
+
if needs_v:
|
|
513
|
+
raise ValueError("Overdamped force must not require velocity (needs_v=False).")
|
|
514
|
+
rank = getattr(F, "rank", None)
|
|
515
|
+
if rank != 1:
|
|
516
|
+
raise ValueError("Force must have rank=vector.")
|
|
517
|
+
dim = getattr(F, "dim", None)
|
|
518
|
+
if dim is None or not isinstance(dim, int):
|
|
519
|
+
raise ValueError("Force must declare an integer `dim` attribute.")
|
|
520
|
+
pdepth = getattr(F, "pdepth", None)
|
|
521
|
+
if pdepth not in (0, 1):
|
|
522
|
+
raise ValueError("Force `pdepth` must be 0 or 1 for overdamped simulations.")
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _check_diffusion_contract(D, *, d: int, f_pdepth: int) -> None:
|
|
526
|
+
"""Validate diffusion against the force contract, allowing benign broadcast."""
|
|
527
|
+
# NoiseModel instances
|
|
528
|
+
from SFI.langevin.noise import NoiseModel
|
|
529
|
+
|
|
530
|
+
if isinstance(D, NoiseModel):
|
|
531
|
+
if D.dim != d:
|
|
532
|
+
raise ValueError(f"NoiseModel n_fields={D.dim} must match force dim={d}.")
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
# Scalars and constant matrices are fine
|
|
536
|
+
if isinstance(D, (int, float)):
|
|
537
|
+
return
|
|
538
|
+
if isinstance(D, jnp.ndarray) and D.ndim == 2:
|
|
539
|
+
if D.shape != (d, d):
|
|
540
|
+
raise ValueError(f"Constant diffusion must be shape (d,d)={(d, d)}, got {D.shape}.")
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
# PSF/SF path
|
|
544
|
+
if not isinstance(D, (PSF, SF)):
|
|
545
|
+
raise TypeError("Diffusion must be a float, (d×d) array, PSF/SF, or NoiseModel.")
|
|
546
|
+
|
|
547
|
+
rank = getattr(D, "rank", None)
|
|
548
|
+
if rank != 2:
|
|
549
|
+
raise ValueError("Diffusion PSF/SF must have rank=matrix.")
|
|
550
|
+
dim = getattr(D, "dim", None)
|
|
551
|
+
if dim != d:
|
|
552
|
+
raise ValueError(f"Diffusion dim={dim} must match force dim={d}.")
|
|
553
|
+
d_pdepth = getattr(D, "pdepth", None)
|
|
554
|
+
if d_pdepth not in (0, 1):
|
|
555
|
+
raise ValueError("Diffusion `pdepth` must be 0 or 1.")
|
|
556
|
+
if not (d_pdepth == f_pdepth or d_pdepth == 0):
|
|
557
|
+
raise ValueError(
|
|
558
|
+
f"Incompatible pdepth: force pdepth={f_pdepth}, diffusion pdepth={d_pdepth}. "
|
|
559
|
+
"Only equal depths or diffusion depth=0 (broadcast) are allowed."
|
|
560
|
+
)
|