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,878 @@
1
+ """GridLayout — structured-dimensions layout for SPDE systems.
2
+
3
+ Provides :class:`GridLayout`, a layout for regular-grid SPDE systems that
4
+ carries ``ndim`` (spatial dimension) and stencil parameters (``bc``, ``order``).
5
+ Differential operators are exposed as methods (``grad``, ``lap``, ``div``,
6
+ ``biharmonic``, etc.). The :meth:`~GridLayout.embed` method compiles
7
+ inner-world :class:`~SFI.statefunc.structexpr.StructuredExpr` trees into
8
+ outer-world :class:`~SFI.statefunc.stateexpr.StateExpr` objects.
9
+
10
+ Generic stencil composition
11
+ ---------------------------
12
+ Any expression tree — including nested differential operators like
13
+ ``layout.div(Q * layout.grad(phi))`` — compiles to a *single* fat-stencil
14
+ gather + locally-unrolled evaluation function. The stencil footprint is
15
+ computed via Minkowski sums of per-operator template offsets (see
16
+ :mod:`._fd_atoms`), and the evaluation function is recursively compiled
17
+ with trace-time offset resolution (see :mod:`._eval_compiler`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import Any, Callable, Literal
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+
28
+ from ..nodes.contract import Rank
29
+ from ..structexpr import (
30
+ StructuredExpr,
31
+ _BinaryOp,
32
+ _ConcatOp,
33
+ _ConstNode,
34
+ _DiffOpNode,
35
+ _EinsumOp,
36
+ _SectorLeaf,
37
+ _SliceOp,
38
+ _StackOp,
39
+ _StructNode,
40
+ _UnaryOp,
41
+ )
42
+ from ._base import _BaseLayout
43
+ from ._sectors import (
44
+ ScalarSector,
45
+ Sector,
46
+ SymTensorSector,
47
+ TensorSector,
48
+ VectorSector,
49
+ )
50
+
51
+ # =====================================================================
52
+ # StencilMeta — opaque metadata attached to _DiffOpNode
53
+ # =====================================================================
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class StencilMeta:
58
+ """Metadata for a finite-difference differential operator node.
59
+
60
+ Stored on ``_DiffOpNode.engine_meta`` and consumed by the embed
61
+ compiler to wire stencil dispatch.
62
+ """
63
+
64
+ op_name: str
65
+ ndim: int
66
+ bc: str
67
+ order: str
68
+ key_grid_shape: str
69
+ key_dx: str
70
+
71
+
72
+ # =====================================================================
73
+ # Voigt helpers
74
+ # =====================================================================
75
+
76
+
77
+ def _voigt_expand(raw: jax.Array, sector: SymTensorSector) -> jax.Array:
78
+ """Expand Voigt-packed ``(n_data,)`` to full ``(sdim, sdim)`` tensor.
79
+
80
+ For traceless sectors, the last diagonal is reconstructed as
81
+ ``-sum(other diagonals)``.
82
+ """
83
+ sdim = sector.sdim
84
+ pairs = sector.voigt_pairs
85
+
86
+ # Build full symmetric matrix
87
+ mat = jnp.zeros((sdim, sdim), dtype=raw.dtype)
88
+ for k, (i, j) in enumerate(pairs):
89
+ mat = mat.at[i, j].set(raw[k])
90
+ if i != j:
91
+ mat = mat.at[j, i].set(raw[k])
92
+
93
+ if sector.traceless:
94
+ # Reconstruct last diagonal: -(sum of other diags)
95
+ diag_sum = jnp.zeros((), dtype=raw.dtype)
96
+ for d in range(sdim - 1):
97
+ diag_sum = diag_sum + mat[d, d]
98
+ mat = mat.at[sdim - 1, sdim - 1].set(-diag_sum)
99
+
100
+ return mat
101
+
102
+
103
+ def _voigt_pack(mat: jax.Array, sector: SymTensorSector) -> jax.Array:
104
+ """Extract independent Voigt components from ``(sdim, sdim)`` tensor."""
105
+ return jnp.array([mat[i, j] for i, j in sector.voigt_pairs])
106
+
107
+
108
+ def _sector_pack(val: jax.Array, sector: Sector) -> jax.Array:
109
+ """Pack a structured value to flat ``(n_data,)`` for a sector.
110
+
111
+ Handles Voigt packing for SymTensorSector, flat reshape for others.
112
+ """
113
+ if isinstance(sector, SymTensorSector):
114
+ return _voigt_pack(val, sector)
115
+ elif isinstance(sector, ScalarSector):
116
+ return val.reshape(1)
117
+ else:
118
+ # VectorSector, TensorSector: flat reshape
119
+ return val.reshape(-1)
120
+
121
+
122
+ def _sector_expand(raw: jax.Array, sector: Sector) -> jax.Array:
123
+ """Expand flat ``(n_data,)`` to structured ``(*sdims)``."""
124
+ if isinstance(sector, SymTensorSector):
125
+ return _voigt_expand(raw, sector)
126
+ elif isinstance(sector, ScalarSector):
127
+ return raw[0]
128
+ elif isinstance(sector, VectorSector):
129
+ return raw
130
+ elif isinstance(sector, TensorSector):
131
+ return raw.reshape(sector.sdims)
132
+ else:
133
+ raise TypeError(f"Unknown sector type: {type(sector)}")
134
+
135
+
136
+ # =====================================================================
137
+ # Pointwise tree compiler
138
+ # =====================================================================
139
+
140
+
141
+ def _compile_pointwise_fn(
142
+ node: _StructNode,
143
+ sectors: dict[str, Sector],
144
+ ) -> Callable:
145
+ """Compile a pointwise _StructNode tree to ``fn(x) -> (*sdims) array``.
146
+
147
+ The resulting function takes a single state vector ``x: (dim,)`` and
148
+ returns the structured value with shape ``(*sdims)``.
149
+
150
+ Only used for *pure-pointwise* paths (no ``_DiffOpNode`` in tree).
151
+ Trees with stencil nodes go through :func:`._eval_compiler.compile_eval`.
152
+ """
153
+ if isinstance(node, _SectorLeaf):
154
+ indices = jnp.array(node.indices)
155
+ sector = sectors[node.sector_name]
156
+ if isinstance(sector, SymTensorSector):
157
+
158
+ def _fn(x):
159
+ return _voigt_expand(x[indices], sector)
160
+
161
+ return _fn
162
+ elif isinstance(sector, ScalarSector):
163
+ idx0 = node.indices[0]
164
+
165
+ def _fn(x):
166
+ return x[idx0]
167
+
168
+ return _fn
169
+ else:
170
+ # VectorSector or TensorSector
171
+ def _fn(x):
172
+ raw = x[indices]
173
+ if node.sdims:
174
+ return raw.reshape(node.sdims)
175
+ return raw
176
+
177
+ return _fn
178
+
179
+ if isinstance(node, _ConstNode):
180
+ val = node.value
181
+ if isinstance(val, (int, float)):
182
+ arr = jnp.array(val, dtype=jnp.float32)
183
+
184
+ def _fn(x):
185
+ return arr.astype(x.dtype)
186
+
187
+ return _fn
188
+ else:
189
+ arr = jnp.asarray(val)
190
+
191
+ def _fn(x):
192
+ return arr
193
+
194
+ return _fn
195
+
196
+ if isinstance(node, _BinaryOp):
197
+ left_fn = _compile_pointwise_fn(node.left, sectors)
198
+ right_fn = _compile_pointwise_fn(node.right, sectors)
199
+ op_map = {
200
+ "+": jnp.add,
201
+ "-": jnp.subtract,
202
+ "*": jnp.multiply,
203
+ "/": jnp.true_divide,
204
+ "**": jnp.power,
205
+ }
206
+ op_fn = op_map[node.op]
207
+
208
+ def _fn(x):
209
+ return op_fn(left_fn(x), right_fn(x))
210
+
211
+ return _fn
212
+
213
+ if isinstance(node, _EinsumOp):
214
+ child_fns = [_compile_pointwise_fn(c, sectors) for c in node.children]
215
+ spec = node.spec
216
+
217
+ def _fn(x):
218
+ operands = [cfn(x) for cfn in child_fns]
219
+ return jnp.einsum(spec, *operands)
220
+
221
+ return _fn
222
+
223
+ if isinstance(node, _UnaryOp):
224
+ child_fn = _compile_pointwise_fn(node.child, sectors)
225
+ if node.op == "neg":
226
+
227
+ def _fn(x):
228
+ return -child_fn(x)
229
+
230
+ return _fn
231
+ elif node.op == "T":
232
+
233
+ def _fn(x):
234
+ v = child_fn(x)
235
+ if v.ndim >= 2:
236
+ return jnp.swapaxes(v, -2, -1)
237
+ return v
238
+
239
+ return _fn
240
+ elif node.op == "ew":
241
+ ew = node.fn
242
+ assert ew is not None
243
+
244
+ def _fn(x):
245
+ return ew(child_fn(x))
246
+
247
+ return _fn
248
+ else:
249
+ raise ValueError(f"Unknown unary op: {node.op!r}")
250
+
251
+ if isinstance(node, _StackOp):
252
+ child_fns = [_compile_pointwise_fn(c, sectors) for c in node.children]
253
+
254
+ def _fn(x):
255
+ return jnp.stack([cfn(x) for cfn in child_fns], axis=0)
256
+
257
+ return _fn
258
+
259
+ if isinstance(node, _SliceOp):
260
+ child_fn = _compile_pointwise_fn(node.child, sectors)
261
+ idx = node.idx
262
+
263
+ def _fn(x):
264
+ v = child_fn(x)
265
+ return v[..., idx]
266
+
267
+ return _fn
268
+
269
+ raise NotImplementedError(f"Cannot compile {type(node).__name__} as pointwise.")
270
+
271
+
272
+ # =====================================================================
273
+ # GridLayout
274
+ # =====================================================================
275
+
276
+
277
+ class GridLayout(_BaseLayout):
278
+ """Layout for SPDE systems on regular Cartesian grids.
279
+
280
+ Provides differential operator methods and an ``embed()`` compiler
281
+ that turns inner-world :class:`~SFI.statefunc.structexpr.StructuredExpr`
282
+ trees into outer-world ``Basis`` / ``PSF`` objects.
283
+
284
+ Parameters
285
+ ----------
286
+ ndim : int
287
+ Number of spatial dimensions.
288
+ bc : {``"pbc"``, ``"noflux"``}
289
+ Boundary condition for stencil operators.
290
+ order : str
291
+ Grid flattening order (``"C"`` default).
292
+ key_grid_shape : str
293
+ Extras key for the grid shape array.
294
+ key_dx : str
295
+ Extras key for the grid spacing.
296
+ **sectors : Sector
297
+ Named sectors (keyword arguments).
298
+
299
+ Examples
300
+ --------
301
+ >>> layout = GridLayout(
302
+ ... velocity=VectorSector([0, 1], sdim=2, spatial=True),
303
+ ... Q=SymTensorSector([2, 3], sdim=2, traceless=True),
304
+ ... dim=4, ndim=2, bc="pbc",
305
+ ... )
306
+ >>> v = layout.velocity
307
+ >>> Q = layout.Q
308
+ >>> force = layout.embed(rank=1,
309
+ ... velocity=layout.lap(v),
310
+ ... Q=layout.lap(Q),
311
+ ... )
312
+ """
313
+
314
+ def __init__(
315
+ self,
316
+ *,
317
+ dim: int,
318
+ ndim: int,
319
+ bc: Literal["pbc", "noflux"] = "pbc",
320
+ order: Literal["C", "F"] = "C",
321
+ key_grid_shape: str = "box/grid_shape",
322
+ key_dx: str = "box/dx",
323
+ **sectors: Sector,
324
+ ) -> None:
325
+ self._ndim = int(ndim)
326
+ self._bc: Literal["pbc", "noflux"] = bc
327
+ self._order: Literal["C", "F"] = order
328
+ self._key_grid_shape = key_grid_shape
329
+ self._key_dx = key_dx
330
+ super().__init__(dim=dim, **sectors)
331
+ self._validate_spatial_sectors()
332
+
333
+ # --- validation ---------------------------------------------------
334
+
335
+ def _validate_spatial_sectors(self) -> None:
336
+ for name, sector in self._sectors.items():
337
+ if isinstance(sector, VectorSector) and sector.spatial:
338
+ if sector.sdim != self._ndim:
339
+ raise ValueError(f"Sector '{name}' is spatial but sdim={sector.sdim} != ndim={self._ndim}")
340
+
341
+ # --- properties ---------------------------------------------------
342
+
343
+ @property
344
+ def ndim(self) -> int:
345
+ """Number of spatial dimensions."""
346
+ return self._ndim
347
+
348
+ @property
349
+ def bc(self) -> Literal["pbc", "noflux"]:
350
+ """Boundary condition."""
351
+ return self._bc
352
+
353
+ # --- private: create StencilMeta ----------------------------------
354
+
355
+ def _make_meta(self, op_name: str) -> StencilMeta:
356
+ return StencilMeta(
357
+ op_name=op_name,
358
+ ndim=self._ndim,
359
+ bc=self._bc,
360
+ order=self._order,
361
+ key_grid_shape=self._key_grid_shape,
362
+ key_dx=self._key_dx,
363
+ )
364
+
365
+ # --- private: validate expression belongs to this layout ----------
366
+
367
+ def _check_layout(self, expr: StructuredExpr, method: str) -> None:
368
+ if expr._layout_id != self._layout_id:
369
+ from ..structexpr import _FREE_LAYOUT
370
+
371
+ if expr._layout_id != _FREE_LAYOUT:
372
+ raise TypeError(
373
+ f"{method}: expression belongs to a different layout (id {expr._layout_id} vs {self._layout_id})"
374
+ )
375
+
376
+ # ==================================================================
377
+ # Differential operator methods
378
+ # ==================================================================
379
+
380
+ # ------------------------------------------------------------------
381
+ # Internal label helper
382
+ # ------------------------------------------------------------------
383
+
384
+ @staticmethod
385
+ def _op_label(prefix: str, expr: StructuredExpr, suffix: str = "") -> tuple:
386
+ """Return a single-element label tuple for a unary differential op.
387
+
388
+ Uses the operand's existing single label if available; otherwise
389
+ returns an empty tuple so the fallback in ``embed`` is used.
390
+ """
391
+ if expr.labels and len(expr.labels) == 1:
392
+ return (f"{prefix}{expr.labels[0]}{suffix}",)
393
+ return ()
394
+
395
+ def grad(self, expr: StructuredExpr) -> StructuredExpr:
396
+ """Spatial gradient. Adds an ``(ndim,)`` trailing axis.
397
+
398
+ Input sdims ``S`` → output sdims ``S + (ndim,)``.
399
+ """
400
+ self._check_layout(expr, "grad")
401
+ out_sdims = expr.sdims + (self._ndim,)
402
+ return expr._new(
403
+ sdims=out_sdims,
404
+ n_features=expr.n_features,
405
+ labels=self._op_label("∇", expr),
406
+ node=_DiffOpNode(
407
+ op_name="grad",
408
+ child=expr._node,
409
+ engine_meta=self._make_meta("grad"),
410
+ ),
411
+ )
412
+
413
+ def lap(self, expr: StructuredExpr) -> StructuredExpr:
414
+ """Laplacian ∇². Rank-preserving (same sdims as input)."""
415
+ self._check_layout(expr, "lap")
416
+ return expr._new(
417
+ sdims=expr.sdims,
418
+ n_features=expr.n_features,
419
+ labels=self._op_label("∇²", expr),
420
+ node=_DiffOpNode(
421
+ op_name="lap",
422
+ child=expr._node,
423
+ engine_meta=self._make_meta("lap"),
424
+ ),
425
+ )
426
+
427
+ def div(self, expr: StructuredExpr) -> StructuredExpr:
428
+ """Divergence. Contracts the last axis (must be ``ndim``).
429
+
430
+ Input sdims ``(*S, ndim)`` → output sdims ``S``.
431
+ """
432
+ self._check_layout(expr, "div")
433
+ if not expr.sdims or expr.sdims[-1] != self._ndim:
434
+ raise ValueError(f"div requires last sdim == ndim={self._ndim}, got sdims={expr.sdims}")
435
+ out_sdims = expr.sdims[:-1]
436
+ return expr._new(
437
+ sdims=out_sdims,
438
+ n_features=expr.n_features,
439
+ labels=self._op_label("∇·", expr),
440
+ node=_DiffOpNode(
441
+ op_name="div",
442
+ child=expr._node,
443
+ engine_meta=self._make_meta("div"),
444
+ ),
445
+ )
446
+
447
+ def biharmonic(self, expr: StructuredExpr) -> StructuredExpr:
448
+ """Biharmonic ∇⁴. Rank-preserving. Requires ``ndim >= 2``."""
449
+ self._check_layout(expr, "biharmonic")
450
+ if self._ndim < 2:
451
+ raise ValueError("biharmonic requires ndim >= 2")
452
+ return expr._new(
453
+ sdims=expr.sdims,
454
+ n_features=expr.n_features,
455
+ labels=self._op_label("∇⁴", expr),
456
+ node=_DiffOpNode(
457
+ op_name="biharmonic",
458
+ child=expr._node,
459
+ engine_meta=self._make_meta("biharmonic"),
460
+ ),
461
+ )
462
+
463
+ def lap_of_grad_sq(self, expr: StructuredExpr) -> StructuredExpr:
464
+ r"""∇²(\|∇f\|²) — the Active Model B+ term.
465
+
466
+ Convenience sugar for ``self.lap(self.grad(expr).dot(self.grad(expr)))``.
467
+ Compiles to the biharmonic-radius stencil automatically via footprint
468
+ computation. Requires ``ndim >= 2``.
469
+ """
470
+ self._check_layout(expr, "lap_of_grad_sq")
471
+ if self._ndim < 2:
472
+ raise ValueError("lap_of_grad_sq requires ndim >= 2")
473
+ g = self.grad(expr)
474
+ return self.lap(g.dot(g))
475
+
476
+ # ------------------------------------------------------------------
477
+ # Convenience differential operator methods
478
+ # ------------------------------------------------------------------
479
+
480
+ def advection_by(
481
+ self,
482
+ velocity: StructuredExpr,
483
+ transported: StructuredExpr,
484
+ ) -> StructuredExpr:
485
+ r"""Advection :math:`(\mathbf{v}\cdot\nabla)\phi`.
486
+
487
+ *velocity* must have sdims ``(…, ndim)`` and *transported* is
488
+ arbitrary. Contracts the spatial gradient with the velocity
489
+ vector.
490
+ """
491
+ self._check_layout(velocity, "advection_by")
492
+ self._check_layout(transported, "advection_by")
493
+ v_lbl = velocity.labels[0] if len(velocity.labels) == 1 else "v"
494
+ f_lbl = transported.labels[0] if len(transported.labels) == 1 else "f"
495
+ g = self.grad(transported) # sdims: (*T, ndim)
496
+ # Contract the trailing ndim axis of grad with trailing ndim of v.
497
+ result = g.dot(velocity)
498
+ return result._new(
499
+ labels=(f"({v_lbl}·∇){f_lbl}",),
500
+ node=result._node,
501
+ )
502
+
503
+ def strain_rate(self, v_expr: StructuredExpr) -> StructuredExpr:
504
+ r"""Symmetric strain rate :math:`E = (\nabla v + (\nabla v)^T)/2`.
505
+
506
+ Input: vector ``(ndim,)``. Output: symmetric tensor ``(ndim, ndim)``.
507
+ """
508
+ self._check_layout(v_expr, "strain_rate")
509
+ v_lbl = v_expr.labels[0] if len(v_expr.labels) == 1 else "v"
510
+ gv = self.grad(v_expr) # (ndim, ndim)
511
+ result = (gv + gv.T) * 0.5
512
+ return result._new(labels=(f"E[{v_lbl}]",), node=result._node)
513
+
514
+ def vorticity(self, v_expr: StructuredExpr) -> StructuredExpr:
515
+ r"""Antisymmetric vorticity :math:`\Omega = (\nabla v - (\nabla v)^T)/2`.
516
+
517
+ Input: vector ``(ndim,)``. Output: antisymmetric tensor ``(ndim, ndim)``.
518
+ """
519
+ self._check_layout(v_expr, "vorticity")
520
+ gv = self.grad(v_expr) # (ndim, ndim)
521
+ return (gv - gv.T) * 0.5
522
+
523
+ # ==================================================================
524
+ # embed() — the compilation boundary
525
+ # ==================================================================
526
+
527
+ def embed(
528
+ self,
529
+ rank: int = 1,
530
+ **named_fields: StructuredExpr,
531
+ ) -> Any:
532
+ """Compile inner-world expressions into an outer-world Basis/PSF.
533
+
534
+ Parameters
535
+ ----------
536
+ rank : int
537
+ Output rank (1 for forces, 2 for diffusion matrices).
538
+ **named_fields
539
+ ``sector_name=expression`` pairs. Each expression's sdims
540
+ must match the corresponding sector's sdims.
541
+
542
+ Returns
543
+ -------
544
+ Basis
545
+ Outer-world expression ready for inference.
546
+ """
547
+ if rank not in (1, 2):
548
+ raise ValueError(f"Only rank=1 and rank=2 are supported, got {rank}")
549
+
550
+ # Validate sector names and sdims
551
+ for name, expr in named_fields.items():
552
+ if name not in self._sectors:
553
+ raise ValueError(f"Unknown sector '{name}'. Available: {list(self._sectors.keys())}")
554
+ sector = self._sectors[name]
555
+ if expr.sdims != sector.sdims:
556
+ raise ValueError(f"Sector '{name}' has sdims={sector.sdims}, but expression has sdims={expr.sdims}")
557
+
558
+ # Compile each sector
559
+ sector_bases = []
560
+ for name, expr in named_fields.items():
561
+ sector = self._sectors[name]
562
+ compiled = self._compile_sector(expr._node, name, sector, rank, labels=list(expr.labels))
563
+ sector_bases.append(compiled)
564
+
565
+ # Combine all sectors with feature concatenation
566
+ if not sector_bases:
567
+ raise ValueError("At least one sector expression is required")
568
+
569
+ result = sector_bases[0]
570
+ for sb in sector_bases[1:]:
571
+ result = result & sb
572
+ return result
573
+
574
+ # ------------------------------------------------------------------
575
+ # Sector compilation — footprint-based dispatch
576
+ # ------------------------------------------------------------------
577
+
578
+ def _compile_sector(
579
+ self,
580
+ node: _StructNode,
581
+ sector_name: str,
582
+ sector: Sector,
583
+ rank: int,
584
+ labels: list | None = None,
585
+ ) -> Any:
586
+ """Compile a single sector's _StructNode tree to outer StateExpr.
587
+
588
+ Uses footprint analysis: if the tree reads only the local site
589
+ (footprint = {origin}), compile as pure-pointwise. Otherwise
590
+ compile the *entire tree* — including nested stencil ops,
591
+ nonlinear algebra, etc. — as a single fat-stencil dispatch.
592
+
593
+ Parameters
594
+ ----------
595
+ labels : list[str] or None
596
+ Human-readable term labels propagated from the outer
597
+ ``StructuredExpr.labels``. One label per feature (i.e. per
598
+ term after ``&``-concatenation). ``None`` / empty list falls
599
+ back to ``"stencil(<sector_name>)"``.
600
+ """
601
+ from ._fd_atoms import _origin, compute_footprint
602
+
603
+ labels = labels or []
604
+
605
+ # Handle _ConcatOp at top level (feature concatenation)
606
+ if isinstance(node, _ConcatOp):
607
+ parts = []
608
+ for i, c in enumerate(node.children):
609
+ child_lbl = [labels[i]] if i < len(labels) else []
610
+ parts.append(self._compile_sector(c, sector_name, sector, rank, labels=child_lbl))
611
+ result = parts[0]
612
+ for p in parts[1:]:
613
+ result = result & p
614
+ return result
615
+
616
+ footprint = compute_footprint(node, self._ndim)
617
+ origin = _origin(self._ndim)
618
+
619
+ if footprint == origin:
620
+ # Pure pointwise — no stencil needed
621
+ return self._compile_pointwise_term(node, sector_name, sector, rank, labels=labels)
622
+ else:
623
+ # Stencil tree (may include nested diff ops + algebra)
624
+ return self._compile_stencil_tree(node, footprint, sector_name, sector, rank, labels=labels)
625
+
626
+ # ------------------------------------------------------------------
627
+ # Stencil tree compilation (generic, supports nesting)
628
+ # ------------------------------------------------------------------
629
+
630
+ def _compile_stencil_tree(
631
+ self,
632
+ node: _StructNode,
633
+ footprint,
634
+ sector_name: str,
635
+ sector: Sector,
636
+ rank: int,
637
+ labels: list | None = None,
638
+ ) -> Any:
639
+ """Compile an arbitrary tree with stencil nodes to outer Basis.
640
+
641
+ 1. Convert footprint → sorted offset array + o2i dict.
642
+ 2. Recursively compile the tree via ``_eval_compiler.compile_eval``.
643
+ 3. Wrap the eval function with sector pack + scatter.
644
+ 4. Dispatch via ``make_interactor`` + ``PreparedSquareStencilFromBox``.
645
+ """
646
+ from ..factory import make_interactor
647
+ from ..nodes.interactions.stencils import PreparedSquareStencilFromBox
648
+ from ._eval_compiler import compile_eval, wrap_as_local_fn
649
+ from ._fd_atoms import build_offset_to_idx, offsets_to_sorted_array
650
+
651
+ ndim = self._ndim
652
+ offsets_array = offsets_to_sorted_array(footprint)
653
+ o2i = build_offset_to_idx(offsets_array)
654
+
655
+ # Build the recursive eval function (trace-time offset resolution)
656
+ eval_fn = compile_eval(node, o2i, self._sectors, ndim, self._key_dx)
657
+
658
+ # Wrap: mask pre-processing + evaluation at origin
659
+ raw_local_fn = wrap_as_local_fn(eval_fn, ndim, o2i)
660
+
661
+ # Determine output sdims
662
+ out_sdims = _node_sdims(node, self._sectors)
663
+
664
+ # Wrap with sector pack + scatter to dim-wide output
665
+ local_fn = self._wrap_scatter(raw_local_fn, out_sdims, sector, rank)
666
+
667
+ K = int(offsets_array.shape[0])
668
+
669
+ # Determine stencil cache prefix from the footprint size
670
+ # (The prefix is only needed for the hyper-table cache key)
671
+ prefix = f"_cache/stencil/composed_{ndim}d_{self._bc}_K{K}"
672
+
673
+ labels = labels or []
674
+ label_str = labels[0] if labels else f"stencil({sector_name})"
675
+ inter = make_interactor(
676
+ local_fn,
677
+ dim=self._dim,
678
+ rank=Rank(rank),
679
+ K=K,
680
+ n_features=1,
681
+ labels=[label_str],
682
+ )
683
+
684
+ spec = PreparedSquareStencilFromBox(
685
+ offsets=jnp.asarray(offsets_array),
686
+ bc=self._bc,
687
+ order=self._order,
688
+ key_grid_shape=self._key_grid_shape,
689
+ key_prefix=prefix,
690
+ include_slot_extras_offset=True,
691
+ )
692
+
693
+ return inter.dispatch(
694
+ spec,
695
+ owners="focal",
696
+ focal_index=0,
697
+ reducer="sum",
698
+ return_as="basis",
699
+ )
700
+
701
+ # ------------------------------------------------------------------
702
+ # Pointwise term compilation
703
+ # ------------------------------------------------------------------
704
+
705
+ def _compile_pointwise_term(
706
+ self,
707
+ node: _StructNode,
708
+ sector_name: str,
709
+ sector: Sector,
710
+ rank: int,
711
+ labels: list | None = None,
712
+ ) -> Any:
713
+ """Compile a pure pointwise _StructNode to outer Basis."""
714
+ from ..factory import make_basis
715
+
716
+ labels = labels or []
717
+ label_str = labels[0] if labels else sector_name
718
+
719
+ pw_fn = _compile_pointwise_fn(node, self._sectors)
720
+ dim = self._dim
721
+ indices = jnp.array(sector.indices)
722
+
723
+ if rank == 1:
724
+
725
+ def fn(x):
726
+ val = pw_fn(x) # (*sdims)
727
+ packed = _sector_pack(val, sector) # (n_data,)
728
+ out = jnp.zeros(dim, dtype=x.dtype)
729
+ out = out.at[indices].set(packed)
730
+ return out # (dim,) → rank=1, n_features=1
731
+
732
+ return make_basis(
733
+ fn,
734
+ dim=self._dim,
735
+ rank=1,
736
+ n_features=1,
737
+ labels=[label_str],
738
+ )
739
+ elif rank == 2:
740
+ # Block-diagonal embedding
741
+ def fn(x):
742
+ val = pw_fn(x) # (*sdims)
743
+ packed = _sector_pack(val, sector) # (n_data,)
744
+ out = jnp.zeros((dim, dim), dtype=x.dtype)
745
+ # Place as diagonal block
746
+ for i_local, i_global in enumerate(sector.indices):
747
+ out = out.at[i_global, i_global].set(packed[i_local] if i_local < len(packed) else 0.0)
748
+ return out # (dim, dim) → rank=2
749
+
750
+ return make_basis(
751
+ fn,
752
+ dim=self._dim,
753
+ rank=2,
754
+ n_features=1,
755
+ labels=[label_str],
756
+ )
757
+ else:
758
+ raise ValueError(f"Unsupported rank={rank}")
759
+
760
+ # ------------------------------------------------------------------
761
+ # Helpers
762
+ # ------------------------------------------------------------------
763
+
764
+ def _wrap_scatter(
765
+ self,
766
+ fd_fn: Callable,
767
+ out_sdims: tuple[int, ...],
768
+ sector: Sector,
769
+ rank: int,
770
+ ) -> Callable:
771
+ """Wrap an FD local function with sector scatter to dim-wide output."""
772
+ dim = self._dim
773
+ indices = jnp.array(sector.indices)
774
+
775
+ if rank == 1:
776
+
777
+ def local_fn(Xk, *, mask=None, extras=None):
778
+ fd_result = fd_fn(Xk, mask=mask, extras=extras) # (n_flat_out,)
779
+ # Reshape to structured
780
+ structured = fd_result.reshape(out_sdims) if out_sdims else fd_result
781
+ # Pack for sector
782
+ packed = _sector_pack(structured, sector) # (n_data,)
783
+ # Scatter to dim-wide
784
+ out = jnp.zeros(dim, dtype=fd_result.dtype)
785
+ out = out.at[indices].set(packed)
786
+ return out # (dim,) → rank=1, n_features=1
787
+
788
+ return local_fn
789
+ elif rank == 2:
790
+
791
+ def local_fn(Xk, *, mask=None, extras=None):
792
+ fd_result = fd_fn(Xk, mask=mask, extras=extras)
793
+ structured = fd_result.reshape(out_sdims) if out_sdims else fd_result
794
+ packed = _sector_pack(structured, sector)
795
+ out = jnp.zeros((dim, dim), dtype=fd_result.dtype)
796
+ for i_local, i_global in enumerate(sector.indices):
797
+ out = out.at[i_global, i_global].set(packed[i_local] if i_local < len(packed) else 0.0)
798
+ return out # (dim, dim)
799
+
800
+ return local_fn
801
+ else:
802
+ raise ValueError(f"Unsupported rank={rank}")
803
+
804
+ def __repr__(self) -> str:
805
+ parts = [f"dim={self._dim}", f"ndim={self._ndim}", f"bc={self._bc!r}"]
806
+ for name, sector in self._sectors.items():
807
+ parts.append(f"{name}={sector!r}")
808
+ return f"GridLayout({', '.join(parts)})"
809
+
810
+
811
+ # =====================================================================
812
+ # Tree utilities
813
+ # =====================================================================
814
+
815
+
816
+ def _node_sdims(
817
+ node: _StructNode,
818
+ sectors: dict[str, Sector],
819
+ ) -> tuple[int, ...]:
820
+ """Infer sdims of a node from the tree structure."""
821
+ if isinstance(node, _SectorLeaf):
822
+ return node.sdims
823
+ if isinstance(node, _ConstNode):
824
+ return node.sdims
825
+ if isinstance(node, _BinaryOp):
826
+ left_s = _node_sdims(node.left, sectors)
827
+ right_s = _node_sdims(node.right, sectors)
828
+ # Broadcasting: scalar with anything -> anything
829
+ if left_s == ():
830
+ return right_s
831
+ if right_s == ():
832
+ return left_s
833
+ return left_s # should match
834
+ if isinstance(node, _UnaryOp):
835
+ child_s = _node_sdims(node.child, sectors)
836
+ if node.op == "T" and len(child_s) >= 2:
837
+ return child_s[:-2] + (child_s[-1], child_s[-2])
838
+ return child_s
839
+ if isinstance(node, _EinsumOp):
840
+ # Parse the spec to get output sdims
841
+ from ..structexpr import _parse_einsum
842
+
843
+ operand_specs, rhs = _parse_einsum(node.spec)
844
+ letter_size: dict[str, int] = {}
845
+ for spec_str, child in zip(operand_specs, node.children):
846
+ child_sdims = _node_sdims(child, sectors)
847
+ for ch, sz in zip(spec_str, child_sdims):
848
+ letter_size[ch] = sz
849
+ return tuple(letter_size[ch] for ch in rhs)
850
+ if isinstance(node, _StackOp):
851
+ return (node.sdim,) + _node_sdims(node.children[0], sectors)
852
+ if isinstance(node, _SliceOp):
853
+ return _node_sdims(node.child, sectors)[:-1]
854
+ if isinstance(node, _ConcatOp):
855
+ return _node_sdims(node.children[0], sectors)
856
+ if isinstance(node, _DiffOpNode):
857
+ child_s = _node_sdims(node.child, sectors)
858
+ meta = node.engine_meta
859
+ return _op_output_sdims(node.op_name, child_s, meta.ndim)
860
+ return ()
861
+
862
+
863
+ def _op_output_sdims(
864
+ op_name: str,
865
+ child_sdims: tuple[int, ...],
866
+ ndim: int,
867
+ ) -> tuple[int, ...]:
868
+ """Compute output sdims for a differential operator."""
869
+ if op_name == "lap":
870
+ return child_sdims
871
+ elif op_name == "grad":
872
+ return child_sdims + (ndim,)
873
+ elif op_name == "div":
874
+ return child_sdims[:-1]
875
+ elif op_name == "biharmonic":
876
+ return child_sdims
877
+ else:
878
+ raise ValueError(f"Unknown op: {op_name!r}")