StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
SFI/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'}")
|