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,225 @@
1
+ """Chunked simulation with periodic neighbor-list rebuilds.
2
+
3
+ Provides :func:`simulate_chunked`, a thin wrapper around
4
+ :meth:`OverdampedProcess.simulate` that breaks a long simulation into
5
+ shorter chunks and rebuilds the CSR neighbor list (via
6
+ :func:`~SFI.utils.neighbors.build_neighbor_csr`) between chunks.
7
+
8
+ This avoids the O(N²) cost of ``AutoPairs`` for large particle systems
9
+ while keeping the extras interface exactly as-is.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ import jax
17
+ import jax.numpy as jnp
18
+ import numpy as np
19
+
20
+ from SFI.trajectory.collection import TrajectoryCollection
21
+ from SFI.utils.neighbors import build_neighbor_csr, pad_neighbor_csr
22
+
23
+
24
+ def simulate_chunked(
25
+ proc,
26
+ dt: float,
27
+ Nsteps: int,
28
+ key,
29
+ *,
30
+ cutoff: float,
31
+ box: np.ndarray,
32
+ skin: float = 0.0,
33
+ rebuild_every: int = 50,
34
+ save_every: Optional[int] = None,
35
+ spatial_dims: slice = slice(None, 2),
36
+ indptr_key: str = "indptr",
37
+ indices_key: str = "indices",
38
+ nnz_safety: float = 1.25,
39
+ oversampling: int = 1,
40
+ prerun: int = 0,
41
+ compute_observables: bool = False,
42
+ jit_compile: bool = True,
43
+ verbose: bool = False,
44
+ ) -> TrajectoryCollection:
45
+ """Run a chunked overdamped simulation with periodic neighbor rebuilds.
46
+
47
+ Parameters
48
+ ----------
49
+ proc : OverdampedProcess
50
+ An initialized process whose force uses
51
+ ``dispatch_pairs_from_extras(indptr_key, indices_key)``.
52
+ dt : float
53
+ Time step.
54
+ Nsteps : int
55
+ Total number of steps.
56
+ key : jax PRNG key
57
+ Random key for the simulation.
58
+ cutoff : float
59
+ Cutoff radius for neighbor list construction.
60
+ box : array-like, shape ``(d,)``
61
+ Periodic box lengths.
62
+ skin : float
63
+ Verlet skin width. The neighbor list is built with radius
64
+ ``cutoff + skin`` so that particles drifting into range between
65
+ rebuilds are already included. After each chunk the maximum
66
+ particle displacement is checked; a warning is printed if it
67
+ exceeds ``skin / 2`` (the Verlet safety threshold).
68
+ rebuild_every : int
69
+ Number of simulation steps between neighbor-list rebuilds.
70
+ save_every : int, optional
71
+ Number of simulation steps per output dataset. If *None*,
72
+ defaults to ``rebuild_every``. Must be a multiple of
73
+ ``rebuild_every``.
74
+ spatial_dims : slice
75
+ Slice into the state vector that selects spatial coordinates
76
+ (default: first two components ``[:2]``).
77
+ indptr_key, indices_key : str
78
+ Extras keys for the CSR neighbor list.
79
+ nnz_safety : float
80
+ Fraction by which ``max_nnz`` is enlarged beyond the initial
81
+ neighbor count to absorb fluctuations (default 1.25 = 25%).
82
+ oversampling, prerun, compute_observables, jit_compile
83
+ Forwarded to ``proc.simulate()``.
84
+ verbose : bool
85
+ Print progress info.
86
+
87
+ Returns
88
+ -------
89
+ TrajectoryCollection
90
+ Concatenated trajectory from all chunks.
91
+ """
92
+ # Time-dependent extras are not supported here: simulate() is re-invoked
93
+ # per rebuild chunk, which would mis-slice frame-aligned schedules.
94
+ from SFI.trajectory.dataset import TimeSeriesExtra
95
+
96
+ for src in (proc.extras_global, proc.extras_local):
97
+ for k, v in (src or {}).items():
98
+ if isinstance(v, TimeSeriesExtra) or (callable(v) and not hasattr(v, "func")):
99
+ raise NotImplementedError(
100
+ f"simulate_chunked does not support time-dependent extras (got {k!r}); "
101
+ "use proc.simulate() directly."
102
+ )
103
+
104
+ box = np.asarray(box, dtype=np.float64)
105
+ cutoff_list = cutoff + skin # Verlet list radius
106
+
107
+ if save_every is None:
108
+ save_every = rebuild_every
109
+ if save_every % rebuild_every != 0:
110
+ raise ValueError(f"save_every ({save_every}) must be a multiple of rebuild_every ({rebuild_every})")
111
+ rebuilds_per_save = save_every // rebuild_every
112
+
113
+ # --- initial neighbor list ---
114
+ positions = np.asarray(proc._x) # (P, d) or (d,)
115
+ # Assumes layout (P, d) for ndim==2 or (d,) for ndim==1; (d, P) would silently mislabel.
116
+ pos_spatial = positions[:, spatial_dims] if positions.ndim == 2 else positions[spatial_dims]
117
+ indptr, indices = build_neighbor_csr(pos_spatial, cutoff_list, box)
118
+ pos_at_rebuild = pos_spatial.copy() # track displacements
119
+
120
+ # Fixed max_nnz with safety margin
121
+ max_nnz = max(int(len(indices) * nnz_safety), len(indices) + 1)
122
+ indptr_pad, indices_pad = pad_neighbor_csr(indptr, indices, max_nnz)
123
+
124
+ # Merge CSR into existing extras (don't clobber other keys)
125
+ base_extras = dict(proc.extras_global or {})
126
+ base_extras[indptr_key] = jnp.array(indptr_pad)
127
+ base_extras[indices_key] = jnp.array(indices_pad)
128
+ proc.set_extras(extras_global=base_extras)
129
+
130
+ # --- chunk the simulation ---
131
+ n_rebuilds = max(1, (Nsteps + rebuild_every - 1) // rebuild_every)
132
+ remaining = Nsteps
133
+ collections = [] # final output datasets
134
+ sub_collections = [] # accumulate rebuild-chunks within a save-chunk
135
+ step_done = 0
136
+ max_disp_in_save = 0.0 # track across rebuilds within a save-chunk
137
+
138
+ for rebuild_i in range(n_rebuilds):
139
+ chunk_steps = min(rebuild_every, remaining)
140
+ if chunk_steps <= 0:
141
+ break
142
+
143
+ key, sub_key = jax.random.split(key)
144
+
145
+ coll = proc.simulate(
146
+ dt,
147
+ Nsteps=chunk_steps,
148
+ key=sub_key,
149
+ oversampling=oversampling,
150
+ prerun=prerun if rebuild_i == 0 else 0,
151
+ compute_observables=compute_observables,
152
+ jit_compile=jit_compile,
153
+ )
154
+ sub_collections.append(coll)
155
+ remaining -= chunk_steps
156
+ step_done += chunk_steps
157
+
158
+ # Emit a save-chunk when we've accumulated enough rebuilds
159
+ if (rebuild_i + 1) % rebuilds_per_save == 0 or remaining <= 0:
160
+ # Merge sub-collection X arrays into one contiguous dataset.
161
+ # Each sub-collection has one dataset with X shape (T_sub, N, d)
162
+ # where T_sub = rebuild_every (no duplicate frames).
163
+ X_parts = [np.asarray(sc.datasets[0].X) for sc in sub_collections]
164
+ X_merged = np.concatenate(X_parts, axis=0)
165
+ if verbose:
166
+ print(f" merged {len(sub_collections)} sub-chunks → X shape {X_merged.shape}")
167
+ merged = TrajectoryCollection.from_arrays(
168
+ X=X_merged,
169
+ dt=dt,
170
+ )
171
+ collections.append(merged)
172
+ sub_collections = []
173
+
174
+ if verbose:
175
+ print(f" chunk {len(collections)}: {step_done}/{Nsteps} steps")
176
+
177
+ # Rebuild neighbors from updated positions
178
+ if remaining > 0:
179
+ positions = np.asarray(proc._x) # (P, d) or (d,)
180
+ pos_spatial = positions[:, spatial_dims] if positions.ndim == 2 else positions[spatial_dims]
181
+
182
+ # --- Verlet displacement check ---
183
+ disp = pos_spatial - pos_at_rebuild
184
+ if box is not None:
185
+ disp = disp - box * np.round(disp / box)
186
+ max_disp = float(np.sqrt((disp * disp).sum(axis=-1)).max())
187
+ max_disp_in_save = max(max_disp_in_save, max_disp)
188
+ if skin > 0 and max_disp > skin / 2:
189
+ import warnings
190
+
191
+ warnings.warn(
192
+ f"Rebuild {rebuild_i}: max displacement {max_disp:.3f} "
193
+ f"> skin/2 = {skin / 2:.3f}. Neighbor list may have "
194
+ f"missed interactions. Increase skin or decrease "
195
+ f"rebuild_every.",
196
+ stacklevel=2,
197
+ )
198
+ if verbose and (rebuild_i + 1) % rebuilds_per_save == 0:
199
+ print(f" max displacement = {max_disp_in_save:.3f} (skin/2 = {skin / 2:.3f})")
200
+ max_disp_in_save = 0.0
201
+
202
+ indptr, indices = build_neighbor_csr(pos_spatial, cutoff_list, box)
203
+ pos_at_rebuild = pos_spatial.copy()
204
+
205
+ # Grow max_nnz if needed
206
+ if len(indices) > max_nnz:
207
+ max_nnz = int(len(indices) * nnz_safety)
208
+ if verbose:
209
+ print(f" max_nnz grew to {max_nnz}")
210
+
211
+ indptr_pad, indices_pad = pad_neighbor_csr(indptr, indices, max_nnz)
212
+
213
+ # Update extras, preserving all non-CSR keys
214
+ updated_extras = dict(proc.extras_global or {})
215
+ updated_extras[indptr_key] = jnp.array(indptr_pad)
216
+ updated_extras[indices_key] = jnp.array(indices_pad)
217
+ proc.set_extras(extras_global=updated_extras)
218
+
219
+ # Free old JIT caches to prevent GPU memory buildup
220
+ jax.clear_caches()
221
+
222
+ # --- concatenate chunks ---
223
+ if len(collections) == 1:
224
+ return collections[0]
225
+ return collections[0].concat(collections[1:], weights="pool")
SFI/langevin/noise.py ADDED
@@ -0,0 +1,446 @@
1
+ """
2
+ Noise models for Langevin simulators.
3
+
4
+ This module provides a hierarchy of noise models that go beyond the
5
+ simple diagonal diffusion ``D = σ I``. In particular, it supports
6
+ **conserved noise** needed by SPDE models such as Active Model B+, where
7
+ the noise takes the form ``∇·(σ η)`` and preserves the spatial integral
8
+ of the field.
9
+
10
+ Class hierarchy
11
+ ---------------
12
+ - :class:`NoiseModel` — abstract base;
13
+ - :class:`WhiteNoise` — i.i.d. per-site Gaussian (recovers current behaviour);
14
+ - :class:`ConservedNoise` — ``sqrt(-σ² ∇²) ξ`` via FFT on a periodic grid;
15
+ - :class:`CompositeNoise` — different noise models on different field components.
16
+
17
+ Usage
18
+ -----
19
+ Pass a ``NoiseModel`` instance as ``D=`` to :class:`~SFI.langevin.OverdampedProcess`
20
+ (or any ``LangevinBase`` subclass). The simulator detects the noise model and
21
+ delegates noise generation to it instead of the traditional ``sqrt(2D)·ξ`` path.
22
+
23
+ .. code-block:: python
24
+
25
+ from SFI.langevin.noise import ConservedNoise
26
+
27
+ noise = ConservedNoise(sigma=0.3, grid_shape=(64, 64), dx=1.0)
28
+ proc = OverdampedProcess(BASIS, D=noise)
29
+ proc.set_params(theta_F=theta)
30
+ proc.set_extras(extras_global=box_extras)
31
+ proc.initialize(X0)
32
+ coll = proc.simulate(dt=0.02, Nsteps=3000, key=key, oversampling=4)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import abc
38
+ from typing import List, Sequence, Tuple, Union
39
+
40
+ import jax.numpy as jnp
41
+ from jax import random
42
+
43
+ Array = jnp.ndarray
44
+
45
+
46
+ # ============================================================================
47
+ # Abstract base
48
+ # ============================================================================
49
+
50
+
51
+ class NoiseModel(abc.ABC):
52
+ """Abstract base for noise models used by Langevin simulators.
53
+
54
+ Subclasses must implement :meth:`sample` and :meth:`effective_D_per_site`.
55
+ The simulator calls ``sample(key, x, extras)`` once per Euler–Maruyama
56
+ substep to obtain the noise increment (already scaled by ``sqrt(2)`` so
57
+ that the step becomes ``x += dt*F + sqrt(dt)*sample(key, x, extras)``).
58
+
59
+ Parameters
60
+ ----------
61
+ n_fields : int
62
+ Number of field components per grid site (= ``dim`` in the force
63
+ contract). E.g. 1 for a scalar field, 2 for a 2-component system.
64
+ """
65
+
66
+ def __init__(self, *, n_fields: int) -> None:
67
+ self._n_fields = n_fields
68
+
69
+ @property
70
+ def n_fields(self) -> int:
71
+ """Number of field components per site."""
72
+ return self._n_fields
73
+
74
+ # Alias for the force-contract convention
75
+ @property
76
+ def dim(self) -> int:
77
+ return self._n_fields
78
+
79
+ @abc.abstractmethod
80
+ def sample(self, key: Array, x: Array, extras: dict) -> Array:
81
+ r"""Draw one noise increment.
82
+
83
+ Parameters
84
+ ----------
85
+ key : PRNG key
86
+ x : Array, shape ``(P, d)`` or ``(d,)``
87
+ Current state (used only for shape; not accessed by white/conserved
88
+ noise, but may be needed by multiplicative noise subclasses).
89
+ extras : dict
90
+ Process extras (contains grid geometry, etc.).
91
+
92
+ Returns
93
+ -------
94
+ Array, same shape as *x*
95
+ Noise increment already multiplied by ``sqrt(2)`` so the
96
+ integrator applies ``x += dt*F + sqrt(dt) * sample(...)``.
97
+ """
98
+ ...
99
+
100
+ @abc.abstractmethod
101
+ def effective_D_per_site(self, extras: dict) -> Array:
102
+ r"""Return an approximate per-site diffusion matrix ``(d, d)``.
103
+
104
+ This is used by the inference pipeline as a *pragmatic approximation*
105
+ when the noise is not white-in-space. For ``WhiteNoise(σ)`` this
106
+ returns exactly ``σ·I``. For ``ConservedNoise`` it returns the
107
+ spatially-averaged effective variance per site.
108
+
109
+ Returns
110
+ -------
111
+ Array, shape ``(d, d)``
112
+ """
113
+ ...
114
+
115
+ @property
116
+ def noise_kind(self) -> str:
117
+ """Short string tag for the noise type."""
118
+ return self.__class__.__name__
119
+
120
+
121
+ # ============================================================================
122
+ # White noise (recovers current constant-scalar behaviour)
123
+ # ============================================================================
124
+
125
+
126
+ class WhiteNoise(NoiseModel):
127
+ r"""Spatially uncorrelated Gaussian noise: ``B = sqrt(2σ) I``.
128
+
129
+ Each grid site receives i.i.d. ``N(0, 2σ dt)`` noise per component.
130
+ This recovers the current ``D = σ`` (scalar constant) behaviour.
131
+
132
+ Parameters
133
+ ----------
134
+ sigma : float
135
+ Scalar diffusion coefficient (the *D* in ``dx = F dt + sqrt(2D) dW``).
136
+ n_fields : int
137
+ Number of field components per site.
138
+ """
139
+
140
+ def __init__(self, sigma: float, *, n_fields: int = 1) -> None:
141
+ super().__init__(n_fields=n_fields)
142
+ if sigma < 0:
143
+ raise ValueError(f"sigma must be non-negative, got {sigma}")
144
+ self._sigma = float(sigma)
145
+ # Precompute sqrt(2σ) for efficiency
146
+ self._sqrt2sigma = float(jnp.sqrt(2.0 * sigma))
147
+
148
+ @property
149
+ def sigma(self) -> float:
150
+ return self._sigma
151
+
152
+ def sample(self, key: Array, x: Array, extras: dict) -> Array:
153
+ xi = random.normal(key, shape=x.shape)
154
+ return self._sqrt2sigma * xi
155
+
156
+ def effective_D_per_site(self, extras: dict) -> Array:
157
+ return self._sigma * jnp.eye(self._n_fields)
158
+
159
+ def __repr__(self) -> str:
160
+ return f"WhiteNoise(sigma={self._sigma}, n_fields={self._n_fields})"
161
+
162
+
163
+ # ============================================================================
164
+ # Conserved noise (sqrt(-σ² ∇²) via FFT on periodic grids)
165
+ # ============================================================================
166
+
167
+
168
+ def _build_freq_amplitudes(
169
+ grid_shape: Sequence[int],
170
+ dx: Union[float, Sequence[float]],
171
+ ndim: int,
172
+ ) -> Array:
173
+ r"""Build the Fourier-space multiplier ``|k|`` for conserved noise.
174
+
175
+ For a periodic grid with spacing *dx*, the wavenumbers along axis α are
176
+
177
+ .. math::
178
+
179
+ k_\alpha = \frac{2\pi\,n_\alpha}{N_\alpha\,\Delta x_\alpha}
180
+
181
+ and the multiplier is ``|k| = sqrt(sum_α k_α²)``, which corresponds to
182
+ the operator ``sqrt(-∇²)`` in Fourier space.
183
+
184
+ We use ``rfft`` along the last spatial axis, so the returned array has
185
+ shape ``(N_0, N_1, ..., N_{d-2}, N_{d-1}//2+1)`` for an *ndim*-D grid.
186
+
187
+ The ``k = 0`` mode is set to zero (conserved noise has zero mean).
188
+
189
+ Returns
190
+ -------
191
+ Array, real, shape matching rfft output
192
+ ``|k|`` on the half-complex grid.
193
+ """
194
+ grid_shape = tuple(int(n) for n in grid_shape)
195
+ if isinstance(dx, (int, float)):
196
+ dx_arr = [float(dx)] * ndim
197
+ else:
198
+ dx_arr = [float(d) for d in dx]
199
+
200
+ # Build k² = sum_α k_α²
201
+ k_sq = jnp.zeros(grid_shape[:-1] + (grid_shape[-1] // 2 + 1,))
202
+ for axis in range(ndim):
203
+ N = grid_shape[axis]
204
+ if axis < ndim - 1:
205
+ # Full-size axis: use fftfreq
206
+ freq = jnp.fft.fftfreq(N, d=dx_arr[axis]) # shape (N,)
207
+ else:
208
+ # Last axis: rfft convention → only non-negative frequencies
209
+ freq = jnp.fft.rfftfreq(N, d=dx_arr[axis]) # shape (N//2+1,)
210
+
211
+ k_alpha = 2 * jnp.pi * freq # angular wavenumber
212
+
213
+ # Broadcast to the full rfft grid shape
214
+ shape = [1] * ndim
215
+ if axis < ndim - 1:
216
+ shape[axis] = N
217
+ else:
218
+ shape[axis] = grid_shape[-1] // 2 + 1
219
+ k_alpha = k_alpha.reshape(shape)
220
+
221
+ k_sq = k_sq + k_alpha**2
222
+
223
+ # |k| = sqrt(k²), with k=0 mode zeroed out
224
+ k_abs = jnp.sqrt(k_sq)
225
+ return k_abs
226
+
227
+
228
+ class ConservedNoise(NoiseModel):
229
+ r"""Conserved (divergence-form) noise on a periodic square grid.
230
+
231
+ Implements noise of the form
232
+
233
+ .. math::
234
+
235
+ \eta(x, t) = \nabla \cdot \bigl(\sigma\, \vec{\Lambda}(x,t)\bigr)
236
+
237
+ where :math:`\vec{\Lambda}` is spatiotemporal white vector noise.
238
+ In Fourier space this is equivalent to multiplying each mode by
239
+ :math:`|k|`:
240
+
241
+ .. math::
242
+
243
+ \hat{\eta}_k = \sigma\,|k|\,\hat{\xi}_k
244
+
245
+ This noise **conserves the spatial average** of the field
246
+ (:math:`\sum_i \phi_i` is a constant of the noise process), as
247
+ required by Model B / Active Model B+ dynamics.
248
+
249
+ Parameters
250
+ ----------
251
+ sigma : float
252
+ Noise amplitude (the :math:`\sigma` in the equations above).
253
+ This is the *continuum* amplitude; the grid discretisation is
254
+ handled internally.
255
+ grid_shape : sequence of int
256
+ Grid dimensions ``(Nx, Ny, ...)`` — must match the simulation grid.
257
+ dx : float or sequence of float
258
+ Grid spacing (uniform or per-axis).
259
+ n_fields : int
260
+ Number of field components per site.
261
+
262
+ Notes
263
+ -----
264
+ The ``sample`` method uses real FFT (``rfftn`` / ``irfftn``) for
265
+ efficiency. It draws white noise in real space, transforms to
266
+ Fourier space, multiplies by :math:`\sigma\,|k|\,\sqrt{2/\Delta V}`
267
+ (where :math:`\Delta V = \prod \Delta x_\alpha` is the cell volume),
268
+ and transforms back.
269
+
270
+ The factor :math:`1/\sqrt{\Delta V}` provides the correct continuum
271
+ limit: the noise covariance
272
+ :math:`\langle\eta_i\,\eta_j\rangle = -\sigma^2 \nabla^2 \delta_{ij} / \Delta V`
273
+ is independent of grid resolution when *sigma* is held fixed.
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ sigma: float,
279
+ *,
280
+ grid_shape: Sequence[int],
281
+ dx: Union[float, Sequence[float]] = 1.0,
282
+ n_fields: int = 1,
283
+ ) -> None:
284
+ super().__init__(n_fields=n_fields)
285
+ if sigma < 0:
286
+ raise ValueError(f"sigma must be non-negative, got {sigma}")
287
+ self._sigma = float(sigma)
288
+ self._grid_shape = tuple(int(n) for n in grid_shape)
289
+ self._ndim = len(self._grid_shape)
290
+
291
+ if isinstance(dx, (int, float)):
292
+ self._dx = tuple([float(dx)] * self._ndim)
293
+ else:
294
+ self._dx = tuple(float(d) for d in dx)
295
+
296
+ self._P = 1
297
+ for n in self._grid_shape:
298
+ self._P *= n
299
+
300
+ # Cell volume for continuum-limit normalisation
301
+ dV = 1.0
302
+ for d in self._dx:
303
+ dV *= d
304
+ self._dV = dV
305
+
306
+ # Precompute |k| array for rfft
307
+ self._k_abs = _build_freq_amplitudes(self._grid_shape, self._dx, self._ndim)
308
+
309
+ # Combined multiplier: sigma * |k| * sqrt(2 / dV)
310
+ # The sqrt(2) enters because the integrator step is
311
+ # x += sqrt(dt) * sample(...)
312
+ # and we need <sample_i sample_j> = 2 * D_eff * delta_{ij}
313
+ # where D_eff is the noise operator.
314
+ self._multiplier = self._sigma * self._k_abs * jnp.sqrt(2.0 / self._dV)
315
+
316
+ # Effective per-site D for inference approximation:
317
+ # Var(noise_i) = sigma² * <|k|²> / dV
318
+ # where <|k|²> = (1/N) sum_k |k|² (excluding k=0)
319
+ k_sq_mean = float(jnp.mean(self._k_abs**2))
320
+ self._D_eff = float(self._sigma**2 * k_sq_mean / self._dV)
321
+
322
+ @property
323
+ def sigma(self) -> float:
324
+ return self._sigma
325
+
326
+ @property
327
+ def grid_shape(self) -> Tuple[int, ...]:
328
+ return self._grid_shape
329
+
330
+ def sample(self, key: Array, x: Array, extras: dict) -> Array:
331
+ r"""Draw one conserved-noise increment.
332
+
333
+ Steps:
334
+ 1. Draw white noise ξ ~ N(0,1) on the grid, shape (Nx, Ny, ..., d)
335
+ 2. rFFT along spatial axes
336
+ 3. Multiply by :math:`\sigma\,|k|\,\sqrt{2/\Delta V}` (the ``_multiplier``)
337
+ 4. iFFT back to real space
338
+ 5. Flatten back to (P, d)
339
+
340
+ The k=0 mode of ``_multiplier`` is zero, so sum_i η_i = 0 exactly.
341
+ """
342
+ d = self._n_fields
343
+ grid_d = self._grid_shape + (d,)
344
+
345
+ # Draw white noise on the grid
346
+ xi = random.normal(key, shape=grid_d)
347
+
348
+ # FFT axes = spatial only (not the field axis)
349
+ fft_axes = tuple(range(self._ndim))
350
+
351
+ # Forward real FFT along spatial axes
352
+ xi_hat = jnp.fft.rfftn(xi, axes=fft_axes)
353
+
354
+ # Multiply by the precomputed |k|*sigma*sqrt(2/dV)
355
+ # multiplier shape: (Nx, ..., Ny//2+1) — broadcast over field axis d
356
+ eta_hat = xi_hat * self._multiplier[..., None]
357
+
358
+ # Inverse real FFT
359
+ eta = jnp.fft.irfftn(eta_hat, s=self._grid_shape, axes=fft_axes)
360
+
361
+ # Flatten spatial axes: (Nx, Ny, ..., d) → (P, d)
362
+ return eta.reshape(self._P, d)
363
+
364
+ def effective_D_per_site(self, extras: dict) -> Array:
365
+ return self._D_eff * jnp.eye(self._n_fields)
366
+
367
+ def __repr__(self) -> str:
368
+ return (
369
+ f"ConservedNoise(sigma={self._sigma}, "
370
+ f"grid_shape={self._grid_shape}, dx={self._dx}, "
371
+ f"n_fields={self._n_fields})"
372
+ )
373
+
374
+
375
+ # ============================================================================
376
+ # Composite noise (different models on different field components)
377
+ # ============================================================================
378
+
379
+
380
+ class CompositeNoise(NoiseModel):
381
+ r"""Apply different noise models to different field components.
382
+
383
+ Useful when some fields have conserved dynamics (e.g. concentration)
384
+ and others have non-conserved dynamics (e.g. velocity).
385
+
386
+ Parameters
387
+ ----------
388
+ components : list of ``(NoiseModel, field_indices)`` pairs
389
+ Each element specifies a noise model and the field indices it
390
+ applies to. ``field_indices`` is a list of ints. Together the
391
+ indices must cover ``range(n_fields)`` exactly once.
392
+
393
+ Example
394
+ -------
395
+ >>> conserved = ConservedNoise(sigma=0.3, grid_shape=(64, 64), n_fields=1)
396
+ >>> white = WhiteNoise(sigma=0.1, n_fields=2)
397
+ >>> composite = CompositeNoise(
398
+ ... components=[(conserved, [0]), (white, [1, 2])],
399
+ ... n_fields=3,
400
+ ... )
401
+ """
402
+
403
+ def __init__(
404
+ self,
405
+ *,
406
+ components: List[Tuple[NoiseModel, List[int]]],
407
+ n_fields: int,
408
+ ) -> None:
409
+ super().__init__(n_fields=n_fields)
410
+
411
+ # Validate coverage
412
+ all_indices: set[int] = set()
413
+ for model, indices in components:
414
+ idx_set = set(indices)
415
+ if idx_set & all_indices:
416
+ raise ValueError(f"Overlapping field indices: {idx_set & all_indices}")
417
+ all_indices |= idx_set
418
+ if all_indices != set(range(n_fields)):
419
+ raise ValueError(f"Field indices must cover range({n_fields}), got {sorted(all_indices)}")
420
+
421
+ self._components = components
422
+
423
+ def sample(self, key: Array, x: Array, extras: dict) -> Array:
424
+ result = jnp.zeros_like(x)
425
+ for i, (model, indices) in enumerate(self._components):
426
+ key, sub = random.split(key)
427
+ # Extract the sub-state for this component's fields
428
+ idx = jnp.array(indices)
429
+ x_sub = x[..., idx]
430
+ noise_sub = model.sample(sub, x_sub, extras)
431
+ # Place back into the full field
432
+ result = result.at[..., idx].set(noise_sub)
433
+ return result
434
+
435
+ def effective_D_per_site(self, extras: dict) -> Array:
436
+ D = jnp.zeros((self._n_fields, self._n_fields))
437
+ for model, indices in self._components:
438
+ D_sub = model.effective_D_per_site(extras)
439
+ for i_local, i_global in enumerate(indices):
440
+ for j_local, j_global in enumerate(indices):
441
+ D = D.at[i_global, j_global].set(D_sub[i_local, j_local])
442
+ return D
443
+
444
+ def __repr__(self) -> str:
445
+ parts = [f"({m!r}, {idx})" for m, idx in self._components]
446
+ return f"CompositeNoise(components=[{', '.join(parts)}], n_fields={self._n_fields})"