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/bases/pairs.py ADDED
@@ -0,0 +1,998 @@
1
+ """
2
+ SFI.bases.pairs
3
+ ===============
4
+
5
+ Generic building blocks for multi-particle (pair-interaction) systems.
6
+
7
+ This module provides:
8
+
9
+ - **PBC utilities**: minimum-image displacement in arbitrary dimension.
10
+ - **Kernel families**: pre-built 1-D radial kernel functions.
11
+ - **Radial / scalar pair bases**: build Interactor objects from kernel
12
+ families, ready for dispatch over neighbor lists.
13
+ - **Angular coupling bases**: weighted orientation-coupling interactors.
14
+ - **Heading vector**: single-particle heading vector from an angle coordinate.
15
+ - **Tensor pair features**: rank-2 dyadic basis for diffusion tensors, nematic order, etc.
16
+
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Callable, Sequence
22
+
23
+ import jax.numpy as jnp
24
+
25
+ from ..statefunc import Basis, Interactor, make_basis, make_interactor
26
+
27
+ # ═══════════════════════════════════════════════════════════════════════
28
+ # PBC UTILITIES
29
+ # ═══════════════════════════════════════════════════════════════════════
30
+
31
+
32
+ def pbc_displacement(xj, xi, box):
33
+ """Minimum-image displacement ``xj - xi`` under periodic boundaries.
34
+
35
+ Works in any dimension. All inputs are plain JAX arrays of shape
36
+ ``(d,)`` (or broadcastable).
37
+
38
+ Parameters
39
+ ----------
40
+ xj, xi : array, shape ``(d,)``
41
+ Positions (or sub-positions) of two particles.
42
+ box : array, shape ``(d,)``
43
+ Box lengths along each axis.
44
+
45
+ Returns
46
+ -------
47
+ dx : array, shape ``(d,)``
48
+ ``xj - xi`` folded via minimum-image convention.
49
+ """
50
+ dx = xj - xi
51
+ return dx - box * jnp.round(dx / box)
52
+
53
+
54
+ def wrap_angle(a):
55
+ """Wrap angle(s) to ``(-π, π]``."""
56
+ return (a + jnp.pi) % (2.0 * jnp.pi) - jnp.pi
57
+
58
+
59
+ def _pairwise_dr(XK, *, box=None, spatial_dims=None, eps=1e-12):
60
+ """Compute displacement, distance and unit vector for a K=2 pair.
61
+
62
+ Parameters
63
+ ----------
64
+ XK : array, shape ``(2, dim)``
65
+ Stacked pair ``[xi, xj]``.
66
+ box : array or None
67
+ PBC box lengths (applied to ``spatial_dims`` only).
68
+ spatial_dims : slice or index array, optional
69
+ Which dimensions of state vector are spatial coordinates.
70
+ Default: all.
71
+ eps : float
72
+ Regularisation to avoid division by zero.
73
+
74
+ Returns
75
+ -------
76
+ dx : array, shape ``(d_spatial,)``
77
+ r : scalar
78
+ rhat : array, shape ``(d_spatial,)``
79
+ """
80
+ xi, xj = XK[0], XK[1]
81
+ if spatial_dims is not None:
82
+ xi_s, xj_s = xi[spatial_dims], xj[spatial_dims]
83
+ else:
84
+ xi_s, xj_s = xi, xj
85
+ if box is not None:
86
+ dx = pbc_displacement(xj_s, xi_s, box)
87
+ else:
88
+ dx = xj_s - xi_s
89
+ r = jnp.sqrt(jnp.sum(dx**2) + eps)
90
+ rhat = dx / r
91
+ return dx, r, rhat
92
+
93
+
94
+ # ═══════════════════════════════════════════════════════════════════════
95
+ # KERNEL FAMILIES
96
+ # ═══════════════════════════════════════════════════════════════════════
97
+
98
+
99
+ def exp_poly_kernels(degrees, lengths):
100
+ r"""Radial kernels :math:`\phi_{k,L}(r) = r^k \exp(-r/L)`.
101
+
102
+ Parameters
103
+ ----------
104
+ degrees : sequence of int
105
+ Polynomial degrees *k*.
106
+ lengths : sequence of float
107
+ Exponential decay lengths *L*.
108
+
109
+ Returns
110
+ -------
111
+ list of (callable, str)
112
+ Each entry is ``(phi, label)`` where ``phi(r) -> scalar``.
113
+ """
114
+ out = []
115
+ for k in degrees:
116
+ for L in lengths:
117
+ k_, L_ = int(k), float(L)
118
+
119
+ def _phi(r, _k=k_, _L=L_):
120
+ return (r**_k) * jnp.exp(-r / _L)
121
+
122
+ out.append((_phi, f"r^{k_}·exp(-r/{L_:g})"))
123
+ return out
124
+
125
+
126
+ def gaussian_kernels(sigmas):
127
+ r"""Radial kernels :math:`\phi_\sigma(r) = \exp(-r^2 / 2\sigma^2)`.
128
+
129
+ Parameters
130
+ ----------
131
+ sigmas : sequence of float
132
+ Gaussian widths.
133
+
134
+ Returns
135
+ -------
136
+ list of (callable, str)
137
+ """
138
+ out = []
139
+ for s in sigmas:
140
+ s_ = float(s)
141
+
142
+ def _phi(r, _s=s_):
143
+ return jnp.exp(-(r**2) / (2.0 * _s**2))
144
+
145
+ out.append((_phi, f"r^0·gauss(σ={s_:g})"))
146
+ return out
147
+
148
+
149
+ def power_kernels(degrees):
150
+ r"""Radial kernels :math:`\phi_k(r) = r^k`.
151
+
152
+ Parameters
153
+ ----------
154
+ degrees : sequence of int
155
+
156
+ Returns
157
+ -------
158
+ list of (callable, str)
159
+ """
160
+ out = []
161
+ for k in degrees:
162
+ k_ = int(k)
163
+
164
+ def _phi(r, _k=k_):
165
+ return r**_k
166
+
167
+ out.append((_phi, f"r^{k_}"))
168
+ return out
169
+
170
+
171
+ def compact_kernels(degrees, cutoff):
172
+ r"""Compactly-supported kernels :math:`r^k (1 - r/r_c)^2` for :math:`r < r_c`.
173
+
174
+ Parameters
175
+ ----------
176
+ degrees : sequence of int
177
+ cutoff : float
178
+ Support radius *r_c*.
179
+
180
+ Returns
181
+ -------
182
+ list of (callable, str)
183
+ """
184
+ rc = float(cutoff)
185
+ out = []
186
+ for k in degrees:
187
+ k_ = int(k)
188
+
189
+ def _phi(r, _k=k_, _rc=rc):
190
+ w = jnp.where(r < _rc, (1.0 - r / _rc) ** 2, 0.0)
191
+ return (r**_k) * w
192
+
193
+ out.append((_phi, f"r^{k_}·comp(rc={rc:g})"))
194
+ return out
195
+
196
+
197
+ # ═══════════════════════════════════════════════════════════════════════
198
+ # RADIAL / SCALAR PAIR BASES
199
+ # ═══════════════════════════════════════════════════════════════════════
200
+
201
+
202
+ def radial_pair_basis(
203
+ kernels: Sequence[tuple[Callable, str]],
204
+ *,
205
+ dim: int,
206
+ box: Any = None,
207
+ spatial_dims: slice | Sequence[int] | None = None,
208
+ embed_dim: int | None = None,
209
+ embed_axes: Sequence[int] | None = None,
210
+ labels: Sequence[str] | None = None,
211
+ ) -> Interactor:
212
+ r"""Build a rank-1 pair Interactor with radial-kernel features.
213
+
214
+ Each feature is :math:`\phi_\alpha(r_{ij})\,\hat{\mathbf{r}}_{ij}`
215
+ where :math:`r_{ij}` is the pairwise distance (optionally with PBC).
216
+
217
+ .. physics:: Radial pair interaction basis
218
+ :label: radial-pair-basis
219
+ :category: Basis functions
220
+
221
+ .. math::
222
+
223
+ f_\alpha(\mathbf{r}_{ij})
224
+ = \phi_\alpha(r_{ij})\;\hat{\mathbf{r}}_{ij}
225
+
226
+ Scalar radial kernel :math:`\phi_\alpha` times the unit displacement
227
+ vector. Available kernel families: exponential-polynomial,
228
+ Gaussian, power-law, and compactly supported.
229
+
230
+ Parameters
231
+ ----------
232
+ kernels : list of (callable, str)
233
+ 1-D kernel functions and their labels, as returned by
234
+ :func:`exp_poly_kernels`, :func:`gaussian_kernels`, etc.
235
+ dim : int
236
+ Full state-vector dimension per particle.
237
+ box : array, ``"extras"``, or None
238
+ PBC box lengths. ``None`` (default) = free-space, no periodic
239
+ boundaries. Pass an array for a static box captured in the
240
+ closure, or ``"extras"`` to read the box from
241
+ ``extras["box"]`` at evaluation time. The box is applied over
242
+ ``spatial_dims`` only.
243
+ spatial_dims : slice or index array, optional
244
+ Which axes of the state vector are spatial coordinates (the ones
245
+ over which distances are computed and the output vector is
246
+ defined). Default: ``slice(None)`` (all axes are spatial).
247
+ embed_dim : int, optional
248
+ If the output should be embedded into a larger vector (e.g., the
249
+ displacement lives in 2D but the state vector is 3D with an
250
+ angle), set ``embed_dim`` to the output vector size. Spatial
251
+ components are placed at ``embed_axes`` indices; remaining indices
252
+ are zero.
253
+ embed_axes : sequence of int, optional
254
+ Indices into the ``embed_dim``-length output where spatial
255
+ components are placed. Required when ``embed_dim is not None``.
256
+ labels : sequence of str, optional
257
+ Override labels (one per kernel).
258
+
259
+ Returns
260
+ -------
261
+ Interactor
262
+ Rank-1 (vector) interactor with ``K=2``, ``n_features=len(kernels)``.
263
+ Call ``.dispatch_pairs(...)`` to stream over neighbour lists.
264
+ """
265
+ n_feat = len(kernels)
266
+ if labels is None:
267
+ labels = [lab for _, lab in kernels]
268
+ fns = [fn for fn, _ in kernels]
269
+
270
+ # Determine output vector size
271
+ if embed_dim is not None:
272
+ out_d = embed_dim
273
+ if embed_axes is None:
274
+ raise ValueError("embed_axes required when embed_dim is set")
275
+ _embed_axes = jnp.array(embed_axes, dtype=jnp.int32)
276
+ if len(embed_axes) != (
277
+ dim if spatial_dims is None
278
+ else len(range(*spatial_dims.indices(dim))) if isinstance(spatial_dims, slice)
279
+ else len(spatial_dims)
280
+ ):
281
+ raise ValueError(
282
+ f"len(embed_axes)={len(embed_axes)} must equal the number of spatial "
283
+ f"dimensions selected by spatial_dims."
284
+ )
285
+ else:
286
+ out_d = (
287
+ dim
288
+ if spatial_dims is None
289
+ else len(range(*spatial_dims.indices(dim)))
290
+ if isinstance(spatial_dims, slice)
291
+ else len(spatial_dims)
292
+ )
293
+ _embed_axes = None
294
+
295
+ # Resolve box mode
296
+ _use_extras_box = box == "extras"
297
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
298
+
299
+ def _pair_local(XK, *, extras=None):
300
+ # Resolve box
301
+ b = _box
302
+ if b is None and _use_extras_box and extras is not None:
303
+ b = extras["box"]
304
+ if spatial_dims is not None:
305
+ b = b[spatial_dims] if b.ndim > 0 else b
306
+ dx, r, rhat = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
307
+
308
+ # Evaluate kernels → (n_feat,)
309
+ vals = jnp.stack([fn(r) for fn in fns]) # (F,)
310
+
311
+ # Build output: (out_d, F) = rhat[:, None] * vals[None, :]
312
+ if _embed_axes is not None:
313
+ # Embed into larger vector
314
+ if len(embed_axes) != rhat.shape[0]:
315
+ raise ValueError(
316
+ f"embed_axes length ({len(embed_axes)}) must equal the number of "
317
+ f"selected spatial dims ({rhat.shape[0]}) from spatial_dims."
318
+ )
319
+ full = jnp.zeros((out_d, n_feat), dtype=XK.dtype)
320
+ for k, ax in enumerate(embed_axes):
321
+ full = full.at[ax, :].set(rhat[k] * vals)
322
+ return full
323
+ else:
324
+ return rhat[:, None] * vals[None, :] # (d_spatial, F)
325
+
326
+ extras_keys = ("box",) if _use_extras_box else ()
327
+ return make_interactor(
328
+ _pair_local,
329
+ dim=dim,
330
+ rank=1,
331
+ K=2,
332
+ n_features=n_feat,
333
+ extras_keys=extras_keys,
334
+ labels=list(labels),
335
+ descriptor="radial-pair-basis",
336
+ )
337
+
338
+
339
+ def scalar_pair_basis(
340
+ kernels: Sequence[tuple[Callable, str]],
341
+ *,
342
+ dim: int,
343
+ box: Any = None,
344
+ spatial_dims: slice | Sequence[int] | None = None,
345
+ labels: Sequence[str] | None = None,
346
+ ) -> Interactor:
347
+ r"""Build a rank-0 pair Interactor with scalar radial-kernel features.
348
+
349
+ Each feature is :math:`\phi_\alpha(r_{ij})` — the raw kernel value
350
+ without the directional :math:`\hat{r}` factor.
351
+
352
+ Use this for energy-like quantities, as radial weights for angular
353
+ coupling, or as building blocks for tensor pair features composed
354
+ via the ``*`` operator.
355
+
356
+ Parameters
357
+ ----------
358
+ kernels, dim, box, spatial_dims, labels
359
+ Same as :func:`radial_pair_basis`.
360
+
361
+ Returns
362
+ -------
363
+ Interactor
364
+ Rank-0 (scalar) interactor with ``K=2``.
365
+ """
366
+ n_feat = len(kernels)
367
+ if labels is None:
368
+ labels = [lab for _, lab in kernels]
369
+ fns = [fn for fn, _ in kernels]
370
+
371
+ _use_extras_box = box == "extras"
372
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
373
+
374
+ def _pair_local(XK, *, extras=None):
375
+ b = _box
376
+ if b is None and _use_extras_box and extras is not None:
377
+ b = extras["box"]
378
+ if spatial_dims is not None:
379
+ b = b[spatial_dims] if b.ndim > 0 else b
380
+ _, r, _ = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
381
+ return jnp.stack([fn(r) for fn in fns]) # (F,)
382
+
383
+ extras_keys = ("box",) if _use_extras_box else ()
384
+ return make_interactor(
385
+ _pair_local,
386
+ dim=dim,
387
+ rank=0,
388
+ K=2,
389
+ n_features=n_feat,
390
+ extras_keys=extras_keys,
391
+ labels=list(labels),
392
+ descriptor="scalar-pair-basis",
393
+ )
394
+
395
+
396
+ # ═══════════════════════════════════════════════════════════════════════
397
+ # ANGULAR / ORIENTATION COUPLING
398
+ # ═══════════════════════════════════════════════════════════════════════
399
+
400
+
401
+ def angular_pair_basis(
402
+ kernels: Sequence[tuple[Callable, str]],
403
+ coupling_fn: Callable,
404
+ *,
405
+ dim: int,
406
+ angle_index: int,
407
+ output_index: int | None = None,
408
+ box: Any = None,
409
+ spatial_dims: slice | Sequence[int] | None = None,
410
+ coupling_label: str = "g",
411
+ labels: Sequence[str] | None = None,
412
+ ) -> Interactor:
413
+ r"""Build a rank-1 pair Interactor for orientation coupling.
414
+
415
+ Each feature computes
416
+ :math:`\phi_\alpha(r_{ij})\,g(\theta_j - \theta_i)` and embeds the
417
+ result along ``output_index`` in a ``dim``-d output vector.
418
+
419
+ Parameters
420
+ ----------
421
+ kernels : list of (callable, str)
422
+ Radial weight functions (same format as other kernel factories).
423
+ coupling_fn : callable
424
+ Scalar function of the angle difference, e.g. ``jnp.sin`` for
425
+ alignment, ``lambda a: jnp.cos(2*a)`` for nematic coupling.
426
+ dim : int
427
+ Full state-vector dimension.
428
+ angle_index : int
429
+ Index of the angle coordinate in the state vector.
430
+ output_index : int, optional
431
+ Index along which the coupled output is placed. Defaults to
432
+ ``angle_index``.
433
+ box, spatial_dims
434
+ PBC and spatial-dimension controls (same as :func:`radial_pair_basis`).
435
+ coupling_label : str
436
+ Short label for the coupling (appears in feature labels).
437
+ labels : list of str, optional
438
+ Override labels.
439
+
440
+ Returns
441
+ -------
442
+ Interactor
443
+ Rank-1 (vector) interactor with ``K=2``.
444
+ """
445
+ n_feat = len(kernels)
446
+ if output_index is None:
447
+ output_index = angle_index
448
+ if labels is None:
449
+ labels = [f"{coupling_label}·{lab}" for _, lab in kernels]
450
+ fns = [fn for fn, _ in kernels]
451
+
452
+ _use_extras_box = box == "extras"
453
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
454
+
455
+ def _pair_local(XK, *, extras=None):
456
+ xi, xj = XK[0], XK[1]
457
+ # distance
458
+ b = _box
459
+ if b is None and _use_extras_box and extras is not None:
460
+ b = extras["box"]
461
+ if spatial_dims is not None:
462
+ b = b[spatial_dims] if b.ndim > 0 else b
463
+ _, r, _ = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
464
+ # angle coupling
465
+ dth = wrap_angle(xj[angle_index] - xi[angle_index])
466
+ g = coupling_fn(dth)
467
+ vals = jnp.stack([fn(r) * g for fn in fns]) # (F,)
468
+ # embed along output_index
469
+ out = jnp.zeros((dim, n_feat), dtype=XK.dtype)
470
+ out = out.at[output_index, :].set(vals)
471
+ return out # (dim, F)
472
+
473
+ extras_keys = ("box",) if _use_extras_box else ()
474
+ return make_interactor(
475
+ _pair_local,
476
+ dim=dim,
477
+ rank=1,
478
+ K=2,
479
+ n_features=n_feat,
480
+ extras_keys=extras_keys,
481
+ labels=list(labels),
482
+ descriptor="angular-pair-basis",
483
+ )
484
+
485
+
486
+ # ═══════════════════════════════════════════════════════════════════════
487
+ # SINGLE-PARTICLE: HEADING VECTOR
488
+ # ═══════════════════════════════════════════════════════════════════════
489
+
490
+
491
+ def heading_vector(dim: int, angle_index: int, *, spatial_axes: tuple[int, ...] | None = None) -> Basis:
492
+ r"""Single-particle heading vector from an angle coordinate.
493
+
494
+ Returns a rank-1 Basis whose single feature is the unit vector
495
+ :math:`(\cos\theta, \sin\theta)` embedded in a ``dim``-d vector,
496
+ with the cosine and sine placed at ``spatial_axes[0]`` and
497
+ ``spatial_axes[1]`` respectively.
498
+
499
+ Parameters
500
+ ----------
501
+ dim : int
502
+ State-vector dimension.
503
+ angle_index : int
504
+ Index of the angle coordinate :math:`\theta`.
505
+ spatial_axes : (int, int), optional
506
+ Indices for (cos θ, sin θ) in the output.
507
+ Default: ``(0, 1)`` — i.e. the first two axes.
508
+
509
+ Returns
510
+ -------
511
+ Basis
512
+ Rank-1, 1-feature heading-vector basis.
513
+ """
514
+ if spatial_axes is None:
515
+ spatial_axes = (0, 1)
516
+ ax_cos, ax_sin = spatial_axes[0], spatial_axes[1]
517
+
518
+ def _f(x, *, mask=None):
519
+ th = x[angle_index]
520
+ out = jnp.zeros(dim, dtype=x.dtype)
521
+ out = out.at[ax_cos].set(jnp.cos(th))
522
+ out = out.at[ax_sin].set(jnp.sin(th))
523
+ return out[:, None] # (dim, 1) — rank-1, 1 feature
524
+
525
+ return make_basis(
526
+ _f,
527
+ dim=dim,
528
+ rank=1,
529
+ n_features=1,
530
+ labels=("e_heading",),
531
+ descriptor="heading-vector",
532
+ )
533
+
534
+
535
+ # ═══════════════════════════════════════════════════════════════════════
536
+ # TENSOR PAIR FEATURES
537
+ # ═══════════════════════════════════════════════════════════════════════
538
+
539
+
540
+ def dyadic_pair_basis(
541
+ kernels: Sequence[tuple[Callable, str]],
542
+ *,
543
+ dim: int,
544
+ box: Any = None,
545
+ spatial_dims: slice | Sequence[int] | None = None,
546
+ labels: Sequence[str] | None = None,
547
+ ) -> Interactor:
548
+ r"""Build a rank-2 (tensor) pair Interactor: :math:`\phi(r)\,\hat{r}\otimes\hat{r}`.
549
+
550
+ Each feature is the outer product of the unit displacement vector
551
+ with itself, weighted by a radial kernel. Useful for directional
552
+ diffusion tensors, nematic order parameters, etc.
553
+
554
+ Parameters
555
+ ----------
556
+ kernels, dim, box, spatial_dims, labels
557
+ Same as :func:`radial_pair_basis`.
558
+
559
+ Returns
560
+ -------
561
+ Interactor
562
+ Rank-2 (matrix) interactor with ``K=2``.
563
+ """
564
+ n_feat = len(kernels)
565
+ if labels is None:
566
+ labels = [f"rr·{lab}" for _, lab in kernels]
567
+ fns = [fn for fn, _ in kernels]
568
+
569
+ _use_extras_box = box == "extras"
570
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
571
+
572
+ def _pair_local(XK, *, extras=None):
573
+ b = _box
574
+ if b is None and _use_extras_box and extras is not None:
575
+ b = extras["box"]
576
+ if spatial_dims is not None:
577
+ b = b[spatial_dims] if b.ndim > 0 else b
578
+ _, r, rhat = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
579
+ vals = jnp.stack([fn(r) for fn in fns]) # (F,)
580
+ # rhat ⊗ rhat: (d, d) then weight by each kernel → (d, d, F)
581
+ rr = jnp.outer(rhat, rhat) # (d, d)
582
+ return rr[:, :, None] * vals[None, None, :] # (d, d, F)
583
+
584
+ extras_keys = ("box",) if _use_extras_box else ()
585
+ return make_interactor(
586
+ _pair_local,
587
+ dim=dim,
588
+ rank=2,
589
+ K=2,
590
+ n_features=n_feat,
591
+ extras_keys=extras_keys,
592
+ labels=list(labels),
593
+ descriptor="dyadic-pair-basis",
594
+ )
595
+
596
+
597
+ # ═══════════════════════════════════════════════════════════════════════
598
+ # COMPOSABLE GEOMETRIC PRIMITIVES
599
+ # ═══════════════════════════════════════════════════════════════════════
600
+ #
601
+ # Single-feature Interactors designed for composition via ``*``
602
+ # (element-wise spatial multiplication with feature Cartesian product).
603
+ # Combine a direction (rank-1), a scalar gate (rank-0), and a
604
+ # parametric radial kernel (rank-0) to build rich pair forces.
605
+ # ═══════════════════════════════════════════════════════════════════════
606
+
607
+
608
+ def pair_direction(
609
+ *,
610
+ dim: int,
611
+ box: Any = None,
612
+ spatial_dims: slice | Sequence[int] | None = None,
613
+ embed_dim: int | None = None,
614
+ embed_axes: Sequence[int] | None = None,
615
+ ) -> Interactor:
616
+ r"""Unit displacement vector :math:`\hat{r}_{ij}` as a rank-1 Interactor.
617
+
618
+ Returns a single-feature, rank-1 pair Interactor whose output is the
619
+ unit vector pointing from particle *i* to particle *j*.
620
+
621
+ Parameters
622
+ ----------
623
+ dim : int
624
+ Full state-vector dimension per particle.
625
+ box : array, ``"extras"``, or None
626
+ PBC box lengths (same semantics as :func:`radial_pair_basis`).
627
+ spatial_dims : slice or index array, optional
628
+ Which axes are spatial coordinates.
629
+ embed_dim : int, optional
630
+ Embed into a larger output vector (e.g. 2-D displacement in 3-D state).
631
+ embed_axes : sequence of int, optional
632
+ Indices for embedding (required when ``embed_dim`` is set).
633
+
634
+ Returns
635
+ -------
636
+ Interactor
637
+ Rank-1, 1-feature interactor with ``K=2``.
638
+ """
639
+ if embed_dim is not None:
640
+ out_d = embed_dim
641
+ if embed_axes is None:
642
+ raise ValueError("embed_axes required when embed_dim is set")
643
+ else:
644
+ out_d = (
645
+ dim
646
+ if spatial_dims is None
647
+ else (len(range(*spatial_dims.indices(dim))) if isinstance(spatial_dims, slice) else len(spatial_dims))
648
+ )
649
+
650
+ _use_extras_box = box == "extras"
651
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
652
+
653
+ def _pair_local(XK, *, extras=None):
654
+ b = _box
655
+ if b is None and _use_extras_box and extras is not None:
656
+ b = extras["box"]
657
+ if spatial_dims is not None:
658
+ b = b[spatial_dims] if b.ndim > 0 else b
659
+ _, _, rhat = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
660
+ if embed_dim is not None:
661
+ full = jnp.zeros(out_d, dtype=XK.dtype)
662
+ for k, ax in enumerate(embed_axes):
663
+ full = full.at[ax].set(rhat[k])
664
+ return full[:, None] # (out_d, 1)
665
+ return rhat[:, None] # (d_spatial, 1)
666
+
667
+ extras_keys = ("box",) if _use_extras_box else ()
668
+ return make_interactor(
669
+ _pair_local,
670
+ dim=dim,
671
+ rank=1,
672
+ K=2,
673
+ n_features=1,
674
+ extras_keys=extras_keys,
675
+ labels=("r̂_ij",),
676
+ descriptor="pair-direction",
677
+ )
678
+
679
+
680
+ def angle_coupling(
681
+ coupling_fn: Callable,
682
+ *,
683
+ dim: int,
684
+ angle_index: int,
685
+ output_index: int | None = None,
686
+ label: str = "g",
687
+ ) -> Interactor:
688
+ r"""Scalar orientation coupling embedded as a rank-1 Interactor.
689
+
690
+ Computes ``coupling_fn(θ_j − θ_i)`` and places the result along
691
+ ``output_index`` in a ``dim``-d output vector.
692
+
693
+ Parameters
694
+ ----------
695
+ coupling_fn : callable
696
+ Scalar function of the wrapped angle difference, e.g. ``jnp.sin``.
697
+ dim : int
698
+ Full state-vector dimension.
699
+ angle_index : int
700
+ Index of the angle coordinate in the state vector.
701
+ output_index : int, optional
702
+ Output axis for the coupling value. Defaults to ``angle_index``.
703
+ label : str
704
+ Short label used in feature names.
705
+
706
+ Returns
707
+ -------
708
+ Interactor
709
+ Rank-1, 1-feature interactor with ``K=2``.
710
+ """
711
+ if output_index is None:
712
+ output_index = angle_index
713
+
714
+ def _pair_local(XK, *, extras=None):
715
+ xi, xj = XK[0], XK[1]
716
+ dth = wrap_angle(xj[angle_index] - xi[angle_index])
717
+ g = coupling_fn(dth)
718
+ out = jnp.zeros(dim, dtype=XK.dtype)
719
+ out = out.at[output_index].set(g)
720
+ return out[:, None] # (dim, 1)
721
+
722
+ return make_interactor(
723
+ _pair_local,
724
+ dim=dim,
725
+ rank=1,
726
+ K=2,
727
+ n_features=1,
728
+ labels=(label,),
729
+ descriptor="angle-coupling",
730
+ )
731
+
732
+
733
+ def particle_heading(
734
+ which: int,
735
+ *,
736
+ dim: int,
737
+ angle_index: int,
738
+ spatial_axes: tuple[int, ...] | None = None,
739
+ ) -> Interactor:
740
+ r"""Heading vector of one particle in a pair, as a rank-1 Interactor.
741
+
742
+ Returns :math:`(\cos\theta, \sin\theta)` of the selected particle
743
+ (``which=0`` for the focal particle, ``which=1`` for the neighbor),
744
+ embedded in a ``dim``-d output vector.
745
+
746
+ Parameters
747
+ ----------
748
+ which : int
749
+ ``0`` for the focal particle, ``1`` for the neighbor.
750
+ dim : int
751
+ Full state-vector dimension.
752
+ angle_index : int
753
+ Index of the angle coordinate.
754
+ spatial_axes : (int, int), optional
755
+ Indices for (cos θ, sin θ) in the output. Default: ``(0, 1)``.
756
+
757
+ Returns
758
+ -------
759
+ Interactor
760
+ Rank-1, 1-feature interactor with ``K=2``.
761
+ """
762
+ if spatial_axes is None:
763
+ spatial_axes = (0, 1)
764
+ ax_cos, ax_sin = spatial_axes[0], spatial_axes[1]
765
+
766
+ def _pair_local(XK, *, extras=None):
767
+ th = XK[which][angle_index]
768
+ out = jnp.zeros(dim, dtype=XK.dtype)
769
+ out = out.at[ax_cos].set(jnp.cos(th))
770
+ out = out.at[ax_sin].set(jnp.sin(th))
771
+ return out[:, None] # (dim, 1)
772
+
773
+ return make_interactor(
774
+ _pair_local,
775
+ dim=dim,
776
+ rank=1,
777
+ K=2,
778
+ n_features=1,
779
+ labels=(f"ê_θ[{which}]",),
780
+ descriptor="particle-heading",
781
+ )
782
+
783
+
784
+ def vision_gate(
785
+ gate_fn: Callable,
786
+ *,
787
+ dim: int,
788
+ angle_index: int,
789
+ box: Any = None,
790
+ spatial_dims: slice | Sequence[int] | None = None,
791
+ ) -> Interactor:
792
+ r"""Scalar vision-cone gate as a rank-0 Interactor.
793
+
794
+ Computes the bearing angle :math:`\delta` from the focal particle's
795
+ heading to the displacement toward the neighbor, then returns
796
+ ``gate_fn(δ)``. This makes interactions *nonreciprocal*: the gate
797
+ value depends on whether the neighbor is "in front" or "behind" the
798
+ focal particle.
799
+
800
+ Parameters
801
+ ----------
802
+ gate_fn : callable
803
+ Scalar function of the bearing angle, e.g.
804
+ ``lambda d: (1 + jnp.cos(d)) / 2`` for a cosine vision cone.
805
+ dim : int
806
+ Full state-vector dimension.
807
+ angle_index : int
808
+ Index of the angle coordinate.
809
+ box : array, ``"extras"``, or None
810
+ PBC box (same semantics as :func:`radial_pair_basis`).
811
+ spatial_dims : slice or index array, optional
812
+ Which axes are spatial coordinates (for the displacement).
813
+ Must select exactly 2 dimensions — bearing angle is 2-D only.
814
+
815
+ Returns
816
+ -------
817
+ Interactor
818
+ Rank-0, 1-feature interactor with ``K=2``.
819
+ """
820
+ _use_extras_box = box == "extras"
821
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
822
+
823
+ def _pair_local(XK, *, extras=None):
824
+ xi, _ = XK[0], XK[1]
825
+ # Spatial displacement
826
+ b = _box
827
+ if b is None and _use_extras_box and extras is not None:
828
+ b = extras["box"]
829
+ if spatial_dims is not None:
830
+ b = b[spatial_dims] if b.ndim > 0 else b
831
+ dx, _, _ = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
832
+ if dx.shape[0] != 2:
833
+ raise ValueError(
834
+ f"vision_gate requires exactly 2 spatial dimensions, got {dx.shape[0]}. "
835
+ "Use spatial_dims to select 2 axes from a higher-dimensional state vector."
836
+ )
837
+ # Bearing angle: direction of neighbour relative to heading
838
+ phi_ij = jnp.arctan2(dx[1], dx[0])
839
+ theta_i = xi[angle_index]
840
+ delta = wrap_angle(phi_ij - theta_i)
841
+ return gate_fn(delta)[None] # (1,) — rank-0, 1 feature
842
+
843
+ extras_keys = ("box",) if _use_extras_box else ()
844
+ return make_interactor(
845
+ _pair_local,
846
+ dim=dim,
847
+ rank=0,
848
+ K=2,
849
+ n_features=1,
850
+ extras_keys=extras_keys,
851
+ labels=("vision",),
852
+ descriptor="vision-gate",
853
+ )
854
+
855
+
856
+ def parametric_radial_kernel(
857
+ kernel_fn: Callable,
858
+ *,
859
+ params: dict,
860
+ dim: int,
861
+ box: Any = None,
862
+ spatial_dims: slice | Sequence[int] | None = None,
863
+ ) -> Interactor:
864
+ r"""Parametric scalar radial kernel as a rank-0 Interactor.
865
+
866
+ Wraps a user-supplied function ``kernel_fn(r, params)`` into a
867
+ rank-0 pair Interactor with learnable parameters.
868
+
869
+ Parameters
870
+ ----------
871
+ kernel_fn : callable
872
+ ``kernel_fn(r, params) -> scalar`` where *r* is the inter-particle
873
+ distance and *params* is a dict of JAX arrays.
874
+ params : dict
875
+ Parameter specification passed to :func:`make_interactor`, e.g.
876
+ ``{"eps": (), "R0": ()}`` for two scalar parameters.
877
+ dim : int
878
+ Full state-vector dimension.
879
+ box : array, ``"extras"``, or None
880
+ PBC box (same semantics as :func:`radial_pair_basis`).
881
+ spatial_dims : slice or index array, optional
882
+ Which axes are spatial coordinates.
883
+
884
+ Returns
885
+ -------
886
+ Interactor
887
+ Rank-0, 1-feature parametric interactor with ``K=2``.
888
+ """
889
+ _use_extras_box = box == "extras"
890
+ _box = None if (box is None or _use_extras_box) else jnp.asarray(box)
891
+
892
+ def _pair_local(XK, *, params=None, extras=None):
893
+ b = _box
894
+ if b is None and _use_extras_box and extras is not None:
895
+ b = extras["box"]
896
+ if spatial_dims is not None:
897
+ b = b[spatial_dims] if b.ndim > 0 else b
898
+ _, r, _ = _pairwise_dr(XK, box=b, spatial_dims=spatial_dims)
899
+ return kernel_fn(r, params)[None] # (1,) — rank-0, 1 feature
900
+
901
+ extras_keys = ("box",) if _use_extras_box else ()
902
+ return make_interactor(
903
+ _pair_local,
904
+ dim=dim,
905
+ rank=0,
906
+ K=2,
907
+ n_features=1,
908
+ params=params,
909
+ extras_keys=extras_keys,
910
+ labels=("k(r)",),
911
+ descriptor="parametric-radial-kernel",
912
+ )
913
+
914
+
915
+ # ═══════════════════════════════════════════════════════════════════════
916
+ # VELOCITY-DEPENDENT PAIR BASES
917
+ # ═══════════════════════════════════════════════════════════════════════
918
+
919
+
920
+ def pair_velocity_difference(
921
+ *,
922
+ dim: int,
923
+ ) -> Interactor:
924
+ r"""Velocity difference :math:`\mathbf{v}_j - \mathbf{v}_i` as a rank-1 Interactor.
925
+
926
+ Returns a single-feature, rank-1 pair Interactor whose output is the
927
+ velocity difference between neighbor and focal particle. Designed
928
+ for composition with scalar pair Interactors via the ``*`` operator,
929
+ e.g.::
930
+
931
+ scalar_pair_basis(kernels, dim=d) * pair_velocity_difference(dim=d)
932
+
933
+ Parameters
934
+ ----------
935
+ dim : int
936
+ State-vector dimension per particle.
937
+
938
+ Returns
939
+ -------
940
+ Interactor
941
+ Rank-1, 1-feature interactor with ``K=2``, ``needs_v=True``.
942
+ """
943
+
944
+ def _pair_local(XK, *, v, extras=None):
945
+ dv = v[1] - v[0] # (dim,)
946
+ return dv[:, None] # (dim, 1)
947
+
948
+ return make_interactor(
949
+ _pair_local,
950
+ dim=dim,
951
+ rank=1,
952
+ K=2,
953
+ n_features=1,
954
+ needs_v=True,
955
+ labels=("Δv_ij",),
956
+ descriptor="pair-velocity-difference",
957
+ )
958
+
959
+
960
+ def particle_velocity(
961
+ which: int,
962
+ *,
963
+ dim: int,
964
+ ) -> Interactor:
965
+ r"""Velocity of one particle in a pair, as a rank-1 Interactor.
966
+
967
+ Returns the velocity of either the focal particle (``which=0``) or
968
+ the neighbor (``which=1``) as a rank-1 Interactor. Designed for
969
+ composition with scalar pair Interactors via the ``*`` operator, e.g.::
970
+
971
+ scalar_pair_basis(kernels, dim=d) * particle_velocity(which=1, dim=d)
972
+
973
+ Parameters
974
+ ----------
975
+ which : int
976
+ ``0`` for the focal particle's velocity, ``1`` for the neighbor's.
977
+ dim : int
978
+ State-vector dimension per particle.
979
+
980
+ Returns
981
+ -------
982
+ Interactor
983
+ Rank-1, 1-feature interactor with ``K=2``, ``needs_v=True``.
984
+ """
985
+
986
+ def _pair_local(XK, *, v, extras=None):
987
+ return v[which][:, None] # (dim, 1)
988
+
989
+ return make_interactor(
990
+ _pair_local,
991
+ dim=dim,
992
+ rank=1,
993
+ K=2,
994
+ n_features=1,
995
+ needs_v=True,
996
+ labels=(f"v[{which}]",),
997
+ descriptor="particle-velocity",
998
+ )