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