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