StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
SFI/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ SFI – Stochastic Force Inference - main entry point
3
+ """
4
+
5
+ import logging as _logging
6
+ import os as _os
7
+ from importlib.metadata import version as _v
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # JAX persistent compilation cache (opt-in)
11
+ # ---------------------------------------------------------------------------
12
+ # Loading cached XLA executables triggers a per-hit C++ warning from the
13
+ # PjRt-IFRT layer ("Assume version compatibility …"), so the cache is OFF
14
+ # by default. Enable it by setting SFI_JAX_CACHE_DIR to a directory path,
15
+ # e.g. export SFI_JAX_CACHE_DIR=~/.cache/sfi/jax_cache
16
+ # XLA compilations dominate small-data runtime (~5 s for lorenz_demo T=100);
17
+ # caching cuts repeat runs to ~1 s.
18
+ _cache_dir = _os.environ.get("SFI_JAX_CACHE_DIR", "")
19
+ if _cache_dir:
20
+ import jax
21
+
22
+ jax.config.update("jax_compilation_cache_dir", _cache_dir)
23
+ # Many SFI compilations are 50–500 ms each; the default 1 s threshold
24
+ # would skip them, defeating the purpose of the cache.
25
+ jax.config.update("jax_persistent_cache_min_compile_time_secs", 0)
26
+ del jax
27
+ del _cache_dir
28
+
29
+ # Library-level null handler (silences output unless the user configures logging)
30
+ _logging.getLogger(__name__).addHandler(_logging.NullHandler())
31
+
32
+
33
+ def enable_logging(level: str = "INFO") -> None:
34
+ """Quick helper to turn on SFI log output.
35
+
36
+ >>> import SFI
37
+ >>> SFI.enable_logging() # INFO-level messages
38
+ >>> SFI.enable_logging("DEBUG") # everything
39
+ """
40
+ _logger = _logging.getLogger(__name__)
41
+ _logger.setLevel(getattr(_logging, level.upper(), _logging.INFO))
42
+ if not any(
43
+ isinstance(h, _logging.StreamHandler) for h in _logger.handlers if not isinstance(h, _logging.NullHandler)
44
+ ):
45
+ _handler = _logging.StreamHandler()
46
+ _handler.setFormatter(_logging.Formatter("[SFI %(levelname)s] %(message)s"))
47
+ _logger.addHandler(_handler)
48
+
49
+
50
+ # Public API --------------------------------------------------------------
51
+ from . import bases, diagnostics, inference, integrate, langevin, trajectory, utils
52
+
53
+ # Convenience re-exports so users can write ``from SFI import ...``
54
+ from .diagnostics import DiagnosticsReport, DynamicsOrderReport, assess, classify_dynamics
55
+ from .inference import (
56
+ InferenceResultSF,
57
+ OverdampedLangevinInference,
58
+ UnderdampedLangevinInference,
59
+ )
60
+ from .statefunc import PSF, SF, Basis, make_sf
61
+ from .trajectory import TrajectoryCollection, TrajectoryDataset
62
+
63
+ __version__ = _v("StochasticForceInference")
64
+ del _v
SFI/bases/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ # SFI/bases/__init__.py
2
+ """
3
+ SFI.bases
4
+ =========
5
+
6
+ Library of ready-made basis builders on top of :mod:`SFI.statefunc`.
7
+
8
+ Submodules
9
+ ----------
10
+ - monomials : scalar (or lifted) monomial families in x and/or v.
11
+ - constants : structural bases: ones, unit vectors, identity/symmetric matrices.
12
+ - linear : coordinate-extraction helpers (X, V, x_coordinate, ...).
13
+ - pairs : pair-interaction toolkit: radial kernels, PBC, heading vectors.
14
+ - spde : composable spatial operators (Laplacian, Gradient, Divergence, Curl, ...) on regular grids.
15
+ """
16
+
17
+ from .constants import (
18
+ constant_array,
19
+ dataset_indicator,
20
+ extra_scalar,
21
+ identity_matrix_basis,
22
+ named_scalar,
23
+ named_scalars,
24
+ ones_basis,
25
+ per_dataset_scalar,
26
+ symmetric_matrix_basis,
27
+ time_fourier,
28
+ unit_vector_basis,
29
+ )
30
+ from .linear import (
31
+ V,
32
+ X,
33
+ field_component,
34
+ frame,
35
+ linear_basis,
36
+ unit_axes,
37
+ v_components,
38
+ v_coordinate,
39
+ v_coordinates,
40
+ x_components,
41
+ x_coordinate,
42
+ x_coordinates,
43
+ )
44
+ from .monomials import monomials_degree, monomials_up_to
45
+
46
+ __all__ = [
47
+ # monomials
48
+ "monomials_degree",
49
+ "monomials_up_to",
50
+ # constants / structural
51
+ "ones_basis",
52
+ "unit_vector_basis",
53
+ "identity_matrix_basis",
54
+ "symmetric_matrix_basis",
55
+ "constant_array",
56
+ "named_scalar",
57
+ "named_scalars",
58
+ "extra_scalar",
59
+ "time_fourier",
60
+ "per_dataset_scalar",
61
+ "dataset_indicator",
62
+ # linear / coordinates
63
+ "linear_basis",
64
+ "X",
65
+ "V",
66
+ "x_coordinate",
67
+ "x_coordinates",
68
+ "field_component",
69
+ "v_coordinate",
70
+ "v_coordinates",
71
+ # component / axis unpackers + frame bundle
72
+ "x_components",
73
+ "v_components",
74
+ "unit_axes",
75
+ "frame",
76
+ ]
77
+
78
+
79
+ # Lazy imports for submodules that have heavier dependencies
80
+ def __getattr__(name):
81
+ if name in ("pairs", "spde"):
82
+ import importlib
83
+
84
+ return importlib.import_module(f".{name}", __name__)
85
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
SFI/bases/constants.py ADDED
@@ -0,0 +1,492 @@
1
+ from typing import Sequence
2
+
3
+ import jax.numpy as jnp
4
+
5
+ from SFI.statefunc import Basis, Rank
6
+ from SFI.statefunc.nodes import SimpleLeaf
7
+
8
+
9
+ def ones_basis(dim: int, pdepth: int = 0) -> Basis:
10
+ def _ones(x):
11
+ return jnp.ones((*x.shape[:-1], 1))
12
+
13
+ return Basis(
14
+ SimpleLeaf(
15
+ func=_ones,
16
+ n_features=1,
17
+ labels=("1",),
18
+ descriptor="scalar-one",
19
+ dim=dim,
20
+ rank=Rank.SCALAR,
21
+ pdepth=pdepth,
22
+ )
23
+ )
24
+
25
+
26
+ def constant_array(A, *, label: str = "const", descriptor: str = "constant-array", as_sf=True):
27
+ """
28
+ Constant basis/sf with a single feature whose value is a fixed tensor A of shape
29
+ ``(dim,)*rank`` (rank inferred from ``A.ndim``, dim from ``A.shape[0]``).
30
+
31
+ - Errors if A is not a hypercube tensor (all axes same length).
32
+ - Broadcasts over batch/particles: output shape is
33
+ ``(*x.shape[:-1], (dim,)*rank, 1)``.
34
+ """
35
+ A = jnp.asarray(A)
36
+ if A.ndim == 0:
37
+ raise ValueError("Use ones_basis()/scalar constants for rank=SCALAR.")
38
+ if len(set(A.shape)) != 1:
39
+ raise ValueError(f"A must have shape (dim,)*rank (hypercube). Got {A.shape}.")
40
+
41
+ dim = int(A.shape[0])
42
+ rank = int(A.ndim)
43
+
44
+ def _const(x):
45
+ # x: (..., dim). We broadcast A over all leading axes of x (particles/time/...)
46
+ lead = x.shape[:-1]
47
+ return jnp.broadcast_to(A[..., None], (*lead, *A.shape, 1))
48
+
49
+ leaf = SimpleLeaf(
50
+ func=_const,
51
+ n_features=1,
52
+ labels=(label,),
53
+ descriptor=descriptor,
54
+ dim=dim,
55
+ rank=rank,
56
+ pdepth=0,
57
+ )
58
+
59
+ B = Basis(leaf)
60
+ if as_sf:
61
+ return B.to_psf().bind(params={"coeff": 1.0})
62
+ return B
63
+
64
+
65
+ def unit_vector_basis(dim: int, axes: Sequence[int] | None = None) -> Basis:
66
+ if axes is None:
67
+ axes = list(range(dim))
68
+
69
+ labels = [f"e{i}" for i in axes]
70
+
71
+ def _f(x):
72
+ out = jnp.zeros((dim, len(axes)))
73
+ for i, a in enumerate(axes):
74
+ out = out.at[a, i].set(1)
75
+ return out
76
+
77
+ return Basis(
78
+ SimpleLeaf(
79
+ func=_f,
80
+ n_features=len(axes),
81
+ labels=tuple(labels),
82
+ descriptor="unit-vector-set",
83
+ dim=dim,
84
+ rank=Rank.VECTOR,
85
+ pdepth=0,
86
+ particles_input=False,
87
+ needs_v=False,
88
+ )
89
+ )
90
+
91
+
92
+ def identity_matrix_basis(dim: int, pdepth: int = 0) -> Basis:
93
+ def _f(x):
94
+ eye = jnp.eye(dim)
95
+ return jnp.broadcast_to(eye, (*x.shape[:-1], dim, dim))
96
+
97
+ return Basis(
98
+ SimpleLeaf(
99
+ func=_f,
100
+ n_features=1,
101
+ labels=("I",),
102
+ descriptor="identity",
103
+ dim=dim,
104
+ rank=Rank.MATRIX,
105
+ pdepth=pdepth,
106
+ )
107
+ )
108
+
109
+
110
+ def symmetric_matrix_basis(dim: int, pdepth: int = 0) -> Basis:
111
+ """Constant symmetric-matrix templates spanning the space of real symmetric
112
+ ``dim × dim`` matrices.
113
+
114
+ For ``dim=d`` there are ``d(d+1)/2`` features: one per upper-triangle
115
+ entry ``(i,j)`` with ``i <= j``. Each feature is a ``(dim, dim)``
116
+ matrix: ``S_{(i,j)} = δ_{ia}δ_{jb} + δ_{ib}δ_{ja}`` (so the
117
+ off-diagonal templates equal 1 in both symmetric slots, diagonal
118
+ templates equal 1 on the diagonal).
119
+
120
+ Rank is ``MATRIX`` (2), and the output shape is ``(dim, dim, F)``.
121
+ """
122
+ pairs = [(i, j) for i in range(dim) for j in range(i, dim)]
123
+ labels = [f"S{i}{j}" for (i, j) in pairs]
124
+
125
+ # Pre-build the (dim, dim, F) template at module level
126
+ import numpy as _np # deferred: only needed at build time, not at JAX eval time
127
+
128
+ tpl = _np.zeros((dim, dim, len(pairs)), dtype="float32")
129
+ for k, (i, j) in enumerate(pairs):
130
+ tpl[i, j, k] = 1.0
131
+ tpl[j, i, k] = 1.0 # symmetric: both slots
132
+ tpl_jnp = jnp.array(tpl)
133
+
134
+ def _f(x):
135
+ return tpl_jnp
136
+
137
+ return Basis(
138
+ SimpleLeaf(
139
+ func=_f,
140
+ n_features=len(pairs),
141
+ labels=tuple(labels),
142
+ descriptor="symmetric-matrix-templates",
143
+ dim=dim,
144
+ rank=Rank.MATRIX,
145
+ pdepth=pdepth,
146
+ particles_input=False,
147
+ needs_v=False,
148
+ )
149
+ )
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Named scalar parameters (rank-0 PSFs whose value is a single named param)
154
+ # ---------------------------------------------------------------------------
155
+ def named_scalar(name: str, default=None, *, dim: int | None = None, label: str | None = None):
156
+ """Rank-0, 1-feature PSF whose value is a single named scalar parameter.
157
+
158
+ The returned PSF carries a single :class:`ParamSpec` with shape ``()``,
159
+ optional ``default``, and label ``label or name``.
160
+
161
+ Parameters
162
+ ----------
163
+ name : str
164
+ Parameter name; also the default feature label.
165
+ default : scalar or None
166
+ Optional default value. When set, the PSF can be evaluated, bound,
167
+ or passed to a simulator without explicit ``params``.
168
+ dim : int or None
169
+ Spatial dimensionality; ``None`` (default) lets it be inferred at
170
+ first call (the value is independent of ``x``).
171
+ label : str or None
172
+ Optional human-readable feature label (defaults to ``name``).
173
+
174
+ Examples
175
+ --------
176
+ >>> sigma = named_scalar("sigma", default=20.0)
177
+ >>> sigma() # uses default
178
+ Array(20., dtype=float32)
179
+ >>> sigma(params={"sigma": 30.}) # explicit override
180
+ Array(30., dtype=float32)
181
+ """
182
+ from ..statefunc import make_psf
183
+ from ..statefunc.params import ParamSpec
184
+
185
+ spec = ParamSpec(name, (), default=default)
186
+
187
+ def _f(x, *, params):
188
+ return params[name]
189
+
190
+ return make_psf(
191
+ _f,
192
+ dim=dim,
193
+ rank=0,
194
+ n_features=1,
195
+ labels=[label or name],
196
+ descriptor=f"named-scalar({name})",
197
+ params=[spec],
198
+ )
199
+
200
+
201
+ def extra_scalar(name: str, *, dim: int | None = None, label: str | None = None):
202
+ """Rank-0, 1-feature Basis whose value is read from ``extras[name]``.
203
+
204
+ The compositional symbol for data-carried quantities: an extra
205
+ (per-experiment constant, a time-dependent drive delivered per frame,
206
+ a per-particle property) becomes an expression that composes with
207
+ ``x_components`` / ``unit_axes`` / ``named_scalar`` through the usual
208
+ algebra — and, being a parameter-free :class:`Basis`, slots directly
209
+ into linear-estimator dictionaries.
210
+
211
+ Parameters
212
+ ----------
213
+ name : str
214
+ Extras key to read; also the default feature label.
215
+ dim : int or None
216
+ Spatial dimensionality; ``None`` (default) lets it be inferred at
217
+ first call (the value is independent of ``x``).
218
+ label : str or None
219
+ Optional human-readable feature label (defaults to ``name``).
220
+
221
+ Examples
222
+ --------
223
+ >>> from SFI.bases import X, extra_scalar
224
+ >>> k_t = extra_scalar("k_drive") # delivered by the dataset
225
+ >>> B = X(dim=2) & (k_t * X(dim=2)) # static + driven trap terms
226
+ >>> # simulate: OverdampedProcess(F=B, theta_F=jnp.array([-1.0, -1.0]))
227
+ >>> # infer: inf.infer_force_linear(B)
228
+
229
+ Notes
230
+ -----
231
+ At inference time the trajectory layer materializes ``extras[name]``
232
+ per frame (slicing :class:`~SFI.trajectory.TimeSeriesExtra` values);
233
+ in simulation, ``set_extras`` accepts the same time-dependent forms.
234
+ """
235
+ from ..statefunc import make_basis
236
+
237
+ def _f(x, *, extras):
238
+ return jnp.asarray(extras[name])[None]
239
+
240
+ return make_basis(
241
+ _f,
242
+ dim=dim,
243
+ rank=0,
244
+ n_features=1,
245
+ labels=[label or name],
246
+ descriptor=f"extra-scalar({name})",
247
+ extras_keys=(name,),
248
+ )
249
+
250
+
251
+ def time_fourier(
252
+ n_modes: int,
253
+ period: float | None = None,
254
+ *,
255
+ dim: int | None = None,
256
+ label: str | None = None,
257
+ ):
258
+ r"""Rank-0 time-Fourier dictionary read from the reserved ``time`` extra.
259
+
260
+ Emits ``1 + 2 * n_modes`` parameter-free features
261
+
262
+ .. math::
263
+
264
+ \bigl\{\,1,\; \cos(k\omega t),\; \sin(k\omega t)\,\bigr\}_{k=1}^{n_\text{modes}},
265
+ \qquad \omega = 2\pi / P,
266
+
267
+ evaluated at each frame's absolute time ``t`` — the auto-injected
268
+ ``time`` extra (see :meth:`TrajectoryDataset.build_extras`), so no
269
+ bookkeeping is required. Tensor it with a spatial basis to learn an
270
+ *unknown* time-dependent force field by expansion: ``time_fourier(4) *
271
+ X(dim=1)`` recovers a time-varying stiffness :math:`k(t)`, and
272
+ ``time_fourier(4) * unit_vector_basis(1)`` a moving trap centre.
273
+ Sparse selection (:term:`PASTIS`) keeps only the harmonics the data
274
+ support.
275
+
276
+ Parameters
277
+ ----------
278
+ n_modes : int
279
+ Number of harmonics; produces ``1 + 2 * n_modes`` features.
280
+ period : float or None
281
+ Fundamental period :math:`P`. If ``None`` (default), it defaults
282
+ to the **full trajectory duration** (read from the auto-injected
283
+ ``duration`` extra), i.e. the fundamental frequency is the inverse
284
+ of the total observation time.
285
+ dim : int or None
286
+ Spatial dimensionality; ``None`` lets it be inferred (the value is
287
+ independent of ``x``).
288
+ label : str or None
289
+ Optional label prefix for the features.
290
+
291
+ Examples
292
+ --------
293
+ >>> from SFI.bases import X, time_fourier
294
+ >>> B = time_fourier(4) * X(dim=1) # learn k(t) over the trajectory
295
+ >>> inf.infer_force_linear(B)
296
+ """
297
+ from ..statefunc import make_basis
298
+
299
+ if n_modes < 1:
300
+ raise ValueError("time_fourier requires n_modes >= 1")
301
+
302
+ keys = ("time",) if period is not None else ("time", "duration")
303
+ n_feat = 1 + 2 * n_modes
304
+ pre = (label + " ") if label else ""
305
+ labels = [f"{pre}1"]
306
+ for k in range(1, n_modes + 1):
307
+ labels += [f"{pre}cos({k}wt)", f"{pre}sin({k}wt)"]
308
+
309
+ ks = jnp.arange(1, n_modes + 1, dtype=float)
310
+
311
+ def _f(x, *, extras):
312
+ t = jnp.asarray(extras["time"], dtype=float)
313
+ P = period if period is not None else jnp.asarray(extras["duration"], dtype=float)
314
+ ang = ks * (2.0 * jnp.pi / P) * t # (n_modes,)
315
+ cs = jnp.stack([jnp.cos(ang), jnp.sin(ang)], axis=-1).reshape(-1) # (2 n_modes,)
316
+ return jnp.concatenate([jnp.ones((1,), dtype=cs.dtype), cs]) # (1 + 2 n_modes,)
317
+
318
+ return make_basis(
319
+ _f,
320
+ dim=dim,
321
+ rank=0,
322
+ n_features=n_feat,
323
+ labels=labels,
324
+ descriptor=f"time-fourier(n={n_modes})",
325
+ extras_keys=keys,
326
+ )
327
+
328
+
329
+ def per_dataset_scalar(name: str, n_datasets: int, default=None, *, dim: int | None = None):
330
+ """Rank-0, 1-feature PSF whose value is dataset-specific.
331
+
332
+ Carries a parameter array of shape ``(n_datasets,)`` and reads the
333
+ entry of the current dataset through the reserved
334
+ ``extras["dataset_index"]`` (injected automatically by
335
+ :class:`~SFI.trajectory.TrajectoryCollection`). Compose it with
336
+ shared :func:`named_scalar` terms to fit pooled multi-experiment
337
+ models where part of the parameters is experiment-specific and the
338
+ rest is shared.
339
+
340
+ Parameters
341
+ ----------
342
+ name : str
343
+ Parameter name (one entry per dataset).
344
+ n_datasets : int
345
+ Number of datasets in the collection the model will be fit on.
346
+ default : scalar or array of shape (n_datasets,), optional
347
+ Optional default value(s); a scalar is broadcast.
348
+ dim : int or None
349
+ Spatial dimensionality (``None`` → inferred).
350
+
351
+ Notes
352
+ -----
353
+ Indexed parameter access is nonlinear in the bookkeeping sense, so
354
+ the parametric estimators fit models containing this primitive on
355
+ the L-BFGS path. For the **linear estimators**, use the one-hot
356
+ route instead: :func:`dataset_indicator`.
357
+ """
358
+ from ..statefunc import make_psf
359
+ from ..statefunc.params import ParamSpec
360
+
361
+ if default is not None:
362
+ default = jnp.broadcast_to(jnp.asarray(default, dtype=float), (n_datasets,))
363
+ spec = ParamSpec(name, (int(n_datasets),), default=default)
364
+
365
+ def _f(x, *, params, extras):
366
+ return params[name][extras["dataset_index"]]
367
+
368
+ def _specialize_at(k, _name=name, _dim=dim, _default=default):
369
+ # Condition-k slice: the (n_datasets,) param collapses to a scalar and
370
+ # the dataset_index read disappears (see StateExpr.specialize).
371
+ d_k = None if _default is None else jnp.asarray(_default)[int(k)]
372
+ spec_k = ParamSpec(_name, (), default=d_k)
373
+
374
+ def _fk(x, *, params):
375
+ return params[_name]
376
+
377
+ return make_psf(
378
+ _fk,
379
+ dim=_dim,
380
+ rank=0,
381
+ n_features=1,
382
+ labels=[_name],
383
+ descriptor=f"per-dataset-scalar({_name})@{int(k)}",
384
+ params=[spec_k],
385
+ )
386
+
387
+ return make_psf(
388
+ _f,
389
+ dim=dim,
390
+ rank=0,
391
+ n_features=1,
392
+ labels=[name],
393
+ descriptor=f"per-dataset-scalar({name})",
394
+ params=[spec],
395
+ extras_keys=("dataset_index",),
396
+ specialize_at=_specialize_at,
397
+ )
398
+
399
+
400
+ def dataset_indicator(n_datasets: int, *, dim: int | None = None):
401
+ """Rank-0 Basis of ``n_datasets`` one-hot features ``1{dataset == d}``.
402
+
403
+ The **linear-estimator** route to per-dataset coefficients: multiply
404
+ a feature by the indicator and concatenate, and each dataset gets an
405
+ independent linear coefficient for that feature (the Gram is
406
+ block-diagonal across datasets), PASTIS-prunable like any feature.
407
+
408
+ .. code-block:: python
409
+
410
+ B = B_shared & (dataset_indicator(n) * X(dim))
411
+
412
+ Reads the reserved ``extras["dataset_index"]`` injected by
413
+ :class:`~SFI.trajectory.TrajectoryCollection`.
414
+ """
415
+
416
+ from ..statefunc import make_basis
417
+
418
+ n = int(n_datasets)
419
+
420
+ def _f(x, *, extras):
421
+ return (jnp.arange(n) == extras["dataset_index"]).astype(float)
422
+
423
+ def _specialize_at(k, _n=n, _dim=dim):
424
+ # Condition-k one-hot becomes a constant vector; no dataset_index read.
425
+ onehot = (jnp.arange(_n) == int(k)).astype(float)
426
+
427
+ def _fk(x):
428
+ return onehot
429
+
430
+ return make_basis(
431
+ _fk,
432
+ dim=_dim,
433
+ rank=0,
434
+ n_features=_n,
435
+ labels=[f"ds{i}" for i in range(_n)],
436
+ descriptor=f"dataset-indicator({_n})@{int(k)}",
437
+ )
438
+
439
+ return make_basis(
440
+ _f,
441
+ dim=dim,
442
+ rank=0,
443
+ n_features=n,
444
+ labels=[f"ds{i}" for i in range(n)],
445
+ descriptor=f"dataset-indicator({n})",
446
+ extras_keys=("dataset_index",),
447
+ specialize_at=_specialize_at,
448
+ )
449
+
450
+
451
+ # Per-particle inferred parameters ("params_local paralleling extras_local")
452
+ # are expressed inside interactor kernels: declare the reserved
453
+ # ``particle_index`` extra as particle-aligned and index a (P,)-shaped
454
+ # parameter with it —
455
+ #
456
+ # def local(Xk, *, params, extras):
457
+ # mob = params["mob"][extras["particle_index"]] # (K,) per edge
458
+ # ...
459
+ # inter = make_interactor(local, ..., params={"mob": (P,)},
460
+ # extras_keys=("particle_index",),
461
+ # particle_extras=("particle_index",))
462
+ #
463
+ # See :func:`SFI.statefunc.make_interactor`.
464
+
465
+
466
+ def named_scalars(*args, **kwargs):
467
+ """Unpack named scalar parameters, one PSF per name.
468
+
469
+ Two equivalent call styles, both with deterministic ordering:
470
+
471
+ Positional names (no defaults)::
472
+
473
+ sigma, rho, beta = named_scalars("sigma", "rho", "beta")
474
+
475
+ Keyword names with defaults (Python preserves call-site order)::
476
+
477
+ sigma, rho, beta = named_scalars(sigma=20.0, rho=8.0, beta=2.0)
478
+
479
+ Returns
480
+ -------
481
+ tuple[PSF, ...]
482
+ One :class:`PSF` per name, in the order given.
483
+ """
484
+ if args and kwargs:
485
+ raise TypeError("named_scalars: pass either positional names OR keyword name=default, not both.")
486
+ if kwargs:
487
+ return tuple(named_scalar(name, default=val) for name, val in kwargs.items())
488
+ if not args:
489
+ raise TypeError("named_scalars: at least one name is required.")
490
+ if not all(isinstance(a, str) for a in args):
491
+ raise TypeError("named_scalars: positional arguments must be parameter names (str).")
492
+ return tuple(named_scalar(name) for name in args)