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,1122 @@
1
+ # collection.py
2
+ """
3
+ Trajectory collection: index-driven streaming over datasets.
4
+
5
+ This module defines :class:`TrajectoryCollection`, a thin coordinator that:
6
+ - stores multiple :class:`TrajectoryDataset` objects,
7
+ - computes per-dataset weights,
8
+ - yields **(producer, t_idx_chunk)** pairs for vmapped integration.
9
+
10
+ No chunk heuristics live here. The dataset owns valid windows and single-t row
11
+ production. The integrator vmaps over integer indices and reduces.
12
+
13
+ Typical loop
14
+ ------------
15
+ >>> coll = TrajectoryCollection.from_dataset(ds).with_weights("pool")
16
+ >>> for payload in coll.iter_slices(require=req, bytes_hint=bh, chunk_target_bytes=64<<20):
17
+ ... producer = payload["producer"] # Callable[[t], row]
18
+ ... t_idx = payload["t_idx"] # (K_chunk,)
19
+ ... w_ds = payload["weight"] # dataset scalar weight
20
+ ... # integrator: vmap(lambda t: program(**producer(t)))(t_idx)
21
+
22
+ Weights
23
+ -------
24
+ Per-dataset weights are **unnormalised** multipliers applied to every
25
+ estimator (force, diffusion, parametric). Within-dataset weighting is
26
+ intrinsic to each estimator: the force is per-dt, the diffusion per-point.
27
+
28
+ - "pool" (default): multiplier 1 for every dataset — pool all increments on
29
+ equal footing (each dataset then contributes by its effective time for the
30
+ force, by its point count for the diffusion).
31
+ - "per_dataset": each dataset contributes equally (multiplier mean(Teff)/Teff_d).
32
+ - a sequence of floats: explicit unnormalised multipliers.
33
+
34
+ Notes
35
+ -----
36
+ - No cross-dataset vectorization. A small Python loop over datasets is intended.
37
+ - `bytes_hint` is the per-row memory estimate supplied by the integrator.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import (
45
+ Any,
46
+ Callable,
47
+ Dict,
48
+ Iterable,
49
+ Iterator,
50
+ List,
51
+ Literal,
52
+ Mapping,
53
+ Optional,
54
+ Sequence,
55
+ Set,
56
+ Union,
57
+ )
58
+
59
+ import jax.numpy as jnp
60
+ import numpy as np
61
+
62
+ from SFI.trajectory.dataset import TimeSeriesExtra, TrajectoryDataset, time_series_extra
63
+
64
+ from .io import (
65
+ _parse_tabular_with_extras,
66
+ columns_and_extras_to_dataset,
67
+ flatten_X_to_columns,
68
+ load_trajectory,
69
+ save_trajectory,
70
+ )
71
+
72
+ WeightSpec = Union[str, Sequence[float]]
73
+
74
+
75
+ def _is_single_file(path: Union[str, Path]) -> bool:
76
+ p = Path(path)
77
+ return p.suffix.lower() in {".csv", ".parquet", ".pq", ".h5", ".hdf5"}
78
+
79
+
80
+ @dataclass
81
+ class TrajectoryCollection:
82
+ """
83
+ Container for one or more trajectories plus per-dataset weights.
84
+
85
+ This is the main user-facing trajectory object. It wraps a list of
86
+ :class:`TrajectoryDataset` instances and exposes an index-based streaming
87
+ interface used by the integration runtime.
88
+
89
+ Most users should construct collections via :meth:`from_arrays`,
90
+ :meth:`from_dataset` or :meth:`load` rather than instantiating this
91
+ dataclass directly.
92
+
93
+ Parameters
94
+ ----------
95
+ datasets
96
+ List of underlying :class:`TrajectoryDataset` objects. The order is
97
+ preserved in iteration and determines the ordering of the ``weights``
98
+ vector.
99
+ weights
100
+ 1D JAX array of shape ``(D,)`` with non-negative entries, where
101
+ ``D = len(datasets)``. The vector is normalized to sum to 1 by
102
+ :meth:`with_weights`.
103
+
104
+ Notes
105
+ -----
106
+ The collection itself does not impose any chunking heuristic. It only
107
+ coordinates datasets and their weights; the integrator decides how to
108
+ vmap over the indices returned by :meth:`iter_slices`.
109
+ """
110
+
111
+ datasets: List[TrajectoryDataset]
112
+ weights: jnp.ndarray # shape (D,), normalized to sum to 1
113
+
114
+ # ---------- construction ----------
115
+ @classmethod
116
+ def from_dataset(cls, ds: TrajectoryDataset, *, weights: WeightSpec = "pool") -> "TrajectoryCollection":
117
+ """Wrap a single :class:`TrajectoryDataset` in a collection.
118
+
119
+ Parameters
120
+ ----------
121
+ ds
122
+ The dataset to wrap.
123
+ weights
124
+ Initial weight specification; default ``"Teff"``.
125
+ See :meth:`with_weights`.
126
+
127
+ Returns
128
+ -------
129
+ TrajectoryCollection
130
+ A single-dataset collection with weights computed from ``ds``.
131
+ """
132
+ coll = cls([ds], jnp.array([1.0], dtype=jnp.float32))
133
+ return coll.with_weights(weights)
134
+
135
+ def concat(
136
+ self,
137
+ items: Sequence[Union["TrajectoryCollection", TrajectoryDataset]],
138
+ *,
139
+ weights: WeightSpec = "pool",
140
+ ) -> "TrajectoryCollection":
141
+ """
142
+ Concatenate this collection with other collections or datasets.
143
+
144
+ Parameters
145
+ ----------
146
+ items
147
+ Sequence of :class:`TrajectoryCollection` or
148
+ :class:`TrajectoryDataset` instances. Collections are flattened
149
+ into their constituent datasets.
150
+ weights
151
+ Weight specification for the concatenated collection. See
152
+ :meth:`with_weights` for accepted values.
153
+
154
+ Returns
155
+ -------
156
+ TrajectoryCollection
157
+ New collection containing all datasets from ``self`` followed
158
+ by all datasets from ``items``.
159
+ """
160
+ merged: List[TrajectoryDataset] = []
161
+ merged.extend(self.datasets)
162
+ for it in items:
163
+ if isinstance(it, TrajectoryCollection):
164
+ merged.extend(it.datasets)
165
+ else:
166
+ merged.append(it)
167
+ out = TrajectoryCollection(merged, jnp.ones((len(merged),), dtype=jnp.float32))
168
+ return out.with_weights(weights)
169
+
170
+ def __and__(
171
+ self, other: Union["TrajectoryCollection", TrajectoryDataset]
172
+ ) -> "TrajectoryCollection":
173
+ """Merge collections (or a collection and a dataset) with ``&``.
174
+
175
+ ``c1 & c2`` appends the datasets of ``other`` to those of ``self``
176
+ with the default ``"pool"`` policy (every increment on equal
177
+ footing). It chains naturally (``c1 & c2 & c3``); call
178
+ :meth:`concat` or :meth:`with_weights` for the ``"per_dataset"``
179
+ policy or explicit weights.
180
+ """
181
+ return self.concat([other])
182
+
183
+ # ---------- weighting ----------
184
+ def with_weights(
185
+ self,
186
+ spec: WeightSpec = "pool",
187
+ *,
188
+ required: Set[str] = frozenset({"X", "dX"}),
189
+ subsampling: int = 1,
190
+ ) -> "TrajectoryCollection":
191
+ """
192
+ Set the per-dataset weights (an **unnormalised** multiplier).
193
+
194
+ Parameters
195
+ ----------
196
+ spec
197
+ Inter-dataset weight policy — a per-dataset multiplier applied to
198
+ every estimator (force, diffusion, parametric). Accepted values:
199
+
200
+ - ``"pool"`` (default): multiplier ``1`` for all datasets, i.e.
201
+ pool every increment on equal footing. Combined with each
202
+ estimator's intrinsic within-dataset weighting (force is per-dt,
203
+ diffusion per-point), this weights each dataset by its effective
204
+ time (force) or point count (diffusion) — the natural
205
+ maximum-likelihood pooling.
206
+ - ``"per_dataset"``: each dataset contributes equally regardless of
207
+ length (multiplier ``mean(Teff)/Teff_d``). Exact for the force;
208
+ for the diffusion it is exact when ``dt`` is uniform.
209
+ - a sequence of floats: explicit unnormalised multipliers.
210
+
211
+ required
212
+ Streams used to compute ``Teff`` in the ``"per_dataset"`` policy.
213
+ See :meth:`TrajectoryDataset.Teff`.
214
+ subsampling
215
+ Optional subsampling factor used when counting valid indices.
216
+
217
+ Returns
218
+ -------
219
+ TrajectoryCollection
220
+ The same collection with its :attr:`weights` field updated.
221
+
222
+ Notes
223
+ -----
224
+ Weights are exposed to the integrator via the ``"weight"`` entry in the
225
+ payloads yielded by :meth:`iter_slices` and applied in every reduction
226
+ (sum and mean). They are deliberately **unnormalised**: the absolute
227
+ scale cancels in the mean-reduced estimates, while for the force Gram /
228
+ covariance it sets the information scale (a single dataset carries unit
229
+ weight).
230
+ """
231
+ D = len(self.datasets)
232
+ if isinstance(spec, str):
233
+ if spec == "pool":
234
+ w = jnp.ones((D,), dtype=jnp.float32)
235
+ elif spec == "per_dataset":
236
+ teffs = [float(self.datasets[i].Teff(required, subsampling=subsampling)) for i in range(D)]
237
+ pos = [t for t in teffs if t > 0]
238
+ mean_teff = (sum(pos) / len(pos)) if pos else 1.0
239
+ w = jnp.array([(mean_teff / t) if t > 0 else 0.0 for t in teffs], dtype=jnp.float32)
240
+ else:
241
+ raise ValueError(
242
+ f"unknown weight policy {spec!r}; use 'pool', 'per_dataset', "
243
+ "or an explicit per-dataset multiplier array."
244
+ )
245
+ else:
246
+ w = jnp.array(spec, dtype=jnp.float32)
247
+ if w.shape != (D,):
248
+ raise ValueError(f"weights length mismatch: got {w.shape}, expected {(D,)}")
249
+ # Unnormalised relative multipliers: scale cancels in mean-reduced
250
+ # estimates but sets the force Gram / covariance scale.
251
+ self.weights = w
252
+ return self
253
+
254
+ # ---------- streaming ----------
255
+ def iter_slices(
256
+ self,
257
+ *,
258
+ require: Set[str],
259
+ bytes_hint: Optional[int],
260
+ chunk_target_bytes: int = 64 * 1024**2,
261
+ subsampling: int = 1,
262
+ context: Optional[str] = None,
263
+ ) -> Iterator[Mapping[str, Any]]:
264
+ """
265
+ Yield chunks as (producer, t_idx) pairs for vmapped integration.
266
+
267
+ Parameters
268
+ ----------
269
+ require
270
+ Set of stream names required by the integrator (e.g. ``{"X","dX","mask"}``).
271
+ Passed to :meth:`TrajectoryDataset.valid_indices` and
272
+ :meth:`TrajectoryDataset.make_producer`.
273
+ bytes_hint
274
+ Approximate per-row memory footprint (in bytes) of the values
275
+ produced by the program. If ``None`` or ``<= 0``, no chunking is
276
+ performed and all valid indices are yielded at once.
277
+ chunk_target_bytes
278
+ Target chunk size in bytes. Combined with ``bytes_hint`` to
279
+ determine how many rows to include in each chunk.
280
+ subsampling
281
+ Optional subsampling factor applied to the time indices before
282
+ chunking.
283
+ context
284
+ Optional context string passed through to the dataset producer,
285
+ typically used to switch extra fields.
286
+
287
+ Yields
288
+ ------
289
+ dict
290
+ Mapping with keys:
291
+
292
+ - ``"producer"``: ``Callable[[jax.Array], dict]``, single-t row builder.
293
+ - ``"t_idx"``: 1D JAX array of integer time indices.
294
+ - ``"dataset_index"``: index of the underlying dataset in
295
+ :attr:`datasets`.
296
+ - ``"weight"``: float dataset weight, taken from :attr:`weights`.
297
+ """
298
+ if subsampling <= 0:
299
+ raise ValueError("subsampling must be a positive integer")
300
+
301
+ for ds_idx, ds in enumerate(self.datasets):
302
+ base_idx = ds.valid_indices(require, subsampling=subsampling)
303
+ if base_idx.size == 0:
304
+ continue
305
+
306
+ producer = ds.make_producer(
307
+ require,
308
+ include_mask=True,
309
+ include_dt=True,
310
+ context=context,
311
+ force_dt_keys={"dt"},
312
+ dataset_index=self.dataset_index(ds_idx),
313
+ )
314
+
315
+ if not bytes_hint or bytes_hint <= 0:
316
+ yield {
317
+ "producer": producer,
318
+ "t_idx": base_idx,
319
+ "dataset_index": ds_idx,
320
+ "weight": float(self.weights[ds_idx]),
321
+ }
322
+ continue
323
+
324
+ total = int(base_idx.size)
325
+ rows_per_chunk = min(total, max(1, int(chunk_target_bytes // int(bytes_hint))))
326
+ for start in range(0, total, rows_per_chunk):
327
+ sel = base_idx[start : start + rows_per_chunk]
328
+ yield {
329
+ "producer": producer,
330
+ "t_idx": sel,
331
+ "dataset_index": ds_idx,
332
+ "weight": float(self.weights[ds_idx]),
333
+ }
334
+
335
+ def peek_row(
336
+ self,
337
+ *,
338
+ require: Set[str] = frozenset({"X", "dX"}),
339
+ context: Optional[str] = None,
340
+ ) -> Mapping[str, Any]:
341
+ """
342
+ Return a single-t sample row from the first dataset with valid indices.
343
+
344
+ Parameters
345
+ ----------
346
+ require
347
+ Set of stream names required for the sample (as in
348
+ :meth:`iter_slices`).
349
+ context
350
+ Optional context string forwarded to the producer.
351
+
352
+ Returns
353
+ -------
354
+ dict
355
+ Structure matching ``producer(t)`` for the chosen dataset.
356
+
357
+ Notes
358
+ -----
359
+ Useful for memory estimation and debugging program outputs.
360
+ """
361
+ for ds_idx, ds in enumerate(self.datasets):
362
+ idx = ds.valid_indices(require)
363
+ if idx.size == 0:
364
+ continue
365
+ t0 = idx[:1][0]
366
+ producer = ds.make_producer(
367
+ require,
368
+ include_mask=True,
369
+ include_dt=True,
370
+ context=context,
371
+ force_dt_keys={"dt"},
372
+ dataset_index=self.dataset_index(ds_idx),
373
+ )
374
+ return producer(t0)
375
+ raise ValueError("peek_row: no dataset has valid indices for the requested streams.")
376
+
377
+ def peek_X(self):
378
+ """Convenience helper: peek at the "X" stream. Shape-aligned with the first valid row of "X" from peek_row."""
379
+ row = self.peek_row(require={"X"})
380
+ return row["X"]
381
+
382
+ def peek_dX(self):
383
+ """Convenience helper: peek at the "dX" stream. Shape-aligned with the first valid row of "dX" from peek_row."""
384
+ row = self.peek_row(require={"dX"})
385
+ return row["dX"]
386
+
387
+ def peek_mask(self):
388
+ """Convenience helper: peek at the "mask" stream.
389
+
390
+ Shape-aligned with the first valid row of "mask" from peek_row.
391
+ """
392
+ row = self.peek_row(require={"mask"})
393
+ return row["mask"]
394
+
395
+ def peek_dt(self):
396
+ """Convenience helper: peek at the "dt" stream. Shape-aligned with the first valid row of "dt" from peek_row."""
397
+ row = self.peek_row(require={"dt"})
398
+ return row["dt"]
399
+
400
+ # ---------- aggregate Teff over datasets ---------- #
401
+ def Teff(self, required: Set[str], *, subsampling: int = 1) -> float:
402
+ """Total effective exposure time across all datasets.
403
+
404
+ This is simply the sum of per-dataset Teff values:
405
+
406
+ sum_d datasets[d].Teff(required, subsampling=subsampling).
407
+ """
408
+ return float(sum(ds.Teff(required, subsampling=subsampling) for ds in self.datasets))
409
+
410
+ # ---------- persistence API ----------
411
+ def save(
412
+ self,
413
+ path: Union[str, Path],
414
+ *,
415
+ format: Optional[str] = None,
416
+ **format_kw: Any,
417
+ ) -> Path:
418
+ """
419
+ Save the collection.
420
+
421
+ Rules
422
+ -----
423
+ - Single file path (.csv/.parquet/.h5): collection must have exactly one dataset.
424
+ - Directory path: write one file per dataset + manifest.yaml.
425
+ - Masked samples are dropped (no masked rows written).
426
+ - No relabeling at save-time; relabeling is handled at load-time.
427
+ - ``dynamic_mask`` is not persisted; after a save/load round-trip it
428
+ will be ``None`` (equivalent to the static mask).
429
+ """
430
+ dst = Path(path)
431
+
432
+ def _write_one(ds: TrajectoryDataset, filename: Path, fmt: Optional[str]) -> None:
433
+ # flatten and drop masked rows
434
+ X = np.asarray(ds._X3d())
435
+ M = np.asarray(ds._M2d(), dtype=bool)
436
+ pid, tidx, vecs = flatten_X_to_columns(X, mask=M)
437
+
438
+ # extras: persist t or dt if present
439
+ eg = dict(ds.extras_global or {})
440
+ el = dict(ds.extras_local or {})
441
+ if ds.t is not None and "t" not in eg:
442
+ eg["t"] = time_series_extra(np.asarray(np.array(ds.t)))
443
+ elif ds.dt is not None and "t" not in eg and "dt" not in eg:
444
+ dta = np.asarray(ds.dt)
445
+ eg["dt"] = time_series_extra(dta) if dta.ndim == 1 else float(dta)
446
+
447
+ meta = dict(ds.meta or {})
448
+
449
+ save_trajectory(
450
+ str(filename),
451
+ particle_idx=pid,
452
+ time_idx=tidx,
453
+ state_vectors=vecs,
454
+ extras_global=eg,
455
+ extras_local=el,
456
+ metadata=meta,
457
+ format=fmt,
458
+ **format_kw,
459
+ )
460
+
461
+ # Case A: single file
462
+ if _is_single_file(dst):
463
+ if len(self.datasets) != 1:
464
+ raise ValueError("Saving to a single file requires exactly one dataset.")
465
+ _write_one(self.datasets[0], dst, format)
466
+ return dst
467
+
468
+ # Case B: directory of per-dataset files
469
+ dst.mkdir(parents=True, exist_ok=True)
470
+ fmt = (format or "parquet").lower()
471
+ if fmt not in {"csv", "parquet", "h5"}:
472
+ raise ValueError("format must be 'csv', 'parquet', or 'h5'.")
473
+ ext = {"csv": ".csv", "parquet": ".parquet", "h5": ".h5"}[fmt]
474
+
475
+ entries = []
476
+ for i, ds in enumerate(self.datasets):
477
+ rel = Path(f"ds_{i:03d}{ext}")
478
+ _write_one(ds, dst / rel, fmt)
479
+ entries.append({"name": getattr(ds, "name", f"dataset_{i}"), "file": rel.as_posix()})
480
+
481
+ import yaml
482
+
483
+ manifest = {"version": 1, "n_datasets": len(entries), "datasets": entries}
484
+ (dst / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
485
+ return dst
486
+
487
+ @classmethod
488
+ def load(
489
+ cls,
490
+ path: Union[str, Path],
491
+ *,
492
+ relabel: bool = True,
493
+ compress_particles: bool = False,
494
+ particle_column: Union[int, str, None] = "auto",
495
+ time_column: Union[int, str] = "auto",
496
+ state_columns: Optional[Sequence[Union[int, str]]] = None,
497
+ ) -> "TrajectoryCollection":
498
+ """
499
+ Load a collection from a single file or a directory.
500
+
501
+ Parameters
502
+ ----------
503
+ relabel
504
+ If True, compress particle IDs to 0..N-1 and shift time to start at 0.
505
+ compress_particles
506
+ If True, further reduce the column count by merging particles whose
507
+ time supports do not overlap (greedy interval packing with a 2-frame
508
+ buffer). Useful for open-boundary systems where particles enter and
509
+ leave the field of view, causing the naive N to grow as the total
510
+ number of unique particle IDs rather than the concurrent count.
511
+ Per-particle extras are reindexed automatically; the mapping is
512
+ stored in ``dataset.meta['particle_column_map']``.
513
+ particle_column, time_column
514
+ Which columns hold the particle ID and the time index, as a
515
+ column *name* (any format) or a positional *index* (CSV only).
516
+ ``"auto"`` (default) keeps the loader defaults: CSV positional
517
+ (column 0 = particle, column 1 = time), parquet/HDF5 the
518
+ canonical names ``"particle_id"`` / ``"time_step"``. Pass
519
+ ``particle_column=None`` for single-trajectory files.
520
+ state_columns
521
+ Optional explicit selection of the state-vector columns
522
+ (names, or indices for CSV), in order; every other non-extras
523
+ column is dropped. Default: all non-ID, non-extras columns.
524
+
525
+ Notes
526
+ -----
527
+ The default weight policy differs by path: a single-file load uses
528
+ ``"Teff"`` (via :meth:`from_dataset`); a directory load uses
529
+ ``"equal"``. Call :meth:`with_weights` after loading if a consistent
530
+ policy is needed.
531
+ """
532
+ src = Path(path)
533
+
534
+ column_kw: Dict[str, Any] = {}
535
+ if particle_column != "auto":
536
+ column_kw["particle_column"] = particle_column
537
+ if time_column != "auto":
538
+ column_kw["time_column"] = time_column
539
+ if state_columns is not None:
540
+ column_kw["state_columns"] = state_columns
541
+
542
+ def _read_one(filename: Path) -> TrajectoryDataset:
543
+ yaml_meta, _cols, pid, tidx, vecs, eg, el = load_trajectory(
544
+ str(filename), relabel=relabel, **column_kw
545
+ )
546
+ # Prefer t from extras; else use dt if present in extras or YAML meta
547
+ dt_pass = None
548
+ if "t" not in eg and "dt" in eg:
549
+ v = eg["dt"]
550
+ dt_pass = v.data if isinstance(v, TimeSeriesExtra) else float(v)
551
+ elif "t" not in eg and yaml_meta and "dt" in yaml_meta:
552
+ dt_pass = float(yaml_meta["dt"])
553
+ return columns_and_extras_to_dataset(
554
+ pid,
555
+ tidx,
556
+ vecs,
557
+ extras_global=eg,
558
+ extras_local=el,
559
+ dt=dt_pass,
560
+ relabel=relabel,
561
+ compress_particles=compress_particles,
562
+ meta=yaml_meta,
563
+ )
564
+
565
+ # Single file → one dataset
566
+ if _is_single_file(src):
567
+ ds = _read_one(src)
568
+ return cls.from_dataset(ds)
569
+
570
+ # Directory → many datasets
571
+ if not src.is_dir():
572
+ raise FileNotFoundError(f"No such file or directory: {src}")
573
+
574
+ files: Iterable[Path]
575
+ manifest = src / "manifest.yaml"
576
+ if manifest.exists():
577
+ import yaml
578
+
579
+ man = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
580
+ files = [src / Path(e["file"]) for e in man.get("datasets", [])]
581
+ else:
582
+ files = sorted(p for p in src.iterdir() if p.suffix.lower() in {".csv", ".parquet", ".pq", ".h5", ".hdf5"})
583
+
584
+ datasets = [_read_one(fp) for fp in files]
585
+ return cls(datasets=datasets, weights=jnp.ones((len(datasets),), dtype=jnp.float32)).with_weights("pool")
586
+
587
+ # Constructors:
588
+ @classmethod
589
+ def from_arrays(
590
+ cls,
591
+ *,
592
+ X: Any,
593
+ dt: Optional[float] = None,
594
+ t: Optional[Any] = None,
595
+ mask: Optional[Any] = None,
596
+ extras_global: Optional[Dict[str, Any]] = None,
597
+ extras_local: Optional[Dict[str, Any]] = None,
598
+ meta: Optional[Dict[str, Any]] = None,
599
+ weights: WeightSpec = "pool",
600
+ ) -> "TrajectoryCollection":
601
+ """
602
+ Build a single-dataset collection from array-likes.
603
+
604
+ This is the recommended entry point when you already have tensors
605
+ in memory.
606
+
607
+ Parameters
608
+ ----------
609
+ X
610
+ State array of shape ``(T, N, d)`` or ``(T, d)``. If ``(T, d)``,
611
+ a single particle is assumed.
612
+ dt
613
+ Either a scalar step, an array of shape ``(T,)`` (per-step),
614
+ or ``None``. If ``None`` and ``t`` is provided, effective steps
615
+ are derived from ``t`` on demand.
616
+ t
617
+ Optional absolute time vector of shape ``(T,)``. If provided, it
618
+ defines time steps when needed.
619
+ mask
620
+ Optional boolean mask of shape ``(T, N)`` or ``(T,)`` marking valid
621
+ observations. If ``None``, all entries are considered valid.
622
+ extras_global
623
+ Mapping of global extras. Values can be static objects,
624
+ :class:`TimeSeriesExtra`, or JAX-traceable callables
625
+ ``f(t_idx, context=None) -> Array`` with a leading time axis.
626
+ extras_local
627
+ Mapping of per-particle extras, with the same typing as
628
+ ``extras_global``. Time-series entries typically have shape
629
+ ``(T, N, ...)``.
630
+ meta
631
+ Free-form metadata dictionary attached to the underlying dataset.
632
+ weights
633
+ Initial weight specification for the resulting collection.
634
+ See :meth:`with_weights`.
635
+
636
+ Returns
637
+ -------
638
+ TrajectoryCollection
639
+ A collection with one dataset built from the provided arrays.
640
+ """
641
+ ds = TrajectoryDataset.from_arrays(
642
+ X=X,
643
+ dt=dt,
644
+ t=t,
645
+ mask=mask,
646
+ extras_global=extras_global,
647
+ extras_local=extras_local,
648
+ meta=meta,
649
+ )
650
+ return cls.from_dataset(ds, weights=weights)
651
+
652
+ @classmethod
653
+ def from_columns(
654
+ cls,
655
+ particle_idx: np.ndarray,
656
+ time_idx: np.ndarray,
657
+ state_vectors: np.ndarray,
658
+ *,
659
+ extras_global: Mapping[str, Any] | None = None,
660
+ extras_local: Mapping[str, Any] | None = None,
661
+ dt: Optional[float] = None,
662
+ t: Optional[np.ndarray] = None,
663
+ relabel: bool = True,
664
+ compress_particles: bool = False,
665
+ meta: Optional[Dict[str, Any]] = None,
666
+ weights: WeightSpec = "pool",
667
+ ) -> "TrajectoryCollection":
668
+ """
669
+ Build a single-dataset collection from flat (particle, time) columns.
670
+
671
+ This constructor is convenient when reading trajectories from a
672
+ tabular format or a custom pipeline.
673
+
674
+ Parameters
675
+ ----------
676
+ particle_idx
677
+ Integer array of shape ``(L,)`` with particle IDs for each row.
678
+ time_idx
679
+ Integer array of shape ``(L,)`` with time indices ``t`` for each row.
680
+ state_vectors
681
+ Array of shape ``(L, d)`` with state vectors.
682
+ extras_global
683
+ Parsed global extras (e.g. from YAML header), as described in
684
+ :mod:`SFI.trajectory.io`.
685
+ extras_local
686
+ Parsed local extras, including time-series extras, as described
687
+ in :mod:`SFI.trajectory.io`.
688
+ dt
689
+ Optional scalar step; used only if no absolute time axis is
690
+ provided via ``t`` or ``extras_global['t']``.
691
+ t
692
+ Optional time vector of shape ``(T,)`` overriding any time axis
693
+ inferred from extras.
694
+ relabel
695
+ If True, compress particle IDs to ``0..N-1`` and shift time to
696
+ start at 0.
697
+ compress_particles
698
+ If True, apply greedy interval packing to reduce the column count
699
+ by merging particles whose time supports do not overlap (with a
700
+ 2-frame buffer). Per-particle extras are reindexed automatically.
701
+ The mapping is stored in ``dataset.meta['particle_column_map']``.
702
+ meta
703
+ Metadata dictionary to attach to the dataset.
704
+ weights
705
+ Initial weight specification for the resulting collection.
706
+
707
+ Returns
708
+ -------
709
+ TrajectoryCollection
710
+ A collection with one dataset assembled from the columns.
711
+ """
712
+ ds = columns_and_extras_to_dataset(
713
+ particle_idx,
714
+ time_idx,
715
+ state_vectors,
716
+ extras_global=extras_global,
717
+ extras_local=extras_local,
718
+ dt=dt,
719
+ t=t,
720
+ relabel=relabel,
721
+ compress_particles=compress_particles,
722
+ meta=meta,
723
+ )
724
+ return cls.from_dataset(ds, weights=weights)
725
+
726
+ #: Column-name candidates tried (case-insensitively) by `from_dataframe`.
727
+ _PARTICLE_COLUMN_CANDIDATES = ("particle_id", "particle", "track_id", "track", "traj_id")
728
+ _TIME_COLUMN_CANDIDATES = ("time_step", "frame", "time", "t")
729
+
730
+ @classmethod
731
+ def from_dataframe(
732
+ cls,
733
+ df,
734
+ *,
735
+ particle: Optional[str] = None,
736
+ time: Optional[str] = None,
737
+ coords: Optional[Sequence[str]] = None,
738
+ dt: Optional[float] = None,
739
+ t: Optional[Any] = None,
740
+ extras_global: Mapping[str, Any] | None = None,
741
+ extras_local: Mapping[str, Any] | None = None,
742
+ relabel: bool = True,
743
+ compress_particles: bool = False,
744
+ meta: Optional[Dict[str, Any]] = None,
745
+ weights: WeightSpec = "pool",
746
+ ) -> "TrajectoryCollection":
747
+ """
748
+ Build a single-dataset collection from a pandas DataFrame.
749
+
750
+ The natural entry point for raw tracking tables (trackpy,
751
+ TrackMate, custom pipelines): columns are addressed by *name*, in
752
+ any order, and junk columns are dropped.
753
+
754
+ Parameters
755
+ ----------
756
+ df
757
+ A pandas DataFrame with one row per ``(particle, time)``
758
+ observation.
759
+ particle
760
+ Name of the particle/track-ID column. Default: case-insensitive
761
+ auto-detection among ``particle_id, particle, track_id, track,
762
+ traj_id``; if none is present the table is treated as a single
763
+ trajectory, and if several are present a ``ValueError`` asks
764
+ for an explicit choice.
765
+ time
766
+ Name of the time column. Default: auto-detection among
767
+ ``time_step, frame, time, t`` (same ambiguity rule). Integer
768
+ columns are used as frame indices; float columns are
769
+ factorized into frame indices and, unless ``t`` or ``dt`` is
770
+ given, their sorted unique values become the absolute time
771
+ axis.
772
+ coords
773
+ State-vector column names, in order. Default: every remaining
774
+ column without an extras prefix (``G_``, ``TG_``, ``P_``,
775
+ ``TP_``), in dataframe order. Columns not selected are
776
+ silently dropped.
777
+ dt, t
778
+ Time-axis specification, as in :meth:`from_columns`.
779
+ extras_global, extras_local
780
+ Extra fields merged **over** any extras parsed from prefixed
781
+ columns (user values win).
782
+ relabel, compress_particles, meta, weights
783
+ As in :meth:`from_columns`.
784
+
785
+ Examples
786
+ --------
787
+ >>> coll = TrajectoryCollection.from_dataframe(
788
+ ... tracks, particle="track_id", time="frame",
789
+ ... coords=("x", "y"), dt=0.05,
790
+ ... )
791
+ """
792
+ try:
793
+ import pandas as pd # noqa: F401
794
+ except Exception as e: # pragma: no cover
795
+ raise ImportError("TrajectoryCollection.from_dataframe requires pandas.") from e
796
+
797
+ colnames = list(df.columns)
798
+ prefixes = ("G_", "TG_", "P_", "TP_")
799
+
800
+ def _pick(explicit: Optional[str], candidates: Sequence[str], what: str, required: bool) -> Optional[str]:
801
+ if explicit is not None:
802
+ if explicit not in colnames:
803
+ raise ValueError(f"{what} column {explicit!r} not found; available columns: {colnames}")
804
+ return explicit
805
+ lower = {c.lower(): c for c in colnames}
806
+ hits = [lower[c] for c in candidates if c in lower]
807
+ if len(hits) > 1:
808
+ raise ValueError(
809
+ f"ambiguous {what} column — found {hits}; pass {what}= explicitly"
810
+ )
811
+ if hits:
812
+ return hits[0]
813
+ if required:
814
+ raise ValueError(
815
+ f"no {what} column found (tried {tuple(candidates)}); pass {what}= explicitly"
816
+ )
817
+ return None
818
+
819
+ particle_name = _pick(particle, cls._PARTICLE_COLUMN_CANDIDATES, "particle", required=False)
820
+ time_name = _pick(time, cls._TIME_COLUMN_CANDIDATES, "time", required=True)
821
+
822
+ if coords is None:
823
+ skip = {particle_name, time_name}
824
+ coord_names = [
825
+ c for c in colnames if c not in skip and not any(c.startswith(p) for p in prefixes)
826
+ ]
827
+ else:
828
+ coord_names = list(coords)
829
+ missing = [c for c in coord_names if c not in colnames]
830
+ if missing:
831
+ raise ValueError(f"coords columns not found: {missing}; available columns: {colnames}")
832
+ if not coord_names:
833
+ raise ValueError("no state (coordinate) columns selected")
834
+
835
+ keep = ([particle_name] if particle_name else []) + [time_name] + coord_names
836
+ keep += [c for c in colnames if any(c.startswith(p) for p in prefixes) and c not in keep]
837
+ sub = df[keep].copy()
838
+
839
+ # Float time column → factorize to frame indices (+ time axis).
840
+ t_resolved = t
841
+ tvals = np.asarray(sub[time_name].to_numpy())
842
+ if not np.issubdtype(tvals.dtype, np.integer):
843
+ uniq, inv = np.unique(tvals, return_inverse=True)
844
+ if t is None and dt is None:
845
+ t_resolved = uniq
846
+ sub[time_name] = inv.astype(int)
847
+
848
+ _meta, _cols, pid, tidx, vecs, eg, el = _parse_tabular_with_extras(
849
+ sub,
850
+ {},
851
+ particle_column=particle_name,
852
+ time_column=time_name,
853
+ relabel=relabel,
854
+ )
855
+ eg = {**eg, **(dict(extras_global) if extras_global else {})}
856
+ el = {**el, **(dict(extras_local) if extras_local else {})}
857
+
858
+ return cls.from_columns(
859
+ pid,
860
+ tidx,
861
+ vecs,
862
+ extras_global=eg,
863
+ extras_local=el,
864
+ dt=dt,
865
+ t=t_resolved,
866
+ relabel=relabel,
867
+ compress_particles=compress_particles,
868
+ meta=meta,
869
+ weights=weights,
870
+ )
871
+
872
+ def split_time(
873
+ self,
874
+ fraction: float = 0.8,
875
+ *,
876
+ gap: int = 0,
877
+ reweight: Literal["pool", "keep"] = "pool",
878
+ ) -> tuple["TrajectoryCollection", "TrajectoryCollection"]:
879
+ """Split every dataset along time into ``(train, test)`` collections.
880
+
881
+ A side feature for data-abundant scenarios: SFI estimates its own
882
+ accuracy from the training data (``force_predicted_MSE``) and
883
+ validates fits through the diagnostics suite, neither of which
884
+ costs any data. Hold out a test fraction only when data is
885
+ plentiful, or to confirm a suspected bias floor with
886
+ :meth:`~SFI.inference.base.BaseLangevinInference.holdout_score`.
887
+
888
+ Parameters
889
+ ----------
890
+ fraction : float
891
+ Fraction of frames per dataset assigned to the train half.
892
+ gap : int
893
+ Frames dropped between the halves (decorrelation; ``0`` is
894
+ safe for increment-based estimators).
895
+ reweight : {"Teff", "keep"}
896
+ ``"Teff"`` (default) recomputes per-dataset weights on each
897
+ half; ``"keep"`` carries over the current relative weights.
898
+
899
+ Examples
900
+ --------
901
+ >>> train, test = coll.split_time(0.8)
902
+ >>> inf = OverdampedLangevinInference(train)
903
+ >>> # ... fit ...
904
+ >>> inf.holdout_score(test)
905
+ """
906
+ pairs = [ds.split_time(fraction, gap=gap) for ds in self.datasets]
907
+ train_ds = [p[0] for p in pairs]
908
+ test_ds = [p[1] for p in pairs]
909
+ n = len(self.datasets)
910
+ spec: WeightSpec = "pool" if reweight == "pool" else np.asarray(self.weights)
911
+ train = type(self)(datasets=train_ds, weights=jnp.ones((n,), dtype=jnp.float32)).with_weights(spec)
912
+ test = type(self)(datasets=test_ds, weights=jnp.ones((n,), dtype=jnp.float32)).with_weights(spec)
913
+ return train, test
914
+
915
+ def dataset_index(self, position: int) -> int:
916
+ """Dense index of dataset ``position``, keyed on its stable identity.
917
+
918
+ Datasets are numbered by first appearance of their ``uuid``, so the
919
+ index a force sees (e.g. via :func:`~SFI.bases.per_dataset_scalar` or
920
+ :func:`~SFI.bases.dataset_indicator`) is tied to the dataset itself, not
921
+ its slot — stable under concatenation and reordering.
922
+ """
923
+ order: Dict[str, int] = {}
924
+ for ds in self.datasets:
925
+ order.setdefault(ds.uuid, len(order))
926
+ return order[self.datasets[position].uuid]
927
+
928
+ def degrade(
929
+ self,
930
+ *,
931
+ downsample: int = 1,
932
+ motion_blur: int = 0,
933
+ data_loss_fraction: float = 0.0,
934
+ noise: Union[None, float, np.ndarray] = None,
935
+ ROI: Union[None, float, np.ndarray, Callable[[np.ndarray], bool]] = None,
936
+ seed: Optional[int] = None,
937
+ reweight: Literal["pool", "keep"] = "pool",
938
+ ) -> "TrajectoryCollection":
939
+ """
940
+ Return a new degraded collection; the original is not modified.
941
+
942
+ This is the preferred user-facing API for degrading synthetic
943
+ trajectories to mimic experimental noise, blur, and data loss.
944
+
945
+ Parameters
946
+ ----------
947
+ downsample, motion_blur, data_loss_fraction, noise, ROI, seed, reweight
948
+ See :func:`SFI.trajectory.degrade.degrade_collection` for a full
949
+ description of each parameter.
950
+
951
+ Returns
952
+ -------
953
+ TrajectoryCollection
954
+ New degraded collection. The original collection is not modified.
955
+ """
956
+ from SFI.trajectory.degrade import degrade_collection
957
+
958
+ return degrade_collection(
959
+ self,
960
+ downsample=downsample,
961
+ motion_blur=motion_blur,
962
+ data_loss_fraction=data_loss_fraction,
963
+ noise=noise,
964
+ ROI=ROI,
965
+ seed=seed,
966
+ reweight=reweight,
967
+ )
968
+
969
+ def to_arrays(
970
+ self,
971
+ *,
972
+ dataset: int = 0,
973
+ as_numpy: bool = True,
974
+ include_mask: bool = True,
975
+ ):
976
+ """
977
+ Convenience helper: materialize one dataset as dense arrays.
978
+
979
+ Parameters
980
+ ----------
981
+ dataset :
982
+ Index of the dataset inside the collection (default 0).
983
+ as_numpy :
984
+ If True, return NumPy arrays.
985
+ include_mask :
986
+ If True, also return the per-particle mask.
987
+
988
+ Returns
989
+ -------
990
+ t, X, mask :
991
+ See :meth:`TrajectoryDataset.to_arrays`.
992
+ """
993
+ if not (0 <= dataset < len(self.datasets)):
994
+ raise IndexError(f"dataset index {dataset} out of range for D={len(self.datasets)}")
995
+ return self.datasets[dataset].to_arrays(
996
+ as_numpy=as_numpy,
997
+ include_mask=include_mask,
998
+ )
999
+
1000
+ def merge(
1001
+ self,
1002
+ items: Sequence[Union["TrajectoryCollection", TrajectoryDataset]],
1003
+ *,
1004
+ weights: WeightSpec = "pool",
1005
+ ) -> "TrajectoryCollection":
1006
+ """Combine this collection with others into one collection.
1007
+
1008
+ Convenience alias for :meth:`concat` — useful for assembling an
1009
+ ensemble from several single-trajectory collections
1010
+ (``base.merge([c1, c2, ...])``). See :meth:`concat` for the
1011
+ ``weights`` policy.
1012
+ """
1013
+ return self.concat(items, weights=weights)
1014
+
1015
+ def to_array(self, *, axis: Literal["time"] = "time", as_numpy: bool = True):
1016
+ """Materialize the whole collection as one dense ``(T, N, d)`` array.
1017
+
1018
+ Concatenates every dataset along the time axis into a single array
1019
+ of positions. Use this for the legitimate non-plotting reach-ins
1020
+ (disk caching, ensemble bootstrap initial conditions, neighbour
1021
+ lists); for plotting, prefer the toolkit functions in
1022
+ :mod:`SFI.utils.plotting`, and for ``(t, X, mask)`` of a single
1023
+ dataset use :meth:`to_arrays`.
1024
+
1025
+ Parameters
1026
+ ----------
1027
+ axis :
1028
+ Only ``"time"`` is supported (axis-0 concatenation).
1029
+ as_numpy :
1030
+ If True (default), return a NumPy array; else a JAX array.
1031
+
1032
+ Returns
1033
+ -------
1034
+ ndarray, shape ``(sum_T, N, d)``
1035
+ """
1036
+ if axis != "time":
1037
+ raise ValueError(f"to_array only supports axis='time', got {axis!r}.")
1038
+ if not self.datasets:
1039
+ raise ValueError("Empty TrajectoryCollection.")
1040
+ out = jnp.concatenate([ds._X3d() for ds in self.datasets], axis=0)
1041
+ return np.asarray(out) if as_numpy else out
1042
+
1043
+ def velocity_array(
1044
+ self,
1045
+ *,
1046
+ dataset: int = 0,
1047
+ scheme: Literal["central", "forward", "backward"] = "central",
1048
+ as_numpy: bool = True,
1049
+ ):
1050
+ """Finite-difference velocity ``v(t)`` for one dataset.
1051
+
1052
+ Reconstructs velocities from stored positions with
1053
+ :func:`SFI.utils.maths.fd_velocity`, matching the secant-velocity
1054
+ convention of the underdamped engine. Handy for building
1055
+ ``(x, v)`` phase portraits or held-out evaluation grids from
1056
+ position-only recordings.
1057
+
1058
+ Parameters
1059
+ ----------
1060
+ dataset :
1061
+ Dataset index inside the collection (default 0).
1062
+ scheme :
1063
+ Finite-difference stencil; see :func:`SFI.utils.maths.fd_velocity`.
1064
+ as_numpy :
1065
+ If True (default), return a NumPy array; else a JAX array.
1066
+
1067
+ Returns
1068
+ -------
1069
+ v : ndarray, shape ``(T, N, d)``
1070
+ """
1071
+ from SFI.utils.maths import fd_velocity
1072
+
1073
+ t, X, _ = self.to_arrays(dataset=dataset, as_numpy=True, include_mask=True)
1074
+ dt = np.diff(np.asarray(t, dtype=float))
1075
+ if dt.size == 0:
1076
+ raise ValueError("velocity_array needs at least 2 frames.")
1077
+ v = fd_velocity(X, dt, scheme=scheme)
1078
+ return np.asarray(v) if as_numpy else v
1079
+
1080
+ # ------------------------------------------------------------------
1081
+ # Attribute forwarding for the most common case: a single dataset
1082
+ # ------------------------------------------------------------------
1083
+ def _single_dataset(self):
1084
+ """Return the unique dataset if the collection has exactly one, else None."""
1085
+ if len(self.datasets) == 1:
1086
+ return self.datasets[0]
1087
+ return None
1088
+
1089
+ def __getattr__(self, name):
1090
+ """
1091
+ Forward attribute access to the sole dataset when exactly one is present.
1092
+ Does not intercept existing attributes on the collection itself.
1093
+ """
1094
+ # Guard: during pickle/unpickle, __dict__ may not yet contain
1095
+ # 'datasets', so _single_dataset() would recurse back here.
1096
+ if name == "datasets":
1097
+ raise AttributeError(name)
1098
+ ds = self._single_dataset()
1099
+ if ds is not None and hasattr(ds, name):
1100
+ return getattr(ds, name)
1101
+ raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}")
1102
+
1103
+ def __getitem__(self, key):
1104
+ """
1105
+ Optional: dict-style forwarding for convenience.
1106
+ Example: coll["extras_global"].
1107
+ """
1108
+ ds = self._single_dataset()
1109
+ if ds is not None and hasattr(ds, key):
1110
+ return getattr(ds, key)
1111
+ raise KeyError(key)
1112
+
1113
+ def __dir__(self):
1114
+ """
1115
+ Extend tab completion: if a single dataset is present,
1116
+ expose its attributes as if they were part of the collection.
1117
+ """
1118
+ base = super().__dir__()
1119
+ ds = self._single_dataset()
1120
+ if ds is not None:
1121
+ return sorted(set(base) | set(dir(ds)))
1122
+ return base