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
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
|
+
)
|