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/spde.py ADDED
@@ -0,0 +1,1537 @@
1
+ """SPDE-oriented basis helpers.
2
+
3
+ Grid operators here are designed to be *box-polymorphic*: geometry is provided
4
+ via per-dataset extras (typically ``collection.extras_global``), so the same
5
+ basis/PSF object can run on different grid sizes without re-creation.
6
+
7
+ Important conventions
8
+ ---------------------
9
+ We represent a regular grid as a "particle system":
10
+
11
+ * **particle axis** corresponds to flattened grid index ``p = 0...P-1``;
12
+ * **n_fields** (historically called ``dim``) is the number of field components
13
+ per grid site (e.g. 2 for Gray-Scott U/V, 1 for a scalar concentration).
14
+
15
+ Grid-site positions are **purely implicit**: they are reconstructed from
16
+ ``box/grid_shape`` and ``box/dx`` stored in extras. The state vector
17
+ ``X[p, :]`` contains only field values, never spatial coordinates.
18
+
19
+ The Laplacian helper returns a **scalar-rank** basis with
20
+ ``n_features = n_fields``: feature ``a`` is the Laplacian of field
21
+ component ``X[..., a]``.
22
+
23
+ This is deliberate: it composes cleanly with generic StateExpr machinery
24
+ (slice a component and re-embed it into a vector drift with unit vectors).
25
+
26
+ Box extras
27
+ ----------
28
+ Every operator reads two global extras (namespaced ``box`` by default):
29
+
30
+ * ``{box}/grid_shape`` : int array, shape ``(ndim,)``
31
+ * ``{box}/dx`` : float array, shape ``(ndim,)``
32
+
33
+ Use :func:`square_grid_extras` to create them.
34
+
35
+ .. tip::
36
+
37
+ For GPU acceleration with JAX, ensure a CUDA-enabled ``jaxlib`` is
38
+ installed. All operators are JIT-compiled and run transparently on
39
+ any JAX backend (CPU / GPU / TPU).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import hashlib
45
+ from abc import ABC, abstractmethod
46
+ from collections import namedtuple
47
+ from typing import TYPE_CHECKING, Sequence
48
+
49
+ import jax.numpy as jnp
50
+ import numpy as np
51
+ from jaxtyping import Array
52
+
53
+ from ..statefunc.factory import make_interactor
54
+ from ..statefunc.nodes.interactions.stencils import (
55
+ PreparedSquareStencilFromBox,
56
+ square_biharmonic_offsets,
57
+ square_cross_offsets,
58
+ )
59
+ from ..statefunc.nodes.interactions.stencils import (
60
+ box_extras as _stencils_box_extras,
61
+ )
62
+
63
+ if TYPE_CHECKING:
64
+ from ..langevin.noise import ConservedNoise
65
+
66
+ # ============================================================================
67
+ # Box extras (grid geometry -- shared by ALL stencil operators)
68
+ # ============================================================================
69
+
70
+
71
+ def square_grid_extras(
72
+ grid_shape: Sequence[int],
73
+ dx: float | Sequence[float] = 1.0,
74
+ *,
75
+ prefix: str = "box",
76
+ ) -> dict[str, Array]:
77
+ """Return minimal box extras needed by **all** square-grid SPDE operators.
78
+
79
+ Creates two arrays in the returned dict:
80
+
81
+ * ``{prefix}/grid_shape`` -- integer grid dimensions ``(Nx, Ny, ...)``
82
+ * ``{prefix}/dx`` -- float grid spacing per axis
83
+
84
+ Parameters
85
+ ----------
86
+ grid_shape : sequence of int
87
+ Number of grid points along each spatial axis.
88
+ dx : float or sequence of float
89
+ Grid spacing (uniform or per-axis).
90
+ prefix : str
91
+ Namespace prefix for the extras keys (default ``"box"``).
92
+ """
93
+ return _stencils_box_extras(grid_shape=grid_shape, dx=dx, prefix=prefix)
94
+
95
+
96
+ # ============================================================================
97
+ # Stencil algebra: Minkowski sum for operator composition
98
+ # ============================================================================
99
+
100
+
101
+ def minkowski_sum_offsets(offsets_a, offsets_b):
102
+ """Compute the Minkowski sum of two stencil offset arrays.
103
+
104
+ Given outer offsets *O_a* and inner offsets *O_b*, computes the
105
+ deduplicated set ``{a + b : a in O_a, b in O_b}`` and an index
106
+ mapping suitable for composing the two FD formulas.
107
+
108
+ Parameters
109
+ ----------
110
+ offsets_a : array, shape ``(Ka, ndim)``
111
+ Outer operator stencil offsets.
112
+ offsets_b : array, shape ``(Kb, ndim)``
113
+ Inner operator stencil offsets.
114
+
115
+ Returns
116
+ -------
117
+ fused : jnp.ndarray, shape ``(K_fused, ndim)``
118
+ Deduplicated sorted offset vectors.
119
+ index_maps : jnp.ndarray, shape ``(Ka, Kb)``
120
+ ``index_maps[i, j]`` is the position of ``offsets_a[i] + offsets_b[j]``
121
+ in *fused*.
122
+ """
123
+ a = np.asarray(offsets_a, dtype=np.int32)
124
+ b = np.asarray(offsets_b, dtype=np.int32)
125
+ Ka = a.shape[0]
126
+ ndim = a.shape[1]
127
+ sums = a[:, None, :] + b[None, :, :] # (Ka, Kb, ndim)
128
+ flat = sums.reshape(-1, ndim)
129
+ unique, inverse = np.unique(flat, axis=0, return_inverse=True)
130
+
131
+ # Convention: center offset (0,…,0) must sit at slot 0 so that
132
+ # focal_index=0 remains valid after fusion.
133
+ center = np.zeros((1, ndim), dtype=np.int32)
134
+ center_idx = int(np.where(np.all(unique == center, axis=1))[0][0])
135
+ if center_idx != 0:
136
+ unique[[0, center_idx]] = unique[[center_idx, 0]]
137
+ swap_0 = inverse == 0
138
+ swap_c = inverse == center_idx
139
+ inverse[swap_c] = 0
140
+ inverse[swap_0] = center_idx
141
+
142
+ index_maps = inverse.reshape(Ka, b.shape[0])
143
+ return jnp.asarray(unique, dtype=jnp.int32), jnp.asarray(index_maps, dtype=jnp.int32)
144
+
145
+
146
+ _StencilMeta = namedtuple(
147
+ "_StencilMeta",
148
+ [
149
+ "offsets", # jnp.ndarray (K, ndim)
150
+ "inner_root", # callable — the deepest pointwise root
151
+ "n_fields", # int (dim of deepest expression)
152
+ "n_features_out", # int (output features of this expression)
153
+ "fd_only_fn", # callable (K, n_feat_in) -> (n_feat_out,) — FD chain only
154
+ "ndim",
155
+ "bc",
156
+ "order",
157
+ "key_grid_shape",
158
+ "key_dx",
159
+ "labels",
160
+ ],
161
+ )
162
+
163
+
164
+ # ============================================================================
165
+ # Helper: shared dispatch boilerplate (cross *or* arbitrary stencil)
166
+ # ============================================================================
167
+
168
+
169
+ def _dispatch_stencil_operator(
170
+ local_fn,
171
+ *,
172
+ offsets,
173
+ n_fields: int,
174
+ ndim: int,
175
+ rank: int,
176
+ n_features: int,
177
+ bc: str,
178
+ order: str,
179
+ key_grid_shape: str,
180
+ stencil_prefix: str,
181
+ include_slot_extras_offset: bool,
182
+ return_as: str,
183
+ labels: list[str],
184
+ ):
185
+ """Wire local_fn + stencil offsets -> interactor -> prepared spec -> dispatch."""
186
+ K = int(offsets.shape[0])
187
+
188
+ inter = make_interactor(
189
+ local_fn,
190
+ dim=n_fields,
191
+ rank=rank,
192
+ K=K,
193
+ n_features=n_features,
194
+ labels=labels,
195
+ )
196
+
197
+ spec = PreparedSquareStencilFromBox(
198
+ offsets=offsets,
199
+ bc=bc,
200
+ order=order,
201
+ key_grid_shape=key_grid_shape,
202
+ key_prefix=stencil_prefix,
203
+ include_slot_extras_offset=include_slot_extras_offset,
204
+ )
205
+
206
+ return inter.dispatch(
207
+ spec,
208
+ owners="focal",
209
+ focal_index=0,
210
+ reducer="sum",
211
+ return_as=return_as,
212
+ )
213
+
214
+
215
+ def _default_cross_prefix(ndim: int, bc: str) -> str:
216
+ """Shared stencil cache key for the cross stencil."""
217
+ return f"_cache/stencil/cross_{ndim}d_{bc}"
218
+
219
+
220
+ def _default_biharmonic_prefix(ndim: int, bc: str) -> str:
221
+ """Shared stencil cache key for the biharmonic stencil."""
222
+ return f"_cache/stencil/biharmonic_{ndim}d_{bc}"
223
+
224
+
225
+ # ============================================================================
226
+ # StencilOp: composable differential operators that act on StateExpr
227
+ # ============================================================================
228
+
229
+
230
+ class StencilOp(ABC):
231
+ r"""Composable finite-difference operator on a regular Cartesian grid.
232
+
233
+ A ``StencilOp`` wraps a stencil-based finite-difference formula and can
234
+ be **applied to any pointwise** :class:`~SFI.statefunc.StateExpr` to
235
+ produce a new :class:`~SFI.statefunc.Basis`:
236
+
237
+ .. code-block:: python
238
+
239
+ Lap = Laplacian(ndim=2, bc="pbc")
240
+ phi = field_component(0, n_fields=1)
241
+ lap_phi = Lap(phi) # standard ∇²φ
242
+ lap_phi3 = Lap(phi**3) # ∇²(φ³) — exactly conservative on PBC
243
+
244
+ The operator evaluates the inner expression on each stencil neighbor,
245
+ then applies the finite-difference formula to the *transformed* values.
246
+ This guarantees exact discrete conservation properties (e.g., zero
247
+ spatial sum for the Laplacian on periodic grids) regardless of the
248
+ inner nonlinearity.
249
+
250
+ **Operator composition** is fully supported via automatic stencil
251
+ fusion. Nesting operators like ``Lap(Lap(phi))`` computes the
252
+ Minkowski sum of the two stencils at construction time so that only
253
+ a single dispatcher pass is needed at evaluation time:
254
+
255
+ .. code-block:: python
256
+
257
+ bih = Lap(Lap(phi)) # ≡ ∇⁴φ, fused 13-point stencil
258
+ div_grad = Div(Grad(phi)) # ≡ ∇²φ via composition
259
+
260
+ Parameters
261
+ ----------
262
+ ndim : int
263
+ Number of spatial dimensions.
264
+ bc : str
265
+ Boundary condition: ``"pbc"`` (periodic) or ``"noflux"`` (reflecting).
266
+ order : str
267
+ Flattening order for the spatial grid (``"C"`` row-major or ``"F"``
268
+ column-major). Must match the layout used to build the
269
+ ``extras["box/grid_shape"]`` array passed at evaluation time.
270
+ key_grid_shape : str
271
+ Extras key for the grid shape array.
272
+ key_dx : str
273
+ Extras key for the grid spacing array.
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ *,
279
+ ndim: int,
280
+ bc: str = "noflux",
281
+ order: str = "C",
282
+ key_grid_shape: str = "box/grid_shape",
283
+ key_dx: str = "box/dx",
284
+ ):
285
+ self._ndim = int(ndim)
286
+ self._bc = bc
287
+ self._order = order
288
+ self._key_grid_shape = key_grid_shape
289
+ self._key_dx = key_dx
290
+
291
+ # Whether results may carry fusion metadata (overridden by AdvectionBy).
292
+ _composable = True
293
+
294
+ # --- subclass hooks (override) ------------------------------------
295
+
296
+ @abstractmethod
297
+ def _offsets(self):
298
+ """Return stencil offsets array, shape ``(K, ndim)``."""
299
+
300
+ @abstractmethod
301
+ def _default_prefix(self) -> str:
302
+ """Return default shared stencil cache prefix."""
303
+
304
+ @abstractmethod
305
+ def _build_local_fn(self, inner_root, *, n_features_out):
306
+ """Return a ``local_fn(Xk, *, mask, extras)`` for the composed op."""
307
+
308
+ def _n_features_out(self, n_features_inner: int) -> int:
309
+ """Number of output features given inner expression's features."""
310
+ return n_features_inner
311
+
312
+ def _output_rank(self) -> int:
313
+ return 0
314
+
315
+ def _make_labels(self, label, n_features_out, inner_labels):
316
+ """Build human-readable output labels."""
317
+ name = label or type(self).__name__
318
+ if len(inner_labels) >= n_features_out:
319
+ return [f"{name}({il})" for il in inner_labels[:n_features_out]]
320
+ return [f"{name}[{j}]" for j in range(n_features_out)]
321
+
322
+ # --- public API ---------------------------------------------------
323
+
324
+ def __call__(
325
+ self,
326
+ expr,
327
+ *,
328
+ return_as: str = "basis",
329
+ label: str | None = None,
330
+ ):
331
+ """Apply the operator to a pointwise expression.
332
+
333
+ Parameters
334
+ ----------
335
+ expr : StateExpr
336
+ Pointwise expression (``rank=0``, ``particles_input=False``).
337
+ Typical examples: ``field_component(0, n_fields=1)``, ``phi**3``,
338
+ ``jnp.sin(phi)``, or any algebraic combination of such.
339
+
340
+ May also be the result of another ``StencilOp`` — in that case,
341
+ the two stencils are automatically fused via Minkowski sum.
342
+ return_as : str
343
+ ``"basis"`` (default) or ``"psf"``.
344
+ label : str, optional
345
+ Override for the output label prefix.
346
+
347
+ Returns
348
+ -------
349
+ Basis or PSF
350
+ The composed stencil operator evaluated on the expression.
351
+
352
+ Raises
353
+ ------
354
+ TypeError
355
+ If *expr* is not a :class:`~SFI.statefunc.StateExpr`.
356
+ ValueError
357
+ If *expr* has ``particles_input=True`` (and is not a stencil
358
+ composition) or ``rank != 0``.
359
+ """
360
+ from ..statefunc.stateexpr import StateExpr
361
+
362
+ if not isinstance(expr, StateExpr):
363
+ raise TypeError(f"StencilOp expects a StateExpr, got {type(expr).__name__}")
364
+
365
+ # --- Composition path: fuse stencils if inner is a stencil result ---
366
+ inner_meta = getattr(expr, "_stencil_meta", None)
367
+ if inner_meta is not None:
368
+ if not self._composable:
369
+ raise ValueError(
370
+ f"{type(self).__name__} does not support stencil composition. Apply it to a pointwise expression."
371
+ )
372
+ return self._compose_stencils(
373
+ inner_meta,
374
+ return_as=return_as,
375
+ label=label,
376
+ )
377
+
378
+ # --- Normal path: pointwise inner expression -----------------------
379
+ if expr.particles_input:
380
+ raise ValueError(
381
+ "StencilOp requires a pointwise inner expression "
382
+ "(particles_input=False). For compositions like "
383
+ "∇⁴ = ∇²∘∇², simply nest: Lap(Lap(phi))."
384
+ )
385
+ if expr.rank != 0:
386
+ raise ValueError(f"StencilOp requires rank-0 inner expression, got rank={expr.rank}.")
387
+
388
+ n_fields = expr.dim
389
+ n_features_inner = expr.n_features
390
+ n_features_out = self._n_features_out(n_features_inner)
391
+
392
+ inner_root = expr.root
393
+ local_fn = self._build_local_fn(
394
+ inner_root,
395
+ n_features_out=n_features_out,
396
+ )
397
+
398
+ offsets = self._offsets()
399
+ stencil_prefix = self._default_prefix()
400
+
401
+ # Labels
402
+ inner_labels = getattr(expr, "labels", None)
403
+ if inner_labels is None:
404
+ inner_labels = [f"f{j}" for j in range(n_features_inner)]
405
+ labels = self._make_labels(label, n_features_out, inner_labels)
406
+
407
+ basis = _dispatch_stencil_operator(
408
+ local_fn,
409
+ offsets=offsets,
410
+ n_fields=n_fields,
411
+ ndim=self._ndim,
412
+ rank=self._output_rank(),
413
+ n_features=n_features_out,
414
+ bc=self._bc,
415
+ order=self._order,
416
+ key_grid_shape=self._key_grid_shape,
417
+ stencil_prefix=stencil_prefix,
418
+ include_slot_extras_offset=True,
419
+ return_as=return_as,
420
+ labels=labels,
421
+ )
422
+
423
+ # Tag result with stencil metadata for potential composition
424
+ if self._composable:
425
+ fd_fn = self._build_local_fn(
426
+ lambda x: x,
427
+ n_features_out=n_features_out,
428
+ )
429
+ meta = _StencilMeta(
430
+ offsets=offsets,
431
+ inner_root=inner_root,
432
+ n_fields=n_fields,
433
+ n_features_out=n_features_out,
434
+ fd_only_fn=fd_fn,
435
+ ndim=self._ndim,
436
+ bc=self._bc,
437
+ order=self._order,
438
+ key_grid_shape=self._key_grid_shape,
439
+ key_dx=self._key_dx,
440
+ labels=labels,
441
+ )
442
+ object.__setattr__(basis, "_stencil_meta", meta)
443
+
444
+ return basis
445
+
446
+ # --- composition (stencil fusion) ------------------------------------
447
+
448
+ def _compose_stencils(self, inner_meta, *, return_as, label):
449
+ """Fuse this operator's stencil with a prior stencil expression."""
450
+ # --- compatibility checks ---
451
+ if inner_meta.ndim != self._ndim:
452
+ raise ValueError(f"Cannot compose: ndim mismatch ({self._ndim} vs {inner_meta.ndim})")
453
+ if inner_meta.bc != self._bc:
454
+ raise ValueError(f"Cannot compose: bc mismatch ({self._bc!r} vs {inner_meta.bc!r})")
455
+
456
+ inner_offsets = inner_meta.offsets
457
+ inner_root = inner_meta.inner_root
458
+ inner_fd = inner_meta.fd_only_fn
459
+ inner_n_fields = inner_meta.n_fields
460
+ inner_n_features_out = inner_meta.n_features_out
461
+
462
+ outer_offsets = self._offsets()
463
+ K_outer = int(outer_offsets.shape[0])
464
+
465
+ # --- Minkowski sum ---
466
+ fused_offsets, index_maps = minkowski_sum_offsets(
467
+ outer_offsets,
468
+ inner_offsets,
469
+ )
470
+
471
+ # --- output info ---
472
+ n_features_out = self._n_features_out(inner_n_features_out)
473
+ outer_fd = self._build_local_fn(
474
+ lambda x: x,
475
+ n_features_out=n_features_out,
476
+ )
477
+ assert outer_fd is not None
478
+
479
+ # --- build fused FD-only function ---
480
+ _idx = index_maps # capture for closure
481
+ _Ko = K_outer
482
+
483
+ def fused_fd(fXk_fused, *, mask=None, extras=None):
484
+ intermediate = []
485
+ for a in range(_Ko):
486
+ idx_a = _idx[a]
487
+ subset = fXk_fused[idx_a]
488
+ sub_mask = mask[idx_a] if mask is not None else None
489
+ intermediate.append(inner_fd(subset, mask=sub_mask, extras=extras))
490
+ intermediate = jnp.stack(intermediate)
491
+ outer_mask = None
492
+ if mask is not None:
493
+ outer_mask = mask[_idx[:, 0]]
494
+ return outer_fd(intermediate, mask=outer_mask, extras=extras)
495
+
496
+ # --- build full local_fn (root eval + fused FD) ---
497
+ def fused_local_fn(Xk, *, mask=None, extras=None):
498
+ fXk = inner_root(Xk)
499
+ return fused_fd(fXk, mask=mask, extras=extras)
500
+
501
+ # --- unique stencil prefix ---
502
+ _h = hashlib.md5(np.asarray(fused_offsets).tobytes()).hexdigest()[:8]
503
+ stencil_prefix = f"_cache/stencil/fused_{self._ndim}d_{self._bc}_{_h}"
504
+
505
+ # --- labels ---
506
+ inner_labels = inner_meta.labels or [f"f{j}" for j in range(inner_n_features_out)]
507
+ labels = self._make_labels(label, n_features_out, inner_labels)
508
+
509
+ basis = _dispatch_stencil_operator(
510
+ fused_local_fn,
511
+ offsets=fused_offsets,
512
+ n_fields=inner_n_fields,
513
+ ndim=self._ndim,
514
+ rank=self._output_rank(),
515
+ n_features=n_features_out,
516
+ bc=self._bc,
517
+ order=self._order,
518
+ key_grid_shape=self._key_grid_shape,
519
+ stencil_prefix=stencil_prefix,
520
+ include_slot_extras_offset=True,
521
+ return_as=return_as,
522
+ labels=labels,
523
+ )
524
+
525
+ # Tag for further composition
526
+ meta = _StencilMeta(
527
+ offsets=fused_offsets,
528
+ inner_root=inner_root,
529
+ n_fields=inner_n_fields,
530
+ n_features_out=n_features_out,
531
+ fd_only_fn=fused_fd,
532
+ ndim=self._ndim,
533
+ bc=self._bc,
534
+ order=self._order,
535
+ key_grid_shape=self._key_grid_shape,
536
+ key_dx=self._key_dx,
537
+ labels=labels,
538
+ )
539
+ object.__setattr__(basis, "_stencil_meta", meta)
540
+ return basis
541
+
542
+ # --- ASCII stencil visualization -------------------------------------
543
+
544
+ def visualize_stencil(self):
545
+ """Return an ASCII picture of the stencil pattern (2D only).
546
+
547
+ For ``ndim != 2`` a simple offset listing is returned.
548
+
549
+ Returns
550
+ -------
551
+ str
552
+ Multi-line string.
553
+ """
554
+ offsets = self._offsets()
555
+ assert offsets is not None
556
+ ndim = self._ndim
557
+ name = type(self).__name__
558
+ K = int(offsets.shape[0])
559
+ header = f"{name}(ndim={ndim}, bc={self._bc!r}) \u2014 {K}-point stencil"
560
+
561
+ if ndim != 2:
562
+ lines = [header, "Offsets:"]
563
+ for i in range(K):
564
+ off = tuple(int(v) for v in offsets[i])
565
+ tag = " (center)" if all(v == 0 for v in off) else ""
566
+ lines.append(f" {off}{tag}")
567
+ return "\n".join(lines)
568
+
569
+ off_np = np.asarray(offsets, dtype=np.int32)
570
+ xs, ys = off_np[:, 0], off_np[:, 1]
571
+ offset_set = {(int(r[0]), int(r[1])) for r in off_np}
572
+ lines = [header]
573
+ for y in range(int(ys.max()), int(ys.min()) - 1, -1):
574
+ row = []
575
+ for x in range(int(xs.min()), int(xs.max()) + 1):
576
+ if (x, y) == (0, 0):
577
+ row.append(" \u25cf ")
578
+ elif (x, y) in offset_set:
579
+ row.append(" \u25cb ")
580
+ else:
581
+ row.append(" \u00b7 ")
582
+ lines.append("".join(row))
583
+ return "\n".join(lines)
584
+
585
+ def __repr__(self):
586
+ return f"{type(self).__name__}(ndim={self._ndim}, bc={self._bc!r})"
587
+
588
+
589
+ class Laplacian(StencilOp):
590
+ r"""Composable Laplacian operator :math:`\nabla^2` on a Cartesian grid.
591
+
592
+ .. math::
593
+
594
+ [\nabla^2 f(\phi)]_i = \sum_{\alpha=1}^{n_\mathrm{dim}}
595
+ \frac{f(\phi_{i+e_\alpha}) + f(\phi_{i-e_\alpha}) - 2\,f(\phi_i)}
596
+ {\Delta x_\alpha^2}
597
+
598
+ where :math:`f` is the inner expression evaluated at each grid site.
599
+
600
+ On periodic grids, the Laplacian matrix has zero column sums, so
601
+ :math:`\sum_i [\nabla^2 g]_i = 0` for **any** function :math:`g`.
602
+ This guarantees exact discrete conservation for expressions like
603
+ :math:`\nabla^2(\phi^3)`.
604
+
605
+ Examples
606
+ --------
607
+ >>> from SFI.bases import field_component
608
+ >>> from SFI.bases.spde import Laplacian
609
+ >>> Lap = Laplacian(ndim=2, bc="pbc")
610
+ >>> phi = field_component(0, n_fields=1)
611
+ >>> lap_phi = Lap(phi) # ∇²φ
612
+ >>> lap_phi3 = Lap(phi**3) # ∇²(φ³) — conservative
613
+ """
614
+
615
+ def _offsets(self):
616
+ return square_cross_offsets(self._ndim, include_center=True)
617
+
618
+ def _default_prefix(self):
619
+ return _default_cross_prefix(self._ndim, self._bc)
620
+
621
+ def _build_local_fn(self, inner_root, *, n_features_out):
622
+ ndim = self._ndim
623
+ key_dx = self._key_dx
624
+ # Stencil layout from square_cross_offsets: idx 0 = center,
625
+ # then ndim pairs (plus, minus) → plus_idx = 1,3,5,…; minus_idx = 2,4,6,…
626
+ plus_idx = 1 + 2 * jnp.arange(ndim)
627
+ minus_idx = 2 + 2 * jnp.arange(ndim)
628
+
629
+ def local_lap_composed(Xk, *, mask=None, extras=None):
630
+ # Evaluate inner expression on each gathered neighbor.
631
+ # Xk: (K, n_fields) → inner_root treats K as batch → (K, n_feat)
632
+ fXk = inner_root(Xk)
633
+
634
+ f0 = fXk[0, :] # center: (n_feat,)
635
+ fp = fXk[plus_idx, :] # (ndim, n_feat)
636
+ fm = fXk[minus_idx, :] # (ndim, n_feat)
637
+
638
+ if mask is not None:
639
+ mp = mask[plus_idx] # (ndim,) bool
640
+ mm = mask[minus_idx]
641
+ fp = jnp.where(mp[:, None], fp, f0[None, :])
642
+ fm = jnp.where(mm[:, None], fm, f0[None, :])
643
+
644
+ if extras is None or key_dx not in extras:
645
+ raise KeyError(f"Laplacian requires extras[{key_dx!r}]")
646
+ dx = jnp.asarray(extras[key_dx])
647
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
648
+ inv_dx2 = 1.0 / (dx * dx)
649
+
650
+ diffs = fp + fm - 2.0 * f0[None, :] # (ndim, n_feat)
651
+ lap = jnp.sum(diffs * inv_dx2[:, None], axis=0) # (n_feat,)
652
+
653
+ if mask is not None:
654
+ lap = lap * mask[0]
655
+
656
+ return lap
657
+
658
+ return local_lap_composed
659
+
660
+ def _make_labels(self, label, n_features_out, inner_labels):
661
+ name = label or "∇²"
662
+ return [f"{name}({il})" for il in inner_labels[:n_features_out]]
663
+
664
+
665
+ class Biharmonic(StencilOp):
666
+ r"""Composable biharmonic operator :math:`\nabla^4` on a Cartesian grid.
667
+
668
+ Uses the standard 13-point stencil (2D) or 25-point stencil (3D).
669
+ Requires ``ndim >= 2``.
670
+
671
+ On periodic grids, :math:`\sum_i [\nabla^4 g]_i = 0` for any :math:`g`,
672
+ so compositions like :math:`\nabla^4(\phi^2)` are exactly conservative.
673
+
674
+ Examples
675
+ --------
676
+ >>> Bih = Biharmonic(ndim=2, bc="pbc")
677
+ >>> phi = field_component(0, n_fields=1)
678
+ >>> bih_phi = Bih(phi) # ∇⁴φ
679
+ """
680
+
681
+ def __init__(self, **kwargs):
682
+ super().__init__(**kwargs)
683
+ if self._ndim < 2:
684
+ raise ValueError(f"Biharmonic requires ndim >= 2 (got {self._ndim})")
685
+
686
+ def _offsets(self):
687
+ return square_biharmonic_offsets(self._ndim, include_center=True)
688
+
689
+ def _default_prefix(self):
690
+ return _default_biharmonic_prefix(self._ndim, self._bc)
691
+
692
+ def _build_local_fn(self, inner_root, *, n_features_out):
693
+ import itertools
694
+
695
+ ndim = self._ndim
696
+ key_dx = self._key_dx
697
+ n_r2 = 2 * ndim
698
+ n_r1 = 2 * ndim
699
+ diag_pairs = list(itertools.combinations(range(ndim), 2))
700
+
701
+ center = 0
702
+ r2_plus = jnp.array([1 + 2 * a for a in range(ndim)])
703
+ r2_minus = jnp.array([2 + 2 * a for a in range(ndim)])
704
+ r1_base = 1 + n_r2
705
+ r1_plus = jnp.array([r1_base + 2 * a for a in range(ndim)])
706
+ r1_minus = jnp.array([r1_base + 2 * a + 1 for a in range(ndim)])
707
+ diag_base = 1 + n_r2 + n_r1
708
+
709
+ def local_bih_composed(Xk, *, mask=None, extras=None):
710
+ # Evaluate inner expression on all stencil neighbors.
711
+ fXk = inner_root(Xk) # (K, n_feat)
712
+ f0 = fXk[center, :]
713
+
714
+ if extras is None or key_dx not in extras:
715
+ raise KeyError(f"Biharmonic requires extras[{key_dx!r}]")
716
+ dx = jnp.asarray(extras[key_dx])
717
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
718
+ if mask is not None:
719
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
720
+
721
+ inv_dx2 = 1.0 / (dx * dx)
722
+ result = jnp.zeros_like(f0)
723
+
724
+ for a in range(ndim):
725
+ h4_inv = inv_dx2[a] * inv_dx2[a]
726
+ f_2p = fXk[r2_plus[a], :]
727
+ f_2m = fXk[r2_minus[a], :]
728
+ f_p = fXk[r1_plus[a], :]
729
+ f_m = fXk[r1_minus[a], :]
730
+ result = result + h4_inv * (f_2p - 4.0 * f_p + 6.0 * f0 - 4.0 * f_m + f_2m)
731
+
732
+ for pair_i, (a, b) in enumerate(diag_pairs):
733
+ hab_inv = inv_dx2[a] * inv_dx2[b]
734
+ base_s = diag_base + pair_i * 4
735
+ f_pp = fXk[base_s, :]
736
+ f_pm = fXk[base_s + 1, :]
737
+ f_mp = fXk[base_s + 2, :]
738
+ f_mm = fXk[base_s + 3, :]
739
+ f_pa = fXk[r1_plus[a], :]
740
+ f_ma = fXk[r1_minus[a], :]
741
+ f_pb = fXk[r1_plus[b], :]
742
+ f_mb = fXk[r1_minus[b], :]
743
+
744
+ mixed = f_pp + f_pm + f_mp + f_mm - 2.0 * (f_pa + f_ma + f_pb + f_mb) + 4.0 * f0
745
+ result = result + 2.0 * hab_inv * mixed
746
+
747
+ if mask is not None:
748
+ result = result * mask[center]
749
+
750
+ return result
751
+
752
+ return local_bih_composed
753
+
754
+ def _make_labels(self, label, n_features_out, inner_labels):
755
+ name = label or "∇⁴"
756
+ return [f"{name}({il})" for il in inner_labels[:n_features_out]]
757
+
758
+
759
+ class Gradient(StencilOp):
760
+ r"""Composable gradient operator :math:`\nabla` on a Cartesian grid.
761
+
762
+ .. math::
763
+
764
+ [\nabla f(\phi)]_{i,\alpha} =
765
+ \frac{f(\phi_{i+e_\alpha}) - f(\phi_{i-e_\alpha})}{2\,\Delta x_\alpha}
766
+
767
+ Output features = ``n_features_inner × ndim``, ordered as
768
+ ``(feature_0/dx_0, feature_0/dx_1, ..., feature_1/dx_0, ...)``.
769
+
770
+ Examples
771
+ --------
772
+ >>> Grad = Gradient(ndim=2, bc="pbc")
773
+ >>> phi = field_component(0, n_fields=1)
774
+ >>> grad_phi = Grad(phi) # ∇φ (2 features)
775
+ >>> grad_phi2 = Grad(phi**2) # ∇(φ²) ≈ 2φ∇φ
776
+ """
777
+
778
+ def _offsets(self):
779
+ return square_cross_offsets(self._ndim, include_center=True)
780
+
781
+ def _default_prefix(self):
782
+ return _default_cross_prefix(self._ndim, self._bc)
783
+
784
+ def _n_features_out(self, n_features_inner):
785
+ return n_features_inner * self._ndim
786
+
787
+ def _build_local_fn(self, inner_root, *, n_features_out):
788
+ ndim = self._ndim
789
+ key_dx = self._key_dx
790
+ # Same stencil layout as Laplacian (see above)
791
+ plus_idx = 1 + 2 * jnp.arange(ndim)
792
+ minus_idx = 2 + 2 * jnp.arange(ndim)
793
+
794
+ def local_grad_composed(Xk, *, mask=None, extras=None):
795
+ fXk = inner_root(Xk) # (K, n_feat_inner)
796
+ f0 = fXk[0, :]
797
+ fp = fXk[plus_idx, :] # (ndim, n_feat_inner)
798
+ fm = fXk[minus_idx, :]
799
+
800
+ if mask is not None:
801
+ mp = mask[plus_idx]
802
+ mm = mask[minus_idx]
803
+ fp = jnp.where(mp[:, None], fp, f0[None, :])
804
+ fm = jnp.where(mm[:, None], fm, f0[None, :])
805
+
806
+ if extras is None or key_dx not in extras:
807
+ raise KeyError(f"Gradient requires extras[{key_dx!r}]")
808
+ dx = jnp.asarray(extras[key_dx])
809
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
810
+ inv_2dx = 1.0 / (2.0 * dx)
811
+
812
+ diffs = (fp - fm) * inv_2dx[:, None] # (ndim, n_feat_inner)
813
+ # Reorder to (n_feat_inner, ndim) then flatten
814
+ diffs = jnp.moveaxis(diffs, 0, -1) # (n_feat_inner, ndim)
815
+ result = diffs.reshape((-1,)) # (n_features_out,)
816
+
817
+ if mask is not None:
818
+ result = result * mask[0]
819
+
820
+ return result
821
+
822
+ return local_grad_composed
823
+
824
+ def _make_labels(self, label, n_features_out, inner_labels):
825
+ ndim = self._ndim
826
+ n_inner = n_features_out // ndim
827
+ labels = []
828
+ for il in inner_labels[:n_inner]:
829
+ for a in range(ndim):
830
+ labels.append(f"d({il})/dx{a}")
831
+ return labels
832
+
833
+
834
+ class LaplacianOfGradientSquared(StencilOp):
835
+ r"""Composable operator :math:`\nabla^2(|\nabla f(\phi)|^2)`.
836
+
837
+ This is the **genuine Active Model B+** term that breaks time-reversal
838
+ symmetry. It cannot be written as :math:`\nabla^2(g(\phi))` for any
839
+ pointwise function :math:`g`, because :math:`|\nabla\phi|^2` depends
840
+ on spatial derivatives, not just the local field value.
841
+
842
+ The computation proceeds in two stages on the biharmonic (radius-2)
843
+ stencil:
844
+
845
+ 1. Evaluate the inner expression :math:`f(\phi)` at all stencil
846
+ neighbors (13 points in 2D, 25 in 3D).
847
+ 2. Compute :math:`|\nabla f|^2` at each of the Laplacian's 5
848
+ stencil points (center + nearest neighbours) using central
849
+ differences among the gathered values.
850
+ 3. Apply the standard Laplacian formula to the resulting
851
+ :math:`|\nabla f|^2` field.
852
+
853
+ On periodic grids, the outer Laplacian guarantees
854
+ :math:`\sum_i [\nabla^2 G]_i = 0` exactly, so the operator
855
+ is **conservative** even though :math:`G = |\nabla f|^2` is
856
+ nonlinear in the spatial derivatives.
857
+
858
+ .. math::
859
+
860
+ [\nabla^2(|\nabla f|^2)]_i
861
+ = \sum_\alpha \frac{G_{i+e_\alpha} + G_{i-e_\alpha} - 2\,G_i}
862
+ {\Delta x_\alpha^2}
863
+
864
+ where
865
+
866
+ .. math::
867
+
868
+ G_j = |\nabla f(\phi)|^2_j
869
+ = \sum_\beta \left(\frac{f(\phi_{j+e_\beta}) - f(\phi_{j-e_\beta})}
870
+ {2\,\Delta x_\beta}\right)^{\!2}
871
+
872
+ Requires ``ndim >= 2``.
873
+
874
+ Examples
875
+ --------
876
+ >>> LGS = LaplacianOfGradientSquared(ndim=2, bc="pbc")
877
+ >>> phi = field_component(0, n_fields=1)
878
+ >>> lgs_phi = LGS(phi) # ∇²(|∇φ|²) (AMB+ active term)
879
+ >>> lgs_phi2 = LGS(phi**2) # ∇²(|∇(φ²)|²)
880
+ """
881
+
882
+ def __init__(self, **kwargs):
883
+ super().__init__(**kwargs)
884
+ if self._ndim < 2:
885
+ raise ValueError(f"LaplacianOfGradientSquared requires ndim >= 2 (got {self._ndim})")
886
+
887
+ def _offsets(self):
888
+ return square_biharmonic_offsets(self._ndim, include_center=True)
889
+
890
+ def _default_prefix(self):
891
+ return _default_biharmonic_prefix(self._ndim, self._bc)
892
+
893
+ def _build_local_fn(self, inner_root, *, n_features_out):
894
+ ndim = self._ndim
895
+ key_dx = self._key_dx
896
+
897
+ # ----- Build offset → stencil-index lookup -----
898
+ offsets = square_biharmonic_offsets(ndim, include_center=True)
899
+ offset_to_idx: dict[tuple[int, ...], int] = {}
900
+ for i in range(len(offsets)):
901
+ offset_to_idx[tuple(int(x) for x in offsets[i])] = i
902
+
903
+ center_idx = 0
904
+
905
+ def _shifted(base_off, axis, sign):
906
+ """Return tuple of base_off shifted by ±e_axis."""
907
+ off = list(base_off)
908
+ off[axis] += sign
909
+ return tuple(off)
910
+
911
+ # ----- Precompute gradient index pairs for center + cross nbrs -----
912
+ # For each evaluation point q, grad_pairs[q][b] = (idx_plus, idx_minus)
913
+ # so that gradient along axis b = (f[idx_plus] - f[idx_minus]) / (2*dx_b)
914
+
915
+ def _grad_pairs(base_off):
916
+ pairs = {}
917
+ for b in range(ndim):
918
+ ip = offset_to_idx[_shifted(base_off, b, +1)]
919
+ im = offset_to_idx[_shifted(base_off, b, -1)]
920
+ pairs[b] = (ip, im)
921
+ return pairs
922
+
923
+ origin = (0,) * ndim
924
+ gp_center = _grad_pairs(origin)
925
+
926
+ # Cross neighbours: +e_a and -e_a for each axis a
927
+ gp_cross = {} # gp_cross[(a, s)] where s ∈ {+1, -1}
928
+ cross_idx = {} # cross_idx[(a, s)] = stencil index of s*e_a
929
+ for a in range(ndim):
930
+ for s in (+1, -1):
931
+ off_a = [0] * ndim
932
+ off_a[a] = s
933
+ off_a = tuple(off_a)
934
+ cross_idx[(a, s)] = offset_to_idx[off_a]
935
+ gp_cross[(a, s)] = _grad_pairs(off_a)
936
+
937
+ # ----- The local function (called inside vmap over grid sites) -----
938
+
939
+ def local_lap_grad_sq(Xk, *, mask=None, extras=None):
940
+ # Evaluate inner expression at all stencil neighbours
941
+ fXk = inner_root(Xk) # (K, n_feat)
942
+ f0 = fXk[center_idx, :]
943
+
944
+ if mask is not None:
945
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
946
+
947
+ if extras is None or key_dx not in extras:
948
+ raise KeyError(f"LaplacianOfGradientSquared requires extras[{key_dx!r}]")
949
+ dx = jnp.asarray(extras[key_dx])
950
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
951
+ inv_2dx = 1.0 / (2.0 * dx)
952
+ inv_dx2 = 1.0 / (dx * dx)
953
+
954
+ def _grad_sq(pairs):
955
+ """Compute |∇f|² at a point from its gradient pairs."""
956
+ gsq = jnp.zeros_like(f0)
957
+ for b in range(ndim):
958
+ ip, im = pairs[b]
959
+ diff_b = (fXk[ip, :] - fXk[im, :]) * inv_2dx[b]
960
+ gsq = gsq + diff_b**2
961
+ return gsq
962
+
963
+ # |∇f|² at center
964
+ G0 = _grad_sq(gp_center)
965
+
966
+ # Laplacian of |∇f|²
967
+ lap_G = jnp.zeros_like(f0)
968
+ for a in range(ndim):
969
+ Gp = _grad_sq(gp_cross[(a, +1)])
970
+ Gm = _grad_sq(gp_cross[(a, -1)])
971
+ lap_G = lap_G + inv_dx2[a] * (Gp + Gm - 2.0 * G0)
972
+
973
+ if mask is not None:
974
+ lap_G = lap_G * mask[center_idx]
975
+
976
+ return lap_G
977
+
978
+ return local_lap_grad_sq
979
+
980
+ def _make_labels(self, label, n_features_out, inner_labels):
981
+ name = label or "∇²|∇|²"
982
+ return [f"{name}({il})" for il in inner_labels[:n_features_out]]
983
+
984
+
985
+ # ============================================================================
986
+ # Same-space vector operators (require n_features == ndim)
987
+ # ============================================================================
988
+
989
+
990
+ class Divergence(StencilOp):
991
+ r"""Composable divergence :math:`\nabla\!\cdot` on a Cartesian grid.
992
+
993
+ .. math::
994
+
995
+ [\nabla\!\cdot\mathbf{f}]_i = \sum_{\alpha}
996
+ \frac{f_\alpha(\phi_{i+e_\alpha}) - f_\alpha(\phi_{i-e_\alpha})}
997
+ {2\,\Delta x_\alpha}
998
+
999
+ The inner expression must produce exactly ``ndim`` features.
1000
+
1001
+ Examples
1002
+ --------
1003
+ >>> Div = Divergence(ndim=2, bc="pbc")
1004
+ >>> v = vector_field(n_fields=2)
1005
+ >>> div_v = Div(v) # scalar divergence (1 feature)
1006
+
1007
+ Composition with :class:`Gradient` recovers the Laplacian:
1008
+
1009
+ >>> Grad = Gradient(ndim=2, bc="pbc")
1010
+ >>> phi = field_component(0, n_fields=1)
1011
+ >>> lap = Div(Grad(phi)) # ∇·(∇φ) = ∇²φ
1012
+ """
1013
+
1014
+ def _offsets(self):
1015
+ return square_cross_offsets(self._ndim, include_center=True)
1016
+
1017
+ def _default_prefix(self):
1018
+ return _default_cross_prefix(self._ndim, self._bc)
1019
+
1020
+ def _n_features_out(self, n_features_inner):
1021
+ if n_features_inner != self._ndim:
1022
+ raise ValueError(f"Divergence requires n_features == ndim={self._ndim}, got {n_features_inner}")
1023
+ return 1
1024
+
1025
+ def _build_local_fn(self, inner_root, *, n_features_out):
1026
+ ndim = self._ndim
1027
+ key_dx = self._key_dx
1028
+ # Same stencil layout as Laplacian (see above)
1029
+ plus_idx = 1 + 2 * jnp.arange(ndim)
1030
+ minus_idx = 2 + 2 * jnp.arange(ndim)
1031
+
1032
+ def local_div(Xk, *, mask=None, extras=None):
1033
+ fXk = inner_root(Xk) # (K, ndim)
1034
+ f0 = fXk[0, :]
1035
+ if mask is not None:
1036
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
1037
+
1038
+ if extras is None or key_dx not in extras:
1039
+ raise KeyError(f"Divergence requires extras[{key_dx!r}]")
1040
+ dx = jnp.asarray(extras[key_dx])
1041
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
1042
+ inv_2dx = 1.0 / (2.0 * dx)
1043
+
1044
+ result = jnp.zeros(())
1045
+ for a in range(ndim):
1046
+ result = result + (fXk[plus_idx[a], a] - fXk[minus_idx[a], a]) * inv_2dx[a]
1047
+
1048
+ if mask is not None:
1049
+ result = result * mask[0]
1050
+ return result
1051
+
1052
+ return local_div
1053
+
1054
+ def _make_labels(self, label, n_features_out, inner_labels):
1055
+ name = label or "∇·"
1056
+ return [f"{name}({','.join(inner_labels[: self._ndim])})"]
1057
+
1058
+
1059
+ class Curl(StencilOp):
1060
+ r"""Composable curl operator on a Cartesian grid.
1061
+
1062
+ **2D** (scalar curl / vorticity, 1 output feature):
1063
+
1064
+ .. math::
1065
+
1066
+ [\nabla\!\times\!\mathbf{f}]_i =
1067
+ \frac{f_1(i\!+\!e_0) - f_1(i\!-\!e_0)}{2\Delta x_0}
1068
+ - \frac{f_0(i\!+\!e_1) - f_0(i\!-\!e_1)}{2\Delta x_1}
1069
+
1070
+ **3D** (vector curl, 3 output features):
1071
+
1072
+ .. math::
1073
+
1074
+ (\nabla\!\times\!\mathbf{f})_\alpha
1075
+ = \varepsilon_{\alpha\beta\gamma}\,
1076
+ \frac{f_\gamma(i\!+\!e_\beta) - f_\gamma(i\!-\!e_\beta)}
1077
+ {2\,\Delta x_\beta}
1078
+
1079
+ The inner expression must produce exactly ``ndim`` features.
1080
+ Requires ``ndim >= 2``.
1081
+
1082
+ Examples
1083
+ --------
1084
+ >>> Curl2D = Curl(ndim=2, bc="pbc")
1085
+ >>> v = vector_field(n_fields=2)
1086
+ >>> omega = Curl2D(v) # scalar vorticity
1087
+
1088
+ Identity check — curl of a gradient vanishes:
1089
+
1090
+ >>> Grad = Gradient(ndim=2, bc="pbc")
1091
+ >>> phi = field_component(0, n_fields=1)
1092
+ >>> should_vanish = Curl2D(Grad(phi))
1093
+ """
1094
+
1095
+ def __init__(self, **kwargs):
1096
+ super().__init__(**kwargs)
1097
+ if self._ndim < 2:
1098
+ raise ValueError(f"Curl requires ndim >= 2 (got {self._ndim})")
1099
+ if self._ndim > 3:
1100
+ raise ValueError(f"Curl is only defined for ndim 2 or 3 (got {self._ndim})")
1101
+
1102
+ def _offsets(self):
1103
+ return square_cross_offsets(self._ndim, include_center=True)
1104
+
1105
+ def _default_prefix(self):
1106
+ return _default_cross_prefix(self._ndim, self._bc)
1107
+
1108
+ def _n_features_out(self, n_features_inner):
1109
+ if n_features_inner != self._ndim:
1110
+ raise ValueError(f"Curl requires n_features == ndim={self._ndim}, got {n_features_inner}")
1111
+ return 1 if self._ndim == 2 else 3
1112
+
1113
+ def _build_local_fn(self, inner_root, *, n_features_out):
1114
+ ndim = self._ndim
1115
+ key_dx = self._key_dx
1116
+ # Same stencil layout as Laplacian (see above)
1117
+ plus_idx = 1 + 2 * jnp.arange(ndim)
1118
+ minus_idx = 2 + 2 * jnp.arange(ndim)
1119
+
1120
+ if ndim == 2:
1121
+
1122
+ def local_curl(Xk, *, mask=None, extras=None):
1123
+ fXk = inner_root(Xk)
1124
+ f0 = fXk[0, :]
1125
+ if mask is not None:
1126
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
1127
+
1128
+ if extras is None or key_dx not in extras:
1129
+ raise KeyError(f"Curl requires extras[{key_dx!r}]")
1130
+ dx = jnp.asarray(extras[key_dx])
1131
+ dx = dx if dx.ndim == 1 else jnp.full((2,), dx)
1132
+ inv_2dx = 1.0 / (2.0 * dx)
1133
+
1134
+ # df_1/dx_0 - df_0/dx_1
1135
+ dfy_dx = (fXk[plus_idx[0], 1] - fXk[minus_idx[0], 1]) * inv_2dx[0]
1136
+ dfx_dy = (fXk[plus_idx[1], 0] - fXk[minus_idx[1], 0]) * inv_2dx[1]
1137
+ result = jnp.array([dfy_dx - dfx_dy])
1138
+
1139
+ if mask is not None:
1140
+ result = result * mask[0]
1141
+ return result
1142
+
1143
+ return local_curl
1144
+
1145
+ # ndim == 3
1146
+ def local_curl_3d(Xk, *, mask=None, extras=None):
1147
+ fXk = inner_root(Xk)
1148
+ f0 = fXk[0, :]
1149
+ if mask is not None:
1150
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
1151
+
1152
+ if extras is None or key_dx not in extras:
1153
+ raise KeyError(f"Curl requires extras[{key_dx!r}]")
1154
+ dx = jnp.asarray(extras[key_dx])
1155
+ dx = dx if dx.ndim == 1 else jnp.full((3,), dx)
1156
+ inv_2dx = 1.0 / (2.0 * dx)
1157
+
1158
+ def df(i, j):
1159
+ return (fXk[plus_idx[j], i] - fXk[minus_idx[j], i]) * inv_2dx[j]
1160
+
1161
+ curl = jnp.array(
1162
+ [
1163
+ df(2, 1) - df(1, 2),
1164
+ df(0, 2) - df(2, 0),
1165
+ df(1, 0) - df(0, 1),
1166
+ ]
1167
+ )
1168
+
1169
+ if mask is not None:
1170
+ curl = curl * mask[0]
1171
+ return curl
1172
+
1173
+ return local_curl_3d
1174
+
1175
+ def _make_labels(self, label, n_features_out, inner_labels):
1176
+ name = label or "∇×"
1177
+ if self._ndim == 2:
1178
+ return [f"{name}({','.join(inner_labels[:2])})"]
1179
+ return [f"({name})_{a}" for a in ("x", "y", "z")]
1180
+
1181
+
1182
+ class SymGrad(StencilOp):
1183
+ r"""Symmetric gradient (strain-like) on a Cartesian grid.
1184
+
1185
+ Computes the symmetric part of the gradient tensor of a vector field:
1186
+
1187
+ .. math::
1188
+
1189
+ S_{ij} = \tfrac{1}{2}\!\left(
1190
+ \frac{\partial f_i}{\partial x_j}
1191
+ + \frac{\partial f_j}{\partial x_i}
1192
+ \right)
1193
+
1194
+ For velocity fields this is the **strain-rate** tensor; for
1195
+ displacement fields the **strain** tensor.
1196
+
1197
+ Output features = ``ndim*(ndim+1)//2`` (upper triangle), ordered:
1198
+ ``(0,0), (0,1), ..., (0,d-1), (1,1), (1,2), ..., (d-1,d-1)``.
1199
+
1200
+ The inner expression must produce exactly ``ndim`` features.
1201
+ Requires ``ndim >= 2``.
1202
+
1203
+ Examples
1204
+ --------
1205
+ >>> SG = SymGrad(ndim=2, bc="pbc")
1206
+ >>> v = vector_field(n_fields=2)
1207
+ >>> strain = SG(v) # 3 features: S_00, S_01, S_11
1208
+ """
1209
+
1210
+ def __init__(self, **kwargs):
1211
+ super().__init__(**kwargs)
1212
+ if self._ndim < 2:
1213
+ raise ValueError(f"SymGrad requires ndim >= 2 (got {self._ndim})")
1214
+
1215
+ def _offsets(self):
1216
+ return square_cross_offsets(self._ndim, include_center=True)
1217
+
1218
+ def _default_prefix(self):
1219
+ return _default_cross_prefix(self._ndim, self._bc)
1220
+
1221
+ def _n_features_out(self, n_features_inner):
1222
+ ndim = self._ndim
1223
+ if n_features_inner != ndim:
1224
+ raise ValueError(f"SymGrad requires n_features == ndim={ndim}, got {n_features_inner}")
1225
+ return ndim * (ndim + 1) // 2
1226
+
1227
+ def _build_local_fn(self, inner_root, *, n_features_out):
1228
+ ndim = self._ndim
1229
+ key_dx = self._key_dx
1230
+ # Same stencil layout as Laplacian (see above)
1231
+ plus_idx = 1 + 2 * jnp.arange(ndim)
1232
+ minus_idx = 2 + 2 * jnp.arange(ndim)
1233
+
1234
+ pairs = [(i, j) for i in range(ndim) for j in range(i, ndim)]
1235
+
1236
+ def local_symgrad(Xk, *, mask=None, extras=None):
1237
+ fXk = inner_root(Xk)
1238
+ f0 = fXk[0, :]
1239
+ if mask is not None:
1240
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
1241
+
1242
+ if extras is None or key_dx not in extras:
1243
+ raise KeyError(f"SymGrad requires extras[{key_dx!r}]")
1244
+ dx = jnp.asarray(extras[key_dx])
1245
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
1246
+ inv_2dx = 1.0 / (2.0 * dx)
1247
+
1248
+ comps = []
1249
+ for i, j in pairs:
1250
+ dfi_dxj = (fXk[plus_idx[j], i] - fXk[minus_idx[j], i]) * inv_2dx[j]
1251
+ if i == j:
1252
+ comps.append(dfi_dxj)
1253
+ else:
1254
+ dfj_dxi = (fXk[plus_idx[i], j] - fXk[minus_idx[i], j]) * inv_2dx[i]
1255
+ comps.append(0.5 * (dfi_dxj + dfj_dxi))
1256
+
1257
+ result = jnp.array(comps)
1258
+ if mask is not None:
1259
+ result = result * mask[0]
1260
+ return result
1261
+
1262
+ return local_symgrad
1263
+
1264
+ def _make_labels(self, label, n_features_out, inner_labels):
1265
+ name = label or "S"
1266
+ ndim = self._ndim
1267
+ return [f"{name}_{i}{j}" for i in range(ndim) for j in range(i, ndim)]
1268
+
1269
+
1270
+ class SkewGrad(StencilOp):
1271
+ r"""Antisymmetric gradient (rotation-like) on a Cartesian grid.
1272
+
1273
+ Computes the antisymmetric part of the gradient tensor:
1274
+
1275
+ .. math::
1276
+
1277
+ W_{ij} = \tfrac{1}{2}\!\left(
1278
+ \frac{\partial f_i}{\partial x_j}
1279
+ - \frac{\partial f_j}{\partial x_i}
1280
+ \right)
1281
+
1282
+ For velocity fields this is the **rotation-rate** (vorticity) tensor.
1283
+
1284
+ Output features = ``ndim*(ndim-1)//2`` (strict upper triangle),
1285
+ ordered: ``(0,1), (0,2), ..., (1,2), ...``.
1286
+
1287
+ In 2D the single component equals half the scalar curl.
1288
+
1289
+ The inner expression must produce exactly ``ndim`` features.
1290
+ Requires ``ndim >= 2``.
1291
+
1292
+ Examples
1293
+ --------
1294
+ >>> W = SkewGrad(ndim=2, bc="pbc")
1295
+ >>> v = vector_field(n_fields=2)
1296
+ >>> omega = W(v) # 1 feature: W_01
1297
+ """
1298
+
1299
+ def __init__(self, **kwargs):
1300
+ super().__init__(**kwargs)
1301
+ if self._ndim < 2:
1302
+ raise ValueError(f"SkewGrad requires ndim >= 2 (got {self._ndim})")
1303
+
1304
+ def _offsets(self):
1305
+ return square_cross_offsets(self._ndim, include_center=True)
1306
+
1307
+ def _default_prefix(self):
1308
+ return _default_cross_prefix(self._ndim, self._bc)
1309
+
1310
+ def _n_features_out(self, n_features_inner):
1311
+ ndim = self._ndim
1312
+ if n_features_inner != ndim:
1313
+ raise ValueError(f"SkewGrad requires n_features == ndim={ndim}, got {n_features_inner}")
1314
+ return ndim * (ndim - 1) // 2
1315
+
1316
+ def _build_local_fn(self, inner_root, *, n_features_out):
1317
+ ndim = self._ndim
1318
+ key_dx = self._key_dx
1319
+ # Same stencil layout as Laplacian (see above)
1320
+ plus_idx = 1 + 2 * jnp.arange(ndim)
1321
+ minus_idx = 2 + 2 * jnp.arange(ndim)
1322
+
1323
+ pairs = [(i, j) for i in range(ndim) for j in range(i + 1, ndim)]
1324
+
1325
+ def local_skewgrad(Xk, *, mask=None, extras=None):
1326
+ fXk = inner_root(Xk)
1327
+ f0 = fXk[0, :]
1328
+ if mask is not None:
1329
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
1330
+
1331
+ if extras is None or key_dx not in extras:
1332
+ raise KeyError(f"SkewGrad requires extras[{key_dx!r}]")
1333
+ dx = jnp.asarray(extras[key_dx])
1334
+ dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
1335
+ inv_2dx = 1.0 / (2.0 * dx)
1336
+
1337
+ comps = []
1338
+ for i, j in pairs:
1339
+ dfi_dxj = (fXk[plus_idx[j], i] - fXk[minus_idx[j], i]) * inv_2dx[j]
1340
+ dfj_dxi = (fXk[plus_idx[i], j] - fXk[minus_idx[i], j]) * inv_2dx[i]
1341
+ comps.append(0.5 * (dfi_dxj - dfj_dxi))
1342
+
1343
+ result = jnp.array(comps)
1344
+ if mask is not None:
1345
+ result = result * mask[0]
1346
+ return result
1347
+
1348
+ return local_skewgrad
1349
+
1350
+ def _make_labels(self, label, n_features_out, inner_labels):
1351
+ name = label or "W"
1352
+ ndim = self._ndim
1353
+ return [f"{name}_{i}{j}" for i in range(ndim) for j in range(i + 1, ndim)]
1354
+
1355
+
1356
+ # ============================================================================
1357
+ # Advection factory
1358
+ # ============================================================================
1359
+
1360
+
1361
+ def AdvectionBy(v_expr, *, ndim, bc="pbc", **stencil_kwargs):
1362
+ r"""Return a stencil operator for advection :math:`(\mathbf{v}\!\cdot\!\nabla)f`.
1363
+
1364
+ Parameters
1365
+ ----------
1366
+ v_expr : StateExpr
1367
+ Pointwise expression with ``n_features == ndim`` giving the
1368
+ advecting vector field.
1369
+ ndim : int
1370
+ Number of spatial dimensions.
1371
+ bc : str
1372
+ Boundary condition (``"pbc"`` or ``"noflux"``).
1373
+ **stencil_kwargs
1374
+ Forwarded to ``StencilOp.__init__``.
1375
+
1376
+ Returns
1377
+ -------
1378
+ StencilOp
1379
+ Operator computing :math:`\sum_\alpha v_\alpha\,\partial f / \partial x_\alpha`.
1380
+
1381
+ Notes
1382
+ -----
1383
+ Results of this operator are **not** further composable because the
1384
+ advection FD formula references a second expression (the velocity).
1385
+
1386
+ Examples
1387
+ --------
1388
+ >>> v = vector_field(n_fields=2)
1389
+ >>> phi = field_component(0, n_fields=2)
1390
+ >>> Adv = AdvectionBy(v, ndim=2, bc="pbc")
1391
+ >>> adv_phi = Adv(phi) # (v·∇)φ
1392
+ """
1393
+ from ..statefunc.stateexpr import StateExpr
1394
+
1395
+ if not isinstance(v_expr, StateExpr):
1396
+ raise TypeError(f"v_expr must be a StateExpr, got {type(v_expr).__name__}")
1397
+ if v_expr.n_features != ndim:
1398
+ raise ValueError(f"v_expr must have n_features == ndim={ndim}, got {v_expr.n_features}")
1399
+
1400
+ v_root = v_expr.root
1401
+
1402
+ class _Advection(StencilOp):
1403
+ _composable = False
1404
+
1405
+ def _offsets(self):
1406
+ return square_cross_offsets(self._ndim, include_center=True)
1407
+
1408
+ def _default_prefix(self):
1409
+ return _default_cross_prefix(self._ndim, self._bc)
1410
+
1411
+ def _build_local_fn(self, inner_root, *, n_features_out):
1412
+ _ndim = self._ndim
1413
+ _key_dx = self._key_dx
1414
+ _v_root = v_root
1415
+ _plus_idx = 1 + 2 * jnp.arange(_ndim)
1416
+ _minus_idx = 2 + 2 * jnp.arange(_ndim)
1417
+
1418
+ def local_advection(Xk, *, mask=None, extras=None):
1419
+ fXk = inner_root(Xk) # (K, n_feat_f)
1420
+ f0 = fXk[0, :]
1421
+ vXk = _v_root(Xk) # (K, ndim)
1422
+ v_center = vXk[0, :] # (ndim,)
1423
+
1424
+ if mask is not None:
1425
+ fXk = jnp.where(mask[:, None], fXk, f0[None, :])
1426
+
1427
+ if extras is None or _key_dx not in extras:
1428
+ raise KeyError(f"AdvectionBy requires extras[{_key_dx!r}]")
1429
+ dx = jnp.asarray(extras[_key_dx])
1430
+ dx = dx if dx.ndim == 1 else jnp.full((_ndim,), dx)
1431
+ inv_2dx = 1.0 / (2.0 * dx)
1432
+
1433
+ fp = fXk[_plus_idx, :] # (ndim, n_feat_f)
1434
+ fm = fXk[_minus_idx, :]
1435
+ grad_f = (fp - fm) * inv_2dx[:, None] # (ndim, n_feat_f)
1436
+ result = jnp.sum(
1437
+ v_center[:, None] * grad_f,
1438
+ axis=0,
1439
+ ) # (n_feat_f,)
1440
+
1441
+ if mask is not None:
1442
+ result = result * mask[0]
1443
+ return result
1444
+
1445
+ return local_advection
1446
+
1447
+ def _make_labels(self, label, n_features_out, inner_labels):
1448
+ name = label or "(v·∇)"
1449
+ return [f"{name}({il})" for il in inner_labels[:n_features_out]]
1450
+
1451
+ def __repr__(self):
1452
+ return f"AdvectionBy(ndim={self._ndim}, bc={self._bc!r})"
1453
+
1454
+ return _Advection(ndim=ndim, bc=bc, **stencil_kwargs)
1455
+
1456
+
1457
+ # ============================================================================
1458
+ # Convenience helpers
1459
+ # ============================================================================
1460
+
1461
+
1462
+ def vector_field(n_fields: int, *, labels: list[str] | None = None):
1463
+ """All field components as a rank-0 expression with ``n_features == n_fields``.
1464
+
1465
+ Shorthand for concatenating :func:`~SFI.bases.linear.field_component`
1466
+ over all indices. Useful as input to same-space operators
1467
+ (:class:`Divergence`, :class:`Curl`, :class:`SymGrad`, …).
1468
+
1469
+ Parameters
1470
+ ----------
1471
+ n_fields : int
1472
+ Number of field components per grid site.
1473
+ labels : list of str, optional
1474
+ Human-readable labels; defaults to ``field[0], field[1], …``.
1475
+
1476
+ Returns
1477
+ -------
1478
+ Basis
1479
+ ``n_features == n_fields``, ``rank == 0``.
1480
+ """
1481
+ from .linear import x_coordinates
1482
+
1483
+ if labels is None:
1484
+ labels = [f"field[{i}]" for i in range(n_fields)]
1485
+ return x_coordinates(list(range(n_fields)), dim=n_fields, labels=labels)
1486
+
1487
+
1488
+ # ============================================================================
1489
+ # Noise model factories (grid-aware wrappers around langevin.noise)
1490
+ # ============================================================================
1491
+
1492
+
1493
+ def conserved_noise_pbc(
1494
+ sigma: float,
1495
+ *,
1496
+ grid_shape: "Sequence[int]",
1497
+ dx: "float | Sequence[float]" = 1.0,
1498
+ n_fields: int = 1,
1499
+ ) -> "ConservedNoise": # noqa: F821
1500
+ r"""Build a conserved-noise model for a periodic square grid.
1501
+
1502
+ Returns a :class:`~SFI.langevin.noise.ConservedNoise` instance configured
1503
+ for the given grid geometry. The noise implements
1504
+
1505
+ .. math::
1506
+
1507
+ \eta(\mathbf{x}, t) = \nabla\!\cdot\!(\sigma\,\vec{\Lambda})
1508
+
1509
+ where :math:`\vec{\Lambda}` is spatiotemporal white vector noise with
1510
+ periodic boundary conditions. This conserves the spatial average:
1511
+ :math:`\sum_i \eta_i = 0` exactly at every time step.
1512
+
1513
+ Parameters
1514
+ ----------
1515
+ sigma : float
1516
+ Continuum noise amplitude.
1517
+ grid_shape : sequence of int
1518
+ Grid dimensions ``(Nx, Ny, ...)``.
1519
+ dx : float or sequence of float
1520
+ Grid spacing (uniform or per-axis).
1521
+ n_fields : int
1522
+ Number of field components per site.
1523
+
1524
+ Returns
1525
+ -------
1526
+ ConservedNoise
1527
+ Ready to pass as ``D=`` to :class:`~SFI.langevin.OverdampedProcess`.
1528
+
1529
+ Example
1530
+ -------
1531
+ >>> from SFI.bases.spde import conserved_noise_pbc, square_grid_extras
1532
+ >>> noise = conserved_noise_pbc(sigma=0.3, grid_shape=(64, 64), dx=1.0)
1533
+ >>> proc = OverdampedProcess(basis, D=noise)
1534
+ """
1535
+ from ..langevin.noise import ConservedNoise
1536
+
1537
+ return ConservedNoise(sigma, grid_shape=grid_shape, dx=dx, n_fields=n_fields)