StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -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))}