StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
SFI/trajectory/io.py ADDED
@@ -0,0 +1,1027 @@
1
+ """SFI.trajectory.io
2
+ ====================
3
+
4
+ CSV I/O utilities and columnar ↔ tensor conversion for trajectory data.
5
+
6
+ File format
7
+ -----------
8
+ We support a single CSV with optional YAML header. The numerical columns include:
9
+
10
+ - ``particle_id`` (optional): if absent, it is a *single-trajectory* file.
11
+ - ``time_step`` : integer time index t (0-based after relabel).
12
+ - ``x0, x1, ..., x{d-1}`` : state vector components.
13
+
14
+ Extras are stored either in the header (YAML) or as extra numeric columns:
15
+
16
+ Prefixes (numeric columns)
17
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
18
+ - ``TG_`` : *time-dependent globals* — values depend on ``t`` only.
19
+ - ``P_`` : *per-particle constants* — values depend on particle only.
20
+ - ``TP_`` : *time-dependent per-particle* — values depend on ``(t, n)``.
21
+ - ``G_`` : *global scalars* — constants stored in the header via averaging.
22
+
23
+ Note: ``TG_``/``TP_`` columns are parsed as time series and wrapped into
24
+ :class:`TimeSeriesExtra`. Header `extras_global` entries are treated as
25
+ static unless explicitly wrapped when building the dataset.
26
+
27
+ Header
28
+ ~~~~~~
29
+ The YAML header may include a mapping ``extras_global`` of arbitrary scalars or
30
+ arrays. A special key ``"t"`` (vector of length ``T``) is recognized as the time
31
+ axis; when present, it replaces scalar ``dt`` in downstream builders.
32
+
33
+ Round-trip helpers
34
+ ------------------
35
+ - :func:`flatten_X_to_columns` / :func:`assemble_X_from_columns` convert between
36
+ structured tensors ``(T,N,d)`` and flat columns.
37
+ - :func:`save_trajectory_csv_with_extras` / :func:`load_trajectory_csv_with_extras`
38
+ handle extras and header metadata.
39
+ - :func:`columns_and_extras_to_dataset` builds a :class:`TrajectoryDataset`
40
+ ready for inference.
41
+
42
+ All functions are NumPy-based; JAX is optional for basic dtype detection only.
43
+
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import warnings
49
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union
50
+
51
+ import numpy as np
52
+
53
+ from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra # local import to avoid cycles
54
+
55
+ __all__ = [
56
+ "save_trajectory",
57
+ "load_trajectory",
58
+ "columns_and_extras_to_dataset",
59
+ ]
60
+ # ---------------- utilities ----------------
61
+
62
+
63
+ def _sanitize_metadata(obj: Any) -> Any:
64
+ """Convert arrays/scalars into plain Python types recursively for YAML/JSON."""
65
+ # Catch any array-like (numpy, JAX, PyTorch, …) by duck-typing.
66
+ # The old check ("ndarray" in type(obj).__name__) missed JAX ArrayImpl.
67
+ if hasattr(obj, "shape") and hasattr(obj, "dtype"):
68
+ try:
69
+ return np.asarray(obj).tolist()
70
+ except Exception:
71
+ pass
72
+ if hasattr(obj, "item") and not isinstance(obj, (dict, list, tuple)):
73
+ try:
74
+ return obj.item()
75
+ except Exception:
76
+ pass
77
+ if isinstance(obj, dict):
78
+ return {k: _sanitize_metadata(v) for k, v in obj.items()}
79
+ if isinstance(obj, (list, tuple)):
80
+ return [_sanitize_metadata(v) for v in obj]
81
+ return obj
82
+
83
+
84
+ # ---------------- core converters ----------------
85
+
86
+
87
+ def flatten_X_to_columns(X: np.ndarray, mask: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
88
+ """Tensor trajectory to columnar representation.
89
+
90
+ Parameters
91
+ ----------
92
+ X : ndarray, shape (T, N, d)
93
+ State tensor.
94
+ mask : ndarray, optional
95
+ Boolean mask with shape ``(T,N)`` or ``(T,)``; invalid rows are filtered out.
96
+
97
+ Returns
98
+ -------
99
+ particle_idx : ndarray, shape (L,)
100
+ time_idx : ndarray, shape (L,)
101
+ state_vectors : ndarray, shape (L, d)
102
+
103
+ Notes
104
+ -----
105
+ Rows with NaNs in ``state_vectors`` are dropped. If ``mask`` is provided,
106
+ rows where mask is False are also dropped.
107
+ """
108
+ X = np.asarray(X)
109
+ if X.ndim != 3:
110
+ raise ValueError(f"X must be (T,N,d); got {X.shape}")
111
+ T, N, d = X.shape
112
+ time_idx = np.repeat(np.arange(T, dtype=int), N)
113
+ particle_idx = np.tile(np.arange(N, dtype=int), T)
114
+ state_vectors = X.reshape(T * N, d)
115
+ valid = ~np.isnan(state_vectors).any(axis=1)
116
+ if mask is not None:
117
+ m = np.asarray(mask, dtype=bool)
118
+ if m.shape == (T,):
119
+ m = np.broadcast_to(m[:, None], (T, N))
120
+ if m.shape != (T, N):
121
+ raise ValueError(f"mask must be (T,) or (T,N); got {m.shape}")
122
+ valid &= m.reshape(T * N)
123
+ return (
124
+ particle_idx[valid].astype(int, copy=False),
125
+ time_idx[valid].astype(int, copy=False),
126
+ state_vectors[valid],
127
+ )
128
+
129
+
130
+ def assemble_X_from_columns(
131
+ particle_idx: np.ndarray,
132
+ time_idx: np.ndarray,
133
+ state_vectors: np.ndarray,
134
+ *,
135
+ fill_value: float = np.nan,
136
+ relabel: bool = True,
137
+ compress_particles: bool = False,
138
+ ) -> Tuple[np.ndarray, np.ndarray, Optional[Any]]:
139
+ """Columnar to tensor trajectory (and mask).
140
+
141
+ Parameters
142
+ ----------
143
+ particle_idx, time_idx : ndarray
144
+ Integer columns.
145
+ state_vectors : ndarray, shape (L, d)
146
+ Flat state vectors.
147
+ fill_value : float
148
+ Value to fill missing entries in ``X``.
149
+ relabel : bool
150
+ If True, compress particle IDs to contiguous ``0..N-1`` and shift
151
+ ``time_idx`` to start at 0.
152
+ compress_particles : bool
153
+ If True, apply greedy interval packing to further reduce the number of
154
+ columns by merging particles whose time supports do not overlap (with a
155
+ 2-frame buffer). Implies relabeling of particle IDs first.
156
+ See :func:`_greedy_compress_particles`.
157
+
158
+ Returns
159
+ -------
160
+ X : ndarray, shape (T, N, d)
161
+ mask : ndarray, shape (T, N)
162
+ True where entries are present in the columns.
163
+ id_map : ndarray of shape ``(N,)`` or dict or None
164
+ * ``relabel=True``, ``compress_particles=False``: shape ``(N,)`` array
165
+ of original particle IDs in column order.
166
+ * ``compress_particles=True``: dict with keys ``'column_origins'``
167
+ (list of lists of compact IDs per column), ``'t_first'``, ``'t_last'``
168
+ (ndarrays of shape ``(N_orig,)`` giving the time span of each compact
169
+ particle before compression).
170
+ * Otherwise: ``None``.
171
+ """
172
+ particle_idx = np.asarray(particle_idx, dtype=int)
173
+ time_idx = np.asarray(time_idx, dtype=int)
174
+ state_vectors = np.asarray(state_vectors)
175
+ if state_vectors.ndim != 2:
176
+ raise ValueError("state_vectors must be 2D (L, d)")
177
+ time_idx = time_idx - time_idx.min()
178
+ if (time_idx < 0).any():
179
+ raise ValueError("time_idx normalization failed (negative after shift).")
180
+ id_map: Optional[Any] = None
181
+ if relabel or compress_particles:
182
+ uniq = np.unique(particle_idx)
183
+ remap = {old: new for new, old in enumerate(uniq)}
184
+ particle_idx = np.vectorize(remap.__getitem__, otypes=[int])(particle_idx)
185
+ N = len(uniq)
186
+ if not compress_particles:
187
+ # Only record the map when IDs were not already 0..N-1
188
+ if not np.array_equal(uniq, np.arange(len(uniq))):
189
+ id_map = uniq # original IDs in column order
190
+ else:
191
+ N = int(particle_idx.max()) + 1 if len(particle_idx) > 0 else 0
192
+ if compress_particles:
193
+ particle_idx, column_origins, t_first, t_last = _greedy_compress_particles(particle_idx, time_idx)
194
+ N = len(column_origins)
195
+ id_map = {
196
+ "column_origins": column_origins,
197
+ "t_first": t_first,
198
+ "t_last": t_last,
199
+ }
200
+ T = int(time_idx.max()) + 1 if len(time_idx) > 0 else 0
201
+ d = int(state_vectors.shape[1])
202
+ X = np.full((T, N, d), fill_value, dtype=state_vectors.dtype)
203
+ mask = np.zeros((T, N), dtype=bool)
204
+ X[time_idx, particle_idx] = state_vectors
205
+ mask[time_idx, particle_idx] = True
206
+ return X, mask, id_map
207
+
208
+
209
+ def _greedy_compress_particles(
210
+ particle_idx: np.ndarray,
211
+ time_idx: np.ndarray,
212
+ ) -> Tuple[np.ndarray, List[List[int]], np.ndarray, np.ndarray]:
213
+ """Greedy interval packing: merge particle columns with non-overlapping time windows.
214
+
215
+ Two particles assigned to the same column are always separated by at least
216
+ one masked frame (gap ≥ 2 time steps between time supports), preventing
217
+ spurious increments across identity changes.
218
+
219
+ Parameters
220
+ ----------
221
+ particle_idx : ndarray, shape (L,)
222
+ Compact particle indices in ``0..N_orig-1``.
223
+ time_idx : ndarray, shape (L,)
224
+ Compact time indices in ``0..T-1``.
225
+
226
+ Returns
227
+ -------
228
+ new_particle_idx : ndarray, shape (L,)
229
+ Updated column index for each observation row.
230
+ column_origins : list[list[int]]
231
+ ``column_origins[c]`` lists the compact IDs packed into column ``c``,
232
+ in temporal order.
233
+ t_first : ndarray, shape (N_orig,)
234
+ First time index for each compact particle.
235
+ t_last : ndarray, shape (N_orig,)
236
+ Last time index for each compact particle.
237
+ """
238
+ if len(particle_idx) == 0:
239
+ return (
240
+ particle_idx.copy(),
241
+ [],
242
+ np.array([], dtype=np.intp),
243
+ np.array([], dtype=np.intp),
244
+ )
245
+ N_orig = int(particle_idx.max()) + 1
246
+ t_first = np.full(N_orig, np.iinfo(np.intp).max, dtype=np.intp)
247
+ t_last = np.full(N_orig, -1, dtype=np.intp)
248
+ np.minimum.at(t_first, particle_idx, time_idx)
249
+ np.maximum.at(t_last, particle_idx, time_idx)
250
+ # Sort particles by first appearance (stable to make assignment deterministic)
251
+ order = np.argsort(t_first, kind="stable")
252
+ column_last: List[int] = [] # last time index of each open column
253
+ column_origins: List[List[int]] = []
254
+ assignment = np.empty(N_orig, dtype=np.intp)
255
+ for p in order:
256
+ tf = int(t_first[p])
257
+ tl = int(t_last[p])
258
+ assigned = -1
259
+ for c, clast in enumerate(column_last):
260
+ if clast <= tf - 2: # ≥ 2-frame gap ensures at least one masked frame
261
+ assigned = c
262
+ break
263
+ if assigned < 0:
264
+ assigned = len(column_last)
265
+ column_last.append(tl)
266
+ column_origins.append([int(p)])
267
+ else:
268
+ column_last[assigned] = tl
269
+ column_origins[assigned].append(int(p))
270
+ assignment[p] = assigned
271
+ return assignment[particle_idx], column_origins, t_first, t_last
272
+
273
+
274
+ def _reindex_extras_local_for_compression(
275
+ extras_local: Dict[str, Any],
276
+ column_origins: List[List[int]],
277
+ t_first: np.ndarray,
278
+ t_last: np.ndarray,
279
+ T: int,
280
+ N_comp: int,
281
+ ) -> Dict[str, Any]:
282
+ """Reindex per-particle extras from ``N_orig`` to ``N_comp`` columns.
283
+
284
+ After :func:`_greedy_compress_particles` has merged particles into fewer
285
+ columns, the extras stored in ``extras_local`` still refer to the original
286
+ compact particle indices. This function rebuilds them so they match the
287
+ compressed shape ``(T, N_comp, …)``.
288
+
289
+ * ``TimeSeriesExtra`` with shape ``(T, N_orig, …)`` → ``(T, N_comp, …)``.
290
+ * ndarray with leading axis ``N_orig`` → promoted to ``(T, N_comp, …)``
291
+ wrapped in a ``TimeSeriesExtra`` so each ``(t, c)`` slot returns the
292
+ value of whichever compact particle is active in column ``c`` at time ``t``.
293
+ * Callables / ``FunctionExtra`` and other shapes are kept unchanged.
294
+ """
295
+ from SFI.trajectory.dataset import TimeSeriesExtra, time_series_extra
296
+
297
+ N_orig = len(t_first)
298
+ out: Dict[str, Any] = {}
299
+ for key, val in extras_local.items():
300
+ if callable(val):
301
+ out[key] = val
302
+ continue
303
+ if isinstance(val, TimeSeriesExtra):
304
+ arr = np.asarray(val.data)
305
+ if arr.ndim >= 2 and arr.shape[0] == T and arr.shape[1] == N_orig:
306
+ tail = arr.shape[2:]
307
+ new_arr = np.full((T, N_comp) + tail, np.nan, dtype=float)
308
+ for c, orig_list in enumerate(column_origins):
309
+ for p in orig_list:
310
+ tf, tl = int(t_first[p]), int(t_last[p])
311
+ new_arr[tf : tl + 1, c] = arr[tf : tl + 1, p]
312
+ out[key] = time_series_extra(new_arr)
313
+ else:
314
+ out[key] = val
315
+ else:
316
+ arr = np.asarray(val)
317
+ if arr.ndim >= 1 and arr.shape[0] == N_orig:
318
+ # Per-particle constant (N_orig, …) → time-varying (T, N_comp, …)
319
+ tail = arr.shape[1:]
320
+ new_arr = np.full((T, N_comp) + tail, np.nan, dtype=float)
321
+ for c, orig_list in enumerate(column_origins):
322
+ for p in orig_list:
323
+ tf, tl = int(t_first[p]), int(t_last[p])
324
+ new_arr[tf : tl + 1, c] = arr[p]
325
+ out[key] = time_series_extra(new_arr)
326
+ else:
327
+ out[key] = val
328
+ return out
329
+
330
+
331
+ # -------- builder that wires t from extras_global if present --------
332
+
333
+
334
+ def columns_and_extras_to_dataset(
335
+ particle_idx: np.ndarray,
336
+ time_idx: np.ndarray,
337
+ state_vectors: np.ndarray,
338
+ *,
339
+ extras_global: Mapping[str, Any] | None = None,
340
+ extras_local: Mapping[str, Any] | None = None,
341
+ dt: Optional[float] = None,
342
+ t: Optional[np.ndarray] = None,
343
+ mask_fill_value: float = np.nan,
344
+ relabel: bool = True,
345
+ compress_particles: bool = False,
346
+ meta: Optional[Dict[str, Any]] = None,
347
+ ):
348
+ """Build a :class:`TrajectoryDataset` from columns and parsed extras.
349
+
350
+ Preference order for the time axis:
351
+ 1) explicit ``t`` argument,
352
+ 2) ``extras_global['t']`` (from header or ``TG_t``),
353
+ 3) fallback to scalar ``dt``.
354
+
355
+ Parameters
356
+ ----------
357
+ compress_particles : bool
358
+ If True, apply greedy interval packing so that particles with
359
+ non-overlapping time supports share the same column index. This can
360
+ dramatically reduce ``N`` for open-boundary systems where particles
361
+ enter and leave the field of view over time. Per-particle extras are
362
+ automatically reindexed to the compressed column layout. The mapping
363
+ is stored as ``meta['particle_column_map']``.
364
+ When False (default) and ``relabel=True``, the original particle IDs
365
+ are recorded as ``extras_local['original_particle_id']``.
366
+
367
+ Returns
368
+ -------
369
+ TrajectoryDataset
370
+ """
371
+ if isinstance(t, TimeSeriesExtra):
372
+ t = np.asarray(t.data)
373
+ from SFI.trajectory.dataset import TrajectoryDataset
374
+
375
+ X, mask, id_map = assemble_X_from_columns(
376
+ particle_idx=particle_idx,
377
+ time_idx=time_idx,
378
+ state_vectors=state_vectors,
379
+ fill_value=mask_fill_value,
380
+ relabel=relabel,
381
+ compress_particles=compress_particles,
382
+ )
383
+ eg = dict(extras_global or {})
384
+ el = dict(extras_local or {})
385
+ meta_out = dict(meta or {})
386
+ if id_map is not None:
387
+ if isinstance(id_map, dict):
388
+ # compress_particles=True: store column map and reindex per-particle extras
389
+ meta_out["particle_column_map"] = id_map["column_origins"]
390
+ el = _reindex_extras_local_for_compression(
391
+ el,
392
+ id_map["column_origins"],
393
+ id_map["t_first"],
394
+ id_map["t_last"],
395
+ T=X.shape[0],
396
+ N_comp=X.shape[1],
397
+ )
398
+ else:
399
+ # relabel=True: record the original→compact ID mapping
400
+ el["original_particle_id"] = id_map
401
+ t_vec = t
402
+ if t_vec is None and "t" in eg:
403
+ tv = eg["t"]
404
+ raw = tv.data if isinstance(tv, TimeSeriesExtra) else tv
405
+ arr = np.asarray(raw)
406
+ if arr.ndim == 1:
407
+ t_vec = arr
408
+ return TrajectoryDataset.from_arrays(
409
+ X=X,
410
+ dt=None if t_vec is not None else dt,
411
+ t=None if t_vec is None else t_vec,
412
+ mask=mask,
413
+ extras_global=eg,
414
+ extras_local=el,
415
+ meta=meta_out,
416
+ )
417
+
418
+
419
+ # ---------------- unified save/load with extras (csv/parquet/h5) ----------------
420
+
421
+
422
+ def _build_tabular_with_extras(
423
+ *,
424
+ particle_idx: Optional[np.ndarray],
425
+ time_idx: np.ndarray,
426
+ state_vectors: np.ndarray,
427
+ extras_global: Optional[Dict[str, Any]] = None,
428
+ extras_local: Optional[Dict[str, Any]] = None,
429
+ metadata: Optional[Dict[str, Any]] = None,
430
+ prefix_G: str = "G_",
431
+ prefix_TG: str = "TG_",
432
+ prefix_P: str = "P_",
433
+ prefix_TP: str = "TP_",
434
+ ):
435
+ """Create a pandas DataFrame with all columns (x*, IDs, prefixed extras)
436
+ and a YAML-able metadata dict for header/schema.
437
+
438
+ Returns
439
+ -------
440
+ df : pandas.DataFrame
441
+ yaml_meta : dict (already sanitized to plain Python types)
442
+ base_cols : list[str] # column ordering hint
443
+ """
444
+ import pandas as pd
445
+
446
+ time_idx = np.asarray(time_idx, dtype=int)
447
+ particle_idx = None if particle_idx is None else np.asarray(particle_idx, dtype=int)
448
+ state_vectors = np.asarray(state_vectors)
449
+ L, d = state_vectors.shape
450
+
451
+ cols: Dict[str, Any] = {}
452
+ if particle_idx is not None:
453
+ cols["particle_id"] = particle_idx
454
+ cols["time_step"] = time_idx
455
+ for j in range(d):
456
+ cols[f"x{j}"] = state_vectors[:, j]
457
+
458
+ # Shapes
459
+ T = int(time_idx.max()) + 1 if L else 0
460
+ N = (int(particle_idx.max()) + 1) if (L and particle_idx is not None) else (1 if L else 0)
461
+ part = particle_idx if particle_idx is not None else np.zeros_like(time_idx, dtype=int)
462
+
463
+ # Global extras: TimeSeriesExtra → TG_* columns; others → header
464
+ header_globals: Dict[str, Any] = {}
465
+ for key, val in (extras_global or {}).items():
466
+ if isinstance(val, TimeSeriesExtra):
467
+ arr = np.asarray(val.data)
468
+ if arr.shape[0] != T and L:
469
+ raise ValueError(
470
+ f"extras_global['{key}'] TimeSeriesExtra expects leading axis T={T}, got {arr.shape[0]}"
471
+ )
472
+ vals = arr[time_idx] if L else arr.reshape(0, *arr.shape[1:])
473
+ flat = vals.reshape(L, -1) if L else vals.reshape(0, -1)
474
+ for kcol in range(flat.shape[1] if L else (arr.reshape(-1).shape[0] or 1)):
475
+ name = (
476
+ f"{prefix_TG}{key}"
477
+ if (flat.shape[1] if L else arr.reshape(-1).shape[0]) == 1
478
+ else f"{prefix_TG}{key}_{kcol}"
479
+ )
480
+ cols[name] = flat[:, kcol] if L else np.asarray([], dtype=float)
481
+ else:
482
+ if callable(val) or isinstance(val, FunctionExtra):
483
+ warnings.warn(
484
+ f"extras_global['{key}'] is a callable and cannot be serialized to disk. "
485
+ "It will be omitted from the saved file. Re-attach it to the dataset after loading.",
486
+ UserWarning,
487
+ stacklevel=2,
488
+ )
489
+ continue
490
+ header_globals[key] = _sanitize_metadata(val)
491
+
492
+ # Local extras:
493
+ # - TimeSeriesExtra → TP_* columns
494
+ # - array-like with first axis N → P_* columns
495
+ # - otherwise → header
496
+ for key, val in (extras_local or {}).items():
497
+ if isinstance(val, TimeSeriesExtra):
498
+ arr = np.asarray(val.data) # expected (T, N, …) or (T, 1, …) for single-trajectory
499
+ if arr.ndim < 2:
500
+ raise ValueError(
501
+ f"extras_local['{key}'] TimeSeriesExtra must have at least 2 dims (T,N,...), got {arr.shape}"
502
+ )
503
+ if L and arr.shape[0] != T:
504
+ raise ValueError(f"extras_local['{key}'] TimeSeriesExtra expects T={T}, got {arr.shape[0]}")
505
+ pid = part
506
+ if (particle_idx is None) and arr.shape[1] == 1:
507
+ pid = np.zeros_like(time_idx)
508
+ if L:
509
+ vals = arr[time_idx, pid]
510
+ flat = vals.reshape(L, -1)
511
+ for kcol in range(flat.shape[1]):
512
+ name = f"{prefix_TP}{key}" if flat.shape[1] == 1 else f"{prefix_TP}{key}_{kcol}"
513
+ cols[name] = flat[:, kcol]
514
+ else:
515
+ # empty table, still create no data columns; metadata only
516
+ header_globals[key] = _sanitize_metadata(arr)
517
+ else:
518
+ if callable(val) or isinstance(val, FunctionExtra):
519
+ warnings.warn(
520
+ f"extras_local['{key}'] is a callable and cannot be serialized to disk. "
521
+ "It will be omitted from the saved file. Re-attach it to the dataset after loading.",
522
+ UserWarning,
523
+ stacklevel=2,
524
+ )
525
+ continue
526
+ arr = np.asarray(val)
527
+ if arr.ndim >= 1 and (N == 0 or arr.shape[0] == N):
528
+ # per-particle constants: (N, …)
529
+ vals = arr[part if particle_idx is not None else np.zeros_like(time_idx)]
530
+ flat = vals.reshape(L, -1)
531
+ for kcol in range(flat.shape[1]):
532
+ name = f"{prefix_P}{key}" if flat.shape[1] == 1 else f"{prefix_P}{key}_{kcol}"
533
+ cols[name] = flat[:, kcol]
534
+ else:
535
+ header_globals[key] = _sanitize_metadata(arr)
536
+
537
+ df = pd.DataFrame(cols)
538
+
539
+ yaml_meta = dict(_sanitize_metadata(metadata or {}))
540
+ if header_globals:
541
+ yaml_meta.setdefault("extras_global", {}).update(header_globals)
542
+
543
+ base_cols = ([] if particle_idx is None else ["particle_id"]) + ["time_step"] + [f"x{j}" for j in range(d)]
544
+ return df, yaml_meta, base_cols
545
+
546
+
547
+ def _parse_tabular_with_extras(
548
+ df,
549
+ yaml_meta: Dict[str, Any],
550
+ *,
551
+ particle_column: Optional[str],
552
+ time_column: str,
553
+ relabel: bool,
554
+ prefix_G: str = "G_",
555
+ prefix_TG: str = "TG_",
556
+ prefix_P: str = "P_",
557
+ prefix_TP: str = "TP_",
558
+ ):
559
+ """Inverse of _build_tabular_with_extras for a pandas DataFrame + header/metadata dict."""
560
+ import numpy as np
561
+
562
+ from SFI.trajectory.dataset import time_series_extra
563
+
564
+ colnames = list(df.columns)
565
+ has_particles = particle_column is not None and (particle_column in df.columns)
566
+
567
+ if has_particles:
568
+ particle_indices = df[particle_column].to_numpy(dtype=int)
569
+ else:
570
+ particle_indices = np.zeros((len(df),), dtype=int)
571
+
572
+ time_indices = df[time_column].to_numpy(dtype=int)
573
+
574
+ # identify state columns: not ids and not extras prefixes
575
+ def is_extra(name: str) -> bool:
576
+ return any(name.startswith(px) for px in (prefix_G, prefix_TG, prefix_P, prefix_TP)) or name in {
577
+ particle_column,
578
+ time_column,
579
+ }
580
+
581
+ state_cols = [c for c in colnames if not is_extra(c)]
582
+ state_vectors = df[state_cols].to_numpy(dtype=float)
583
+
584
+ # relabel like CSV loader
585
+ if relabel and len(state_vectors) > 0:
586
+ if has_particles:
587
+ _, inv = np.unique(particle_indices, return_inverse=True)
588
+ particle_indices = inv.astype(int, copy=False)
589
+ time_indices = time_indices - int(time_indices.min())
590
+
591
+ L = len(df)
592
+ T = int(time_indices.max()) + 1 if L else 0
593
+ N = int(particle_indices.max()) + 1 if L else 0
594
+
595
+ # Start from header-provided extras (may contain globals)
596
+ extras_global: Dict[str, Any] = dict(yaml_meta.get("extras_global", {}) or {})
597
+ extras_local: Dict[str, Any] = {}
598
+
599
+ # Collect prefixed numeric columns
600
+ def collect(prefix: str) -> Dict[str, np.ndarray]:
601
+ return {name: df[name].to_numpy() for name in df.columns if name.startswith(prefix)}
602
+
603
+ TG_cols = collect(prefix_TG)
604
+ P_cols = collect(prefix_P)
605
+ TP_cols = collect(prefix_TP)
606
+ G_cols = collect(prefix_G)
607
+
608
+ # TG_: time-dependent globals → TimeSeriesExtra(T, …)
609
+ tg_grouped: Dict[str, List[Tuple[str, np.ndarray]]] = {}
610
+ for name, vals in TG_cols.items():
611
+ base = name[len(prefix_TG) :].split("_")[0]
612
+ tg_grouped.setdefault(base, []).append((name, vals))
613
+ for key, items in tg_grouped.items():
614
+ items_sorted = sorted(items, key=lambda kv: kv[0])
615
+ mat = np.column_stack([v for _, v in items_sorted]) # (L, k)
616
+ tg_matrix = np.full((T, mat.shape[1]), np.nan, dtype=float)
617
+ for t in range(T):
618
+ sel = time_indices == t
619
+ if np.any(sel):
620
+ tg_matrix[t] = np.nanmean(mat[sel], axis=0)
621
+ tg_matrix = tg_matrix.squeeze(-1) if tg_matrix.shape[1] == 1 else tg_matrix
622
+ extras_global[key] = time_series_extra(tg_matrix)
623
+
624
+ # TP_: time-dependent per-particle → TimeSeriesExtra(T, N, …)
625
+ tp_grouped: Dict[str, List[Tuple[str, np.ndarray]]] = {}
626
+ for name, vals in TP_cols.items():
627
+ base = name[len(prefix_TP) :].split("_")[0]
628
+ tp_grouped.setdefault(base, []).append((name, vals))
629
+ for key, items in tp_grouped.items():
630
+ items_sorted = sorted(items, key=lambda kv: kv[0])
631
+ mat = np.column_stack([v for _, v in items_sorted]) # (L, k)
632
+ out = np.full((T, N, mat.shape[1]), np.nan, dtype=float)
633
+ for t in range(T):
634
+ sel_t = time_indices == t
635
+ if not np.any(sel_t):
636
+ continue
637
+ for n in range(N):
638
+ sel = sel_t & ((particle_indices == n) if has_particles else (particle_indices == 0))
639
+ if np.any(sel):
640
+ out[t, n] = np.nanmean(mat[sel], axis=0)
641
+ out = out.squeeze(-1) if out.shape[2] == 1 else out
642
+ extras_local[key] = time_series_extra(out)
643
+
644
+ # P_: per-particle constants
645
+ p_grouped: Dict[str, List[Tuple[str, np.ndarray]]] = {}
646
+ for name, vals in P_cols.items():
647
+ base = name[len(prefix_P) :].split("_")[0]
648
+ p_grouped.setdefault(base, []).append((name, vals))
649
+ for key, items in p_grouped.items():
650
+ items_sorted = sorted(items, key=lambda kv: kv[0])
651
+ mat = np.column_stack([v for _, v in items_sorted])
652
+ out = np.full((N, mat.shape[1]), np.nan, dtype=float)
653
+ for n in range(N):
654
+ sel = particle_indices == n
655
+ if np.any(sel):
656
+ out[n] = np.nanmean(mat[sel], axis=0)
657
+ extras_local[key] = out.squeeze(-1) if out.shape[1] == 1 else out
658
+
659
+ # G_: global scalars → average
660
+ for name, vals in G_cols.items():
661
+ key = name[len(prefix_G) :]
662
+ extras_global[key] = float(np.nanmean(vals))
663
+
664
+ return (
665
+ yaml_meta,
666
+ colnames,
667
+ particle_indices,
668
+ time_indices,
669
+ state_vectors,
670
+ extras_global,
671
+ extras_local,
672
+ )
673
+
674
+
675
+ def _resolve_column_name(spec: Union[int, str], colnames: List[str], *, what: str) -> str:
676
+ """Resolve an ``int`` index or ``str`` name to a column name.
677
+
678
+ Raises a ``ValueError`` naming the available columns on a miss.
679
+ """
680
+ if isinstance(spec, str):
681
+ if spec not in colnames:
682
+ raise ValueError(f"{what} column {spec!r} not found; available columns: {colnames}")
683
+ return spec
684
+ i = int(spec)
685
+ if not (-len(colnames) <= i < len(colnames)):
686
+ raise ValueError(f"{what} column index {i} out of range for {len(colnames)} columns: {colnames}")
687
+ return colnames[i]
688
+
689
+
690
+ def _subselect_state_columns(
691
+ df,
692
+ *,
693
+ particle_name: Optional[str],
694
+ time_name: str,
695
+ state_names: List[str],
696
+ prefixes: Tuple[str, ...],
697
+ ):
698
+ """Keep only id/time, the selected state columns (in order), and extras columns."""
699
+ keep = ([particle_name] if particle_name else []) + [time_name] + list(state_names)
700
+ keep += [c for c in df.columns if any(c.startswith(px) for px in prefixes) and c not in keep]
701
+ return df[keep]
702
+
703
+
704
+ def _apply_named_knobs(
705
+ df,
706
+ *,
707
+ fmt: str,
708
+ particle_column: Optional[Union[int, str]],
709
+ time_column: Union[int, str],
710
+ state_columns: Optional[Sequence[Union[int, str]]],
711
+ prefixes: Tuple[str, ...],
712
+ ):
713
+ """Resolve column knobs for the name-addressed formats (parquet / h5).
714
+
715
+ ``str`` values are honored (and validated); ``int`` values keep the
716
+ historical behavior — they cannot be distinguished from the defaults,
717
+ so the canonical names ``particle_id`` / ``time_step`` are used.
718
+ Returns ``(particle_name_or_None, time_name, df_possibly_subselected)``.
719
+ """
720
+ colnames = list(df.columns)
721
+ if isinstance(particle_column, str):
722
+ p_name: Optional[str] = _resolve_column_name(particle_column, colnames, what="particle")
723
+ elif particle_column is None:
724
+ p_name = None
725
+ else:
726
+ p_name = "particle_id" if "particle_id" in colnames else None
727
+ t_name = (
728
+ _resolve_column_name(time_column, colnames, what="time")
729
+ if isinstance(time_column, str)
730
+ else "time_step"
731
+ )
732
+ if state_columns is not None:
733
+ bad = [c for c in state_columns if not isinstance(c, str)]
734
+ if bad:
735
+ raise ValueError(f"state_columns must be column names (str) for {fmt} files; got {bad!r}")
736
+ state_names = [_resolve_column_name(c, colnames, what="state") for c in state_columns]
737
+ df = _subselect_state_columns(
738
+ df, particle_name=p_name, time_name=t_name, state_names=state_names, prefixes=prefixes
739
+ )
740
+ return p_name, t_name, df
741
+
742
+
743
+ def _infer_format_from_suffix(path: str) -> str:
744
+ lower = path.lower()
745
+ if lower.endswith(".parquet") or lower.endswith(".pq"):
746
+ return "parquet"
747
+ if lower.endswith(".h5") or lower.endswith(".hdf5"):
748
+ return "h5"
749
+ return "csv"
750
+
751
+
752
+ def save_trajectory(
753
+ filename: str,
754
+ *,
755
+ particle_idx: Optional[np.ndarray],
756
+ time_idx: np.ndarray,
757
+ state_vectors: np.ndarray,
758
+ extras_global: Optional[Dict[str, Any]] = None,
759
+ extras_local: Optional[Dict[str, Any]] = None,
760
+ metadata: Optional[Dict[str, Any]] = None,
761
+ format: Optional[str] = None,
762
+ # CSV-only knobs:
763
+ float_fmt: str = "%.8f",
764
+ # Parquet-only knobs:
765
+ compression: str = "snappy",
766
+ # Prefixes (shared semantics across formats):
767
+ prefix_G: str = "G_",
768
+ prefix_TG: str = "TG_",
769
+ prefix_P: str = "P_",
770
+ prefix_TP: str = "TP_",
771
+ ) -> None:
772
+ """Unified saver for {'csv','parquet','h5'} (inferred from filename if format=None)."""
773
+ fmt = (format or _infer_format_from_suffix(filename)).lower()
774
+
775
+ # Dispatcher-owned structural arrays (``_cache/`` keys) are derivable and must
776
+ # never be serialized; strip them here so the saver upholds the invariant on
777
+ # its own, mirroring ``simulate`` output and ``degrade`` (no normal path
778
+ # persists them onto a dataset, so this is a defense-in-depth guard).
779
+ from SFI.statefunc.nodes.interactions.prepare import purge_cache_extras
780
+
781
+ extras_global = purge_cache_extras(extras_global)
782
+ extras_local = purge_cache_extras(extras_local)
783
+
784
+ df, yaml_meta, base_cols = _build_tabular_with_extras(
785
+ particle_idx=particle_idx,
786
+ time_idx=time_idx,
787
+ state_vectors=state_vectors,
788
+ extras_global=extras_global,
789
+ extras_local=extras_local,
790
+ metadata=metadata,
791
+ prefix_G=prefix_G,
792
+ prefix_TG=prefix_TG,
793
+ prefix_P=prefix_P,
794
+ prefix_TP=prefix_TP,
795
+ )
796
+
797
+ if fmt == "csv":
798
+ import yaml
799
+
800
+ # YAML header as comment lines + CSV body
801
+ yaml_str = yaml.safe_dump(yaml_meta, sort_keys=False)
802
+ yaml_header = "# ---\n" + "\n".join(f"# {line}" for line in yaml_str.strip().splitlines())
803
+ ordered = base_cols + [c for c in df.columns if c not in base_cols]
804
+ with open(filename, "w") as f:
805
+ f.write(yaml_header + "\n")
806
+ df.to_csv(f, index=False, columns=ordered, float_format=float_fmt)
807
+ return
808
+
809
+ if fmt == "parquet":
810
+ try:
811
+ import pyarrow as pa
812
+ import pyarrow.parquet as pq
813
+ import yaml
814
+ except Exception as e: # pragma: no cover
815
+ raise ImportError("Saving Parquet requires pyarrow and yaml.") from e
816
+ table = pa.Table.from_pandas(df, preserve_index=False)
817
+ md = dict(table.schema.metadata or {})
818
+ md[b"sfi_yaml_header"] = yaml.safe_dump(yaml_meta, sort_keys=False).encode("utf-8")
819
+ table = table.replace_schema_metadata(md)
820
+ pq.write_table(table, filename, compression=compression)
821
+ return
822
+
823
+ if fmt == "h5":
824
+ try:
825
+ import h5py
826
+ import yaml
827
+ except Exception as e: # pragma: no cover
828
+ raise ImportError("Saving HDF5 requires h5py and yaml.") from e
829
+ with h5py.File(filename, "w") as h5:
830
+ grp = h5.create_group("table")
831
+ for c in df.columns:
832
+ data = np.asarray(df[c].to_numpy())
833
+ grp.create_dataset(c, data=data, compression="gzip", shuffle=True, fletcher32=True)
834
+ # store YAML in root attr
835
+ h5.attrs["sfi_yaml_header"] = yaml.safe_dump(yaml_meta, sort_keys=False)
836
+ return
837
+
838
+ raise ValueError("format must be one of {'csv','parquet','h5'}")
839
+
840
+
841
+ def load_trajectory(
842
+ filename: str,
843
+ *,
844
+ format: Optional[str] = None,
845
+ # Column-selection knobs (int index or str name):
846
+ particle_column: Optional[Union[int, str]] = 0, # None => single-trajectory file
847
+ time_column: Union[int, str] = 1,
848
+ state_columns: Optional[Sequence[Union[int, str]]] = None,
849
+ relabel: bool = True,
850
+ # Prefixes:
851
+ prefix_G: str = "G_",
852
+ prefix_TG: str = "TG_",
853
+ prefix_P: str = "P_",
854
+ prefix_TP: str = "TP_",
855
+ ):
856
+ """Unified loader for {'csv','parquet','h5'} (inferred from filename if format=None).
857
+
858
+ Parameters
859
+ ----------
860
+ particle_column, time_column
861
+ Which columns hold the particle ID and the time index. Accept a
862
+ column *name* (``str``) for any format, or a positional *index*
863
+ (``int``) for CSV files only. CSV defaults are positional
864
+ (column 0 = particle, column 1 = time); parquet and HDF5 default
865
+ to the canonical names ``"particle_id"`` and ``"time_step"`` and
866
+ ignore ``int`` values (their defaults cannot be distinguished
867
+ from "unspecified"). ``particle_column=None`` marks a
868
+ single-trajectory file.
869
+ state_columns
870
+ Optional explicit selection of the state-vector columns (names,
871
+ or indices for CSV), in order. When given, every other
872
+ non-extras column is dropped. Default: every column that is not
873
+ an ID and does not carry an extras prefix is a state component.
874
+
875
+ Returns the standard tuple:
876
+ (metadata, column_headers, particle_indices, time_indices, state_vectors, extras_global, extras_local)
877
+ """
878
+ fmt = (format or _infer_format_from_suffix(filename)).lower()
879
+ extra_prefixes = (prefix_G, prefix_TG, prefix_P, prefix_TP)
880
+
881
+ if fmt == "csv":
882
+ # Parse YAML header then read CSV
883
+ import pandas as pd
884
+ import yaml
885
+
886
+ metadata: Dict[str, Any] = {}
887
+ yaml_lines: List[str] = []
888
+ with open(filename, "r") as f:
889
+ for line in f:
890
+ if line.startswith("# "):
891
+ yaml_lines.append(line[2:])
892
+ elif not line.startswith("#"):
893
+ break
894
+ if yaml_lines:
895
+ try:
896
+ metadata = yaml.safe_load("".join(yaml_lines)) or {}
897
+ except Exception:
898
+ metadata = {}
899
+
900
+ df = pd.read_csv(filename, comment="#")
901
+ colnames = list(df.columns)
902
+
903
+ # Resolve int/str knobs to column names.
904
+ particle_name = (
905
+ _resolve_column_name(particle_column, colnames, what="particle")
906
+ if particle_column is not None
907
+ else None
908
+ )
909
+ time_name = _resolve_column_name(time_column, colnames, what="time")
910
+
911
+ # Optional explicit state-column selection (drops everything else).
912
+ if state_columns is not None:
913
+ state_names = [_resolve_column_name(c, colnames, what="state") for c in state_columns]
914
+ df = _subselect_state_columns(
915
+ df,
916
+ particle_name=particle_name,
917
+ time_name=time_name,
918
+ state_names=state_names,
919
+ prefixes=extra_prefixes,
920
+ )
921
+
922
+ # Canonical names for the uniform parser.
923
+ rename: Dict[str, str] = {}
924
+ if particle_name is not None and particle_name != "particle_id":
925
+ if "particle_id" in df.columns:
926
+ raise ValueError(
927
+ f"particle column {particle_name!r} selected, but a distinct "
928
+ "'particle_id' column also exists — drop or rename one."
929
+ )
930
+ rename[particle_name] = "particle_id"
931
+ if time_name != "time_step":
932
+ if "time_step" in df.columns:
933
+ raise ValueError(
934
+ f"time column {time_name!r} selected, but a distinct "
935
+ "'time_step' column also exists — drop or rename one."
936
+ )
937
+ rename[time_name] = "time_step"
938
+ if rename:
939
+ # pandas-stubs rename overloads reject a plain dict mapper (false positive).
940
+ df = df.rename(columns=rename) # type: ignore[call-overload]
941
+
942
+ # parse
943
+ return _parse_tabular_with_extras(
944
+ df,
945
+ metadata,
946
+ particle_column="particle_id" if particle_name is not None else None,
947
+ time_column="time_step",
948
+ relabel=relabel,
949
+ prefix_G=prefix_G,
950
+ prefix_TG=prefix_TG,
951
+ prefix_P=prefix_P,
952
+ prefix_TP=prefix_TP,
953
+ )
954
+
955
+ if fmt == "parquet":
956
+ import pandas as pd
957
+ import pyarrow.parquet as pq
958
+ import yaml
959
+
960
+ table = pq.read_table(filename)
961
+ md = dict(table.schema.metadata or {})
962
+ yaml_bytes = md.get(b"sfi_yaml_header", None)
963
+ metadata = yaml.safe_load(yaml_bytes.decode("utf-8")) if yaml_bytes else {}
964
+ df = table.to_pandas(types_mapper=None)
965
+ p_name, t_name, df = _apply_named_knobs(
966
+ df,
967
+ fmt=fmt,
968
+ particle_column=particle_column,
969
+ time_column=time_column,
970
+ state_columns=state_columns,
971
+ prefixes=extra_prefixes,
972
+ )
973
+ return _parse_tabular_with_extras(
974
+ df,
975
+ metadata,
976
+ particle_column=p_name,
977
+ time_column=t_name,
978
+ relabel=relabel,
979
+ prefix_G=prefix_G,
980
+ prefix_TG=prefix_TG,
981
+ prefix_P=prefix_P,
982
+ prefix_TP=prefix_TP,
983
+ )
984
+
985
+ if fmt == "h5":
986
+ import h5py
987
+ import pandas as pd
988
+ import yaml
989
+
990
+ with h5py.File(filename, "r") as h5:
991
+ metadata = {}
992
+ if "sfi_yaml_header" in h5.attrs:
993
+ try:
994
+ # h5py stubs type attrs values as array-like; the header is str/bytes.
995
+ metadata = yaml.safe_load(h5.attrs["sfi_yaml_header"]) or {} # type: ignore[arg-type]
996
+ except Exception:
997
+ metadata = {}
998
+ if "table" not in h5:
999
+ raise ValueError("HDF5 file missing 'table' group.")
1000
+ grp = h5["table"]
1001
+ if not isinstance(grp, h5py.Group):
1002
+ raise ValueError("HDF5 'table' entry is not a group.")
1003
+ # reconstruct DataFrame from datasets (the group holds only Datasets;
1004
+ # h5py stubs widen __getitem__ to include Datatype)
1005
+ cols = {name: grp[name][...] for name in grp.keys()} # type: ignore[index]
1006
+ df = pd.DataFrame(cols)
1007
+ p_name, t_name, df = _apply_named_knobs(
1008
+ df,
1009
+ fmt=fmt,
1010
+ particle_column=particle_column,
1011
+ time_column=time_column,
1012
+ state_columns=state_columns,
1013
+ prefixes=extra_prefixes,
1014
+ )
1015
+ return _parse_tabular_with_extras(
1016
+ df,
1017
+ metadata,
1018
+ particle_column=p_name,
1019
+ time_column=t_name,
1020
+ relabel=relabel,
1021
+ prefix_G=prefix_G,
1022
+ prefix_TG=prefix_TG,
1023
+ prefix_P=prefix_P,
1024
+ prefix_TP=prefix_TP,
1025
+ )
1026
+
1027
+ raise ValueError("format must be one of {'csv','parquet','h5'}")