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,718 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stencil helpers for grid-based SPDEs.
|
|
3
|
+
|
|
4
|
+
This module provides *spec constructors* for fixed-K regular-grid stencils.
|
|
5
|
+
|
|
6
|
+
Why a separate module?
|
|
7
|
+
---------------------
|
|
8
|
+
The :mod:`~SFI.statefunc.nodes.interactions.specs` module defines generic
|
|
9
|
+
containers (:class:`~SFI.statefunc.nodes.interactions.specs.HyperFixed`,
|
|
10
|
+
:class:`~SFI.statefunc.nodes.interactions.specs.HyperCSR`, ...). Stencils are
|
|
11
|
+
domain-specific *ways of producing* such specs (e.g. finite differences on a
|
|
12
|
+
Cartesian lattice), so keeping them here avoids mixing generic API with
|
|
13
|
+
application-level utilities.
|
|
14
|
+
|
|
15
|
+
Conventions
|
|
16
|
+
-----------
|
|
17
|
+
We assume that a field discretized on a regular grid is represented as a
|
|
18
|
+
particle system with:
|
|
19
|
+
|
|
20
|
+
* particle axis = flattened grid index ``p = 0..P-1``;
|
|
21
|
+
* state dimension ``dim`` = number of field components per grid site.
|
|
22
|
+
|
|
23
|
+
This matches the StateExpr/dispatcher convention used across SFI.
|
|
24
|
+
|
|
25
|
+
Cache-only extras
|
|
26
|
+
----------------
|
|
27
|
+
When stencil hyper tables / slot masks / slot geometry are materialized as extras
|
|
28
|
+
(e.g. via a `prepare_extras()` hook), they must be stored under the ``_cache/``
|
|
29
|
+
prefix so they can be purged safely after any context-changing operation.
|
|
30
|
+
|
|
31
|
+
Box polymorphism strategy
|
|
32
|
+
-------------------------
|
|
33
|
+
A stencil-based operator (e.g. Laplacian) is created **once** with boundary
|
|
34
|
+
conditions and offset layout fixed. The dataset provides only:
|
|
35
|
+
|
|
36
|
+
* ``{box}/grid_shape`` : integer array-like, shape (ndim,)
|
|
37
|
+
* ``{box}/dx`` : float array-like, shape (ndim,) or scalar
|
|
38
|
+
|
|
39
|
+
The neighbor lists (HyperFixed spec) are then built *on demand* from grid_shape
|
|
40
|
+
(and cached by :class:`~SFI.statefunc.nodes.interactions.specs.CachedRule`).
|
|
41
|
+
|
|
42
|
+
This avoids having to precompute and store large structural arrays in dataset
|
|
43
|
+
extras, while keeping the basis / PSF object reusable across grid sizes.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from typing import Any, Dict, Literal, Mapping, Optional, Sequence
|
|
49
|
+
|
|
50
|
+
import equinox as eqx
|
|
51
|
+
import jax.numpy as jnp
|
|
52
|
+
import numpy as np
|
|
53
|
+
from jaxtyping import Array
|
|
54
|
+
|
|
55
|
+
from .specs import FromExtrasHyperFixed, HyperFixed, SpecRule
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# Small utilities (grid <-> flat index)
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _as_int_tuple(x) -> tuple[int, ...]:
|
|
63
|
+
"""Convert a grid shape-like object to a Python tuple of ints.
|
|
64
|
+
|
|
65
|
+
This is used for cache keys and NumPy-side construction of stencil specs.
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(x, tuple):
|
|
68
|
+
return tuple(int(v) for v in x)
|
|
69
|
+
arr = np.asarray(x)
|
|
70
|
+
if arr.ndim != 1:
|
|
71
|
+
raise ValueError(f"grid_shape must be 1D (got shape {arr.shape})")
|
|
72
|
+
return tuple(int(v) for v in arr.tolist())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _as_float_array(dx: float | Sequence[float], *, ndim: int) -> np.ndarray:
|
|
76
|
+
"""Return dx as a NumPy array of shape (ndim,)."""
|
|
77
|
+
if np.isscalar(dx):
|
|
78
|
+
return np.full((ndim,), float(dx), dtype=np.float64)
|
|
79
|
+
arr = np.asarray(dx, dtype=np.float64)
|
|
80
|
+
if arr.shape != (ndim,):
|
|
81
|
+
raise ValueError(f"dx must be scalar or shape ({ndim},) (got {arr.shape})")
|
|
82
|
+
return arr
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _coords_from_flat(p: np.ndarray, grid_shape: tuple[int, ...], *, order: str) -> np.ndarray:
|
|
86
|
+
"""Map flat indices to coordinates.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
p
|
|
91
|
+
Flat indices, shape (...,).
|
|
92
|
+
grid_shape
|
|
93
|
+
Grid shape (n0, n1, ..., n_{ndim-1}).
|
|
94
|
+
order
|
|
95
|
+
Flattening order, either "C" (row-major) or "F" (column-major).
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
coords
|
|
100
|
+
Integer coordinates, shape (..., ndim).
|
|
101
|
+
"""
|
|
102
|
+
if order not in ("C", "F"):
|
|
103
|
+
raise ValueError(f"order must be 'C' or 'F' (got {order!r})")
|
|
104
|
+
coords = np.stack(np.unravel_index(p, grid_shape, order=order), axis=-1)
|
|
105
|
+
return coords.astype(np.int32)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _flat_from_coords(coords: np.ndarray, grid_shape: tuple[int, ...], *, order: str) -> np.ndarray:
|
|
109
|
+
"""Map integer coordinates to flat indices.
|
|
110
|
+
|
|
111
|
+
coords must be shape (..., ndim), with 0 <= coords[...,ax] < grid_shape[ax].
|
|
112
|
+
"""
|
|
113
|
+
if order == "C":
|
|
114
|
+
strides = np.cumprod((1,) + grid_shape[::-1])[:-1][::-1]
|
|
115
|
+
elif order == "F":
|
|
116
|
+
strides = np.cumprod((1,) + grid_shape)[:-1]
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError(f"order must be 'C' or 'F' (got {order!r})")
|
|
119
|
+
strides = np.asarray(strides, dtype=np.int64) # (ndim,)
|
|
120
|
+
return (coords.astype(np.int64) * strides).sum(axis=-1).astype(np.int32)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# =============================================================================
|
|
124
|
+
# Offset constructors and weights (Cartesian square grid)
|
|
125
|
+
# =============================================================================
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def square_axis_offsets(
|
|
129
|
+
ndim: int,
|
|
130
|
+
axis: int,
|
|
131
|
+
*,
|
|
132
|
+
scheme: Literal["central", "forward", "backward"] = "central",
|
|
133
|
+
include_center: bool = True,
|
|
134
|
+
) -> Array:
|
|
135
|
+
"""Offsets for a 1D finite-difference stencil along one grid axis.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
ndim
|
|
140
|
+
Grid dimension.
|
|
141
|
+
axis
|
|
142
|
+
Which grid axis to differentiate along (0 ≤ axis < ndim).
|
|
143
|
+
scheme
|
|
144
|
+
One of: "central" (±e_axis), "forward" (+e_axis), "backward" (-e_axis).
|
|
145
|
+
include_center
|
|
146
|
+
Whether to include the zero offset as the first slot.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
offsets
|
|
151
|
+
Integer offsets shaped (K, ndim).
|
|
152
|
+
"""
|
|
153
|
+
ndim = int(ndim)
|
|
154
|
+
axis = int(axis)
|
|
155
|
+
if not (0 <= axis < ndim):
|
|
156
|
+
raise ValueError(f"axis must be in [0, {ndim}) (got {axis})")
|
|
157
|
+
|
|
158
|
+
offs: list[tuple[int, ...]] = []
|
|
159
|
+
if include_center:
|
|
160
|
+
offs.append((0,) * ndim)
|
|
161
|
+
|
|
162
|
+
e = [0] * ndim
|
|
163
|
+
if scheme == "central":
|
|
164
|
+
e[axis] = 1
|
|
165
|
+
offs.append(tuple(e))
|
|
166
|
+
e[axis] = -1
|
|
167
|
+
offs.append(tuple(e))
|
|
168
|
+
elif scheme == "forward":
|
|
169
|
+
e[axis] = 1
|
|
170
|
+
offs.append(tuple(e))
|
|
171
|
+
elif scheme == "backward":
|
|
172
|
+
e[axis] = -1
|
|
173
|
+
offs.append(tuple(e))
|
|
174
|
+
else:
|
|
175
|
+
raise ValueError(f"Unknown scheme {scheme!r}")
|
|
176
|
+
|
|
177
|
+
return jnp.asarray(offs, dtype=jnp.int32)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def square_cross_offsets(ndim: int, *, include_center: bool = True) -> Array:
|
|
181
|
+
"""Offsets for the standard cross stencil (center + nearest neighbors).
|
|
182
|
+
|
|
183
|
+
Ordering convention (important!)
|
|
184
|
+
-------------------------------
|
|
185
|
+
Returned offsets are:
|
|
186
|
+
|
|
187
|
+
[0, +e0, -e0, +e1, -e1, ..., +e_{ndim-1}, -e_{ndim-1}]
|
|
188
|
+
|
|
189
|
+
This ordering is assumed by :func:`square_weights_laplacian_cross` and by
|
|
190
|
+
the SPDE Laplacian helper in :mod:`SFI.bases.spde`.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
ndim
|
|
195
|
+
Grid dimension.
|
|
196
|
+
include_center
|
|
197
|
+
Whether to include the center offset as the first slot.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
offsets
|
|
202
|
+
Integer offsets shaped (K, ndim), with K = 1 + 2*ndim if include_center.
|
|
203
|
+
"""
|
|
204
|
+
ndim = int(ndim)
|
|
205
|
+
offs: list[tuple[int, ...]] = []
|
|
206
|
+
if include_center:
|
|
207
|
+
offs.append((0,) * ndim)
|
|
208
|
+
for ax in range(ndim):
|
|
209
|
+
e = [0] * ndim
|
|
210
|
+
e[ax] = 1
|
|
211
|
+
offs.append(tuple(e))
|
|
212
|
+
e[ax] = -1
|
|
213
|
+
offs.append(tuple(e))
|
|
214
|
+
return jnp.asarray(offs, dtype=jnp.int32)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def square_weights_laplacian_cross(dx: float | Sequence[float], *, ndim: int) -> Array:
|
|
218
|
+
"""Weights for the cross Laplacian stencil.
|
|
219
|
+
|
|
220
|
+
This matches the ordering returned by :func:`square_cross_offsets`.
|
|
221
|
+
|
|
222
|
+
For anisotropic dx:
|
|
223
|
+
Δu ≈ Σ_ax (u_{+ax} - 2 u_0 + u_{-ax}) / dx_ax^2
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
dx
|
|
228
|
+
Grid spacing (scalar or per-axis sequence).
|
|
229
|
+
ndim
|
|
230
|
+
Grid dimension.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
w
|
|
235
|
+
Float weights shaped (K,), with K = 1 + 2*ndim.
|
|
236
|
+
"""
|
|
237
|
+
dxv = _as_float_array(dx, ndim=int(ndim))
|
|
238
|
+
inv = 1.0 / (dxv * dxv) # (ndim,)
|
|
239
|
+
|
|
240
|
+
# Slot 0: center coefficient = -2 * sum(inv_dx2)
|
|
241
|
+
w0 = -2.0 * inv.sum()
|
|
242
|
+
|
|
243
|
+
# Slots: (+e0, -e0, +e1, -e1, ...)
|
|
244
|
+
w = [w0]
|
|
245
|
+
for ax in range(int(ndim)):
|
|
246
|
+
w.append(inv[ax])
|
|
247
|
+
w.append(inv[ax])
|
|
248
|
+
return jnp.asarray(w, dtype=jnp.float32)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def square_biharmonic_offsets(ndim: int, *, include_center: bool = True) -> Array:
|
|
252
|
+
r"""Offsets for the biharmonic :math:`\nabla^4` stencil on a Cartesian grid.
|
|
253
|
+
|
|
254
|
+
In 2D, this is the standard 13-point stencil
|
|
255
|
+
(extended cross radius-2 **plus** diagonal corners ``(±1, ±1)``).
|
|
256
|
+
|
|
257
|
+
Ordering convention
|
|
258
|
+
-------------------
|
|
259
|
+
Returned offsets are:
|
|
260
|
+
|
|
261
|
+
[center,
|
|
262
|
+
+2e0, -2e0, +2e1, -2e1, ..., # extended along axes (radius 2)
|
|
263
|
+
+e0, -e0, +e1, -e1, ..., # cross (radius 1)
|
|
264
|
+
+e0+e1, +e0-e1, -e0+e1, -e0-e1, ...] # diagonal corners
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
ndim
|
|
269
|
+
Grid dimension (2 or 3).
|
|
270
|
+
include_center
|
|
271
|
+
Whether to include the center offset as the first slot.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
offsets
|
|
276
|
+
Integer offsets shaped ``(K, ndim)``.
|
|
277
|
+
"""
|
|
278
|
+
ndim = int(ndim)
|
|
279
|
+
offs: list[tuple[int, ...]] = []
|
|
280
|
+
|
|
281
|
+
if include_center:
|
|
282
|
+
offs.append((0,) * ndim)
|
|
283
|
+
|
|
284
|
+
# --- radius-2 along each axis ---
|
|
285
|
+
for ax in range(ndim):
|
|
286
|
+
e = [0] * ndim
|
|
287
|
+
e[ax] = 2
|
|
288
|
+
offs.append(tuple(e))
|
|
289
|
+
e[ax] = -2
|
|
290
|
+
offs.append(tuple(e))
|
|
291
|
+
|
|
292
|
+
# --- radius-1 cross ---
|
|
293
|
+
for ax in range(ndim):
|
|
294
|
+
e = [0] * ndim
|
|
295
|
+
e[ax] = 1
|
|
296
|
+
offs.append(tuple(e))
|
|
297
|
+
e[ax] = -1
|
|
298
|
+
offs.append(tuple(e))
|
|
299
|
+
|
|
300
|
+
# --- diagonal corners: all combinations of ±1 in pairs of axes ---
|
|
301
|
+
import itertools
|
|
302
|
+
|
|
303
|
+
for ax_pair in itertools.combinations(range(ndim), 2):
|
|
304
|
+
for signs in itertools.product([1, -1], repeat=2):
|
|
305
|
+
e = [0] * ndim
|
|
306
|
+
for a, s in zip(ax_pair, signs):
|
|
307
|
+
e[a] = s
|
|
308
|
+
offs.append(tuple(e))
|
|
309
|
+
|
|
310
|
+
return jnp.asarray(offs, dtype=jnp.int32)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# =============================================================================
|
|
314
|
+
# HyperFixed construction for square stencils
|
|
315
|
+
# =============================================================================
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def hyperfixed_square_stencil(
|
|
319
|
+
*,
|
|
320
|
+
grid_shape: Sequence[int],
|
|
321
|
+
offsets: Array,
|
|
322
|
+
bc: Literal["pbc", "noflux", "drop"] = "noflux",
|
|
323
|
+
order: Literal["C", "F"] = "C",
|
|
324
|
+
include_slot_extras_offset: bool = True,
|
|
325
|
+
) -> tuple[Array, Array, Optional[Array]]:
|
|
326
|
+
"""Build a fixed-K stencil as HyperFixed tables for a Cartesian grid.
|
|
327
|
+
|
|
328
|
+
Each grid site becomes one hyperedge of arity K, whose participants are
|
|
329
|
+
the focal site and its neighbors at the provided integer `offsets`.
|
|
330
|
+
|
|
331
|
+
Parameters
|
|
332
|
+
----------
|
|
333
|
+
grid_shape
|
|
334
|
+
Spatial grid shape, length ndim.
|
|
335
|
+
offsets
|
|
336
|
+
Integer offsets of shape (K, ndim).
|
|
337
|
+
bc
|
|
338
|
+
Boundary condition handling:
|
|
339
|
+
|
|
340
|
+
- ``"pbc"``: periodic wrap
|
|
341
|
+
- ``"noflux"``: out-of-bounds neighbors are replaced by focal index
|
|
342
|
+
(Neumann-like for cross stencils)
|
|
343
|
+
- ``"drop"``: out-of-bounds slots are deactivated via slot_mask
|
|
344
|
+
(neighbors replaced by focal index but masked out)
|
|
345
|
+
order
|
|
346
|
+
Flattening order used to map coords <-> flat indices.
|
|
347
|
+
include_slot_extras_offset
|
|
348
|
+
If True, also returns `slot_extras_offset` = integer offsets broadcast to
|
|
349
|
+
shape (P, K, ndim). This is useful for direction-aware operators
|
|
350
|
+
(grad/div/elastic contractions), and for non-square lattices you can
|
|
351
|
+
later replace this with real edge vectors as extras.
|
|
352
|
+
|
|
353
|
+
Returns
|
|
354
|
+
-------
|
|
355
|
+
hyper
|
|
356
|
+
Int array, shape (P, K), neighbor indices for each focal site.
|
|
357
|
+
slot_mask
|
|
358
|
+
Bool array, shape (P, K), True for active slots.
|
|
359
|
+
slot_extras_offset
|
|
360
|
+
Optional int array, shape (P, K, ndim), the integer offset vectors.
|
|
361
|
+
"""
|
|
362
|
+
gshape = _as_int_tuple(grid_shape)
|
|
363
|
+
ndim = len(gshape)
|
|
364
|
+
offsets_np = np.asarray(offsets, dtype=np.int32)
|
|
365
|
+
if offsets_np.ndim != 2 or offsets_np.shape[1] != ndim:
|
|
366
|
+
raise ValueError(f"offsets must have shape (K,{ndim}) (got {offsets_np.shape})")
|
|
367
|
+
K = int(offsets_np.shape[0])
|
|
368
|
+
|
|
369
|
+
P = int(np.prod(gshape))
|
|
370
|
+
p = np.arange(P, dtype=np.int32)
|
|
371
|
+
coords = _coords_from_flat(p, gshape, order=order) # (P, ndim)
|
|
372
|
+
|
|
373
|
+
# Neighbor coordinates for each slot: (P, K, ndim)
|
|
374
|
+
nbr = coords[:, None, :] + offsets_np[None, :, :]
|
|
375
|
+
|
|
376
|
+
# In-bounds mask per slot: (P, K)
|
|
377
|
+
inb = np.ones((P, K), dtype=bool)
|
|
378
|
+
for ax, n in enumerate(gshape):
|
|
379
|
+
inb &= (nbr[..., ax] >= 0) & (nbr[..., ax] < n)
|
|
380
|
+
|
|
381
|
+
if bc == "pbc":
|
|
382
|
+
for ax, n in enumerate(gshape):
|
|
383
|
+
nbr[..., ax] %= n
|
|
384
|
+
slot_mask = np.ones((P, K), dtype=bool)
|
|
385
|
+
elif bc in ("noflux", "drop"):
|
|
386
|
+
# Replace any out-of-bounds slot by focal coordinate.
|
|
387
|
+
#
|
|
388
|
+
# - For "noflux": keeping focal indices is the *actual* boundary behavior.
|
|
389
|
+
# - For "drop": focal indices are placeholders; the slot will be deactivated
|
|
390
|
+
# by slot_mask.
|
|
391
|
+
#
|
|
392
|
+
# IMPORTANT: boolean indexing must match shapes; use np.where to avoid
|
|
393
|
+
# shape pitfalls on (P,K,ndim) arrays.
|
|
394
|
+
nbr = np.where(inb[..., None], nbr, coords[:, None, :])
|
|
395
|
+
if bc == "noflux":
|
|
396
|
+
slot_mask = np.ones((P, K), dtype=bool)
|
|
397
|
+
else:
|
|
398
|
+
slot_mask = inb
|
|
399
|
+
else:
|
|
400
|
+
raise ValueError(f"Unknown bc={bc!r}")
|
|
401
|
+
|
|
402
|
+
hyper = _flat_from_coords(nbr, gshape, order=order).astype(np.int32) # (P, K)
|
|
403
|
+
|
|
404
|
+
if include_slot_extras_offset:
|
|
405
|
+
slot_extras_offset = np.broadcast_to(offsets_np[None, :, :], (P, K, ndim)).astype(np.int32)
|
|
406
|
+
else:
|
|
407
|
+
slot_extras_offset = None
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
jnp.asarray(hyper, dtype=jnp.int32),
|
|
411
|
+
jnp.asarray(slot_mask),
|
|
412
|
+
(None if slot_extras_offset is None else jnp.asarray(slot_extras_offset, dtype=jnp.int32)),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class HyperFixedSquareStencilFromBox(SpecRule):
|
|
417
|
+
"""Build a HyperFixed stencil from *box* extras: `grid_shape` (and optionally `dx`).
|
|
418
|
+
|
|
419
|
+
This is the preferred entry point for *box-polymorphic* SPDE operators.
|
|
420
|
+
|
|
421
|
+
Only the *small* box descriptors are required as dataset extras, while the
|
|
422
|
+
large hyper tables are built on demand and typically cached by wrapping
|
|
423
|
+
this rule in :class:`~SFI.statefunc.nodes.interactions.specs.CachedRule`.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
offsets
|
|
428
|
+
Integer offsets, shape (K, ndim). The slot order must match the operator.
|
|
429
|
+
bc, order
|
|
430
|
+
Boundary condition and flattening order.
|
|
431
|
+
key_grid_shape
|
|
432
|
+
Extras key for the grid shape (length ndim).
|
|
433
|
+
include_slot_extras_offset
|
|
434
|
+
Whether to store integer offset vectors in `spec.slot_extras["offset"]`.
|
|
435
|
+
This is useful for future direction-aware operators (grad/div, elasticity),
|
|
436
|
+
and for non-square lattices you can later substitute real edge vectors.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
offsets: tuple = eqx.field(static=True) # tuple-of-tuples of ints
|
|
440
|
+
bc: Literal["pbc", "noflux", "drop"] = eqx.field(static=True)
|
|
441
|
+
order: Literal["C", "F"] = eqx.field(static=True)
|
|
442
|
+
key_grid_shape: str = eqx.field(static=True)
|
|
443
|
+
include_slot_extras_offset: bool = eqx.field(static=True)
|
|
444
|
+
|
|
445
|
+
def __init__(
|
|
446
|
+
self,
|
|
447
|
+
*,
|
|
448
|
+
offsets: Array,
|
|
449
|
+
bc: Literal["pbc", "noflux", "drop"] = "noflux",
|
|
450
|
+
order: Literal["C", "F"] = "C",
|
|
451
|
+
key_grid_shape: str,
|
|
452
|
+
include_slot_extras_offset: bool = True,
|
|
453
|
+
):
|
|
454
|
+
_arr = np.asarray(offsets, dtype=np.int32)
|
|
455
|
+
object.__setattr__(self, "offsets", tuple(tuple(int(x) for x in row) for row in _arr))
|
|
456
|
+
object.__setattr__(self, "bc", bc)
|
|
457
|
+
object.__setattr__(self, "order", order)
|
|
458
|
+
object.__setattr__(self, "key_grid_shape", str(key_grid_shape))
|
|
459
|
+
object.__setattr__(self, "include_slot_extras_offset", bool(include_slot_extras_offset))
|
|
460
|
+
|
|
461
|
+
def arity(self):
|
|
462
|
+
K = len(self.offsets)
|
|
463
|
+
return ("fixed", K)
|
|
464
|
+
|
|
465
|
+
def required_extras(self) -> tuple[str, ...]:
|
|
466
|
+
# These are small globals and should be forwarded.
|
|
467
|
+
return (self.key_grid_shape,)
|
|
468
|
+
|
|
469
|
+
def structural_extras(self) -> tuple[str, ...]:
|
|
470
|
+
# We do not consume large structural arrays from extras here.
|
|
471
|
+
return ()
|
|
472
|
+
|
|
473
|
+
def cache_key(self, extras) -> tuple:
|
|
474
|
+
# CachedRule will combine (P, cache_key). We include full grid_shape to
|
|
475
|
+
# avoid collisions for different factorizations with same P.
|
|
476
|
+
gshape = _as_int_tuple(extras[self.key_grid_shape])
|
|
477
|
+
return (gshape, self.bc, self.order, len(self.offsets))
|
|
478
|
+
|
|
479
|
+
def build(self, x, *, v=None, mask=None, extras=None) -> HyperFixed:
|
|
480
|
+
if extras is None:
|
|
481
|
+
raise KeyError("HyperFixedSquareStencilFromBox: extras is required")
|
|
482
|
+
gshape = _as_int_tuple(extras[self.key_grid_shape])
|
|
483
|
+
hyper, slot_mask, slot_offs = hyperfixed_square_stencil(
|
|
484
|
+
grid_shape=gshape,
|
|
485
|
+
offsets=np.array(self.offsets, dtype=np.int32),
|
|
486
|
+
bc=self.bc,
|
|
487
|
+
order=self.order,
|
|
488
|
+
include_slot_extras_offset=self.include_slot_extras_offset,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
slot_extras = None
|
|
492
|
+
if slot_offs is not None:
|
|
493
|
+
slot_extras = {"offset": slot_offs}
|
|
494
|
+
|
|
495
|
+
return HyperFixed(hyper=hyper, slot_mask=slot_mask, slot_extras=slot_extras)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# =============================================================================
|
|
499
|
+
# Convenience: add box extras
|
|
500
|
+
# =============================================================================
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def box_extras(
|
|
504
|
+
*,
|
|
505
|
+
grid_shape: Sequence[int],
|
|
506
|
+
dx: float | Sequence[float] = 1.0,
|
|
507
|
+
prefix: str = "box",
|
|
508
|
+
) -> Dict[str, Array]:
|
|
509
|
+
"""Return the minimal extras dictionary required by box-polymorphic stencils.
|
|
510
|
+
|
|
511
|
+
This is intended for `TrajectoryCollection.extras_global.update(...)`.
|
|
512
|
+
|
|
513
|
+
Keys
|
|
514
|
+
----
|
|
515
|
+
- ``{prefix}/grid_shape``: int32 array, shape (ndim,)
|
|
516
|
+
- ``{prefix}/dx`` : float32 array, shape (ndim,)
|
|
517
|
+
|
|
518
|
+
Notes
|
|
519
|
+
-----
|
|
520
|
+
- We store dx as a length-ndim vector even when input is scalar, so that
|
|
521
|
+
downstream operators can assume a stable shape.
|
|
522
|
+
- These are *globals* (not structural) and should flow through the dispatcher.
|
|
523
|
+
"""
|
|
524
|
+
gshape = _as_int_tuple(grid_shape)
|
|
525
|
+
if any(n <= 0 for n in gshape):
|
|
526
|
+
raise ValueError(f"grid_shape entries must be positive; got {gshape}")
|
|
527
|
+
dxv = _as_float_array(dx, ndim=len(gshape))
|
|
528
|
+
if np.any(dxv <= 0.0):
|
|
529
|
+
raise ValueError(f"dx must be positive; got {dxv}")
|
|
530
|
+
return {
|
|
531
|
+
f"{prefix}/grid_shape": jnp.asarray(gshape, dtype=jnp.int32),
|
|
532
|
+
f"{prefix}/dx": jnp.asarray(dxv, dtype=jnp.float32),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# =============================================================================
|
|
537
|
+
# Host-prepared stencil rules (box-polymorphic, JIT-safe at runtime)
|
|
538
|
+
# =============================================================================
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class PreparedSquareStencilFromBox(SpecRule):
|
|
542
|
+
"""
|
|
543
|
+
Regular-grid fixed-K stencil whose *structural* arrays are prepared on the host.
|
|
544
|
+
|
|
545
|
+
This rule is the “JIT-friendly” way to use grid-based differential operators:
|
|
546
|
+
|
|
547
|
+
- At **prepare time** (Python side, not under JIT), we:
|
|
548
|
+
* read small box descriptors from `extras` (e.g. grid shape, maybe dx later),
|
|
549
|
+
* build large structural arrays (hyper table, slot mask, slot offsets),
|
|
550
|
+
* store them back into `extras` under a cache prefix.
|
|
551
|
+
|
|
552
|
+
- At **build/eval time** (can be under JIT), we:
|
|
553
|
+
* only *read* those already-prepared arrays from `extras`,
|
|
554
|
+
* never call NumPy, never hash tracer values, never do Python caching.
|
|
555
|
+
|
|
556
|
+
Structural extras policy
|
|
557
|
+
------------------------
|
|
558
|
+
The keys written under the cache prefix are *structural*: they are consumed by the
|
|
559
|
+
dispatcher/spec builder and must not be forwarded to child local-ops.
|
|
560
|
+
|
|
561
|
+
Therefore, these keys are returned by `structural_extras()` and **not** by
|
|
562
|
+
`required_extras()`.
|
|
563
|
+
|
|
564
|
+
Cache naming
|
|
565
|
+
------------
|
|
566
|
+
This class supports two equivalent APIs:
|
|
567
|
+
|
|
568
|
+
- `key_prefix="..."`
|
|
569
|
+
Explicit prefix where arrays are stored/read.
|
|
570
|
+
|
|
571
|
+
Parameters
|
|
572
|
+
----------
|
|
573
|
+
offsets
|
|
574
|
+
Integer offsets, shape (K, ndim). Slot order must match the operator.
|
|
575
|
+
bc, order
|
|
576
|
+
Boundary condition and flattening order passed to `hyperfixed_square_stencil`.
|
|
577
|
+
key_grid_shape
|
|
578
|
+
Extras key holding the grid shape (length ndim). Must be *concrete* at prepare time.
|
|
579
|
+
key_prefix
|
|
580
|
+
Prefix used to store structural arrays in extras.
|
|
581
|
+
Keys written/read:
|
|
582
|
+
- f"{key_prefix}/hyper"
|
|
583
|
+
- f"{key_prefix}/slot_mask"
|
|
584
|
+
- optionally f"{key_prefix}/slot_offset"
|
|
585
|
+
include_slot_extras_offset
|
|
586
|
+
If True, store per-slot offset vectors under f"{key_prefix}/slot_offset".
|
|
587
|
+
This is what you want for direction-aware operators later.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
offsets: tuple = eqx.field(static=True) # tuple-of-tuples of ints
|
|
591
|
+
bc: Literal["pbc", "noflux", "drop"] = eqx.field(static=True)
|
|
592
|
+
order: Literal["C", "F"] = eqx.field(static=True)
|
|
593
|
+
key_grid_shape: str = eqx.field(static=True)
|
|
594
|
+
key_prefix: str = eqx.field(static=True)
|
|
595
|
+
include_slot_extras_offset: bool = eqx.field(static=True)
|
|
596
|
+
|
|
597
|
+
# Internal reader: JIT-safe, just reads arrays from extras.
|
|
598
|
+
_reader: "FromExtrasHyperFixed" = eqx.field(static=True)
|
|
599
|
+
|
|
600
|
+
def __init__(
|
|
601
|
+
self,
|
|
602
|
+
*,
|
|
603
|
+
offsets: Array,
|
|
604
|
+
bc: Literal["pbc", "noflux", "drop"] = "noflux",
|
|
605
|
+
order: Literal["C", "F"] = "C",
|
|
606
|
+
key_grid_shape: str,
|
|
607
|
+
key_prefix: str,
|
|
608
|
+
include_slot_extras_offset: bool = True,
|
|
609
|
+
):
|
|
610
|
+
object.__setattr__(self, "key_prefix", str(key_prefix))
|
|
611
|
+
object.__setattr__(self, "key_grid_shape", str(key_grid_shape))
|
|
612
|
+
|
|
613
|
+
_arr = np.asarray(offsets, dtype=np.int32)
|
|
614
|
+
object.__setattr__(self, "offsets", tuple(tuple(int(x) for x in row) for row in _arr))
|
|
615
|
+
object.__setattr__(self, "bc", bc)
|
|
616
|
+
object.__setattr__(self, "order", order)
|
|
617
|
+
object.__setattr__(self, "include_slot_extras_offset", bool(include_slot_extras_offset))
|
|
618
|
+
|
|
619
|
+
# --- structural keys written/read under key_prefix ----------------
|
|
620
|
+
key_h = f"{self.key_prefix}/hyper"
|
|
621
|
+
key_m = f"{self.key_prefix}/slot_mask"
|
|
622
|
+
key_o = f"{self.key_prefix}/slot_offset" if self.include_slot_extras_offset else None
|
|
623
|
+
|
|
624
|
+
# Reader rule is purely a “read from extras” spec builder: JIT-safe.
|
|
625
|
+
object.__setattr__(
|
|
626
|
+
self,
|
|
627
|
+
"_reader",
|
|
628
|
+
FromExtrasHyperFixed(
|
|
629
|
+
key_hyper=key_h,
|
|
630
|
+
key_slot_mask=key_m,
|
|
631
|
+
key_slot_offset=key_o,
|
|
632
|
+
),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def arity(self):
|
|
636
|
+
# Fixed-K stencil: K is the number of offsets (slots).
|
|
637
|
+
return ("fixed", len(self.offsets))
|
|
638
|
+
|
|
639
|
+
def required_extras(self) -> tuple[str, ...]:
|
|
640
|
+
# Small “box descriptors” should remain forwardable and available to children.
|
|
641
|
+
# They are also what `prepare_extras` needs.
|
|
642
|
+
return (self.key_grid_shape,)
|
|
643
|
+
|
|
644
|
+
def structural_extras(self) -> tuple[str, ...]:
|
|
645
|
+
# Structural arrays live under the cache prefix; these must not be forwarded.
|
|
646
|
+
return self._reader.structural_extras()
|
|
647
|
+
|
|
648
|
+
# ------------------------------------------------------------------
|
|
649
|
+
# Host-side preparation hook
|
|
650
|
+
# ------------------------------------------------------------------
|
|
651
|
+
def prepare_extras(self, extras: Mapping[str, Any]) -> dict[str, Any]:
|
|
652
|
+
"""
|
|
653
|
+
Host-side hook: build and insert structural stencil arrays into `extras`.
|
|
654
|
+
|
|
655
|
+
Contract
|
|
656
|
+
--------
|
|
657
|
+
- Input `extras` must provide `key_grid_shape` as a *concrete* value.
|
|
658
|
+
- Returns a dict of additions/updates to be merged into extras_global.
|
|
659
|
+
- If keys already exist in extras, does nothing (returns {}).
|
|
660
|
+
|
|
661
|
+
Notes
|
|
662
|
+
-----
|
|
663
|
+
This function is intentionally *not JIT-safe* (uses Python/NumPy),
|
|
664
|
+
and must be called outside traced code (e.g. in process.initialize(),
|
|
665
|
+
inference entry points, etc.).
|
|
666
|
+
"""
|
|
667
|
+
if extras is None:
|
|
668
|
+
raise KeyError("PreparedSquareStencilFromBox.prepare_extras: extras is required")
|
|
669
|
+
|
|
670
|
+
# If the structural arrays are already present, do nothing.
|
|
671
|
+
key_h = f"{self.key_prefix}/hyper"
|
|
672
|
+
key_m = f"{self.key_prefix}/slot_mask"
|
|
673
|
+
key_o = f"{self.key_prefix}/slot_offset"
|
|
674
|
+
have_h = key_h in extras
|
|
675
|
+
have_m = key_m in extras
|
|
676
|
+
have_o = (key_o in extras) if self.include_slot_extras_offset else True
|
|
677
|
+
if have_h and have_m and have_o:
|
|
678
|
+
return {}
|
|
679
|
+
|
|
680
|
+
# Read grid shape (must be concrete here).
|
|
681
|
+
gshape = _as_int_tuple(extras[self.key_grid_shape])
|
|
682
|
+
|
|
683
|
+
# Build heavy structural arrays (NumPy-heavy helper).
|
|
684
|
+
hyper, slot_mask, slot_offs = hyperfixed_square_stencil(
|
|
685
|
+
grid_shape=gshape,
|
|
686
|
+
offsets=np.array(self.offsets, dtype=np.int32),
|
|
687
|
+
bc=self.bc,
|
|
688
|
+
order=self.order,
|
|
689
|
+
include_slot_extras_offset=self.include_slot_extras_offset,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Normalize outputs.
|
|
693
|
+
hyper = jnp.asarray(hyper, dtype=jnp.int32)
|
|
694
|
+
if slot_mask is None:
|
|
695
|
+
slot_mask = jnp.ones_like(hyper, dtype=jnp.bool_)
|
|
696
|
+
else:
|
|
697
|
+
slot_mask = jnp.asarray(slot_mask, dtype=jnp.bool_)
|
|
698
|
+
|
|
699
|
+
built: dict[str, Any] = {
|
|
700
|
+
key_h: hyper,
|
|
701
|
+
key_m: slot_mask,
|
|
702
|
+
}
|
|
703
|
+
if self.include_slot_extras_offset:
|
|
704
|
+
# slot_offs has shape (M, K, ndim) with integer offsets.
|
|
705
|
+
built[key_o] = jnp.asarray(slot_offs, dtype=jnp.int32)
|
|
706
|
+
|
|
707
|
+
return built
|
|
708
|
+
|
|
709
|
+
# ------------------------------------------------------------------
|
|
710
|
+
# JIT-safe spec builder: just read arrays prepared above
|
|
711
|
+
# ------------------------------------------------------------------
|
|
712
|
+
def build(self, x, *, v=None, mask=None, extras: Mapping[str, Any] | None = None) -> HyperFixed:
|
|
713
|
+
"""
|
|
714
|
+
Build a `HyperFixed` spec by reading arrays from `extras`.
|
|
715
|
+
|
|
716
|
+
This is JIT-safe provided `extras` already contains the prepared keys.
|
|
717
|
+
"""
|
|
718
|
+
return self._reader.build(x, v=v, mask=mask, extras=extras)
|