StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
# dataset.py
|
|
2
|
+
"""
|
|
3
|
+
Trajectory dataset: single-index producer with explicit valid index window.
|
|
4
|
+
|
|
5
|
+
This module defines :class:`TrajectoryDataset`, an immutable container for one
|
|
6
|
+
trajectory and a JAX-traceable *single-t* row producer used by the integration
|
|
7
|
+
runtime. No shape heuristics are used. Time bounds are enforced by an explicit
|
|
8
|
+
``valid_indices(require)`` window; all gathers assume indices are in-bounds.
|
|
9
|
+
|
|
10
|
+
Key APIs
|
|
11
|
+
--------
|
|
12
|
+
- :meth:`valid_indices(require, subsampling=None)`
|
|
13
|
+
Returns the time indices ``t`` for which all requested streams are in-bounds.
|
|
14
|
+
- :meth:`make_producer(require, include_mask=True, include_dt=True, ...)`
|
|
15
|
+
Returns ``producer(t)`` that builds a fixed-structure single-row mapping.
|
|
16
|
+
- :meth:`build_extras(t, dataset_index=0, context=None)`
|
|
17
|
+
Assembles user + reserved extras at a single time index.
|
|
18
|
+
|
|
19
|
+
Streams
|
|
20
|
+
-------
|
|
21
|
+
- States: ``"X"``, ``"X_minus"``, ``"X_plus"``,
|
|
22
|
+
``"X_minusminus"``, ``"X_plusplus"``.
|
|
23
|
+
- Increments: ``"dX_minus" = X[t]-X[t-1]``, ``"dX" = X[t+1]-X[t]``,
|
|
24
|
+
``"dX_plus" = X[t+2]-X[t+1]``.
|
|
25
|
+
- Windows: ``"X_window:<W>"`` returns ``(W, N, d)`` containing
|
|
26
|
+
``X[t - (W-1)//2], ..., X[t + W//2]`` (W positions, any positive int).
|
|
27
|
+
Odd W → symmetric; even W → one extra to the right.
|
|
28
|
+
- Mask: ``"mask"`` (per-particle validity at ``t``).
|
|
29
|
+
- Time steps: if ``include_dt=True``, provides ``"dt"`` and, when required,
|
|
30
|
+
``"dt_minus"``, ``"dt_plus"`` from either scalar/array ``dt`` or absolute
|
|
31
|
+
times ``t``.
|
|
32
|
+
- Extras: include by adding ``"extras"`` to ``require``. Values are static
|
|
33
|
+
objects or :class:`TimeSeriesExtra` that are sliced at time ``t``. Callables
|
|
34
|
+
are allowed if JAX-traceable and accept ``(t_idx, context=None)``.
|
|
35
|
+
|
|
36
|
+
Boundary policy
|
|
37
|
+
---------------
|
|
38
|
+
- ``valid_indices`` computes the exact interior window from stream offsets.
|
|
39
|
+
- All gathers assume in-bounds indices. No clamping. Passing an invalid
|
|
40
|
+
index is a logic error and leads to undefined behavior under XLA.
|
|
41
|
+
- Edge effects must be handled by downstream masking via ``"mask_out"``.
|
|
42
|
+
|
|
43
|
+
Example
|
|
44
|
+
-------
|
|
45
|
+
>>> ds = TrajectoryDataset(X, dt=0.01, extras_global={"box": jnp.array([Lx, Ly])})
|
|
46
|
+
>>> req = {"X", "dX", "mask", "extras"}
|
|
47
|
+
>>> t_idx = ds.valid_indices(req) # e.g. arange(0, T-1)
|
|
48
|
+
>>> producer = ds.make_producer(req, include_dt=True)
|
|
49
|
+
>>> # Integrator does: Ys = jax.vmap(lambda tt: program(**producer(tt)))(t_idx)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import uuid as _uuid
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Set, Tuple
|
|
57
|
+
|
|
58
|
+
import jax
|
|
59
|
+
import jax.lax as lax
|
|
60
|
+
import jax.numpy as jnp
|
|
61
|
+
import numpy as np
|
|
62
|
+
|
|
63
|
+
from SFI.trajectory.reserved_extras import ExtrasContext, resolve_extras, slice_frame_extras
|
|
64
|
+
|
|
65
|
+
Array = jax.Array
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"FunctionExtra",
|
|
69
|
+
"function_extra",
|
|
70
|
+
"TimeSeriesExtra",
|
|
71
|
+
"time_series_extra",
|
|
72
|
+
"TrajectoryDataset",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# --------------------------------------------------------------------------- #
|
|
76
|
+
# Time-series extras
|
|
77
|
+
# --------------------------------------------------------------------------- #
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TimeSeriesExtra:
|
|
82
|
+
"""Wrapper for time-dependent extras with an explicit leading time axis.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
data :
|
|
87
|
+
Array with shape ``(T, ...)`` for globals or ``(T, N, ...)`` for
|
|
88
|
+
per-particle extras. The dataset will slice ``data[t]``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
data: Array
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def time_series_extra(x: Any) -> TimeSeriesExtra:
|
|
95
|
+
"""Build a :class:`TimeSeriesExtra` from an array-like."""
|
|
96
|
+
return TimeSeriesExtra(jnp.asarray(x))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class FunctionExtra:
|
|
101
|
+
"""Wrapper for a JAX-compatible callable passed through extras.
|
|
102
|
+
|
|
103
|
+
Unlike plain callables (which are invoked eagerly by
|
|
104
|
+
:meth:`TrajectoryDataset.build_extras` as time-dependent generators),
|
|
105
|
+
a ``FunctionExtra`` is **passed through unchanged** so the user's basis
|
|
106
|
+
function can call it inside JIT.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
func : callable
|
|
111
|
+
A JAX-traceable function, e.g. ``func(x) -> Array``. It will be
|
|
112
|
+
captured as a compile-time constant inside ``@jax.jit``.
|
|
113
|
+
|
|
114
|
+
Examples
|
|
115
|
+
--------
|
|
116
|
+
>>> adhesion = FunctionExtra(lambda x: jnp.exp(-jnp.sum(x**2)))
|
|
117
|
+
>>> coll = TrajectoryCollection.from_arrays(
|
|
118
|
+
... X=X, dt=0.01,
|
|
119
|
+
... extras_global={"adhesion": adhesion},
|
|
120
|
+
... )
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
func: Callable
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def function_extra(func: Callable) -> FunctionExtra:
|
|
127
|
+
"""Build a :class:`FunctionExtra` from a callable."""
|
|
128
|
+
if not callable(func):
|
|
129
|
+
raise TypeError(f"function_extra() requires a callable, got {type(func)}")
|
|
130
|
+
return FunctionExtra(func)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _slice_time_extras(extras: Optional[Mapping[str, Any]], a: int, b: int, T: int) -> Dict[str, Any]:
|
|
134
|
+
"""Restrict an extras mapping to frames ``[a, b)``.
|
|
135
|
+
|
|
136
|
+
Applies the runtime contract of :meth:`TrajectoryDataset.build_extras`:
|
|
137
|
+
:class:`TimeSeriesExtra` values are sliced on their leading time axis
|
|
138
|
+
(validated against ``T``), :class:`FunctionExtra` and static values
|
|
139
|
+
pass through, and plain callables (time-dependent generators
|
|
140
|
+
``f(t_idx, context=None)``) are offset-wrapped so the slice keeps
|
|
141
|
+
seeing its original time indices.
|
|
142
|
+
"""
|
|
143
|
+
out: Dict[str, Any] = {}
|
|
144
|
+
for key, val in (extras or {}).items():
|
|
145
|
+
if isinstance(val, TimeSeriesExtra):
|
|
146
|
+
data = val.data
|
|
147
|
+
if int(data.shape[0]) != T:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"TimeSeriesExtra {key!r} has leading axis {int(data.shape[0])} != T={T}; "
|
|
150
|
+
"cannot slice it consistently."
|
|
151
|
+
)
|
|
152
|
+
out[key] = TimeSeriesExtra(data[a:b])
|
|
153
|
+
elif isinstance(val, FunctionExtra):
|
|
154
|
+
out[key] = val
|
|
155
|
+
elif callable(val):
|
|
156
|
+
if a == 0:
|
|
157
|
+
out[key] = val
|
|
158
|
+
else:
|
|
159
|
+
|
|
160
|
+
def _shifted(t_idx, context=None, _f=val, _a=a):
|
|
161
|
+
return _f(t_idx + _a, context=context)
|
|
162
|
+
|
|
163
|
+
out[key] = _shifted
|
|
164
|
+
else:
|
|
165
|
+
out[key] = val
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --------------------------------------------------------------------------- #
|
|
170
|
+
# Dataset
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
|
|
173
|
+
# Streams and their required time offsets relative to the central index t.
|
|
174
|
+
# Values are (min_offset, max_offset).
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_window_key(name: str) -> Optional[int]:
|
|
178
|
+
"""Parse ``'X_window:<W>'`` → W (positive int), or None for other keys.
|
|
179
|
+
|
|
180
|
+
The window delivers W consecutive positions at offsets
|
|
181
|
+
``-(W-1)//2, ..., W//2`` relative to the central time index.
|
|
182
|
+
For odd W the window is symmetric; for even W it extends one
|
|
183
|
+
extra step to the right.
|
|
184
|
+
"""
|
|
185
|
+
if not name.startswith("X_window:"):
|
|
186
|
+
return None
|
|
187
|
+
w = int(name.split(":", 1)[1])
|
|
188
|
+
if w < 1:
|
|
189
|
+
raise ValueError(f"X_window width must be a positive integer, got {w}")
|
|
190
|
+
return w
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
STREAM_OFFSETS: Mapping[str, Tuple[int, int]] = {
|
|
194
|
+
"X_m4": (-4, 0),
|
|
195
|
+
"X_m3": (-3, 0),
|
|
196
|
+
"X_minusminus": (-2, 0),
|
|
197
|
+
"X_minus": (-1, 0),
|
|
198
|
+
"X": (0, 0),
|
|
199
|
+
"X_plus": (0, +1),
|
|
200
|
+
"X_plusplus": (0, +2),
|
|
201
|
+
"X_p3": (0, +3),
|
|
202
|
+
"X_p4": (0, +4),
|
|
203
|
+
"dX_minus": (-1, 0),
|
|
204
|
+
"dX": (0, +1),
|
|
205
|
+
"dX_plus": (+1, +2),
|
|
206
|
+
"mask": (0, 0),
|
|
207
|
+
"__dt__": (0, +1), # pseudo-key: excluded from stream slicing but forces valid t+1 for dt
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class TrajectoryDataset:
|
|
213
|
+
"""Immutable dataset for a single trajectory.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
X :
|
|
218
|
+
State array of shape ``(T, N, d)`` or ``(T, d)``. If ``(T, d)``, N is 1.
|
|
219
|
+
dt :
|
|
220
|
+
Either a scalar step, an array of shape ``(T,)`` (per-step), or ``None``.
|
|
221
|
+
If ``None`` and ``t`` is provided, steps are derived from ``t``.
|
|
222
|
+
t :
|
|
223
|
+
Optional absolute time vector of shape ``(T,)``. If provided, it defines
|
|
224
|
+
dt via finite differences when requested.
|
|
225
|
+
mask :
|
|
226
|
+
Optional boolean mask of shape ``(T, N)`` or ``(T,)`` marking valid
|
|
227
|
+
observations at time t and particle n ("static mask"). If ``None``,
|
|
228
|
+
all ones. A True entry means the particle's *position* is known and
|
|
229
|
+
can be used for state evaluation (e.g. neighbor forces).
|
|
230
|
+
dynamic_mask :
|
|
231
|
+
Optional boolean mask of shape ``(T, N)`` or ``(T,)`` marking entries
|
|
232
|
+
whose *increments* are reliable and should contribute to parameter
|
|
233
|
+
fitting ("dynamic mask"). Must be a subset of ``mask``
|
|
234
|
+
(``dynamic_mask ⊆ mask``). If ``None``, defaults to ``mask``.
|
|
235
|
+
Typical use: particles near open boundaries are statically valid
|
|
236
|
+
(their positions are known) but dynamically masked (their
|
|
237
|
+
neighborhoods are incomplete, biasing their increments).
|
|
238
|
+
extras_global :
|
|
239
|
+
Dict of global extras. Values are static objects, :class:`TimeSeriesExtra`,
|
|
240
|
+
or JAX-traceable callables ``f(t_idx, context=None) -> Array`` with a
|
|
241
|
+
leading time axis.
|
|
242
|
+
extras_local :
|
|
243
|
+
Dict of per-particle extras. Same typing as ``extras_global``.
|
|
244
|
+
Time-series entries typically have shape ``(T, N, ...)``.
|
|
245
|
+
metadata :
|
|
246
|
+
Free-form metadata.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
X: Array
|
|
250
|
+
dt: Optional[Array | float] = None
|
|
251
|
+
t: Optional[Array] = None
|
|
252
|
+
mask: Optional[Array] = None
|
|
253
|
+
dynamic_mask: Optional[Array] = None
|
|
254
|
+
extras_global: Dict[str, Any] | None = field(default_factory=dict)
|
|
255
|
+
extras_local: Dict[str, Any] | None = field(default_factory=dict)
|
|
256
|
+
meta: Dict[str, Any] | None = field(default_factory=dict)
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
# Stable per-dataset identity, carried in ``meta`` so it survives
|
|
260
|
+
# degradation, splitting, and save/load, and used to derive the dense
|
|
261
|
+
# ``dataset_index`` within a collection.
|
|
262
|
+
if self.meta is None:
|
|
263
|
+
object.__setattr__(self, "meta", {})
|
|
264
|
+
self.meta.setdefault("uuid", _uuid.uuid4().hex)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def uuid(self) -> str:
|
|
268
|
+
"""Stable identity of this dataset."""
|
|
269
|
+
return self.meta["uuid"]
|
|
270
|
+
|
|
271
|
+
# ---- basic shapes ----------------------------------------------------- #
|
|
272
|
+
@property
|
|
273
|
+
def T(self) -> int:
|
|
274
|
+
return int(jnp.asarray(self.X).shape[0])
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def N(self) -> int:
|
|
278
|
+
X = jnp.asarray(self.X)
|
|
279
|
+
return int(X.shape[1]) if X.ndim == 3 else 1
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def d(self) -> int:
|
|
283
|
+
X = jnp.asarray(self.X)
|
|
284
|
+
return int(X.shape[-1])
|
|
285
|
+
|
|
286
|
+
def Teff(self, required: Set[str], *, subsampling: int = 1) -> float:
|
|
287
|
+
"""Effective exposure time over valid indices for weighting.
|
|
288
|
+
|
|
289
|
+
Defined as
|
|
290
|
+
|
|
291
|
+
Teff = sum_t N_active[t] * dt[t],
|
|
292
|
+
|
|
293
|
+
where N_active[t] is the number of active (unmasked) particles at time
|
|
294
|
+
index t under the same stream requirements used by the integration
|
|
295
|
+
runtime.
|
|
296
|
+
|
|
297
|
+
This reuses the same dt logic as :meth:`_dt_fields_single` and the same
|
|
298
|
+
masking logic as :meth:`_output_mask_single`, so that weighting matches
|
|
299
|
+
exactly what the runtime sees.
|
|
300
|
+
"""
|
|
301
|
+
idx = self.valid_indices(required, subsampling=subsampling)
|
|
302
|
+
if idx.size == 0:
|
|
303
|
+
return 0.0
|
|
304
|
+
|
|
305
|
+
# Per-step dt for the central index t, using the same rules as the
|
|
306
|
+
# runtime (t vs dt array, scalar dt, etc.).
|
|
307
|
+
force_dt_keys = {"dt"}
|
|
308
|
+
|
|
309
|
+
def _dt_single(t_scalar: Array) -> Array:
|
|
310
|
+
dt_fields = self._dt_fields_single(
|
|
311
|
+
required,
|
|
312
|
+
t_scalar,
|
|
313
|
+
force_dt_keys=force_dt_keys,
|
|
314
|
+
)
|
|
315
|
+
if "dt" not in dt_fields:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"Teff requires dt to be computable; this should not happen if either 't' or 'dt' is provided."
|
|
318
|
+
)
|
|
319
|
+
return dt_fields["dt"]
|
|
320
|
+
|
|
321
|
+
dt_per = jax.vmap(_dt_single)(idx) # (K,)
|
|
322
|
+
|
|
323
|
+
# N_active[t]: same semantics as the "mask_out" produced by
|
|
324
|
+
# make_producer(require, include_mask=True).
|
|
325
|
+
mask_out = jax.vmap(lambda t_scalar: self._output_mask_single(required, t_scalar))(idx) # (K, N)
|
|
326
|
+
N_active = jnp.sum(mask_out.astype(jnp.float32), axis=-1) # (K,)
|
|
327
|
+
|
|
328
|
+
return float(jnp.sum(N_active * dt_per.astype(jnp.float32)))
|
|
329
|
+
|
|
330
|
+
# Convenience builder
|
|
331
|
+
@classmethod
|
|
332
|
+
def from_arrays(
|
|
333
|
+
cls,
|
|
334
|
+
*,
|
|
335
|
+
X: Any,
|
|
336
|
+
dt: Optional[float] = None,
|
|
337
|
+
t: Optional[Any] = None,
|
|
338
|
+
mask: Optional[Any] = None,
|
|
339
|
+
dynamic_mask: Optional[Any] = None,
|
|
340
|
+
extras_global: Optional[Dict[str, Any]] = None,
|
|
341
|
+
extras_local: Optional[Dict[str, Any]] = None,
|
|
342
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
343
|
+
) -> "TrajectoryDataset":
|
|
344
|
+
"""Construct a dataset from array-likes.
|
|
345
|
+
|
|
346
|
+
All inputs are converted to JAX arrays where relevant; extras and meta
|
|
347
|
+
are stored as-is (no deep conversion).
|
|
348
|
+
|
|
349
|
+
Returns
|
|
350
|
+
-------
|
|
351
|
+
TrajectoryDataset
|
|
352
|
+
|
|
353
|
+
Raises
|
|
354
|
+
------
|
|
355
|
+
ValueError
|
|
356
|
+
If X contains NaN/Inf, has wrong dimensionality, dt <= 0,
|
|
357
|
+
or the trajectory is too short for any useful computation.
|
|
358
|
+
"""
|
|
359
|
+
import warnings
|
|
360
|
+
|
|
361
|
+
X = jnp.asarray(X)
|
|
362
|
+
|
|
363
|
+
# ---- shape validation ----
|
|
364
|
+
if X.ndim < 2 or X.ndim > 3:
|
|
365
|
+
raise ValueError(
|
|
366
|
+
f"X must have shape (T, d) or (T, N, d), got shape {tuple(X.shape)}. "
|
|
367
|
+
f"For a single scalar time series, reshape to (T, 1)."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
T = X.shape[0]
|
|
371
|
+
if T < 1:
|
|
372
|
+
raise ValueError(f"Trajectory must have at least 1 time step (got T={T}).")
|
|
373
|
+
if T < 4:
|
|
374
|
+
warnings.warn(
|
|
375
|
+
f"Very short trajectory (T={T}). Most inference methods need T >> 1 for meaningful results.",
|
|
376
|
+
stacklevel=2,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# ---- finiteness check (valid positions only) ----
|
|
380
|
+
# Masked positions may legitimately contain fill values (e.g. 0.0 or
|
|
381
|
+
# even NaN when data is sparse/patchy). Only check finite-ness for the
|
|
382
|
+
# entries that are actually valid (mask=True).
|
|
383
|
+
_check_X: Any = X
|
|
384
|
+
if mask is not None:
|
|
385
|
+
_m = jnp.asarray(mask, dtype=bool)
|
|
386
|
+
if X.ndim == 3 and _m.ndim == 2: # (T,N,d) + (T,N)
|
|
387
|
+
_check_X = X[_m] # (K, d)
|
|
388
|
+
elif X.ndim == 2 and _m.ndim == 1: # (T,d) + (T,)
|
|
389
|
+
_check_X = X[_m]
|
|
390
|
+
if not jnp.all(jnp.isfinite(_check_X)):
|
|
391
|
+
n_nan = int(jnp.sum(jnp.isnan(_check_X)))
|
|
392
|
+
n_inf = int(jnp.sum(jnp.isinf(_check_X)))
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"X contains non-finite values ({n_nan} NaN, {n_inf} Inf) "
|
|
395
|
+
f"in unmasked positions. "
|
|
396
|
+
f"Clean or mask your data before constructing a TrajectoryDataset."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# ---- dt validation ----
|
|
400
|
+
if dt is not None:
|
|
401
|
+
dt_arr = jnp.asarray(dt)
|
|
402
|
+
if dt_arr.ndim == 0:
|
|
403
|
+
if float(dt_arr) <= 0:
|
|
404
|
+
raise ValueError(f"Scalar dt must be positive, got dt={float(dt_arr)}.")
|
|
405
|
+
else:
|
|
406
|
+
if jnp.any(dt_arr <= 0):
|
|
407
|
+
raise ValueError(
|
|
408
|
+
f"All dt values must be positive. Found {int(jnp.sum(dt_arr <= 0))} "
|
|
409
|
+
f"non-positive entries (min={float(jnp.min(dt_arr))})."
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# ---- t validation ----
|
|
413
|
+
t = None if t is None else jnp.asarray(t)
|
|
414
|
+
if t is not None:
|
|
415
|
+
if t.shape[0] != T:
|
|
416
|
+
raise ValueError(f"Time vector length ({t.shape[0]}) must match X's time dimension ({T}).")
|
|
417
|
+
if dt is not None:
|
|
418
|
+
warnings.warn(
|
|
419
|
+
"Both 't' and 'dt' were provided. 't' takes precedence; 'dt' will be ignored.",
|
|
420
|
+
stacklevel=2,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
mask = None if mask is None else jnp.asarray(mask)
|
|
424
|
+
dynamic_mask = None if dynamic_mask is None else jnp.asarray(dynamic_mask)
|
|
425
|
+
|
|
426
|
+
# ---- dynamic_mask ⊆ mask validation ----
|
|
427
|
+
if dynamic_mask is not None and mask is not None:
|
|
428
|
+
_s = jnp.asarray(mask, dtype=bool)
|
|
429
|
+
_d = jnp.asarray(dynamic_mask, dtype=bool)
|
|
430
|
+
# Broadcast to compatible shapes for the subset check.
|
|
431
|
+
if _s.ndim == 1 and _d.ndim == 1:
|
|
432
|
+
pass
|
|
433
|
+
elif _s.ndim == 2 and _d.ndim == 2:
|
|
434
|
+
pass
|
|
435
|
+
elif _s.ndim == 1 and _d.ndim == 2:
|
|
436
|
+
_s = _s[:, None]
|
|
437
|
+
elif _s.ndim == 2 and _d.ndim == 1:
|
|
438
|
+
_d = _d[:, None]
|
|
439
|
+
if bool(jnp.any(_d & ~_s)):
|
|
440
|
+
raise ValueError(
|
|
441
|
+
"dynamic_mask must be a subset of mask: found entries where dynamic_mask is True but mask is False."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return cls(
|
|
445
|
+
X=X,
|
|
446
|
+
dt=dt,
|
|
447
|
+
t=t,
|
|
448
|
+
mask=mask,
|
|
449
|
+
dynamic_mask=dynamic_mask,
|
|
450
|
+
extras_global=extras_global or {},
|
|
451
|
+
extras_local=extras_local or {},
|
|
452
|
+
meta=meta or {},
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# ---- normalized views ------------------------------------------------- #
|
|
456
|
+
def _X3d(self) -> Array:
|
|
457
|
+
"""Return X with shape (T, N, d)."""
|
|
458
|
+
X = jnp.asarray(self.X)
|
|
459
|
+
if X.ndim == 3:
|
|
460
|
+
return X
|
|
461
|
+
if X.ndim == 2:
|
|
462
|
+
return X[:, None, :]
|
|
463
|
+
raise ValueError(f"X must have shape (T,N,d) or (T,d), got {tuple(X.shape)}")
|
|
464
|
+
|
|
465
|
+
def _M2d(self) -> Array:
|
|
466
|
+
"""Return static mask with shape (T, N) and dtype bool."""
|
|
467
|
+
if self.mask is None:
|
|
468
|
+
return jnp.ones((self.T, self.N), dtype=bool)
|
|
469
|
+
M = jnp.asarray(self.mask).astype(bool)
|
|
470
|
+
if M.ndim == 2:
|
|
471
|
+
return M
|
|
472
|
+
if M.ndim == 1:
|
|
473
|
+
return M[:, None]
|
|
474
|
+
raise ValueError(f"mask must have shape (T,N) or (T,), got {tuple(M.shape)}")
|
|
475
|
+
|
|
476
|
+
def _dynamic_M2d(self) -> Array:
|
|
477
|
+
"""Return dynamic mask with shape (T, N) and dtype bool.
|
|
478
|
+
|
|
479
|
+
Falls back to the static mask if no dynamic_mask was provided.
|
|
480
|
+
"""
|
|
481
|
+
if self.dynamic_mask is None:
|
|
482
|
+
return self._M2d()
|
|
483
|
+
M = jnp.asarray(self.dynamic_mask).astype(bool)
|
|
484
|
+
if M.ndim == 2:
|
|
485
|
+
return M
|
|
486
|
+
if M.ndim == 1:
|
|
487
|
+
return M[:, None]
|
|
488
|
+
raise ValueError(f"dynamic_mask must have shape (T,N) or (T,), got {tuple(M.shape)}")
|
|
489
|
+
|
|
490
|
+
# ---- offsets and valid window ---------------------------------------- #
|
|
491
|
+
@staticmethod
|
|
492
|
+
def _required_offsets(required: Set[str]) -> Tuple[int, int]:
|
|
493
|
+
"""Aggregate min/max offsets implied by required streams."""
|
|
494
|
+
amin, amax = 0, 0
|
|
495
|
+
for k in required:
|
|
496
|
+
if k in STREAM_OFFSETS:
|
|
497
|
+
a, b = STREAM_OFFSETS[k]
|
|
498
|
+
amin = min(amin, a)
|
|
499
|
+
amax = max(amax, b)
|
|
500
|
+
else:
|
|
501
|
+
w = _parse_window_key(k)
|
|
502
|
+
if w is not None:
|
|
503
|
+
left = (w - 1) // 2
|
|
504
|
+
right = w - 1 - left # = w // 2
|
|
505
|
+
amin = min(amin, -left)
|
|
506
|
+
amax = max(amax, +right)
|
|
507
|
+
return amin, amax
|
|
508
|
+
|
|
509
|
+
def valid_indices(self, required: Set[str], subsampling: Optional[int] = None) -> Array:
|
|
510
|
+
"""Return valid time indices given required streams.
|
|
511
|
+
|
|
512
|
+
A time index ``t`` is valid iff ``t + amin >= 0`` and ``t + amax <= T-1``,
|
|
513
|
+
where ``(amin, amax)`` aggregates all offsets required by streams in
|
|
514
|
+
``required``. Extras do not affect the window.
|
|
515
|
+
|
|
516
|
+
Parameters
|
|
517
|
+
----------
|
|
518
|
+
required :
|
|
519
|
+
Set of stream names and possibly ``"extras"``.
|
|
520
|
+
subsampling :
|
|
521
|
+
Optional positive integer. If provided, keep only indices where
|
|
522
|
+
``t % subsampling == 0`` (grid-aligned to multiples of
|
|
523
|
+
``subsampling``). This may exclude the first valid index if it
|
|
524
|
+
is not a multiple of ``subsampling``.
|
|
525
|
+
|
|
526
|
+
Returns
|
|
527
|
+
-------
|
|
528
|
+
jax.Array
|
|
529
|
+
1-D array of valid time indices (dtype=int32), possibly empty.
|
|
530
|
+
"""
|
|
531
|
+
T = self.T
|
|
532
|
+
amin, amax = self._required_offsets(required)
|
|
533
|
+
start = max(0, -amin)
|
|
534
|
+
stop_incl = T - 1 - max(0, amax)
|
|
535
|
+
if stop_incl < start:
|
|
536
|
+
idx = jnp.array([], dtype=jnp.int32)
|
|
537
|
+
else:
|
|
538
|
+
idx = jnp.arange(start, stop_incl + 1, dtype=jnp.int32)
|
|
539
|
+
if subsampling is not None:
|
|
540
|
+
if subsampling <= 0:
|
|
541
|
+
raise ValueError("subsampling must be a positive integer.")
|
|
542
|
+
if idx.size == 0:
|
|
543
|
+
return idx
|
|
544
|
+
idx = idx[idx % subsampling == 0]
|
|
545
|
+
return idx
|
|
546
|
+
|
|
547
|
+
# ---- low-level time gather (assumes in-bounds) ------------------------ #
|
|
548
|
+
@staticmethod
|
|
549
|
+
def _take_t(arr: Array, t: Array) -> Array:
|
|
550
|
+
"""Gather arr[t] on axis 0. Assumes 0 <= t < arr.shape[0]."""
|
|
551
|
+
arr = jnp.asarray(arr)
|
|
552
|
+
return lax.dynamic_index_in_dim(arr, t, axis=0, keepdims=False)
|
|
553
|
+
|
|
554
|
+
# ---- single-t stream access ------------------------------------------ #
|
|
555
|
+
def _stream_single(self, name: str, t: Array) -> Array:
|
|
556
|
+
X = self._X3d()
|
|
557
|
+
if name == "mask":
|
|
558
|
+
return self._take_t(self._M2d(), t)
|
|
559
|
+
|
|
560
|
+
if name in ("X", "X_minus", "X_plus", "X_minusminus", "X_plusplus", "X_m3", "X_m4", "X_p3", "X_p4"):
|
|
561
|
+
offsets = {
|
|
562
|
+
"X": 0,
|
|
563
|
+
"X_minus": -1,
|
|
564
|
+
"X_plus": +1,
|
|
565
|
+
"X_minusminus": -2,
|
|
566
|
+
"X_plusplus": +2,
|
|
567
|
+
"X_m3": -3,
|
|
568
|
+
"X_m4": -4,
|
|
569
|
+
"X_p3": +3,
|
|
570
|
+
"X_p4": +4,
|
|
571
|
+
}
|
|
572
|
+
return self._take_t(X, t + offsets[name])
|
|
573
|
+
|
|
574
|
+
if name in ("dX_minus", "dX", "dX_plus"):
|
|
575
|
+
if name == "dX_minus":
|
|
576
|
+
return self._take_t(X, t) - self._take_t(X, t - 1)
|
|
577
|
+
if name == "dX":
|
|
578
|
+
return self._take_t(X, t + 1) - self._take_t(X, t)
|
|
579
|
+
if name == "dX_plus":
|
|
580
|
+
return self._take_t(X, t + 2) - self._take_t(X, t + 1)
|
|
581
|
+
|
|
582
|
+
w = _parse_window_key(name)
|
|
583
|
+
if w is not None:
|
|
584
|
+
left = (w - 1) // 2
|
|
585
|
+
offsets = jnp.arange(w) - left # (W,)
|
|
586
|
+
return X[t + offsets] # (W, N, d)
|
|
587
|
+
|
|
588
|
+
raise KeyError(f"Unknown stream '{name}'")
|
|
589
|
+
|
|
590
|
+
# ---- output mask given requirements ---------------------------------- #
|
|
591
|
+
def _output_mask_single(self, required: Set[str], t: Array) -> Array:
|
|
592
|
+
"""Per-particle validity for this row, based on requested streams.
|
|
593
|
+
|
|
594
|
+
At the central index ``t``, uses the *dynamic mask* (increment
|
|
595
|
+
reliable) so that boundary particles are excluded from fitting even
|
|
596
|
+
when their positions are known. At all neighbor offsets, uses the
|
|
597
|
+
*static mask* (position known) so that force-evaluation neighbours
|
|
598
|
+
are only required to be visible, not dynamically reliable.
|
|
599
|
+
"""
|
|
600
|
+
M = self._M2d() # static mask: position known
|
|
601
|
+
D = self._dynamic_M2d() # dynamic mask: increment reliable (⊆ M)
|
|
602
|
+
m = self._take_t(D, t) # (N,) — central index uses dynamic mask
|
|
603
|
+
for k, (a, b) in STREAM_OFFSETS.items():
|
|
604
|
+
if k in required:
|
|
605
|
+
if a < 0:
|
|
606
|
+
m = jnp.logical_and(m, self._take_t(M, t + a))
|
|
607
|
+
if b > 0:
|
|
608
|
+
m = jnp.logical_and(m, self._take_t(M, t + b))
|
|
609
|
+
for k in required:
|
|
610
|
+
w = _parse_window_key(k)
|
|
611
|
+
if w is not None:
|
|
612
|
+
left = (w - 1) // 2
|
|
613
|
+
for off in range(-left, -left + w):
|
|
614
|
+
if off != 0:
|
|
615
|
+
m = jnp.logical_and(m, self._take_t(M, t + off))
|
|
616
|
+
return m
|
|
617
|
+
|
|
618
|
+
# ---- dt fields for this row ------------------------------------------ #
|
|
619
|
+
def _dt_fields_single(
|
|
620
|
+
self,
|
|
621
|
+
required: Set[str],
|
|
622
|
+
t: Array,
|
|
623
|
+
*,
|
|
624
|
+
force_dt_keys: Optional[Set[str]] = None,
|
|
625
|
+
) -> Dict[str, Array]:
|
|
626
|
+
"""Compute required dt fields for this row (assumes in-bounds).
|
|
627
|
+
|
|
628
|
+
Note: requesting ``"X"`` or ``"mask"`` (in addition to increment
|
|
629
|
+
streams) also triggers ``dt`` computation because the integration
|
|
630
|
+
runtime always needs the time step for exposure-time weighting even
|
|
631
|
+
when only positions are requested.
|
|
632
|
+
"""
|
|
633
|
+
force_dt_keys = force_dt_keys or set()
|
|
634
|
+
|
|
635
|
+
need_dt_minus = any(k in required for k in ("dX_minus", "X_minus", "X_minusminus")) or (
|
|
636
|
+
"dt_minus" in force_dt_keys
|
|
637
|
+
)
|
|
638
|
+
need_dt_plus = any(k in required for k in ("dX_plus", "X_plus", "X_plusplus")) or ("dt_plus" in force_dt_keys)
|
|
639
|
+
need_dt = ("dt" in force_dt_keys) or any(k in required for k in ("dX", "X", "mask"))
|
|
640
|
+
|
|
641
|
+
out: Dict[str, Array] = {}
|
|
642
|
+
|
|
643
|
+
if self.t is not None:
|
|
644
|
+
tv = jnp.asarray(self.t)
|
|
645
|
+
if need_dt:
|
|
646
|
+
out["dt"] = self._take_t(tv, t + 1) - self._take_t(tv, t)
|
|
647
|
+
if need_dt_minus:
|
|
648
|
+
out["dt_minus"] = self._take_t(tv, t) - self._take_t(tv, t - 1)
|
|
649
|
+
if need_dt_plus:
|
|
650
|
+
out["dt_plus"] = self._take_t(tv, t + 2) - self._take_t(tv, t + 1)
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
if self.dt is None:
|
|
654
|
+
raise ValueError("Both 't' and 'dt' are None; one must be provided.")
|
|
655
|
+
|
|
656
|
+
dta = jnp.asarray(self.dt)
|
|
657
|
+
if dta.ndim == 0:
|
|
658
|
+
if need_dt:
|
|
659
|
+
out["dt"] = dta
|
|
660
|
+
if need_dt_minus:
|
|
661
|
+
out["dt_minus"] = dta
|
|
662
|
+
if need_dt_plus:
|
|
663
|
+
out["dt_plus"] = dta
|
|
664
|
+
return out
|
|
665
|
+
|
|
666
|
+
if need_dt:
|
|
667
|
+
out["dt"] = self._take_t(dta, t)
|
|
668
|
+
if need_dt_minus:
|
|
669
|
+
out["dt_minus"] = self._take_t(dta, t - 1)
|
|
670
|
+
if need_dt_plus:
|
|
671
|
+
out["dt_plus"] = self._take_t(dta, t + 1)
|
|
672
|
+
return out
|
|
673
|
+
|
|
674
|
+
# ---- extras at single t ---------------------------------------------- #
|
|
675
|
+
def build_extras(
|
|
676
|
+
self, t_idx: Array, *, dataset_index: int = 0, context: Optional[str] = None
|
|
677
|
+
) -> Dict[str, Any]:
|
|
678
|
+
"""Full model-facing extras at ``t_idx``: user values plus reserved keys.
|
|
679
|
+
|
|
680
|
+
User extras are sliced at the frame(s) — a
|
|
681
|
+
:class:`~SFI.trajectory.dataset.TimeSeriesExtra` is indexed, a callable
|
|
682
|
+
invoked, anything else forwarded — and the reserved ``time`` /
|
|
683
|
+
``duration`` / ``dataset_index`` / ``particle_index`` are resolved for
|
|
684
|
+
this dataset. This single mapping is what every consumer (inference,
|
|
685
|
+
simulation, diagnostics) feeds the force/diffusion expression.
|
|
686
|
+
``extras_local`` overrides ``extras_global`` on key conflicts.
|
|
687
|
+
"""
|
|
688
|
+
try:
|
|
689
|
+
t_vec = jnp.asarray(self.materialize_time(as_numpy=False))
|
|
690
|
+
except ValueError:
|
|
691
|
+
t_vec = jnp.arange(self.T, dtype=float)
|
|
692
|
+
ctx = ExtrasContext(
|
|
693
|
+
n_particles=int(self.N),
|
|
694
|
+
dataset_index=int(dataset_index),
|
|
695
|
+
frame_times=t_vec[t_idx],
|
|
696
|
+
duration=(t_vec[-1] - t_vec[0]) if self.T > 1 else jnp.asarray(1.0, dtype=float),
|
|
697
|
+
)
|
|
698
|
+
user = slice_frame_extras(self.extras_global, self.extras_local, frame_idx=t_idx, context=context)
|
|
699
|
+
extras = resolve_extras(user, ctx)
|
|
700
|
+
if context is not None:
|
|
701
|
+
extras["context"] = context
|
|
702
|
+
return extras
|
|
703
|
+
|
|
704
|
+
# ---- train/test splitting -------------------------------------------- #
|
|
705
|
+
def split_time(
|
|
706
|
+
self, fraction: float = 0.8, *, gap: int = 0
|
|
707
|
+
) -> Tuple["TrajectoryDataset", "TrajectoryDataset"]:
|
|
708
|
+
"""Split into ``(train, test)`` datasets along the time axis.
|
|
709
|
+
|
|
710
|
+
A side feature for data-abundant scenarios: SFI estimates its own
|
|
711
|
+
accuracy from the training data (``force_predicted_MSE``) and
|
|
712
|
+
validates fits through the diagnostics suite, neither of which
|
|
713
|
+
costs any data. Hold out a test fraction only when data is
|
|
714
|
+
plentiful, or to confirm a suspected bias floor.
|
|
715
|
+
|
|
716
|
+
Parameters
|
|
717
|
+
----------
|
|
718
|
+
fraction : float
|
|
719
|
+
Fraction of frames assigned to the train half: train is
|
|
720
|
+
``[0, round(fraction*T))``, test is the remainder (after the
|
|
721
|
+
optional ``gap``).
|
|
722
|
+
gap : int
|
|
723
|
+
Number of frames dropped between the halves. ``0`` is safe
|
|
724
|
+
for increment-based estimators (the boundary increment
|
|
725
|
+
belongs to neither half by construction); use a few
|
|
726
|
+
correlation times for slowly mixing systems.
|
|
727
|
+
"""
|
|
728
|
+
if not (0.0 < fraction < 1.0):
|
|
729
|
+
raise ValueError(f"fraction must be in (0, 1), got {fraction}")
|
|
730
|
+
if gap < 0:
|
|
731
|
+
raise ValueError(f"gap must be >= 0, got {gap}")
|
|
732
|
+
T = int(np.asarray(self.X).shape[0])
|
|
733
|
+
k = int(round(fraction * T))
|
|
734
|
+
start_test = k + gap
|
|
735
|
+
if k < 2 or T - start_test < 2:
|
|
736
|
+
raise ValueError(
|
|
737
|
+
f"split_time(fraction={fraction}, gap={gap}) leaves "
|
|
738
|
+
f"train={k} and test={T - start_test} frames (T={T}); "
|
|
739
|
+
"need at least 2 frames on each side."
|
|
740
|
+
)
|
|
741
|
+
prov = {"fraction": fraction, "gap": gap}
|
|
742
|
+
return (
|
|
743
|
+
self._slice_frames(0, k, role="train", **prov),
|
|
744
|
+
self._slice_frames(start_test, T, role="test", **prov),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
def _slice_frames(self, a: int, b: int, *, role: str, **prov) -> "TrajectoryDataset":
|
|
748
|
+
"""Return a copy restricted to frames ``[a, b)`` (absolute time kept)."""
|
|
749
|
+
from SFI.statefunc.nodes.interactions.prepare import purge_cache_extras
|
|
750
|
+
|
|
751
|
+
T = int(np.asarray(self.X).shape[0])
|
|
752
|
+
|
|
753
|
+
X = jnp.asarray(self.X)[a:b]
|
|
754
|
+
mask = None if self.mask is None else jnp.asarray(self.mask)[a:b]
|
|
755
|
+
dynamic_mask = None if self.dynamic_mask is None else jnp.asarray(self.dynamic_mask)[a:b]
|
|
756
|
+
|
|
757
|
+
t_arg = None if self.t is None else np.asarray(self.t)[a:b]
|
|
758
|
+
dt_arg = None
|
|
759
|
+
if self.t is None and self.dt is not None:
|
|
760
|
+
dta = np.asarray(self.dt)
|
|
761
|
+
dt_arg = float(dta) if dta.ndim == 0 else dta[a:b]
|
|
762
|
+
|
|
763
|
+
extras_g = purge_cache_extras(_slice_time_extras(self.extras_global, a, b, T))
|
|
764
|
+
extras_l = purge_cache_extras(_slice_time_extras(self.extras_local, a, b, T))
|
|
765
|
+
|
|
766
|
+
meta = dict(self.meta or {})
|
|
767
|
+
meta["split_time"] = {"role": role, "start": int(a), "stop": int(b), **prov}
|
|
768
|
+
|
|
769
|
+
return TrajectoryDataset.from_arrays(
|
|
770
|
+
X=X,
|
|
771
|
+
t=t_arg,
|
|
772
|
+
dt=dt_arg,
|
|
773
|
+
mask=mask,
|
|
774
|
+
dynamic_mask=dynamic_mask,
|
|
775
|
+
extras_global=extras_g,
|
|
776
|
+
extras_local=extras_l,
|
|
777
|
+
meta=meta,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# ---- main API: single-t row producer --------------------------------- #
|
|
781
|
+
def make_producer(
|
|
782
|
+
self,
|
|
783
|
+
require: Set[str],
|
|
784
|
+
*,
|
|
785
|
+
include_mask: bool = True,
|
|
786
|
+
include_dt: bool = True,
|
|
787
|
+
context: Optional[str] = None,
|
|
788
|
+
force_dt_keys: Optional[Set[str]] = None,
|
|
789
|
+
dataset_index: int = 0,
|
|
790
|
+
) -> Callable[[Array], Dict[str, Any]]:
|
|
791
|
+
"""Return a JAX-traceable function that builds a single-t row.
|
|
792
|
+
|
|
793
|
+
Parameters
|
|
794
|
+
----------
|
|
795
|
+
require :
|
|
796
|
+
Set of stream names and the special key ``"extras"`` if extras are
|
|
797
|
+
needed by downstream expressions.
|
|
798
|
+
include_mask :
|
|
799
|
+
If True, include ``"mask_out"`` computed from ``require``.
|
|
800
|
+
include_dt :
|
|
801
|
+
If True, include ``"dt"`` and neighbors when needed.
|
|
802
|
+
context :
|
|
803
|
+
Optional string to pass through to extras callables.
|
|
804
|
+
force_dt_keys :
|
|
805
|
+
Extra dt fields to force, e.g. ``{"dt_plus"}``.
|
|
806
|
+
dataset_index :
|
|
807
|
+
Position of this dataset within its collection; resolves the
|
|
808
|
+
reserved ``dataset_index`` extra on every row.
|
|
809
|
+
|
|
810
|
+
Returns
|
|
811
|
+
-------
|
|
812
|
+
producer : Callable[[Array], Dict[str, Any]]
|
|
813
|
+
A function such that ``producer(t)`` returns a dict whose leaves are
|
|
814
|
+
single-row arrays. Structure is fixed across calls.
|
|
815
|
+
|
|
816
|
+
Notes
|
|
817
|
+
-----
|
|
818
|
+
- Use :meth:`valid_indices` to generate in-bounds indices. The producer
|
|
819
|
+
assumes its input is valid and does not clamp.
|
|
820
|
+
"""
|
|
821
|
+
# exclude pseudo-keys like "__dt__" from actual stream slicing
|
|
822
|
+
required_streams = {
|
|
823
|
+
n
|
|
824
|
+
for n in require
|
|
825
|
+
if (n in STREAM_OFFSETS and not n.startswith("__")) or n == "mask" or _parse_window_key(n) is not None
|
|
826
|
+
}
|
|
827
|
+
need_extras = "extras" in require
|
|
828
|
+
force_dt_keys = set(force_dt_keys or ())
|
|
829
|
+
|
|
830
|
+
def producer(t: Array) -> Dict[str, Any]:
|
|
831
|
+
row: Dict[str, Any] = {}
|
|
832
|
+
|
|
833
|
+
# streams
|
|
834
|
+
for name in required_streams:
|
|
835
|
+
row[name] = self._stream_single(name, t)
|
|
836
|
+
|
|
837
|
+
# mask_out
|
|
838
|
+
if include_mask:
|
|
839
|
+
row["mask_out"] = self._output_mask_single(require, t)
|
|
840
|
+
|
|
841
|
+
# dt fields
|
|
842
|
+
if include_dt:
|
|
843
|
+
dtf = self._dt_fields_single(require, t, force_dt_keys=force_dt_keys)
|
|
844
|
+
row.update(dtf)
|
|
845
|
+
|
|
846
|
+
# scalar counts
|
|
847
|
+
row["N_total"] = jnp.array(float(self.N), dtype=jnp.float32)
|
|
848
|
+
row["N_active"] = (
|
|
849
|
+
jnp.sum(row["mask_out"].astype(jnp.float32))
|
|
850
|
+
if "mask_out" in row
|
|
851
|
+
else jnp.array(float(self.N), dtype=jnp.float32)
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# extras
|
|
855
|
+
if need_extras:
|
|
856
|
+
row["extras"] = self.build_extras(t, dataset_index=dataset_index, context=context)
|
|
857
|
+
|
|
858
|
+
return row
|
|
859
|
+
|
|
860
|
+
return producer
|
|
861
|
+
|
|
862
|
+
# ---- batch-t producer (vectorised gather) ----------------------------- #
|
|
863
|
+
|
|
864
|
+
def _stream_batch(self, name: str, t_block: Array) -> Array:
|
|
865
|
+
"""Gather a stream for a block of time indices.
|
|
866
|
+
|
|
867
|
+
Parameters
|
|
868
|
+
----------
|
|
869
|
+
t_block : shape ``(K,)``
|
|
870
|
+
Vector of valid time indices.
|
|
871
|
+
|
|
872
|
+
Returns
|
|
873
|
+
-------
|
|
874
|
+
Array with shape ``(K, N, d)`` for position/increment streams,
|
|
875
|
+
or ``(K, N)`` for mask.
|
|
876
|
+
"""
|
|
877
|
+
X = self._X3d()
|
|
878
|
+
if name == "mask":
|
|
879
|
+
M = self._M2d()
|
|
880
|
+
return M[t_block] # (K, N)
|
|
881
|
+
|
|
882
|
+
offsets = {
|
|
883
|
+
"X": 0,
|
|
884
|
+
"X_minus": -1,
|
|
885
|
+
"X_plus": +1,
|
|
886
|
+
"X_minusminus": -2,
|
|
887
|
+
"X_plusplus": +2,
|
|
888
|
+
"X_m3": -3,
|
|
889
|
+
"X_m4": -4,
|
|
890
|
+
"X_p3": +3,
|
|
891
|
+
"X_p4": +4,
|
|
892
|
+
}
|
|
893
|
+
if name in offsets:
|
|
894
|
+
return X[t_block + offsets[name]] # (K, N, d)
|
|
895
|
+
|
|
896
|
+
if name == "dX_minus":
|
|
897
|
+
return X[t_block] - X[t_block - 1]
|
|
898
|
+
if name == "dX":
|
|
899
|
+
return X[t_block + 1] - X[t_block]
|
|
900
|
+
if name == "dX_plus":
|
|
901
|
+
return X[t_block + 2] - X[t_block + 1]
|
|
902
|
+
|
|
903
|
+
w = _parse_window_key(name)
|
|
904
|
+
if w is not None:
|
|
905
|
+
left = (w - 1) // 2
|
|
906
|
+
win_offsets = jnp.arange(w) - left # (W,)
|
|
907
|
+
idx = t_block[:, None] + win_offsets[None, :] # (K, W)
|
|
908
|
+
return X[idx] # (K, W, N, d)
|
|
909
|
+
|
|
910
|
+
raise KeyError(f"Unknown stream '{name}'")
|
|
911
|
+
|
|
912
|
+
def _output_mask_batch(self, required: Set[str], t_block: Array) -> Array:
|
|
913
|
+
"""Per-particle validity for a block of rows.
|
|
914
|
+
|
|
915
|
+
Same semantics as :meth:`_output_mask_single`: uses ``dynamic_mask``
|
|
916
|
+
at the central indices and ``mask`` at neighbour offsets.
|
|
917
|
+
|
|
918
|
+
Returns
|
|
919
|
+
-------
|
|
920
|
+
Array of shape ``(K, N)``, dtype bool.
|
|
921
|
+
"""
|
|
922
|
+
M = self._M2d() # static mask: position known
|
|
923
|
+
D = self._dynamic_M2d() # dynamic mask: increment reliable
|
|
924
|
+
m = D[t_block] # (K, N) — central indices use dynamic mask
|
|
925
|
+
for k, (a, b) in STREAM_OFFSETS.items():
|
|
926
|
+
if k in required:
|
|
927
|
+
if a < 0:
|
|
928
|
+
m = jnp.logical_and(m, M[t_block + a])
|
|
929
|
+
if b > 0:
|
|
930
|
+
m = jnp.logical_and(m, M[t_block + b])
|
|
931
|
+
for k in required:
|
|
932
|
+
w = _parse_window_key(k)
|
|
933
|
+
if w is not None:
|
|
934
|
+
left = (w - 1) // 2
|
|
935
|
+
for off in range(-left, -left + w):
|
|
936
|
+
if off != 0:
|
|
937
|
+
m = jnp.logical_and(m, M[t_block + off])
|
|
938
|
+
return m
|
|
939
|
+
|
|
940
|
+
def _dt_fields_batch(
|
|
941
|
+
self,
|
|
942
|
+
required: Set[str],
|
|
943
|
+
t_block: Array,
|
|
944
|
+
*,
|
|
945
|
+
force_dt_keys: Optional[Set[str]] = None,
|
|
946
|
+
) -> Dict[str, Array]:
|
|
947
|
+
"""Compute dt fields for a block of time indices.
|
|
948
|
+
|
|
949
|
+
Returns a dict mapping, e.g., ``"dt"`` to an array of shape ``(K,)``.
|
|
950
|
+
"""
|
|
951
|
+
force_dt_keys = force_dt_keys or set()
|
|
952
|
+
|
|
953
|
+
need_dt_minus = any(k in required for k in ("dX_minus", "X_minus", "X_minusminus")) or (
|
|
954
|
+
"dt_minus" in force_dt_keys
|
|
955
|
+
)
|
|
956
|
+
need_dt_plus = any(k in required for k in ("dX_plus", "X_plus", "X_plusplus")) or ("dt_plus" in force_dt_keys)
|
|
957
|
+
need_dt = ("dt" in force_dt_keys) or any(k in required for k in ("dX", "X", "mask"))
|
|
958
|
+
|
|
959
|
+
out: Dict[str, Array] = {}
|
|
960
|
+
|
|
961
|
+
if self.t is not None:
|
|
962
|
+
tv = jnp.asarray(self.t)
|
|
963
|
+
if need_dt:
|
|
964
|
+
out["dt"] = tv[t_block + 1] - tv[t_block]
|
|
965
|
+
if need_dt_minus:
|
|
966
|
+
out["dt_minus"] = tv[t_block] - tv[t_block - 1]
|
|
967
|
+
if need_dt_plus:
|
|
968
|
+
out["dt_plus"] = tv[t_block + 2] - tv[t_block + 1]
|
|
969
|
+
return out
|
|
970
|
+
|
|
971
|
+
if self.dt is None:
|
|
972
|
+
raise ValueError("Both 't' and 'dt' are None; one must be provided.")
|
|
973
|
+
|
|
974
|
+
dta = jnp.asarray(self.dt)
|
|
975
|
+
if dta.ndim == 0:
|
|
976
|
+
# Scalar dt → broadcast to (K,)
|
|
977
|
+
K = t_block.shape[0]
|
|
978
|
+
if need_dt:
|
|
979
|
+
out["dt"] = jnp.broadcast_to(dta, (K,))
|
|
980
|
+
if need_dt_minus:
|
|
981
|
+
out["dt_minus"] = jnp.broadcast_to(dta, (K,))
|
|
982
|
+
if need_dt_plus:
|
|
983
|
+
out["dt_plus"] = jnp.broadcast_to(dta, (K,))
|
|
984
|
+
return out
|
|
985
|
+
|
|
986
|
+
# Per-step dt array
|
|
987
|
+
if need_dt:
|
|
988
|
+
out["dt"] = dta[t_block]
|
|
989
|
+
if need_dt_minus:
|
|
990
|
+
out["dt_minus"] = dta[t_block - 1]
|
|
991
|
+
if need_dt_plus:
|
|
992
|
+
out["dt_plus"] = dta[t_block + 1]
|
|
993
|
+
return out
|
|
994
|
+
|
|
995
|
+
def make_batch_producer(
|
|
996
|
+
self,
|
|
997
|
+
require: Set[str],
|
|
998
|
+
*,
|
|
999
|
+
include_mask: bool = True,
|
|
1000
|
+
include_dt: bool = True,
|
|
1001
|
+
context: Optional[str] = None,
|
|
1002
|
+
force_dt_keys: Optional[Set[str]] = None,
|
|
1003
|
+
dataset_index: int = 0,
|
|
1004
|
+
) -> Callable[[Array], Dict[str, Any]]:
|
|
1005
|
+
"""Return a function that gathers a batch of rows in one vectorised pass.
|
|
1006
|
+
|
|
1007
|
+
This is the batch counterpart of :meth:`make_producer`. Instead of
|
|
1008
|
+
building one row at a time (designed for use inside ``jax.vmap``), this
|
|
1009
|
+
function gathers *K* rows at once using array indexing, producing
|
|
1010
|
+
arrays with a leading ``K`` axis.
|
|
1011
|
+
|
|
1012
|
+
Parameters
|
|
1013
|
+
----------
|
|
1014
|
+
require, include_mask, include_dt, context, force_dt_keys
|
|
1015
|
+
Same meaning as in :meth:`make_producer`.
|
|
1016
|
+
|
|
1017
|
+
Returns
|
|
1018
|
+
-------
|
|
1019
|
+
batch_producer : ``Callable[[Array], Dict[str, Any]]``
|
|
1020
|
+
``batch_producer(t_block)`` with ``t_block`` of shape ``(K,)``
|
|
1021
|
+
returns a dict whose arrays have a leading ``K`` axis.
|
|
1022
|
+
|
|
1023
|
+
Notes
|
|
1024
|
+
-----
|
|
1025
|
+
**Extras limitation**: time-varying extras (``TimeSeriesExtra`` and
|
|
1026
|
+
callables) are evaluated at ``t_block[0]`` only, not at each index in
|
|
1027
|
+
the block. This batch producer is designed for use cases where extras
|
|
1028
|
+
are global constants across the chunk (e.g. static boundary tensors).
|
|
1029
|
+
For per-step time-varying extras, use :meth:`make_producer` with
|
|
1030
|
+
``jax.vmap`` instead.
|
|
1031
|
+
"""
|
|
1032
|
+
required_streams = {
|
|
1033
|
+
n
|
|
1034
|
+
for n in require
|
|
1035
|
+
if (n in STREAM_OFFSETS and not n.startswith("__")) or n == "mask" or _parse_window_key(n) is not None
|
|
1036
|
+
}
|
|
1037
|
+
need_extras = "extras" in require
|
|
1038
|
+
force_dt_keys = set(force_dt_keys or ())
|
|
1039
|
+
|
|
1040
|
+
def batch_producer(t_block: Array) -> Dict[str, Any]:
|
|
1041
|
+
row: Dict[str, Any] = {}
|
|
1042
|
+
K = t_block.shape[0]
|
|
1043
|
+
|
|
1044
|
+
for name in required_streams:
|
|
1045
|
+
row[name] = self._stream_batch(name, t_block)
|
|
1046
|
+
|
|
1047
|
+
if include_mask:
|
|
1048
|
+
row["mask_out"] = self._output_mask_batch(require, t_block)
|
|
1049
|
+
|
|
1050
|
+
if include_dt:
|
|
1051
|
+
dtf = self._dt_fields_batch(
|
|
1052
|
+
require,
|
|
1053
|
+
t_block,
|
|
1054
|
+
force_dt_keys=force_dt_keys,
|
|
1055
|
+
)
|
|
1056
|
+
row.update(dtf)
|
|
1057
|
+
|
|
1058
|
+
row["N_total"] = jnp.array(float(self.N), dtype=jnp.float32)
|
|
1059
|
+
row["N_active"] = (
|
|
1060
|
+
jnp.sum(row["mask_out"].astype(jnp.float32), axis=1) # (K,)
|
|
1061
|
+
if "mask_out" in row
|
|
1062
|
+
else jnp.full((K,), float(self.N), dtype=jnp.float32)
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
if need_extras:
|
|
1066
|
+
# Extras are batch-constant — resolve them at the first valid index.
|
|
1067
|
+
row["extras"] = self.build_extras(t_block[0], dataset_index=dataset_index, context=context)
|
|
1068
|
+
|
|
1069
|
+
return row
|
|
1070
|
+
|
|
1071
|
+
return batch_producer
|
|
1072
|
+
|
|
1073
|
+
# ---- convenience: dense views for plotting / inspection --------------- #
|
|
1074
|
+
def materialize_time(self, *, as_numpy: bool = True) -> Array | np.ndarray:
|
|
1075
|
+
"""
|
|
1076
|
+
Return a dense absolute time vector ``t`` of shape ``(T,)``.
|
|
1077
|
+
|
|
1078
|
+
Rules
|
|
1079
|
+
-----
|
|
1080
|
+
- If ``self.t`` is not None, it is returned as-is.
|
|
1081
|
+
- Else, if ``self.dt`` is a scalar, use ``t[k] = k * dt``.
|
|
1082
|
+
- Else, if ``self.dt`` is an array of shape ``(T,)``, interpret
|
|
1083
|
+
``dt[k]`` as the step between ``X[k]`` and ``X[k+1]`` and build
|
|
1084
|
+
|
|
1085
|
+
t[0] = 0
|
|
1086
|
+
t[k+1] = t[k] + dt[k] for k = 0..T-2
|
|
1087
|
+
|
|
1088
|
+
The last entry ``dt[T-1]`` (if any) is ignored.
|
|
1089
|
+
- If both ``t`` and ``dt`` are None, a ValueError is raised.
|
|
1090
|
+
|
|
1091
|
+
Parameters
|
|
1092
|
+
----------
|
|
1093
|
+
as_numpy :
|
|
1094
|
+
If True, return a NumPy array; otherwise a JAX array.
|
|
1095
|
+
|
|
1096
|
+
Returns
|
|
1097
|
+
-------
|
|
1098
|
+
t : array, shape (T,)
|
|
1099
|
+
"""
|
|
1100
|
+
T = self.T
|
|
1101
|
+
|
|
1102
|
+
if self.t is not None:
|
|
1103
|
+
t = jnp.asarray(self.t)
|
|
1104
|
+
else:
|
|
1105
|
+
if self.dt is None:
|
|
1106
|
+
raise ValueError("Cannot materialize time: both 't' and 'dt' are None.")
|
|
1107
|
+
dta = jnp.asarray(self.dt)
|
|
1108
|
+
if dta.ndim == 0:
|
|
1109
|
+
t = jnp.arange(T, dtype=float) * dta
|
|
1110
|
+
elif dta.ndim == 1:
|
|
1111
|
+
if T == 0:
|
|
1112
|
+
t = jnp.zeros((0,), dtype=float)
|
|
1113
|
+
else:
|
|
1114
|
+
# t[0] = 0; t[1:] = cumsum(dt[:-1])
|
|
1115
|
+
t0 = jnp.zeros((1,), dtype=float)
|
|
1116
|
+
rest = jnp.cumsum(dta[:-1])
|
|
1117
|
+
t = jnp.concatenate([t0, rest], axis=0)
|
|
1118
|
+
else:
|
|
1119
|
+
raise ValueError("dt must be scalar or (T,) to build a time axis.")
|
|
1120
|
+
|
|
1121
|
+
return np.asarray(t) if as_numpy else t
|
|
1122
|
+
|
|
1123
|
+
def to_arrays(
|
|
1124
|
+
self,
|
|
1125
|
+
*,
|
|
1126
|
+
as_numpy: bool = True,
|
|
1127
|
+
include_mask: bool = True,
|
|
1128
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | tuple[Array, Array, Array | None]:
|
|
1129
|
+
"""
|
|
1130
|
+
Materialize dense trajectory arrays for this dataset.
|
|
1131
|
+
|
|
1132
|
+
This is intended for plotting and quick inspection, not for JAX
|
|
1133
|
+
integration (use :meth:`make_producer` for that).
|
|
1134
|
+
|
|
1135
|
+
Parameters
|
|
1136
|
+
----------
|
|
1137
|
+
as_numpy :
|
|
1138
|
+
If True (default), return NumPy arrays.
|
|
1139
|
+
include_mask :
|
|
1140
|
+
If True (default), return the per-particle validity mask.
|
|
1141
|
+
|
|
1142
|
+
Returns
|
|
1143
|
+
-------
|
|
1144
|
+
t :
|
|
1145
|
+
Absolute time vector of shape ``(T,)``; see :meth:`materialize_time`.
|
|
1146
|
+
X :
|
|
1147
|
+
State tensor of shape ``(T, N, d)``.
|
|
1148
|
+
mask :
|
|
1149
|
+
Boolean mask of shape ``(T, N)`` if ``include_mask`` is True,
|
|
1150
|
+
otherwise ``None``.
|
|
1151
|
+
"""
|
|
1152
|
+
X3 = self._X3d()
|
|
1153
|
+
M2 = self._M2d() if include_mask else None
|
|
1154
|
+
t = self.materialize_time(as_numpy=False)
|
|
1155
|
+
|
|
1156
|
+
if as_numpy:
|
|
1157
|
+
import numpy as _np
|
|
1158
|
+
|
|
1159
|
+
t = _np.asarray(t)
|
|
1160
|
+
X3 = _np.asarray(X3)
|
|
1161
|
+
if M2 is not None:
|
|
1162
|
+
M2 = _np.asarray(M2)
|
|
1163
|
+
|
|
1164
|
+
return (t, X3, M2)
|