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
SFI/langevin/base.py ADDED
@@ -0,0 +1,863 @@
1
+ """
2
+ Langevin base utilities (shape-agnostic, PSF/SF-centric).
3
+
4
+ This module provides:
5
+
6
+ - A light base class with common plumbing shared by overdamped/underdamped
7
+ simulators (parameter binding, diffusion handling, scan loop, metadata).
8
+ - A small integrator protocol type used by concrete simulators.
9
+
10
+ Design
11
+ ------
12
+ We *do not* insert or manage particle axes here. Particle axes are owned by the
13
+ PSF/SF contract via ``pdepth`` and ``particles_input``. Hence this module uses
14
+ ellipsis-friendly einsums and lets models decide whether an input has a particle
15
+ axis (pdepth=1) or not (pdepth=0). In particular:
16
+
17
+ - ``F(x)`` must return an array with the same leading shape as x.
18
+ - ``D(x)`` must return either a constant (d x d) matrix or an array with shape
19
+ ``(..., d, d)``, where the leading axes ``...`` match those of x.
20
+
21
+ Einsum index convention
22
+ -----------------------
23
+ - Spatial (coordinate) indices: m, n (and following letters if needed).
24
+ - Particle indices: i, j.
25
+
26
+ We therefore use einsums of the form::
27
+
28
+ Constant diffusion: 'mn,...n->...m'
29
+ State-dependent diff: '...mn,...n->...m'
30
+
31
+ which transparently covers both pdepth=0 (``...`` empty) and pdepth=1 (``...`` = i).
32
+
33
+ Extras
34
+ ------
35
+ PSF/SF may accept an ``extras`` keyword (e.g., neighbor lists for SPDE grids).
36
+ The base class lets you *freeze* time-independent extras via
37
+ ``extras_global`` / ``extras_local`` and update them later via
38
+ :meth:`set_extras`. At runtime we merge these into a single dictionary
39
+ that is passed as ``extras=...`` to both F and D.
40
+
41
+ .. note::
42
+
43
+ Extras may be **time-dependent**: pass a
44
+ :class:`~SFI.trajectory.TimeSeriesExtra` whose leading axis matches the
45
+ ``Nsteps`` of the upcoming ``simulate`` call (one value per recorded
46
+ frame, held constant across oversampling substeps), or a plain callable
47
+ ``f(t)`` of physical time, materialized host-side at the frame times
48
+ ``t = k·dt`` before the scan. Schedules enter the scan as consumed
49
+ inputs and are attached to the output collection so that inference can
50
+ consume them per frame. Static extras behave exactly as before.
51
+ """
52
+
53
+ from dataclasses import dataclass, field
54
+ from enum import Enum, auto
55
+ from typing import Any, Callable, Dict, Optional, Tuple, Union
56
+
57
+ import jax
58
+ import jax.numpy as jnp
59
+ from jax import jit, lax, random
60
+
61
+ from SFI.bases.constants import constant_array
62
+ from SFI.trajectory.reserved_extras import RESERVED_NAMES, ExtrasContext, resolve_reserved
63
+
64
+ # Public API types
65
+ from SFI.statefunc import PSF, SF, Basis # noqa: F401
66
+ from SFI.statefunc.nodes.interactions.prepare import (
67
+ prepare_structural_extras_for_expr,
68
+ purge_cache_extras,
69
+ )
70
+ from SFI.utils.maths import sqrtm_psd # PSD matrix square-root
71
+
72
+ # Defer import to avoid circular dependency; the isinstance check in
73
+ # _setup_diffusion uses a lazy import guard.
74
+ _NoiseModel = None # populated on first use
75
+
76
+ Array = jnp.ndarray
77
+
78
+
79
+ def _get_noise_model_class():
80
+ """Lazy import of NoiseModel to avoid circular dependency."""
81
+ global _NoiseModel
82
+ if _NoiseModel is None:
83
+ from SFI.langevin.noise import NoiseModel as _NM
84
+
85
+ _NoiseModel = _NM
86
+ return _NoiseModel
87
+
88
+
89
+ class DiffusionKind(Enum):
90
+ """Kinds of diffusion representations accepted by the simulators."""
91
+
92
+ CONST_SCALAR = auto() # σ: float, interpreted as σ·I
93
+ CONST_MATRIX = auto() # D ∈ ℝ^{d×d}
94
+ STATE_FUNC = auto() # PSF/SF mapping x (or x,v) → (..., d, d)
95
+ NOISE_MODEL = auto() # NoiseModel instance (conserved noise, etc.)
96
+
97
+
98
+ @dataclass
99
+ class LangevinBase:
100
+ """Common plumbing for Langevin simulators.
101
+
102
+ This class is *not* a simulator itself. Overdamped/underdamped simulators
103
+ should subclass it and (i) bind models during `initialize()` by calling
104
+ :meth:`_bind_force` and :meth:`_setup_diffusion`, (ii) prepare a suitable
105
+ integrator, and (iii) call :meth:`_scan`.
106
+
107
+ Parameters
108
+ ----------
109
+ F : Basis | PSF | SF
110
+ Force model. A :class:`~SFI.statefunc.Basis` is auto-promoted to a
111
+ linear PSF via :meth:`Basis.to_psf`; pass ``theta_F`` as the
112
+ coefficient vector (or as ``{"coeff": array}``).
113
+ D : float | Array | Basis | PSF | SF
114
+ Diffusion model: scalar, constant (d×d) matrix, or a Basis/PSF/SF.
115
+ theta_F, theta_D : Optional[Array], optional
116
+ Parameters to bind PSF → SF. If `F`/`D` are already `SF` or constants, ignored.
117
+
118
+ Attributes
119
+ ----------
120
+ metadata : dict
121
+ Filled by simulators (algorithm name, dt, number of steps, etc.).
122
+ """
123
+
124
+ # unbound/original objects as provided by the user
125
+ F: Union[Basis, PSF, SF]
126
+ D: Union[float, Array, Basis, PSF, SF]
127
+
128
+ # parameter vectors to bind PSF → SF (kept separate; we don't overwrite F/D)
129
+ theta_F: Optional[Array] = None
130
+ theta_D: Optional[Array] = None
131
+
132
+ # extras dictionaries (frozen at init unless changed via set_extras)
133
+ # Users classify extras explicitly as global vs local. At runtime we
134
+ # merge them into a single dict passed as `extras=...` to both F and D.
135
+ extras_global: Optional[Dict[str, Any]] = None
136
+ extras_local: Optional[Dict[str, Any]] = None
137
+
138
+ # runtime fields (derived, not part of the public state)
139
+ _F_sf: Optional[SF] = field(init=False, default=None, repr=False) # specialized force
140
+ _D_kind: DiffusionKind = field(init=False, default=DiffusionKind.CONST_SCALAR, repr=False)
141
+ _B_const: Optional[Array] = field(init=False, default=None, repr=False) # sqrt(2D) if constant
142
+ _B_fn: Optional[Callable[..., Array]] = field(init=False, default=None, repr=False) # sqrt(2D(x[,v]))
143
+ _noise_model: Optional[Any] = field(
144
+ init=False, default=None, repr=False
145
+ ) # NoiseModel instance (conserved noise, etc.)
146
+
147
+ metadata: dict = field(default_factory=dict, init=False)
148
+
149
+ # Track whether we've already materialized structural extras for the current model+extras.
150
+ _structural_extras_prepared: bool = False
151
+
152
+ # Build-once store for dispatcher-owned structural arrays (CSR / stencil tables).
153
+ # Kept off the user-facing ``extras_global`` so it never pollutes it or leaks
154
+ # into output datasets; merged into the model-facing extras at evaluation time.
155
+ _prepared_structural: Optional[Dict[str, Any]] = None
156
+
157
+ # ----------------------- Parameter / extras API -----------------------
158
+ def set_params(self, *, theta_F: Optional[Array] = None, theta_D: Optional[Array] = None) -> None:
159
+ """Bind PSF parameters to specialize models (PSF → SF).
160
+
161
+ If `F` or `D` are `PSF`, these will be consumed during `initialize()`
162
+ when the subclass calls :meth:`_bind_force` and :meth:`_setup_diffusion`.
163
+
164
+ Notes
165
+ -----
166
+ We **do not** overwrite the user-provided `F` / `D` objects. Instead,
167
+ we keep them unmodified and store specialized callables separately
168
+ (e.g., `_F_sf`), derived from the pair (object, theta, extras).
169
+ """
170
+ if theta_F is not None:
171
+ self.theta_F = theta_F
172
+ if theta_D is not None:
173
+ self.theta_D = theta_D
174
+
175
+ def set_extras(
176
+ self,
177
+ *,
178
+ extras_global: Optional[Dict[str, Any]] = None,
179
+ extras_local: Optional[Dict[str, Any]] = None,
180
+ ) -> None:
181
+ """Freeze or update extras dictionaries used when calling F and D.
182
+
183
+ Parameters
184
+ ----------
185
+ extras_global :
186
+ System-wide extras (geometry, neighbor lists, drive protocols,
187
+ ...). Time-dependent entries are supported: a
188
+ :class:`~SFI.trajectory.TimeSeriesExtra` with one value per
189
+ recorded frame of the next ``simulate`` call, or a plain
190
+ callable ``f(t)`` of physical time (materialized at the frame
191
+ times before the scan).
192
+ extras_local :
193
+ Per-particle extras (species labels, radii, ...), with the same
194
+ time-dependence options.
195
+
196
+ Notes
197
+ -----
198
+ Both dictionaries are merged into a single model-facing extras mapping
199
+ that is passed as `extras=...` to *both* F and D. Local keys override
200
+ global keys on conflicts. Time-dependent values are held constant
201
+ across the oversampling substeps of each frame (zeroth-order hold);
202
+ the prerun uses the frame-0 value.
203
+ """
204
+ if extras_global is not None:
205
+ self.extras_global = extras_global
206
+ if extras_local is not None:
207
+ self.extras_local = extras_local
208
+ # If extras change, subclasses should re-run `initialize()` so any
209
+ # cached JIT-compiled callables see the new extras.
210
+
211
+ # Structural extras depend on geometry -> must be recomputed.
212
+ self._invalidate_prepared_extras()
213
+
214
+ # ----------------------- Public accessors -----------------------------
215
+
216
+ @property
217
+ def force_sf(self) -> "SF":
218
+ """Bound force state function (read-only).
219
+
220
+ Available after :meth:`initialize` has been called. This is the
221
+ same callable stored internally as ``_F_sf``; exposing it publicly
222
+ avoids callers reaching into private attributes.
223
+
224
+ Returns
225
+ -------
226
+ SF
227
+ ``force_sf(X)`` evaluates the (vector) force at positions *X*.
228
+ """
229
+ if self._F_sf is None:
230
+ raise RuntimeError("force_sf is not available before initialize() has been called.")
231
+ return self._F_sf
232
+
233
+ @property
234
+ def diffusion_sf(self) -> "Optional[SF]":
235
+ """Bound diffusion state function (read-only), or ``None``.
236
+
237
+ Returns the diffusion *matrix* as an ``SF`` when available.
238
+ For constant-scalar or constant-matrix diffusion that was not
239
+ built from a ``Basis``/``PSF``, this returns ``None`` (since
240
+ there is no callable ``SF``).
241
+
242
+ Available after :meth:`initialize` has been called.
243
+
244
+ Returns
245
+ -------
246
+ SF or None
247
+ ``diffusion_sf(X)`` evaluates the diffusion matrix at *X*,
248
+ or ``None`` if diffusion is not representable as an SF.
249
+ """
250
+ return getattr(self, "_D_sf", None)
251
+
252
+ # NOTE: all model-facing calls (F, D) should go through this helper.
253
+ def _model_extras(self) -> Optional[Dict[str, Any]]:
254
+ """Merged extras dict passed to F and D (global + local).
255
+
256
+ Time-dependent entries (:class:`TimeSeriesExtra`, plain callables
257
+ ``f(t)``) are materialized **at frame 0** here — the value used by
258
+ initialization probes, structural-extras preparation, and any
259
+ model evaluation outside :meth:`simulate` (which overrides them
260
+ per frame via schedules).
261
+ """
262
+ eg = self.extras_global
263
+ el = self.extras_local
264
+ required_reserved = self._model_required_extras() & RESERVED_NAMES
265
+
266
+ if eg is None and el is None and not required_reserved and not self._prepared_structural:
267
+ return None
268
+
269
+ merged: Dict[str, Any] = dict(eg or {})
270
+ merged.update(el or {}) # local overrides global on conflicts
271
+ if self._prepared_structural:
272
+ merged.update(self._prepared_structural) # dispatcher-owned structural arrays
273
+
274
+ from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra
275
+
276
+ out: Dict[str, Any] = {}
277
+ for key, val in merged.items():
278
+ if isinstance(val, TimeSeriesExtra):
279
+ out[key] = jnp.asarray(val.data)[0]
280
+ elif isinstance(val, FunctionExtra):
281
+ out[key] = val
282
+ elif callable(val):
283
+ out[key] = jnp.asarray(val(0.0))
284
+ else:
285
+ out[key] = val
286
+ # Reserved extras at frame 0 — present for probes and setup; simulate()
287
+ # supplies the per-frame ``time`` through schedules.
288
+ if required_reserved:
289
+ probe = resolve_reserved(
290
+ self._reserved_context(frame_times=jnp.asarray(0.0), duration=jnp.asarray(1.0))
291
+ )
292
+ for key in required_reserved:
293
+ out.setdefault(key, probe[key])
294
+ return out
295
+
296
+ def _materialize_step_extras(
297
+ self, *, dt: float, Nsteps: int
298
+ ) -> Tuple[Optional[Dict[str, Any]], Dict[str, Array], Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
299
+ """Split extras into static values and per-frame schedules.
300
+
301
+ Classification (per value, for both ``extras_global`` and
302
+ ``extras_local``; local overrides global on key conflicts):
303
+
304
+ - :class:`~SFI.trajectory.TimeSeriesExtra` — its array is the
305
+ schedule; the leading axis must equal ``Nsteps`` (one value per
306
+ recorded frame).
307
+ - plain callable — interpreted as ``f(t)`` of *physical time* and
308
+ materialized host-side at the frame times ``t = k·dt``.
309
+ - :class:`~SFI.trajectory.FunctionExtra` and anything else — static.
310
+
311
+ Returns
312
+ -------
313
+ (static_extras, schedules, extras_global_out, extras_local_out)
314
+ ``static_extras`` is the merged model-facing dict without the
315
+ scheduled keys; ``schedules`` maps key → ``(Nsteps, ...)``
316
+ array; the two ``*_out`` dicts mirror the inputs with
317
+ schedules wrapped as :class:`TimeSeriesExtra` (what the output
318
+ collection carries).
319
+ """
320
+ from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra, time_series_extra
321
+
322
+ schedules: Dict[str, Array] = {}
323
+
324
+ def _classify(src: Optional[Dict[str, Any]]):
325
+ if src is None:
326
+ return None, None
327
+ static: Dict[str, Any] = {}
328
+ out: Dict[str, Any] = {}
329
+ for key, val in src.items():
330
+ if isinstance(val, TimeSeriesExtra):
331
+ arr = jnp.asarray(val.data)
332
+ if int(arr.shape[0]) != int(Nsteps):
333
+ raise ValueError(
334
+ f"Time-dependent extra {key!r} has leading axis "
335
+ f"{int(arr.shape[0])} but simulate() records Nsteps={Nsteps} "
336
+ "frames — provide one value per recorded frame "
337
+ "(held constant across oversampling substeps)."
338
+ )
339
+ schedules[key] = arr
340
+ out[key] = val
341
+ elif isinstance(val, FunctionExtra):
342
+ static[key] = val
343
+ out[key] = val
344
+ elif callable(val):
345
+ arr = jnp.stack([jnp.asarray(val(k * dt)) for k in range(int(Nsteps))])
346
+ schedules[key] = arr
347
+ out[key] = time_series_extra(arr)
348
+ else:
349
+ static[key] = val
350
+ out[key] = val
351
+ return static, out
352
+
353
+ static_g, out_g = _classify(self.extras_global)
354
+ static_l, out_l = _classify(self.extras_local)
355
+
356
+ if static_g is None and static_l is None and not self._prepared_structural:
357
+ static_extras: Optional[Dict[str, Any]] = None
358
+ else:
359
+ static_extras = dict(static_g or {})
360
+ static_extras.update(static_l or {}) # local overrides global
361
+ if self._prepared_structural:
362
+ # Dispatcher-owned structural arrays are static across frames.
363
+ static_extras.update(self._prepared_structural)
364
+
365
+ # Reserved extras a model reads are resolved from the same registry the
366
+ # inference runtime uses, so a force sees the identical mapping whether
367
+ # simulated or inferred: the per-frame ``time`` becomes a schedule, the
368
+ # rest (``duration``, and — when bootstrapping an inferred force —
369
+ # ``dataset_index`` / ``particle_index``) are static.
370
+ required_reserved = self._model_required_extras() & RESERVED_NAMES
371
+ if required_reserved:
372
+ reserved = resolve_reserved(
373
+ self._reserved_context(
374
+ frame_times=jnp.arange(Nsteps, dtype=float) * dt,
375
+ duration=jnp.asarray((Nsteps - 1) * dt if Nsteps > 1 else 1.0, dtype=float),
376
+ )
377
+ )
378
+ if "time" in required_reserved:
379
+ schedules["time"] = reserved["time"]
380
+ statics = {k: reserved[k] for k in required_reserved if k != "time"}
381
+ if statics:
382
+ static_extras = dict(static_extras or {})
383
+ static_extras.update(statics)
384
+
385
+ return static_extras, schedules, out_g, out_l
386
+
387
+ def _model_required_extras(self) -> set:
388
+ """Extras keys read by the bound force or diffusion expression."""
389
+ required: set = set()
390
+ for sf in (getattr(self, "_F_sf", None), getattr(self, "_D_sf", None)):
391
+ req = getattr(sf, "required_extras", None) if sf is not None else None
392
+ if req:
393
+ required |= set(req)
394
+ return required
395
+
396
+ def _reserved_context(self, *, frame_times, duration) -> ExtrasContext:
397
+ """Context for resolving reserved extras during a simulation.
398
+
399
+ ``dataset_index`` and the particle count come from
400
+ ``_reserved_overrides`` (set, e.g., when bootstrapping an inferred force
401
+ that reads them); the ``time`` / ``duration`` clock is built from the
402
+ simulation's own frame schedule.
403
+ """
404
+ overrides = getattr(self, "_reserved_overrides", None) or {}
405
+ pidx = overrides.get("particle_index")
406
+ return ExtrasContext(
407
+ n_particles=int(jnp.asarray(pidx).shape[0]) if pidx is not None else 1,
408
+ dataset_index=int(overrides.get("dataset_index", 0)),
409
+ frame_times=frame_times,
410
+ duration=duration,
411
+ )
412
+
413
+ @staticmethod
414
+ def _shift_schedules(schedules: Dict[str, Array]) -> Dict[str, Array]:
415
+ """Shift schedules by one recorded step for the scan.
416
+
417
+ The scan records the state *after* each step, so the step that
418
+ produces frame ``k`` from frame ``k-1`` must consume ``a[k-1]``:
419
+ ``xs[i] = a[i-1]`` with ``xs[0] = a[0]`` (the step from the initial
420
+ state). This makes ``dX[k] = X[k+1] - X[k]`` generated under
421
+ ``a[k]`` — the pairing the inference layer assumes.
422
+ """
423
+ return {k: jnp.concatenate([v[:1], v[:-1]], axis=0) for k, v in schedules.items()}
424
+
425
+ def _invalidate_prepared_extras(self) -> None:
426
+ """Mark structural extras as stale.
427
+
428
+ Call this whenever extras or the model graph changes (e.g. in set_extras
429
+ or on rebind). Binding PSF parameters via :meth:`set_params` does *not*
430
+ invalidate: structural arrays derive from geometry, not from
431
+ ``theta_F``/``theta_D``. We keep it tiny so you can sprinkle it safely.
432
+ """
433
+ self._structural_extras_prepared = False
434
+ self._prepared_structural = None
435
+ self.extras_global = purge_cache_extras(self.extras_global)
436
+
437
+ def _prepare_model_extras(
438
+ self,
439
+ *,
440
+ x_probe,
441
+ v_probe=None,
442
+ mask_probe=None,
443
+ exprs, # list/tuple only
444
+ ) -> Optional[Dict[str, Any]]:
445
+ """Materialize structural extras (CSR/hyper tables, stencil masks, …) eagerly.
446
+
447
+ This runs on the **Python side**, before the first JIT evaluation of any
448
+ provided expression. The intended pattern is:
449
+
450
+ self._F_sf = self._bind_force()
451
+ extras = self._prepare_model_extras(x_probe=x0, exprs=[self._F_sf, ...])
452
+ y = self._F_sf(x0, extras=extras) # safe under JIT
453
+
454
+ Contract
455
+ --------
456
+ This function expects that *somewhere* in the expression graph (or inside
457
+ nodes/specs it contains) there is an eager hook:
458
+
459
+ obj.prepare_extras(x, v=v, mask=mask, extras=extras) -> Optional[Dict]
460
+
461
+ which:
462
+ - may mutate `extras` in-place, and/or
463
+ - may return a dict of extra keys to merge into model extras.
464
+
465
+ If no such hook exists, this is a no-op and simply returns `_model_extras()`.
466
+ """
467
+ # 1) Build the merged model-facing extras dict
468
+ extras = self._model_extras()
469
+ if extras is None:
470
+ # Ensure we have a mutable dict to inject into.
471
+ if self.extras_global is None:
472
+ self.extras_global = {}
473
+ extras = self._model_extras()
474
+ assert extras is not None # now must exist
475
+
476
+ if self._structural_extras_prepared:
477
+ return extras
478
+
479
+ if not isinstance(exprs, (list, tuple)):
480
+ raise TypeError("exprs must be a list or tuple of expressions, e.g. [self._F_sf, self._D_sf].")
481
+
482
+ additions: Optional[Dict[str, Any]] = None
483
+ for expr in exprs:
484
+ add_i = prepare_structural_extras_for_expr(expr, extras)
485
+ if add_i:
486
+ if additions is None:
487
+ additions = dict(add_i)
488
+ else:
489
+ additions.update(add_i)
490
+
491
+ # 2) Stash any returned additions in the private build-once store so
492
+ # `_model_extras()` (and the simulation scan) see the prepared structural
493
+ # keys later — without polluting the user-facing `extras_global`.
494
+ if additions:
495
+ self._prepared_structural = dict(additions)
496
+
497
+ self._structural_extras_prepared = True
498
+ return self._model_extras()
499
+
500
+ # ------------------- Utilities for subclasses to use ------------------
501
+
502
+ def _normalize_basis_to_psf(self) -> None:
503
+ """Promote any ``Basis`` objects to their linear ``PSF`` form.
504
+
505
+ Call at the top of ``initialize()`` so that all downstream isinstance
506
+ checks naturally see a ``PSF``. Raw-array ``theta_F`` / ``theta_D``
507
+ are wrapped into ``{"coeff": array}`` so ``.bind()`` works directly.
508
+ """
509
+ if isinstance(self.F, Basis):
510
+ self.F = self.F.to_psf()
511
+ if self.theta_F is not None and not isinstance(self.theta_F, dict):
512
+ self.theta_F = {"coeff": self.theta_F}
513
+ if isinstance(self.D, Basis):
514
+ self.D = self.D.to_psf()
515
+ if self.theta_D is not None and not isinstance(self.theta_D, dict):
516
+ self.theta_D = {"coeff": self.theta_D}
517
+
518
+ @staticmethod
519
+ def _is_psf(obj: Any) -> bool:
520
+ """Robust PSF check (supports subclassing)."""
521
+ return isinstance(obj, PSF)
522
+
523
+ @staticmethod
524
+ def _is_sf(obj: Any) -> bool:
525
+ """Robust SF check (supports subclassing)."""
526
+ return isinstance(obj, SF)
527
+
528
+ def _bind_force(self) -> SF:
529
+ """Return an SF for the force, binding `F` with `theta_F` if it is a PSF.
530
+
531
+ Raises
532
+ ------
533
+ ValueError
534
+ If `F` is PSF and `theta_F` is None (uninitialized).
535
+ """
536
+ if self._is_sf(self.F):
537
+ return self.F # type: ignore[return-value]
538
+ if self._is_psf(self.F):
539
+ if self.theta_F is None:
540
+ # Fall back to any defaults carried on the PSF template.
541
+ defaults = self.F.template.defaults() # type: ignore[attr-defined]
542
+ if defaults is None:
543
+ raise ValueError("Force PSF not bound: call set_params(theta_F=...).")
544
+ return self.F.bind(defaults) # type: ignore[attr-defined]
545
+ # Keep F unmodified; store the specialized SF separately.
546
+ return self.F.bind(self.theta_F) # type: ignore[attr-defined]
547
+ if isinstance(self.F, Basis):
548
+ psf = self.F.to_psf()
549
+ if self.theta_F is None:
550
+ # Default behaviour: use defaults baked into to_psf() (coeff=1).
551
+ return psf.bind()
552
+ theta = self.theta_F if isinstance(self.theta_F, dict) else {"coeff": self.theta_F}
553
+ return psf.bind(theta)
554
+ raise TypeError("F must be a Basis, PSF, or SF.")
555
+
556
+ def _setup_diffusion(self, *, d: int, with_v: bool = False) -> None:
557
+ r"""Prepare diffusion representation and its sqrt(2D) mapping.
558
+
559
+ .. physics:: Noise amplitude from diffusion tensor
560
+ :label: noise-amplitude
561
+ :category: Simulation
562
+
563
+ .. math::
564
+
565
+ B = \\sqrt{2\\,D}
566
+
567
+ For scalar :math:`\\sigma`: :math:`B = \\sqrt{2\\sigma}\\,I_d`.
568
+ For constant matrix :math:`D`: PSD matrix square root.
569
+ For state-dependent :math:`D(x)`: evaluated at each step.
570
+
571
+ Parameters
572
+ ----------
573
+ d : int
574
+ Spatial dimension.
575
+ with_v : bool, default=False
576
+ Whether the simulator will pass velocity alongside x to D (underdamped).
577
+
578
+ Side effects
579
+ ------------
580
+ Sets ``_D_kind``, ``_B_const``, ``_B_fn`` accordingly. ``_B_const``
581
+ stores ``sqrt(2D)`` if diffusion is constant. ``_B_fn`` is a callable
582
+ returning ``sqrt(2D(x))`` (or ``sqrt(2D(x, v))``) when diffusion is
583
+ state-dependent.
584
+ """
585
+ Dobj = self.D
586
+
587
+ # NoiseModel instance (conserved noise, composite, etc.)
588
+ NM = _get_noise_model_class()
589
+ if isinstance(Dobj, NM):
590
+ self._D_kind = DiffusionKind.NOISE_MODEL
591
+ self._noise_model = Dobj
592
+ self._B_const = None
593
+ self._B_fn = None
594
+ # Store effective D for observables / inference approximation
595
+ extras = self._model_extras()
596
+ D_eff = Dobj.effective_D_per_site(extras)
597
+ self._D_sf = constant_array(D_eff)
598
+ return
599
+
600
+ # Scalar constant: σ·I
601
+ if isinstance(Dobj, (int, float)):
602
+ self._D_kind = DiffusionKind.CONST_SCALAR
603
+ sigma = float(Dobj)
604
+ from SFI.utils.maths import as_default_float
605
+
606
+ self._B_const = as_default_float(jnp.sqrt(2.0 * sigma) * jnp.eye(d))
607
+ self._B_fn = None
608
+ self._D_sf = constant_array(as_default_float(float(Dobj) * jnp.eye(d)))
609
+ return
610
+
611
+ # Constant matrix: D (d×d)
612
+ if isinstance(Dobj, jnp.ndarray) and Dobj.ndim == 2:
613
+ from SFI.utils.maths import as_default_float
614
+
615
+ Dobj_c = as_default_float(Dobj)
616
+ self._D_kind = DiffusionKind.CONST_MATRIX
617
+ self._B_const = jnp.real(sqrtm_psd(2.0 * Dobj_c))
618
+ self._B_fn = None
619
+ self._D_sf = constant_array(Dobj_c)
620
+ return
621
+
622
+ # Callable: PSF or SF mapping state → (..., d, d)
623
+ if self._is_sf(Dobj):
624
+ D_sf = Dobj # type: ignore[assignment]
625
+ elif self._is_psf(Dobj):
626
+ if self.theta_D is None:
627
+ defaults = Dobj.template.defaults() # type: ignore[attr-defined]
628
+ if defaults is None:
629
+ raise ValueError("Diffusion PSF not bound: call set_params(theta_D=...).")
630
+ D_sf = Dobj.bind(defaults) # type: ignore[assignment, attr-defined]
631
+ else:
632
+ D_sf = Dobj.bind(self.theta_D) # type: ignore[assignment, attr-defined]
633
+ elif isinstance(Dobj, Basis):
634
+ psf = Dobj.to_psf()
635
+ if self.theta_D is None:
636
+ D_sf = psf.bind()
637
+ else:
638
+ theta = self.theta_D if isinstance(self.theta_D, dict) else {"coeff": self.theta_D}
639
+ D_sf = psf.bind(theta)
640
+ else:
641
+ raise TypeError("D must be a float, (d×d) array, or a Basis/PSF/SF.")
642
+
643
+ self._D_kind = DiffusionKind.STATE_FUNC
644
+
645
+ # Merge process-level extras once and close over them in the JITted
646
+ # callable. For constant extras this is cheap and keeps them out of
647
+ # the traced region.
648
+ extras = self._model_extras()
649
+
650
+ # The default extras are the merged static dict; per-step schedules
651
+ # override them at call time via the explicit ``extras`` argument.
652
+ if with_v:
653
+ self._B_fn = jit(
654
+ lambda x, v, extras=extras: jnp.real(sqrtm_psd(2.0 * D_sf(x, v=v, extras=extras)))
655
+ )
656
+ else:
657
+ self._B_fn = jit(lambda x, extras=extras: jnp.real(sqrtm_psd(2.0 * D_sf(x, extras=extras))))
658
+ self._B_const = None
659
+
660
+ @staticmethod
661
+ def _noise(key: Array, like: Array) -> Array:
662
+ """Draw standard normal noise with the same leading shape as `like`.
663
+
664
+ The trailing dimension of `like` is interpreted as the spatial axis (size d).
665
+ The leading axes (possibly empty) can host particle indices i or any batch
666
+ axes the PSF/SF expects.
667
+ """
668
+ return random.normal(key, shape=like.shape)
669
+
670
+ @staticmethod
671
+ def _apply_B(B: Array, xi: Array, *, state_dependent: bool) -> Array:
672
+ """Compute (sqrt(2D) · ξ) with correct index conventions.
673
+
674
+ Parameters
675
+ ----------
676
+ B : Array
677
+ If constant: shape (m,n) representing the matrix with spatial indices.
678
+ If state-dependent: shape (..., m, n) with leading axes matching `xi`.
679
+ xi : Array
680
+ Standard normal noise of shape (..., n), where "..." are particle/batch axes.
681
+ state_dependent : bool
682
+ Whether `B` depends on state (has leading axes) or is constant.
683
+
684
+ Returns
685
+ -------
686
+ Array
687
+ The product with shape (..., m).
688
+ """
689
+ if state_dependent:
690
+ return jnp.einsum("...mn,...n->...m", B, xi)
691
+ return jnp.einsum("mn,...n->...m", B, xi)
692
+
693
+ # --------------------------- Scan machinery ---------------------------
694
+ @staticmethod
695
+ def _scan(
696
+ initial_state: Any,
697
+ *,
698
+ step_fn: Callable[..., Any],
699
+ dt: float,
700
+ Nsteps: int,
701
+ oversampling: int,
702
+ prerun: int,
703
+ key: Array,
704
+ schedules: Optional[Dict[str, Array]] = None,
705
+ ) -> Tuple[Any, dict]:
706
+ """
707
+ Generic simulate loop with oversampling + prerun using :func:`jax.lax.scan`.
708
+
709
+ Parameters
710
+ ----------
711
+ initial_state
712
+ State at t = 0 (shape determined by the concrete simulator).
713
+ step_fn
714
+ Function implementing one Euler–Maruyama substep:
715
+ ``(state, ddt, key) -> state`` — or, when ``schedules`` is
716
+ given, ``(state, ddt, key, sched) -> state`` with ``sched`` the
717
+ per-frame slice of the scheduled extras.
718
+ dt
719
+ Output sampling time step for recorded frames.
720
+ Nsteps
721
+ Number of *recorded* steps (after oversampling/decimation).
722
+ oversampling
723
+ Number of substeps between recorded frames (must be ``>= 1``).
724
+ prerun
725
+ Number of recorded steps to discard before recording (burn-in).
726
+ key
727
+ PRNG key.
728
+ schedules
729
+ Optional per-frame extras schedules, key → ``(Nsteps, ...)``
730
+ array. Each recorded frame consumes one slice (zeroth-order
731
+ hold across its substeps; the prerun and the first recorded
732
+ step use the frame-0 values). Entered as scan inputs, never
733
+ carried.
734
+
735
+ Returns
736
+ -------
737
+ traj, info
738
+ ``traj`` is a pytree of recorded states with a leading time axis.
739
+ ``info`` is a small dictionary with counters
740
+ ``{"Nsteps", "oversampling", "prerun"}``.
741
+ """
742
+ if oversampling < 1:
743
+ raise ValueError("oversampling must be >= 1")
744
+
745
+ ddt = dt / float(oversampling)
746
+ scheduled = bool(schedules)
747
+
748
+ if scheduled:
749
+ sched0 = {k: v[0] for k, v in schedules.items()}
750
+
751
+ def one_substep_at(sched):
752
+ def one_substep(carry, _):
753
+ st, k = carry
754
+ k, sub = random.split(k)
755
+ st = step_fn(st, ddt, sub, sched)
756
+ return (st, k), None
757
+
758
+ return one_substep
759
+
760
+ def one_recorded_step(carry, sched_t):
761
+ st, k = carry
762
+ (st, k), _ = lax.scan(one_substep_at(sched_t), (st, k), None, length=oversampling)
763
+ return (st, k), st
764
+
765
+ if prerun > 0:
766
+ (state, key), _ = lax.scan(
767
+ lambda c, _: one_recorded_step(c, sched0), (initial_state, key), None, length=prerun
768
+ )
769
+ else:
770
+ state = initial_state
771
+
772
+ if Nsteps > 0:
773
+ xs = LangevinBase._shift_schedules(schedules)
774
+ (state, key), traj = lax.scan(one_recorded_step, (state, key), xs, length=Nsteps)
775
+ else:
776
+ example = initial_state
777
+ traj = jax.tree_util.tree_map(lambda a: a[None, ...][:0], example)
778
+
779
+ else:
780
+
781
+ def one_substep(carry, _):
782
+ st, k = carry
783
+ k, sub = random.split(k)
784
+ st = step_fn(st, ddt, sub)
785
+ return (st, k), None
786
+
787
+ def one_recorded_step_plain(carry, _):
788
+ st, k = carry
789
+ (st, k), _ = lax.scan(one_substep, (st, k), None, length=oversampling)
790
+ return (st, k), st
791
+
792
+ # ------------- Burn-in (avoid zero-length scan under disable_jit) -----
793
+ if prerun > 0:
794
+ (state, key), _ = lax.scan(one_recorded_step_plain, (initial_state, key), None, length=prerun)
795
+ else:
796
+ state = initial_state
797
+
798
+ # ------------- Recording (also guard Nsteps==0) -----------------------
799
+ if Nsteps > 0:
800
+ (state, key), traj = lax.scan(one_recorded_step_plain, (state, key), None, length=Nsteps)
801
+ else:
802
+ # Build an empty time axis (0, ...) matching the state pytree
803
+ example = initial_state
804
+ traj = jax.tree_util.tree_map(lambda a: a[None, ...][:0], example)
805
+
806
+ info = {
807
+ "Nsteps": int(Nsteps),
808
+ "oversampling": int(oversampling),
809
+ "prerun": int(prerun),
810
+ }
811
+ return traj, info
812
+
813
+ # ----------------------------- Data export -----------------------------
814
+ def _traj_to_collection(
815
+ self,
816
+ traj: Array,
817
+ *,
818
+ dt: float,
819
+ meta: Optional[dict] = None,
820
+ extras_global_out: Optional[Dict[str, Any]] = None,
821
+ extras_local_out: Optional[Dict[str, Any]] = None,
822
+ ):
823
+ """
824
+ Convert a trajectory of positions into a :class:`TrajectoryCollection`.
825
+
826
+ Parameters
827
+ ----------
828
+ traj
829
+ Recorded states with a leading time axis. Expected shapes are
830
+
831
+ - ``(T, d)`` for single-trajectory simulations (no particle axis),
832
+ - ``(T, P, d)`` for particle-based simulations (particle axis = 1).
833
+
834
+ The shape must be consistent with the force model's ``pdepth``
835
+ convention.
836
+ dt
837
+ Time step between successive recorded frames.
838
+ meta
839
+ Optional metadata dictionary to attach to the underlying dataset.
840
+ A shallow copy is taken before storage.
841
+
842
+ Returns
843
+ -------
844
+ TrajectoryCollection
845
+ A collection with a single dataset, containing the positions only.
846
+ """
847
+ # Local import to avoid a hard dependency at module import time.
848
+ from SFI.trajectory.collection import TrajectoryCollection
849
+
850
+ meta_copy = None if meta is None else dict(meta)
851
+ # Extras are attached directly to the dataset so that analysis can
852
+ # access them via `extras` in the integration runtime. Time-dependent
853
+ # extras arrive through the *_out overrides as frame-aligned
854
+ # TimeSeriesExtra values.
855
+ eg = self.extras_global if extras_global_out is None else extras_global_out
856
+ el = self.extras_local if extras_local_out is None else extras_local_out
857
+ return TrajectoryCollection.from_arrays(
858
+ X=traj,
859
+ dt=dt,
860
+ extras_global=purge_cache_extras(eg),
861
+ extras_local=purge_cache_extras(el),
862
+ meta=meta_copy,
863
+ )