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,402 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence, Set, Tuple
5
+
6
+ if TYPE_CHECKING:
7
+ from .timeops import TimeOp
8
+
9
+ import jax
10
+ import jax.numpy as jnp
11
+ import opt_einsum as oe # required
12
+
13
+ __all__ = ["ExprOperand", "TimeOperand", "ConstOperand", "Term", "Integrand"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ExprOperand:
18
+ """
19
+ Wrap a state expression evaluated at x (and optionally v).
20
+
21
+ Contract
22
+ --------
23
+ expr(x, v=..., mask=..., extras=..., params=...) -> array
24
+ - features sit on the last axis
25
+ - 'mask' is forwarded from the 'mask_out' stream when present
26
+ """
27
+
28
+ expr: object
29
+ x: "TimeOp"
30
+ v: Optional["TimeOp"] = None
31
+ # Template / default parameters. Call-time params may override this.
32
+ params_template: Optional[Mapping] = None
33
+ alias: str = "E"
34
+
35
+ @property
36
+ def required_extras(self) -> Tuple[str, ...]:
37
+ """Keys this expression demands in the extras mapping."""
38
+ re = getattr(self.expr, "required_extras", ()) or ()
39
+ return tuple(re)
40
+
41
+ @property
42
+ def requires(self) -> Set[str]:
43
+ req = set(self.x.requires)
44
+ if self.v is not None:
45
+ req |= set(self.v.requires)
46
+ if self.required_extras:
47
+ req.add("extras") # ensure collection streams extras
48
+ return req
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class TimeOperand:
53
+ """Time operand (scalar/vector/tensor) built from streams via a TimeOp."""
54
+
55
+ op: "TimeOp"
56
+ alias: str = "T"
57
+
58
+ @property
59
+ def requires(self) -> Set[str]:
60
+ return set(self.op.requires)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ConstOperand:
65
+ """Constant array captured by the program (e.g. A_inv)."""
66
+
67
+ value: jnp.ndarray
68
+ alias: str = "C"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Term:
73
+ """
74
+ One einsum contraction over the named operands.
75
+
76
+ Axis conventions
77
+ ----------------
78
+ i : particle axis (kept until the integrator reduces over i)
79
+ m,n,... : spatial indices
80
+ a,b,... : feature axes (always last on any ExprOperand output)
81
+ """
82
+
83
+ eq: str
84
+ ops: Tuple[str, ...]
85
+ scale: float = 1.0
86
+
87
+
88
+ class Integrand:
89
+ """
90
+ Compose state expressions and time operands via einsum per time slice.
91
+
92
+ ``require()`` -> ``Set[str]``
93
+ Stream keys needed to evaluate this program on one time slice.
94
+
95
+ ``__call__(**streams)`` -> ``jnp.ndarray``
96
+ Evaluate on one time slice. The collection handles mask and reduction.
97
+
98
+ ``estimate_bytes_per_sample(sample_streams)`` -> ``Optional[int]``
99
+ Upper bound on per-particle bytes using a real sample (via opt_einsum).
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ exprs: Sequence[ExprOperand] = (),
105
+ times: Sequence[TimeOperand] = (),
106
+ consts: Sequence[ConstOperand] = (),
107
+ terms: Sequence[Term] = (),
108
+ ):
109
+ self._check_aliases(exprs, times, consts)
110
+ self._exprs: Dict[str, ExprOperand] = {e.alias: e for e in exprs}
111
+ self._times: Dict[str, TimeOperand] = {t.alias: t for t in times}
112
+ self._consts: Dict[str, ConstOperand] = {c.alias: c for c in consts}
113
+ self._terms: Tuple[Term, ...] = tuple(terms)
114
+
115
+ @staticmethod
116
+ def _check_aliases(
117
+ exprs: Sequence[ExprOperand],
118
+ times: Sequence[TimeOperand],
119
+ consts: Sequence[ConstOperand],
120
+ ) -> None:
121
+ seen: Dict[str, str] = {}
122
+ groups = [("expr", exprs), ("time", times), ("const", consts)]
123
+ for kind, ops in groups:
124
+ for op in ops:
125
+ alias = op.alias # type: ignore[union-attr]
126
+ if alias in seen:
127
+ raise ValueError(
128
+ f"Duplicate alias {alias!r}: already registered as "
129
+ f"{seen[alias]}, now also as {kind}."
130
+ )
131
+ seen[alias] = kind
132
+
133
+ @staticmethod
134
+ def _merge_operand_dicts(
135
+ d1: Dict[str, Any],
136
+ d2: Dict[str, Any],
137
+ kind: str,
138
+ ) -> Dict[str, Any]:
139
+ """Merge two alias→operand dicts; allow same-object sharing, raise on conflict."""
140
+ merged = dict(d1)
141
+ for alias, op in d2.items():
142
+ if alias in merged:
143
+ if merged[alias] is not op:
144
+ raise ValueError(
145
+ f"Cannot add Integrands: alias {alias!r} maps to "
146
+ f"different {kind} operands in the two summands."
147
+ )
148
+ else:
149
+ merged[alias] = op
150
+ return merged
151
+
152
+ # ---------- integration API ----------
153
+ def require(self) -> Set[str]:
154
+ req = set()
155
+ for e in self._exprs.values():
156
+ req |= e.requires
157
+ for t in self._times.values():
158
+ req |= t.requires
159
+ return req
160
+
161
+ def required_extras(self) -> Set[str]:
162
+ keys: Set[str] = set()
163
+ for E in self._exprs.values():
164
+ keys.update(E.required_extras)
165
+ return keys
166
+
167
+ def __call__(self, *, params: Optional[Any] = None, **streams):
168
+ bufs: Dict[str, jnp.ndarray] = {}
169
+
170
+ # --- early extras validation (fail fast, clear errors)
171
+ extras = streams.get("extras", None)
172
+ for a, E in self._exprs.items():
173
+ if E.required_extras:
174
+ try:
175
+ # StateExpr provides the validator
176
+ E.expr._validate_extras_presence(extras) # type: ignore[attr-defined]
177
+ except KeyError:
178
+ avail = sorted(list(extras.keys())) if isinstance(extras, Mapping) else None
179
+ msg = f"[{a}] missing extras {list(E.required_extras)}"
180
+ if avail is not None:
181
+ msg += f"; available: {avail}"
182
+ raise KeyError(msg) from None
183
+
184
+ x = E.x(**streams)
185
+ v = None if E.v is None else E.v(**streams)
186
+ kwargs: Dict[str, Any] = {}
187
+ if v is not None:
188
+ kwargs["v"] = v
189
+ if "mask_out" in streams and streams["mask_out"] is not None:
190
+ kwargs["mask"] = streams["mask_out"]
191
+ if "extras" in streams:
192
+ kwargs["extras"] = streams["extras"]
193
+
194
+ # Parameter routing:
195
+ # - if params is not None: use it as the actual parameter object
196
+ # - else: fall back to the operand's params_template
197
+ par = E.params_template if params is None else params
198
+ if par is not None:
199
+ kwargs["params"] = par
200
+
201
+ bufs[a] = E.expr(x, **kwargs)
202
+
203
+ # time operands
204
+ for a, T in self._times.items():
205
+ bufs[a] = T.op(**streams)
206
+
207
+ # constants
208
+ for a, C in self._consts.items():
209
+ bufs[a] = C.value
210
+
211
+ # contractions
212
+ out = None
213
+ for term in self._terms:
214
+ args = [bufs[a] for a in term.ops]
215
+ val = jnp.einsum(term.eq, *args)
216
+ out = term.scale * val if out is None else out + term.scale * val
217
+ return out
218
+
219
+ def batch_call(self, *, params: Optional[Any] = None, **streams):
220
+ """Evaluate with streams that carry a leading batch (K) axis.
221
+
222
+ This is the batched counterpart of :meth:`__call__`. Streams such as
223
+ ``X`` have shape ``(K, N, d)`` instead of ``(N, d)``, and ``dt`` has
224
+ shape ``(K,)`` instead of being a scalar.
225
+
226
+ State-expression operands receive the full ``(K, N, d)`` tensor and
227
+ handle arbitrary leading batch dimensions internally (the leaf's
228
+ ``_apply_user_func`` flattens the batch prefix and uses a single
229
+ ``jax.vmap``).
230
+
231
+ Time operands (e.g. velocity) are evaluated on the batch streams
232
+ directly; the batch-safe ``velocity`` TimeOp handles dt broadcasting.
233
+
234
+ Einsum contractions are vmapped over the leading K axis so that
235
+ existing subscript strings (which reference particle/spatial/feature
236
+ axes only) work unchanged.
237
+
238
+ Returns
239
+ -------
240
+ jax.Array
241
+ Result with a leading ``K`` axis. Shape is
242
+ ``(K, <per-row output shape>)``.
243
+ """
244
+ bufs: Dict[str, jnp.ndarray] = {}
245
+
246
+ # --- extras validation (same as __call__) ---
247
+ extras = streams.get("extras", None)
248
+ for a, E in self._exprs.items():
249
+ if E.required_extras:
250
+ try:
251
+ E.expr._validate_extras_presence(extras)
252
+ except KeyError:
253
+ avail = sorted(list(extras.keys())) if isinstance(extras, Mapping) else None
254
+ msg = f"[{a}] missing extras {list(E.required_extras)}"
255
+ if avail is not None:
256
+ msg += f"; available: {avail}"
257
+ raise KeyError(msg) from None
258
+
259
+ x = E.x(**streams) # (K, N, d)
260
+ v = None if E.v is None else E.v(**streams) # (K, N, d) or None
261
+ kwargs: Dict[str, Any] = {}
262
+ if v is not None:
263
+ kwargs["v"] = v
264
+ if "mask_out" in streams and streams["mask_out"] is not None:
265
+ kwargs["mask"] = streams["mask_out"] # (K, N)
266
+ if "extras" in streams:
267
+ kwargs["extras"] = streams["extras"]
268
+
269
+ par = E.params_template if params is None else params
270
+ if par is not None:
271
+ kwargs["params"] = par
272
+
273
+ bufs[a] = E.expr(x, **kwargs)
274
+ # Output: (K, N, ..., F) or (K, ..., F) depending on pdepth/rank
275
+
276
+ # --- time operands: call directly if batch_safe, else vmap over K ---
277
+ for a, T in self._times.items():
278
+ if getattr(T.op, "batch_safe", False):
279
+ # TimeOp already handles leading batch dims — call directly
280
+ bufs[a] = T.op(**streams)
281
+ else:
282
+ req_keys = T.op.requires
283
+ req_streams = {k: streams[k] for k in req_keys if k in streams}
284
+ # Separate batched (ndim≥1) vs scalar entries for in_axes
285
+ in_axes_t = {k: 0 if v.ndim >= 1 else None for k, v in req_streams.items()}
286
+ bufs[a] = jax.vmap(lambda s: T.op(**s), in_axes=(in_axes_t,))(req_streams)
287
+
288
+ # --- constants ---
289
+ for a, C in self._consts.items():
290
+ bufs[a] = C.value
291
+
292
+ # --- contractions: vmap each einsum over the leading K axis ---
293
+ const_aliases = frozenset(self._consts.keys())
294
+ out = None
295
+ for term in self._terms:
296
+ operands = [bufs[a] for a in term.ops]
297
+ # Constants have no K axis → in_axes=None; others → 0
298
+ in_axes = tuple(None if a in const_aliases else 0 for a in term.ops)
299
+ val = jax.vmap(
300
+ lambda *args, _eq=term.eq: jnp.einsum(_eq, *args),
301
+ in_axes=in_axes,
302
+ )(*operands)
303
+ out = term.scale * val if out is None else out + term.scale * val
304
+
305
+ return out
306
+
307
+ # ---------- sugar for linear combos ----------
308
+ def __add__(self, other: "Integrand") -> "Integrand":
309
+ me = self._merge_operand_dicts(self._exprs, other._exprs, "expr")
310
+ mt = self._merge_operand_dicts(self._times, other._times, "time")
311
+ mc = self._merge_operand_dicts(self._consts, other._consts, "const")
312
+ return Integrand(
313
+ exprs=list(me.values()),
314
+ times=list(mt.values()),
315
+ consts=list(mc.values()),
316
+ terms=[*self._terms, *other._terms],
317
+ )
318
+
319
+ def __radd__(self, other): # allow sum([...], start=0)
320
+ return self if other == 0 else NotImplemented
321
+
322
+ def __mul__(self, alpha: float) -> "Integrand":
323
+ return Integrand(
324
+ exprs=self._exprs.values(),
325
+ times=self._times.values(),
326
+ consts=self._consts.values(),
327
+ terms=[Term(eq=t.eq, ops=t.ops, scale=t.scale * alpha) for t in self._terms],
328
+ )
329
+
330
+ __rmul__ = __mul__
331
+
332
+ # ---------- memory hint on a real sample ----------
333
+ def estimate_bytes_per_sample(
334
+ self,
335
+ sample_streams: Mapping[str, jnp.ndarray],
336
+ *,
337
+ dtypesize: int = 4,
338
+ ) -> Optional[int]:
339
+ """
340
+ Conservative upper bound in bytes per **time-sample** (one k-row).
341
+ Uses StateExpr static hints + shapes-only einsum path. No evaluation.
342
+ """
343
+ _X = sample_streams.get("X", None)
344
+ _dtype = None if _X is None else _X.dtype
345
+ P = int(sample_streams["N_total"]) # use provided, conservative
346
+
347
+ # ---- shapes without evaluation
348
+ def expr_shape(E) -> tuple[int, ...]:
349
+ p = int(getattr(E.expr, "pdepth", 0))
350
+ r = int(getattr(E.expr, "rank", 0))
351
+ d = int(getattr(E.expr, "dim", 0))
352
+ F = int(getattr(E.expr, "n_features", 1))
353
+ paxes = () if p == 0 else (P,) * p # full particle axes (per-sample)
354
+ return (*paxes, *(d,) * r, F)
355
+
356
+ def time_shape(T) -> tuple[int, ...]:
357
+ arr = T.op(**sample_streams) # shape-only probe
358
+ return tuple(arr.shape)
359
+
360
+ def const_shape(C) -> tuple[int, ...]:
361
+ return tuple(C.value.shape)
362
+
363
+ shapes = {}
364
+ for a, E in self._exprs.items():
365
+ shapes[a] = expr_shape(E)
366
+ for a, T in self._times.items():
367
+ shapes[a] = time_shape(T)
368
+ for a, C in self._consts.items():
369
+ shapes[a] = const_shape(C)
370
+
371
+ def match_len(shape: tuple[int, ...], subscript: str) -> tuple[int, ...]:
372
+ L = len(subscript)
373
+ return (*shape, *([1] * (L - len(shape)))) if len(shape) < L else shape
374
+
375
+ def size_for_term(t: Term) -> int:
376
+ lhs = t.eq.split("->", 1)[0]
377
+ subs = [s.strip() for s in lhs.split(",")]
378
+ ops_shapes = [match_len(shapes[a], subs[i]) for i, a in enumerate(t.ops)]
379
+ try:
380
+ _, info = oe.contract_path(t.eq, *ops_shapes, shapes=True, optimize="greedy")
381
+ if hasattr(info, "largest_intermediate"):
382
+ return int(info.largest_intermediate) * int(dtypesize)
383
+ if hasattr(info, "intermediate_shapes") and info.intermediate_shapes:
384
+ mx = max(int(jnp.prod(jnp.array(sh))) for sh in info.intermediate_shapes) # type: ignore
385
+ return mx * int(dtypesize)
386
+ except Exception:
387
+ pass
388
+ total_el = sum(int(jnp.prod(jnp.array(s))) for s in ops_shapes)
389
+ return total_el * int(dtypesize)
390
+
391
+ peak_terms = max((size_for_term(t) for t in self._terms), default=0)
392
+
393
+ state_bytes = sum(int(jnp.prod(jnp.array(shapes[a]))) * int(dtypesize) for a in self._exprs.keys())
394
+
395
+ expr_transient = 0
396
+ for E in self._exprs.values():
397
+ estimator = getattr(E.expr, "estimate_bytes_per_sample", None)
398
+ if callable(estimator):
399
+ expr_transient += int(estimator(dtype=_dtype, particle_size=P))
400
+
401
+ total = expr_transient + state_bytes + peak_terms
402
+ return int(total) if total > 0 else None
SFI/integrate/rk4.py ADDED
@@ -0,0 +1,156 @@
1
+ # RK4 ODE flow integrator for parametric windowed inference.
2
+ """
3
+ rk4.py
4
+ ======
5
+ Classical fourth-order Runge-Kutta integrator in JAX, suitable for
6
+ differentiating through the ODE flow via ``jax.jacobian`` / ``jax.hessian``.
7
+ """
8
+
9
+ import jax
10
+
11
+ __all__ = [
12
+ "rk4_step",
13
+ "euler_step",
14
+ "euler_flow",
15
+ "ode_flow",
16
+ "select_flow",
17
+ ]
18
+
19
+
20
+ def rk4_step(f, x, h):
21
+ """One classical fourth-order Runge-Kutta step.
22
+
23
+ Parameters
24
+ ----------
25
+ f : callable (d,) → (d,)
26
+ Autonomous vector field, JAX-traceable.
27
+ x : array (d,)
28
+ Current state.
29
+ h : scalar
30
+ Step size.
31
+
32
+ Returns
33
+ -------
34
+ x_new : array (d,)
35
+ State after one RK4 step.
36
+ """
37
+ k1 = f(x)
38
+ k2 = f(x + 0.5 * h * k1)
39
+ k3 = f(x + 0.5 * h * k2)
40
+ k4 = f(x + h * k3)
41
+ return x + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
42
+
43
+
44
+ def euler_step(f, x, h):
45
+ """One forward-Euler step.
46
+
47
+ Parameters
48
+ ----------
49
+ f : callable (d,) → (d,)
50
+ Autonomous vector field, JAX-traceable.
51
+ x : array (d,)
52
+ Current state.
53
+ h : scalar
54
+ Step size.
55
+
56
+ Returns
57
+ -------
58
+ x_new : array (d,)
59
+ State after one Euler step.
60
+ """
61
+ return x + h * f(x)
62
+
63
+
64
+ def _generic_flow(step_fn, f, x0, dt, n_substeps):
65
+ """Integrate dx/dt = f(x) from *x0* over total time *dt* using *step_fn*.
66
+
67
+ Uses ``jax.lax.scan`` so the full computation is JAX-traceable and
68
+ differentiable.
69
+ """
70
+ if n_substeps < 1:
71
+ raise ValueError(
72
+ f"n_substeps must be >= 1, got {n_substeps}. "
73
+ "Use integrator='euler' with n_substeps=1 for the "
74
+ "single-step Euler mode."
75
+ )
76
+ h = dt / n_substeps
77
+
78
+ def body(x, _):
79
+ return step_fn(f, x, h), None
80
+
81
+ x_final, _ = jax.lax.scan(body, x0, None, length=n_substeps)
82
+ return x_final
83
+
84
+
85
+ def euler_flow(f, x0, dt, n_substeps):
86
+ """Integrate dx/dt = f(x) from *x0* over total time *dt* using Euler.
87
+
88
+ Uses *n_substeps* forward-Euler micro-steps each of size
89
+ ``h = dt / n_substeps``. The loop is implemented via
90
+ ``jax.lax.scan`` so the full computation is JAX-traceable and
91
+ differentiable.
92
+
93
+ Parameters
94
+ ----------
95
+ f : callable (d,) → (d,)
96
+ Drift vector field (parameters should already be closed over).
97
+ x0 : array (d,)
98
+ Initial state.
99
+ dt : scalar
100
+ Total integration interval.
101
+ n_substeps : int (**static** Python int, not a JAX tracer)
102
+ Number of Euler micro-steps. Must be a compile-time constant.
103
+
104
+ Returns
105
+ -------
106
+ x_final : array (d,)
107
+ State at time *dt*.
108
+ """
109
+ return _generic_flow(euler_step, f, x0, dt, n_substeps)
110
+
111
+
112
+ def ode_flow(f, x0, dt, n_substeps):
113
+ """Integrate dx/dt = f(x) from *x0* over total time *dt*.
114
+
115
+ Uses *n_substeps* RK4 micro-steps each of size ``h = dt / n_substeps``.
116
+ The loop is implemented via ``jax.lax.scan`` so the full computation is
117
+ JAX-traceable and differentiable.
118
+
119
+ Parameters
120
+ ----------
121
+ f : callable (d,) → (d,)
122
+ Drift vector field (parameters should already be closed over).
123
+ x0 : array (d,)
124
+ Initial state.
125
+ dt : scalar
126
+ Total integration interval.
127
+ n_substeps : int (**static** Python int, not a JAX tracer)
128
+ Number of RK4 micro-steps. Must be a compile-time constant.
129
+ Must be >= 1; use ``integrator='euler'`` for the Euler path.
130
+
131
+ Returns
132
+ -------
133
+ x_final : array (d,)
134
+ State at time *dt*.
135
+ """
136
+ return _generic_flow(rk4_step, f, x0, dt, n_substeps)
137
+
138
+
139
+ def select_flow(integrator: str):
140
+ """Return the ODE flow function for the given integrator name.
141
+
142
+ Parameters
143
+ ----------
144
+ integrator : {'rk4', 'euler'}
145
+
146
+ Returns
147
+ -------
148
+ flow : callable
149
+ Either :func:`ode_flow` or :func:`euler_flow`.
150
+ """
151
+ if integrator == "rk4":
152
+ return ode_flow
153
+ elif integrator == "euler":
154
+ return euler_flow
155
+ else:
156
+ raise ValueError(f"Unknown integrator {integrator!r}; expected 'rk4' or 'euler'")