StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )