StochasticForceInference 2.0.0__py3-none-any.whl

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