StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Recursive stencil-tree compiler for GridLayout.
|
|
2
|
+
|
|
3
|
+
Given an arbitrary ``_StructNode`` tree (possibly containing nested
|
|
4
|
+
``_DiffOpNode`` s mixed with pointwise algebra) and a pre-computed
|
|
5
|
+
**offset-to-index** dict, this module compiles the tree into a single
|
|
6
|
+
JAX local function ``local_fn(Xk, *, mask, extras)`` that reads the
|
|
7
|
+
gathered data ``Xk: (K, dim)`` and produces the output for one grid
|
|
8
|
+
site.
|
|
9
|
+
|
|
10
|
+
Architecture
|
|
11
|
+
------------
|
|
12
|
+
|
|
13
|
+
The core abstraction is **base_offset**: a *Python tuple* of ints
|
|
14
|
+
that represents "the grid offset at which we are evaluating this
|
|
15
|
+
sub-expression". It starts at ``(0, …, 0)`` for the outermost call
|
|
16
|
+
and gets shifted when we descend into a ``_DiffOpNode``: the
|
|
17
|
+
gradient needs values at ``base + e_a`` and ``base − e_a``, so the
|
|
18
|
+
child is called with those shifted base offsets.
|
|
19
|
+
|
|
20
|
+
Because ``base_offset`` is a Python tuple and ``o2i`` lookup is done
|
|
21
|
+
at **trace time**, all stencil index resolution becomes static integer
|
|
22
|
+
constants in the XLA graph — no runtime dict lookups.
|
|
23
|
+
|
|
24
|
+
Layout-independence
|
|
25
|
+
-------------------
|
|
26
|
+
|
|
27
|
+
This compiler is specific to **finite-difference** stencils on a grid.
|
|
28
|
+
A future PINN layout would implement the same ``_DiffOpNode`` IR nodes
|
|
29
|
+
but compile them via ``jax.grad`` instead of FD formulas. The
|
|
30
|
+
``StructuredExpr`` algebra and IR tree are layout-agnostic.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Any, Callable
|
|
36
|
+
|
|
37
|
+
import jax.numpy as jnp
|
|
38
|
+
|
|
39
|
+
# =====================================================================
|
|
40
|
+
# Offset arithmetic (trace-time only — pure Python)
|
|
41
|
+
# =====================================================================
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _shift(base: tuple[int, ...], axis: int, sign: int) -> tuple[int, ...]:
|
|
45
|
+
"""Shift *base* by ±e_axis."""
|
|
46
|
+
lst = list(base)
|
|
47
|
+
lst[axis] += sign
|
|
48
|
+
return tuple(lst)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# =====================================================================
|
|
52
|
+
# Recursive evaluator builder
|
|
53
|
+
# =====================================================================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compile_eval(
|
|
57
|
+
node: Any,
|
|
58
|
+
o2i: dict[tuple[int, ...], int],
|
|
59
|
+
sectors: dict[str, Any],
|
|
60
|
+
ndim: int,
|
|
61
|
+
key_dx: str,
|
|
62
|
+
) -> Callable:
|
|
63
|
+
"""Compile a ``_StructNode`` tree into a trace-time-unrolled evaluator.
|
|
64
|
+
|
|
65
|
+
Returns ``eval_fn(Xk, extras, mask, base_offset) -> jnp.array``
|
|
66
|
+
where ``Xk: (K, dim)`` is the gathered neighbour data and
|
|
67
|
+
``base_offset`` is a Python int-tuple resolved at trace time.
|
|
68
|
+
|
|
69
|
+
The returned function is meant to be called at the top level with
|
|
70
|
+
``base_offset = (0,) * ndim``.
|
|
71
|
+
"""
|
|
72
|
+
from ..structexpr import (
|
|
73
|
+
_BinaryOp,
|
|
74
|
+
_ConcatOp,
|
|
75
|
+
_ConstNode,
|
|
76
|
+
_DiffOpNode,
|
|
77
|
+
_EinsumOp,
|
|
78
|
+
_SectorLeaf,
|
|
79
|
+
_SliceOp,
|
|
80
|
+
_StackOp,
|
|
81
|
+
_UnaryOp,
|
|
82
|
+
)
|
|
83
|
+
from ._grid import _voigt_expand # reuse existing helper
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Leaf: read from Xk at o2i[base_offset]
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
if isinstance(node, _SectorLeaf):
|
|
90
|
+
indices = tuple(node.indices)
|
|
91
|
+
sector = sectors[node.sector_name]
|
|
92
|
+
sdims = node.sdims
|
|
93
|
+
|
|
94
|
+
# Check if it's a SymTensor (needs Voigt expansion)
|
|
95
|
+
from ._sectors import ScalarSector, SymTensorSector
|
|
96
|
+
|
|
97
|
+
if isinstance(sector, SymTensorSector):
|
|
98
|
+
|
|
99
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
100
|
+
idx = o2i[base_offset]
|
|
101
|
+
raw = Xk[idx] # (dim,)
|
|
102
|
+
vals = jnp.array([raw[i] for i in indices])
|
|
103
|
+
return _voigt_expand(vals, sector)
|
|
104
|
+
|
|
105
|
+
return _eval
|
|
106
|
+
|
|
107
|
+
elif isinstance(sector, ScalarSector):
|
|
108
|
+
i0 = indices[0]
|
|
109
|
+
|
|
110
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
111
|
+
idx = o2i[base_offset]
|
|
112
|
+
return Xk[idx, i0]
|
|
113
|
+
|
|
114
|
+
return _eval
|
|
115
|
+
|
|
116
|
+
else:
|
|
117
|
+
# VectorSector / TensorSector
|
|
118
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
119
|
+
idx = o2i[base_offset]
|
|
120
|
+
raw = jnp.array([Xk[idx, i] for i in indices])
|
|
121
|
+
if sdims:
|
|
122
|
+
return raw.reshape(sdims)
|
|
123
|
+
return raw
|
|
124
|
+
|
|
125
|
+
return _eval
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Constant: independent of Xk and base_offset
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
if isinstance(node, _ConstNode):
|
|
132
|
+
val = node.value
|
|
133
|
+
if isinstance(val, (int, float)):
|
|
134
|
+
arr = jnp.array(val, dtype=jnp.float32)
|
|
135
|
+
|
|
136
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
137
|
+
return arr.astype(Xk.dtype)
|
|
138
|
+
|
|
139
|
+
return _eval
|
|
140
|
+
else:
|
|
141
|
+
arr = jnp.asarray(val)
|
|
142
|
+
|
|
143
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
144
|
+
return arr
|
|
145
|
+
|
|
146
|
+
return _eval
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Binary op: + - * / **
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
if isinstance(node, _BinaryOp):
|
|
153
|
+
left_fn = compile_eval(node.left, o2i, sectors, ndim, key_dx)
|
|
154
|
+
right_fn = compile_eval(node.right, o2i, sectors, ndim, key_dx)
|
|
155
|
+
_ops = {
|
|
156
|
+
"+": jnp.add,
|
|
157
|
+
"-": jnp.subtract,
|
|
158
|
+
"*": jnp.multiply,
|
|
159
|
+
"/": jnp.true_divide,
|
|
160
|
+
"**": jnp.power,
|
|
161
|
+
}
|
|
162
|
+
op_fn = _ops[node.op]
|
|
163
|
+
|
|
164
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
165
|
+
return op_fn(
|
|
166
|
+
left_fn(Xk, extras, mask, base_offset),
|
|
167
|
+
right_fn(Xk, extras, mask, base_offset),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return _eval
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Unary op: neg, T, ew (element-wise function)
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
if isinstance(node, _UnaryOp):
|
|
177
|
+
child_fn = compile_eval(node.child, o2i, sectors, ndim, key_dx)
|
|
178
|
+
|
|
179
|
+
if node.op == "neg":
|
|
180
|
+
|
|
181
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
182
|
+
return -child_fn(Xk, extras, mask, base_offset)
|
|
183
|
+
|
|
184
|
+
return _eval
|
|
185
|
+
|
|
186
|
+
elif node.op == "T":
|
|
187
|
+
|
|
188
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
189
|
+
v = child_fn(Xk, extras, mask, base_offset)
|
|
190
|
+
if v.ndim >= 2:
|
|
191
|
+
return jnp.swapaxes(v, -2, -1)
|
|
192
|
+
return v
|
|
193
|
+
|
|
194
|
+
return _eval
|
|
195
|
+
|
|
196
|
+
elif node.op == "ew":
|
|
197
|
+
ew = node.fn
|
|
198
|
+
assert ew is not None
|
|
199
|
+
|
|
200
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
201
|
+
return ew(child_fn(Xk, extras, mask, base_offset))
|
|
202
|
+
|
|
203
|
+
return _eval
|
|
204
|
+
|
|
205
|
+
else:
|
|
206
|
+
raise ValueError(f"Unknown unary op: {node.op!r}")
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Einsum
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
if isinstance(node, _EinsumOp):
|
|
213
|
+
child_fns = [compile_eval(c, o2i, sectors, ndim, key_dx) for c in node.children]
|
|
214
|
+
spec = node.spec
|
|
215
|
+
|
|
216
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
217
|
+
operands = [cfn(Xk, extras, mask, base_offset) for cfn in child_fns]
|
|
218
|
+
return jnp.einsum(spec, *operands)
|
|
219
|
+
|
|
220
|
+
return _eval
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# Stack
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
if isinstance(node, _StackOp):
|
|
227
|
+
child_fns = [compile_eval(c, o2i, sectors, ndim, key_dx) for c in node.children]
|
|
228
|
+
|
|
229
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
230
|
+
return jnp.stack(
|
|
231
|
+
[cfn(Xk, extras, mask, base_offset) for cfn in child_fns],
|
|
232
|
+
axis=0,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return _eval
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Concat
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
if isinstance(node, _ConcatOp):
|
|
242
|
+
child_fns = [compile_eval(c, o2i, sectors, ndim, key_dx) for c in node.children]
|
|
243
|
+
|
|
244
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
245
|
+
return jnp.concatenate(
|
|
246
|
+
[cfn(Xk, extras, mask, base_offset) for cfn in child_fns],
|
|
247
|
+
axis=0,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return _eval
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# Slice
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
if isinstance(node, _SliceOp):
|
|
257
|
+
child_fn = compile_eval(node.child, o2i, sectors, ndim, key_dx)
|
|
258
|
+
idx = node.idx
|
|
259
|
+
|
|
260
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
261
|
+
v = child_fn(Xk, extras, mask, base_offset)
|
|
262
|
+
return v[..., idx]
|
|
263
|
+
|
|
264
|
+
return _eval
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# DiffOpNode — THE KEY CASE: FD formula with shifted base_offset
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
if isinstance(node, _DiffOpNode):
|
|
271
|
+
op_name = node.op_name
|
|
272
|
+
child_fn = compile_eval(node.child, o2i, sectors, ndim, key_dx)
|
|
273
|
+
|
|
274
|
+
if op_name == "lap":
|
|
275
|
+
return _compile_lap(child_fn, ndim, key_dx)
|
|
276
|
+
elif op_name == "grad":
|
|
277
|
+
return _compile_grad(child_fn, ndim, key_dx)
|
|
278
|
+
elif op_name == "div":
|
|
279
|
+
return _compile_div(child_fn, ndim, key_dx)
|
|
280
|
+
elif op_name == "biharmonic":
|
|
281
|
+
return _compile_biharmonic(child_fn, ndim, key_dx)
|
|
282
|
+
else:
|
|
283
|
+
raise ValueError(f"Unknown diff op in eval compiler: {op_name!r}")
|
|
284
|
+
|
|
285
|
+
raise NotImplementedError(f"compile_eval: unsupported node type {type(node).__name__}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# =====================================================================
|
|
289
|
+
# FD formula implementations (all operate on base_offset, trace-time)
|
|
290
|
+
# =====================================================================
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _compile_lap(child_fn: Callable, ndim: int, key_dx: str) -> Callable:
|
|
294
|
+
"""Laplacian: Σ_a (f(+e_a) + f(−e_a) − 2·f(0)) / dx_a²."""
|
|
295
|
+
|
|
296
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
297
|
+
dx = jnp.asarray(extras[key_dx])
|
|
298
|
+
dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
|
|
299
|
+
inv_dx2 = 1.0 / (dx * dx)
|
|
300
|
+
|
|
301
|
+
f0 = child_fn(Xk, extras, mask, base_offset)
|
|
302
|
+
result = jnp.zeros_like(f0)
|
|
303
|
+
|
|
304
|
+
for a in range(ndim):
|
|
305
|
+
fp = child_fn(Xk, extras, mask, _shift(base_offset, a, +1))
|
|
306
|
+
fm = child_fn(Xk, extras, mask, _shift(base_offset, a, -1))
|
|
307
|
+
result = result + inv_dx2[a] * (fp + fm - 2.0 * f0)
|
|
308
|
+
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
return _eval
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _compile_grad(child_fn: Callable, ndim: int, key_dx: str) -> Callable:
|
|
315
|
+
"""Gradient: (f(+e_a) − f(−e_a)) / (2·dx_a) for each axis a.
|
|
316
|
+
|
|
317
|
+
Appends an ``(ndim,)`` trailing axis.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
321
|
+
dx = jnp.asarray(extras[key_dx])
|
|
322
|
+
dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
|
|
323
|
+
inv_2dx = 1.0 / (2.0 * dx)
|
|
324
|
+
|
|
325
|
+
components = []
|
|
326
|
+
for a in range(ndim):
|
|
327
|
+
fp = child_fn(Xk, extras, mask, _shift(base_offset, a, +1))
|
|
328
|
+
fm = child_fn(Xk, extras, mask, _shift(base_offset, a, -1))
|
|
329
|
+
components.append((fp - fm) * inv_2dx[a])
|
|
330
|
+
|
|
331
|
+
# Stack along a new trailing axis
|
|
332
|
+
# Each component has shape (*child_sdims), result: (*child_sdims, ndim)
|
|
333
|
+
return jnp.stack(components, axis=-1)
|
|
334
|
+
|
|
335
|
+
return _eval
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _compile_div(child_fn: Callable, ndim: int, key_dx: str) -> Callable:
|
|
339
|
+
"""Divergence: Σ_a ∂_a f[…, a]. Contracts last axis (must be ndim)."""
|
|
340
|
+
|
|
341
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
342
|
+
dx = jnp.asarray(extras[key_dx])
|
|
343
|
+
dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
|
|
344
|
+
inv_2dx = 1.0 / (2.0 * dx)
|
|
345
|
+
|
|
346
|
+
# Evaluate at center to get shape
|
|
347
|
+
f0 = child_fn(Xk, extras, mask, base_offset)
|
|
348
|
+
# f0 has shape (*prefix, ndim) — we contract the last axis
|
|
349
|
+
result = jnp.zeros(f0.shape[:-1], dtype=f0.dtype)
|
|
350
|
+
|
|
351
|
+
for a in range(ndim):
|
|
352
|
+
fp = child_fn(Xk, extras, mask, _shift(base_offset, a, +1))
|
|
353
|
+
fm = child_fn(Xk, extras, mask, _shift(base_offset, a, -1))
|
|
354
|
+
# Select the a-th component of the last axis
|
|
355
|
+
diff_a = fp[..., a] - fm[..., a]
|
|
356
|
+
result = result + diff_a * inv_2dx[a]
|
|
357
|
+
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
return _eval
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _compile_biharmonic(child_fn: Callable, ndim: int, key_dx: str) -> Callable:
|
|
364
|
+
"""Biharmonic ∇⁴ using the dedicated stencil.
|
|
365
|
+
|
|
366
|
+
Uses the direct 4th-order formula rather than composing lap∘lap
|
|
367
|
+
(which would give the same mathematical result but on a wider
|
|
368
|
+
stencil if other operators are also present in the tree).
|
|
369
|
+
"""
|
|
370
|
+
import itertools as _it
|
|
371
|
+
|
|
372
|
+
diag_pairs = list(_it.combinations(range(ndim), 2))
|
|
373
|
+
|
|
374
|
+
def _eval(Xk, extras, mask, base_offset):
|
|
375
|
+
dx = jnp.asarray(extras[key_dx])
|
|
376
|
+
dx = dx if dx.ndim == 1 else jnp.full((ndim,), dx)
|
|
377
|
+
inv_dx2 = 1.0 / (dx * dx)
|
|
378
|
+
|
|
379
|
+
f0 = child_fn(Xk, extras, mask, base_offset)
|
|
380
|
+
result = jnp.zeros_like(f0)
|
|
381
|
+
|
|
382
|
+
# Pure 4th derivatives along each axis:
|
|
383
|
+
# (f(+2e) - 4f(+e) + 6f(0) - 4f(-e) + f(-2e)) / dx^4
|
|
384
|
+
for a in range(ndim):
|
|
385
|
+
h4_inv = inv_dx2[a] * inv_dx2[a]
|
|
386
|
+
f_2p = child_fn(Xk, extras, mask, _shift(_shift(base_offset, a, +1), a, +1))
|
|
387
|
+
f_p = child_fn(Xk, extras, mask, _shift(base_offset, a, +1))
|
|
388
|
+
f_m = child_fn(Xk, extras, mask, _shift(base_offset, a, -1))
|
|
389
|
+
f_2m = child_fn(Xk, extras, mask, _shift(_shift(base_offset, a, -1), a, -1))
|
|
390
|
+
result = result + h4_inv * (f_2p - 4.0 * f_p + 6.0 * f0 - 4.0 * f_m + f_2m)
|
|
391
|
+
|
|
392
|
+
# Mixed 4th derivatives: 2 * d^4f/(dx_a^2 dx_b^2) for each pair
|
|
393
|
+
for a, b in diag_pairs:
|
|
394
|
+
hab_inv = inv_dx2[a] * inv_dx2[b]
|
|
395
|
+
|
|
396
|
+
# f(+e_a+e_b), f(+e_a-e_b), f(-e_a+e_b), f(-e_a-e_b)
|
|
397
|
+
f_pp = child_fn(Xk, extras, mask, _shift(_shift(base_offset, a, +1), b, +1))
|
|
398
|
+
f_pm = child_fn(Xk, extras, mask, _shift(_shift(base_offset, a, +1), b, -1))
|
|
399
|
+
f_mp = child_fn(Xk, extras, mask, _shift(_shift(base_offset, a, -1), b, +1))
|
|
400
|
+
f_mm = child_fn(Xk, extras, mask, _shift(_shift(base_offset, a, -1), b, -1))
|
|
401
|
+
|
|
402
|
+
# f(+e_a), f(-e_a), f(+e_b), f(-e_b)
|
|
403
|
+
f_pa = child_fn(Xk, extras, mask, _shift(base_offset, a, +1))
|
|
404
|
+
f_ma = child_fn(Xk, extras, mask, _shift(base_offset, a, -1))
|
|
405
|
+
f_pb = child_fn(Xk, extras, mask, _shift(base_offset, b, +1))
|
|
406
|
+
f_mb = child_fn(Xk, extras, mask, _shift(base_offset, b, -1))
|
|
407
|
+
|
|
408
|
+
mixed = f_pp + f_pm + f_mp + f_mm - 2.0 * (f_pa + f_ma + f_pb + f_mb) + 4.0 * f0
|
|
409
|
+
result = result + 2.0 * hab_inv * mixed
|
|
410
|
+
|
|
411
|
+
return result
|
|
412
|
+
|
|
413
|
+
return _eval
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# =====================================================================
|
|
417
|
+
# Top-level wrapper: eval_fn → stencil local_fn
|
|
418
|
+
# =====================================================================
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def wrap_as_local_fn(
|
|
422
|
+
eval_fn: Callable,
|
|
423
|
+
ndim: int,
|
|
424
|
+
o2i: dict[tuple[int, ...], int],
|
|
425
|
+
) -> Callable:
|
|
426
|
+
"""Wrap a compiled eval function as a stencil ``local_fn``.
|
|
427
|
+
|
|
428
|
+
The returned function has signature
|
|
429
|
+
``local_fn(Xk, *, mask, extras) -> (n_flat_out,)``
|
|
430
|
+
and evaluates the full tree at ``base_offset = (0,) * ndim``.
|
|
431
|
+
|
|
432
|
+
Mask handling: for noflux BCs, out-of-bounds neighbours are
|
|
433
|
+
replaced by the center value *before* the eval function sees them.
|
|
434
|
+
This is done by pre-processing ``Xk`` using the mask.
|
|
435
|
+
"""
|
|
436
|
+
origin = (0,) * ndim
|
|
437
|
+
center_idx = o2i[origin]
|
|
438
|
+
|
|
439
|
+
def local_fn(Xk, *, mask=None, extras=None):
|
|
440
|
+
# Pre-apply mask: replace masked-out rows with center row
|
|
441
|
+
if mask is not None:
|
|
442
|
+
x0 = Xk[center_idx]
|
|
443
|
+
Xk = jnp.where(mask[:, None], Xk, x0[None, :])
|
|
444
|
+
|
|
445
|
+
result = eval_fn(Xk, extras, mask, origin)
|
|
446
|
+
|
|
447
|
+
# Final mask: zero out if center itself is masked
|
|
448
|
+
if mask is not None:
|
|
449
|
+
result = result * mask[center_idx]
|
|
450
|
+
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
return local_fn
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Finite-difference offset geometry for stencil composition.
|
|
2
|
+
|
|
3
|
+
This module provides pure-Python (no JAX) utilities to compute the
|
|
4
|
+
**footprint** of an arbitrary StructuredExpr tree: the union of all
|
|
5
|
+
grid offsets that the compiled local function will need to read.
|
|
6
|
+
|
|
7
|
+
The key idea: each differential operator (``lap``, ``grad``, ``div``, …)
|
|
8
|
+
has a *template offset set* (the stencil it reads relative to its
|
|
9
|
+
evaluation point). Nesting operators produces a **Minkowski sum** of
|
|
10
|
+
their template offsets — exactly the set of points needed by the fused
|
|
11
|
+
stencil.
|
|
12
|
+
|
|
13
|
+
This module is layout-engine–agnostic: it only understands the IR node
|
|
14
|
+
types from :mod:`~SFI.statefunc.structexpr`.
|
|
15
|
+
|
|
16
|
+
.. note::
|
|
17
|
+
|
|
18
|
+
A future PINN layout would *not* use this module at all — it would
|
|
19
|
+
compile ``_DiffOpNode`` via autodiff instead of FD stencils.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import itertools
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from ..structexpr import _StructNode
|
|
31
|
+
|
|
32
|
+
# =====================================================================
|
|
33
|
+
# Offset sets (frozenset of int tuples)
|
|
34
|
+
# =====================================================================
|
|
35
|
+
|
|
36
|
+
OffsetSet = frozenset[tuple[int, ...]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _origin(ndim: int) -> OffsetSet:
|
|
40
|
+
"""Single-point footprint at the grid-site origin."""
|
|
41
|
+
return frozenset({(0,) * ndim})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cross_offsets(ndim: int) -> OffsetSet:
|
|
45
|
+
"""Cross stencil: center + ±e_a for all axes. K = 1 + 2·ndim."""
|
|
46
|
+
pts: list[tuple[int, ...]] = [(0,) * ndim]
|
|
47
|
+
for a in range(ndim):
|
|
48
|
+
e = [0] * ndim
|
|
49
|
+
e[a] = 1
|
|
50
|
+
pts.append(tuple(e))
|
|
51
|
+
e[a] = -1
|
|
52
|
+
pts.append(tuple(e))
|
|
53
|
+
return frozenset(pts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def biharmonic_offsets(ndim: int) -> OffsetSet:
|
|
57
|
+
"""Biharmonic stencil: radius-2 + cross-1 + diagonal corners.
|
|
58
|
+
|
|
59
|
+
Matches the ordering contract of
|
|
60
|
+
:func:`~SFI.statefunc.nodes.interactions.stencils.square_biharmonic_offsets`
|
|
61
|
+
but returned as *set* (ordering doesn't matter for footprint computation).
|
|
62
|
+
"""
|
|
63
|
+
pts: list[tuple[int, ...]] = [(0,) * ndim]
|
|
64
|
+
# radius-2 along each axis
|
|
65
|
+
for a in range(ndim):
|
|
66
|
+
e = [0] * ndim
|
|
67
|
+
e[a] = 2
|
|
68
|
+
pts.append(tuple(e))
|
|
69
|
+
e[a] = -2
|
|
70
|
+
pts.append(tuple(e))
|
|
71
|
+
# radius-1 cross
|
|
72
|
+
for a in range(ndim):
|
|
73
|
+
e = [0] * ndim
|
|
74
|
+
e[a] = 1
|
|
75
|
+
pts.append(tuple(e))
|
|
76
|
+
e[a] = -1
|
|
77
|
+
pts.append(tuple(e))
|
|
78
|
+
# diagonal corners
|
|
79
|
+
for pair in itertools.combinations(range(ndim), 2):
|
|
80
|
+
for signs in itertools.product([1, -1], repeat=2):
|
|
81
|
+
e = [0] * ndim
|
|
82
|
+
for ax, s in zip(pair, signs):
|
|
83
|
+
e[ax] = s
|
|
84
|
+
pts.append(tuple(e))
|
|
85
|
+
return frozenset(pts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def op_template_offsets(op_name: str, ndim: int) -> OffsetSet:
|
|
89
|
+
"""Template offset set for a named differential operator."""
|
|
90
|
+
if op_name in ("lap", "grad", "div"):
|
|
91
|
+
return cross_offsets(ndim)
|
|
92
|
+
elif op_name == "biharmonic":
|
|
93
|
+
return biharmonic_offsets(ndim)
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError(f"Unknown diff op: {op_name!r}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# =====================================================================
|
|
99
|
+
# Minkowski sum
|
|
100
|
+
# =====================================================================
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def minkowski_sum(a: OffsetSet, b: OffsetSet) -> OffsetSet:
|
|
104
|
+
"""Minkowski sum of two offset sets.
|
|
105
|
+
|
|
106
|
+
Returns ``{oa + ob for oa in a for ob in b}`` (element-wise
|
|
107
|
+
addition of tuples, deduplicating).
|
|
108
|
+
"""
|
|
109
|
+
result: set[tuple[int, ...]] = set()
|
|
110
|
+
for oa in a:
|
|
111
|
+
for ob in b:
|
|
112
|
+
result.add(tuple(ia + ib for ia, ib in zip(oa, ob)))
|
|
113
|
+
return frozenset(result)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# =====================================================================
|
|
117
|
+
# Footprint computation (bottom-up tree walk)
|
|
118
|
+
# =====================================================================
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def compute_footprint(node: _StructNode, ndim: int) -> OffsetSet:
|
|
122
|
+
"""Compute the set of grid offsets read by *node*.
|
|
123
|
+
|
|
124
|
+
A pure-pointwise node returns ``{origin}`` (reads only the local
|
|
125
|
+
site). Each ``_DiffOpNode`` inflates the footprint by Minkowski-
|
|
126
|
+
summing its template offsets with the child's footprint.
|
|
127
|
+
|
|
128
|
+
Binary / einsum / stack nodes return the union of their children's
|
|
129
|
+
footprints (all children are evaluated at the same base offset).
|
|
130
|
+
"""
|
|
131
|
+
from ..structexpr import (
|
|
132
|
+
_BinaryOp,
|
|
133
|
+
_ConcatOp,
|
|
134
|
+
_ConstNode,
|
|
135
|
+
_DiffOpNode,
|
|
136
|
+
_EinsumOp,
|
|
137
|
+
_SectorLeaf,
|
|
138
|
+
_SliceOp,
|
|
139
|
+
_StackOp,
|
|
140
|
+
_UnaryOp,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
origin = _origin(ndim)
|
|
144
|
+
|
|
145
|
+
if isinstance(node, (_SectorLeaf, _ConstNode)):
|
|
146
|
+
return origin
|
|
147
|
+
|
|
148
|
+
if isinstance(node, _BinaryOp):
|
|
149
|
+
return compute_footprint(node.left, ndim) | compute_footprint(node.right, ndim)
|
|
150
|
+
|
|
151
|
+
if isinstance(node, _UnaryOp):
|
|
152
|
+
return compute_footprint(node.child, ndim)
|
|
153
|
+
|
|
154
|
+
if isinstance(node, (_EinsumOp, _StackOp, _ConcatOp)):
|
|
155
|
+
fp = origin
|
|
156
|
+
for c in node.children:
|
|
157
|
+
fp = fp | compute_footprint(c, ndim)
|
|
158
|
+
return fp
|
|
159
|
+
|
|
160
|
+
if isinstance(node, _SliceOp):
|
|
161
|
+
return compute_footprint(node.child, ndim)
|
|
162
|
+
|
|
163
|
+
if isinstance(node, _DiffOpNode):
|
|
164
|
+
child_fp = compute_footprint(node.child, ndim)
|
|
165
|
+
template = op_template_offsets(node.op_name, ndim)
|
|
166
|
+
return minkowski_sum(template, child_fp)
|
|
167
|
+
|
|
168
|
+
# Fallback: unknown node → origin (conservative)
|
|
169
|
+
return origin
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# =====================================================================
|
|
173
|
+
# Sorted offset array (for dispatch)
|
|
174
|
+
# =====================================================================
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def offsets_to_sorted_array(offsets: OffsetSet) -> np.ndarray:
|
|
178
|
+
"""Convert offset set to a deterministic ``(K, ndim)`` int32 array.
|
|
179
|
+
|
|
180
|
+
The origin ``(0, …, 0)`` is always placed first (index 0).
|
|
181
|
+
Remaining offsets are in lexicographic order. This convention is
|
|
182
|
+
important: the mask pre-processing in ``wrap_as_local_fn`` expects
|
|
183
|
+
the center at index 0.
|
|
184
|
+
"""
|
|
185
|
+
if not offsets:
|
|
186
|
+
raise ValueError("Empty offset set")
|
|
187
|
+
ndim = len(next(iter(offsets)))
|
|
188
|
+
origin = (0,) * ndim
|
|
189
|
+
rest = sorted(o for o in offsets if o != origin)
|
|
190
|
+
sorted_list = [origin] + rest if origin in offsets else rest
|
|
191
|
+
return np.array(sorted_list, dtype=np.int32)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_offset_to_idx(offsets: np.ndarray) -> dict[tuple[int, ...], int]:
|
|
195
|
+
"""Build a ``{tuple(offset): stencil_index}`` lookup dict."""
|
|
196
|
+
return {tuple(int(x) for x in offsets[i]): i for i in range(len(offsets))}
|