tsdynamics 2.2.1__tar.gz → 2.3.0__tar.gz

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 (74) hide show
  1. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/.gitignore +8 -0
  2. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/PKG-INFO +1 -1
  3. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/__init__.py +1 -1
  4. tsdynamics-2.3.0/src/tsdynamics/backends/rustcore.py +307 -0
  5. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/chaotic_attractors.py +6 -0
  6. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/chem_bio_systems.py +3 -0
  7. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_ode_systems.py +3 -4
  8. tsdynamics-2.3.0/tests/test_rustcore.py +81 -0
  9. tsdynamics-2.3.0/tests/test_rustcore_translation.py +49 -0
  10. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/LICENSE +0 -0
  11. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/README.md +0 -0
  12. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/pyproject.toml +0 -0
  13. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/analysis/__init__.py +0 -0
  14. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/analysis/fixed_points.py +0 -0
  15. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/analysis/lyapunov.py +0 -0
  16. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/analysis/orbit_diagram.py +0 -0
  17. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/analysis/poincare.py +0 -0
  18. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/backends/__init__.py +0 -0
  19. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/backends/diffsol.py +0 -0
  20. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/base/__init__.py +0 -0
  21. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/base/base.py +0 -0
  22. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/base/dde_base.py +0 -0
  23. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/base/map_base.py +0 -0
  24. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/base/ode_base.py +0 -0
  25. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/base/protocol.py +0 -0
  26. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/__init__.py +0 -0
  27. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/_base.py +0 -0
  28. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/ensemble.py +0 -0
  29. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/poincare.py +0 -0
  30. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/projected.py +0 -0
  31. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/stroboscopic.py +0 -0
  32. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/tangent.py +0 -0
  33. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/derived/wrapped.py +0 -0
  34. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/py.typed +0 -0
  35. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/registry.py +0 -0
  36. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/sampling.py +0 -0
  37. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/__init__.py +0 -0
  38. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/__init__.py +0 -0
  39. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/climate_geophysics.py +0 -0
  40. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/coupled_systems.py +0 -0
  41. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/delayed_systems.py +0 -0
  42. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/exotic_systems.py +0 -0
  43. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/oscillatory_systems.py +0 -0
  44. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/physical_systems.py +0 -0
  45. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/continuous/population_dynamics.py +0 -0
  46. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/discrete/__init__.py +0 -0
  47. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/discrete/chaotic_maps.py +0 -0
  48. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/discrete/exotic_maps.py +0 -0
  49. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/discrete/geometric_maps.py +0 -0
  50. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/discrete/polynomial_maps.py +0 -0
  51. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/systems/discrete/population_maps.py +0 -0
  52. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/utils/__init__.py +0 -0
  53. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/utils/general.py +0 -0
  54. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/src/tsdynamics/utils/sagitta_dt.py +0 -0
  55. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/_sampling.py +0 -0
  56. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/conftest.py +0 -0
  57. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_analysis.py +0 -0
  58. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_base.py +0 -0
  59. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_cache_isolation.py +0 -0
  60. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_dde_systems.py +0 -0
  61. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_derived.py +0 -0
  62. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_diffsol_backend.py +0 -0
  63. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_diffsol_translation.py +0 -0
  64. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_jacobians.py +0 -0
  65. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_known_values.py +0 -0
  66. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_map_systems.py +0 -0
  67. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_ode_rhs_symbolic.py +0 -0
  68. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_protocol.py +0 -0
  69. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_registry.py +0 -0
  70. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_sampling.py +0 -0
  71. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_signatures.py +0 -0
  72. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_smoke.py +0 -0
  73. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_utils.py +0 -0
  74. {tsdynamics-2.2.1 → tsdynamics-2.3.0}/tests/test_wrapped.py +0 -0
@@ -175,3 +175,11 @@ src/tsdynamics/_version.py
175
175
  # Generated docs artifacts (figures render at build time, cache in .cache/)
176
176
  docs/assets/figures/systems/
177
177
  site/
178
+
179
+ # Rust core crate build artifacts (Cargo.lock IS committed for reproducible
180
+ # wheel builds — the crate ships a distributable artifact).
181
+ crates/*/target/
182
+ crates/*/dist/
183
+ crates/*/.venv/
184
+
185
+ crates/*/uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsdynamics
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: Compiled ODE/DDE integration, discrete maps, and chaos analysis for dynamical systems.
5
5
  Project-URL: Source, https://github.com/El3ssar/TSDynamics
6
6
  Project-URL: Documentation, https://el3ssar.github.io/TSDynamics/
@@ -48,7 +48,7 @@ from .systems import continuous as _continuous
48
48
  from .systems import discrete as _discrete
49
49
 
50
50
  # Single source of truth for the package version; rewritten by python-semantic-release.
51
- __version__ = "2.2.1"
51
+ __version__ = "2.3.0"
52
52
 
53
53
  # Re-export every system class at the top level so ``from tsdynamics import Lorenz``
54
54
  # works without users having to remember which submodule a system lives in.
@@ -0,0 +1,307 @@
1
+ """Experimental Rust-backed RHS evaluation and ensemble integration (``tsdynamics-core``).
2
+
3
+ The same symbolic ``_equations`` that feed the DiffSL backend are lowered here
4
+ to a flat *instruction tape* — single-static-assignment primitives over a
5
+ register file — which the Rust crate evaluates with no Python callbacks and no
6
+ runtime compiler. That makes ensemble integration GIL-free and rayon-parallel
7
+ (the basin/Monte-Carlo primitive), and is the foundation the future Rust SDE
8
+ and DDE solvers build on.
9
+
10
+ ``tsdynamics`` itself stays pure-Python; this module is inert unless the
11
+ optional accelerator is installed::
12
+
13
+ pip install tsdynamics-core # or the [rustcore] extra
14
+
15
+ Limitations (experimental): ODEs only; fixed-step explicit RK4 (no stiff
16
+ support — use the ``jitcode``/``diffsol`` backends for stiff systems); the RHS
17
+ must use functions the tape VM provides (the same set the DiffSL backend
18
+ supports). Unsupported constructs raise :class:`TapeCompileError`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+
28
+ __all__ = [
29
+ "TapeCompileError",
30
+ "available",
31
+ "compile_tape",
32
+ "ensemble_final",
33
+ "eval_rhs",
34
+ "integrate_dense",
35
+ ]
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Opcodes — MUST stay in sync with crates/tsdynamics-core/src/vm.rs
39
+ # ---------------------------------------------------------------------------
40
+ OP_CONST = 0
41
+ OP_STATE = 1
42
+ OP_PARAM = 2
43
+ OP_TIME = 3
44
+ OP_ADD = 10
45
+ OP_SUB = 11
46
+ OP_MUL = 12
47
+ OP_DIV = 13
48
+ OP_POW = 14 # regs[a] ** regs[b]
49
+ OP_POWI = 15 # regs[a] ** b (b is the integer exponent)
50
+ OP_NEG = 20
51
+ OP_RECIP = 21
52
+ _FUNC_OPS = {
53
+ "sin": 30,
54
+ "cos": 31,
55
+ "tan": 32,
56
+ "exp": 33,
57
+ "log": 34,
58
+ "sqrt": 35,
59
+ "Abs": 36,
60
+ "sign": 37,
61
+ "sinh": 38,
62
+ "cosh": 39,
63
+ "tanh": 40,
64
+ "asin": 41,
65
+ "acos": 42,
66
+ "atan": 43,
67
+ "asinh": 44,
68
+ "acosh": 45,
69
+ "atanh": 46,
70
+ }
71
+ _OP_SQRT = 35
72
+
73
+
74
+ class TapeCompileError(NotImplementedError):
75
+ """The system's RHS uses a construct the tape VM cannot express."""
76
+
77
+
78
+ def available() -> bool:
79
+ """Whether the optional ``tsdynamics-core`` accelerator is installed."""
80
+ try:
81
+ import tsdynamics_core # noqa: F401
82
+ except ImportError:
83
+ return False
84
+ return True
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Symbolic RHS → instruction tape
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class CompiledTape:
94
+ """A system RHS lowered to flat arrays for the Rust tape VM."""
95
+
96
+ ops: np.ndarray # int32 (n_instr,)
97
+ a: np.ndarray # int32
98
+ b: np.ndarray # int32
99
+ imm: np.ndarray # float64
100
+ outputs: np.ndarray # int32 (dim,) — register holding each derivative
101
+ n_state: int
102
+ n_param: int
103
+ control_names: list[str]
104
+
105
+
106
+ class _Emitter:
107
+ """Lower a sympy expression DAG to SSA instructions, sharing subexpressions."""
108
+
109
+ def __init__(self) -> None:
110
+ self.ops: list[int] = []
111
+ self.a: list[int] = []
112
+ self.b: list[int] = []
113
+ self.imm: list[float] = []
114
+ self._cache: dict[Any, int] = {}
115
+
116
+ def _push(self, op: int, a: int = 0, b: int = 0, imm: float = 0.0) -> int:
117
+ idx = len(self.ops)
118
+ self.ops.append(op)
119
+ self.a.append(a)
120
+ self.b.append(b)
121
+ self.imm.append(imm)
122
+ return idx
123
+
124
+ def emit(self, expr: Any) -> int:
125
+ cached = self._cache.get(expr)
126
+ if cached is not None:
127
+ return cached
128
+ idx = self._emit(expr)
129
+ self._cache[expr] = idx
130
+ return idx
131
+
132
+ def _emit(self, expr: Any) -> int:
133
+ import sympy
134
+
135
+ # Any symbol-free subexpression (numbers, pi, e, constant folds).
136
+ if not expr.free_symbols:
137
+ return self._push(OP_CONST, imm=float(expr))
138
+
139
+ if isinstance(expr, sympy.Symbol):
140
+ name = expr.name
141
+ if name == "t":
142
+ return self._push(OP_TIME)
143
+ if name.startswith("u"):
144
+ return self._push(OP_STATE, a=int(name[1:]))
145
+ if name.startswith("p"):
146
+ return self._push(OP_PARAM, a=int(name[1:]))
147
+ raise TapeCompileError(f"unexpected symbol {name!r} in RHS")
148
+
149
+ if isinstance(expr, sympy.Add):
150
+ args = expr.args
151
+ acc = self.emit(args[0])
152
+ for term in args[1:]:
153
+ acc = self._push(OP_ADD, a=acc, b=self.emit(term))
154
+ return acc
155
+
156
+ if isinstance(expr, sympy.Mul):
157
+ args = expr.args
158
+ acc = self.emit(args[0])
159
+ for fac in args[1:]:
160
+ acc = self._push(OP_MUL, a=acc, b=self.emit(fac))
161
+ return acc
162
+
163
+ if isinstance(expr, sympy.Pow):
164
+ return self._emit_pow(expr)
165
+
166
+ name = type(expr).__name__
167
+ op = _FUNC_OPS.get(name)
168
+ if op is not None:
169
+ if len(expr.args) != 1:
170
+ raise TapeCompileError(f"function {name!r} expects 1 argument")
171
+ return self._push(op, a=self.emit(expr.args[0]))
172
+
173
+ raise TapeCompileError(f"tape VM has no equivalent for {name!r}.")
174
+
175
+ def _emit_pow(self, expr: Any) -> int:
176
+ import sympy
177
+
178
+ base, exp = expr.base, expr.exp
179
+ base_reg = self.emit(base)
180
+ if isinstance(exp, sympy.Integer):
181
+ e = int(exp)
182
+ if e == -1:
183
+ return self._push(OP_RECIP, a=base_reg)
184
+ return self._push(OP_POWI, a=base_reg, b=e)
185
+ if exp == sympy.Rational(1, 2):
186
+ return self._push(_OP_SQRT, a=base_reg)
187
+ if exp == sympy.Rational(-1, 2):
188
+ sqrt_reg = self._push(_OP_SQRT, a=base_reg)
189
+ return self._push(OP_RECIP, a=sqrt_reg)
190
+ # general exponent: a constant non-integer power, or a symbolic exponent
191
+ return self._push(OP_POW, a=base_reg, b=self.emit(exp))
192
+
193
+
194
+ def compile_tape(system: Any) -> CompiledTape:
195
+ """
196
+ Lower a :class:`~tsdynamics.base.ContinuousSystem`'s RHS to a tape.
197
+
198
+ Structural parameters are folded to constants; control parameters become
199
+ inputs in ``control_names`` order (the layout the solve-time params vector
200
+ must follow).
201
+ """
202
+ import symengine
203
+ from jitcode import t as t_sym
204
+ from jitcode import y
205
+
206
+ dim = system.dim
207
+ struct_vals = system._structural_vals()
208
+ control_names = list(system._control_params())
209
+ control_syms = {k: symengine.Symbol(f"p{i}") for i, k in enumerate(control_names)}
210
+
211
+ exprs = list(type(system)._equations(y, t_sym, **{**struct_vals, **control_syms}))
212
+ if len(exprs) != dim:
213
+ raise ValueError(f"_equations must return {dim} expressions, got {len(exprs)}")
214
+
215
+ subs = {y(i): symengine.Symbol(f"u{i}") for i in range(dim)}
216
+ subs[t_sym] = symengine.Symbol("t")
217
+
218
+ em = _Emitter()
219
+ outputs = [em.emit(symengine.sympify(e).subs(subs)._sympy_()) for e in exprs]
220
+
221
+ return CompiledTape(
222
+ ops=np.asarray(em.ops, dtype=np.int32),
223
+ a=np.asarray(em.a, dtype=np.int32),
224
+ b=np.asarray(em.b, dtype=np.int32),
225
+ imm=np.asarray(em.imm, dtype=np.float64),
226
+ outputs=np.asarray(outputs, dtype=np.int32),
227
+ n_state=dim,
228
+ n_param=len(control_names),
229
+ control_names=control_names,
230
+ )
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Thin wrappers over the Rust kernels
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ def _require():
239
+ if not available():
240
+ raise ImportError(
241
+ "the Rust core is not installed: pip install tsdynamics-core (or the 'rustcore' extra)"
242
+ )
243
+ import tsdynamics_core
244
+
245
+ return tsdynamics_core
246
+
247
+
248
+ def _params_vec(system: Any, tape: CompiledTape) -> np.ndarray:
249
+ return np.asarray([float(system.params[k]) for k in tape.control_names], dtype=np.float64)
250
+
251
+
252
+ def _args(tape: CompiledTape):
253
+ return (tape.ops, tape.a, tape.b, tape.imm, tape.outputs, tape.n_state, tape.n_param)
254
+
255
+
256
+ def eval_rhs(
257
+ system: Any, u: Any, t: float = 0.0, *, tape: CompiledTape | None = None
258
+ ) -> np.ndarray:
259
+ """Evaluate ``du/dt`` once in Rust — used to cross-check the tape."""
260
+ core = _require()
261
+ tape = tape or compile_tape(system)
262
+ u = np.asarray(u, dtype=np.float64)
263
+ return np.asarray(core.eval_rhs(*_args(tape), u, _params_vec(system, tape), float(t)))
264
+
265
+
266
+ def integrate_dense(
267
+ system: Any,
268
+ ic: Any,
269
+ t_eval: Any,
270
+ *,
271
+ h: float | None = None,
272
+ tape: CompiledTape | None = None,
273
+ ) -> np.ndarray:
274
+ """Fixed-step RK4 trajectory at ``t_eval`` (internal step ``h``)."""
275
+ core = _require()
276
+ tape = tape or compile_tape(system)
277
+ t_eval = np.asarray(t_eval, dtype=np.float64)
278
+ if h is None:
279
+ h = float(np.min(np.diff(t_eval))) if t_eval.size > 1 else 1e-2
280
+ ic = np.asarray(ic, dtype=np.float64)
281
+ return np.asarray(
282
+ core.integrate_dense_py(*_args(tape), ic, _params_vec(system, tape), t_eval, float(h))
283
+ )
284
+
285
+
286
+ def ensemble_final(
287
+ system: Any,
288
+ u0_batch: Any,
289
+ t0: float,
290
+ t1: float,
291
+ *,
292
+ h: float = 1e-2,
293
+ tape: CompiledTape | None = None,
294
+ ) -> np.ndarray:
295
+ """Integrate a batch of initial conditions in parallel; return final states.
296
+
297
+ ``u0_batch`` is ``(n, dim)``; each row is integrated from ``t0`` to ``t1``
298
+ and its final state returned as row of the ``(n, dim)`` result.
299
+ """
300
+ core = _require()
301
+ tape = tape or compile_tape(system)
302
+ u0_batch = np.ascontiguousarray(u0_batch, dtype=np.float64)
303
+ return np.asarray(
304
+ core.integrate_ensemble_final_py(
305
+ *_args(tape), u0_batch, _params_vec(system, tape), float(t0), float(t1), float(h)
306
+ )
307
+ )
@@ -547,6 +547,9 @@ class MultiChua(ContinuousSystem):
547
547
  class Duffing(ContinuousSystem):
548
548
  params = {"alpha": 1.0, "beta": -1.0, "delta": 0.1, "gamma": 0.35, "omega": 1.4}
549
549
  dim = 3
550
+ # The explicit default (dopri5) fails to integrate this system; an implicit
551
+ # solver handles it robustly, so make that the default.
552
+ _default_method = "LSODA"
550
553
 
551
554
  @staticmethod
552
555
  def _equations(Y, t, *, alpha, beta, delta, gamma, omega):
@@ -889,6 +892,7 @@ class SprottK(ContinuousSystem):
889
892
  class SprottL(ContinuousSystem):
890
893
  params = {"a": 0.9, "b": 3.9}
891
894
  dim = 3
895
+ _default_method = "LSODA" # explicit default solver fails; use an implicit one
892
896
 
893
897
  @staticmethod
894
898
  def _equations(Y, t, *, a, b):
@@ -975,6 +979,7 @@ class SprottO(ContinuousSystem):
975
979
  class SprottP(ContinuousSystem):
976
980
  params = {"a": 2.7}
977
981
  dim = 3
982
+ _default_method = "LSODA" # explicit default solver fails; use an implicit one
978
983
 
979
984
  @staticmethod
980
985
  def _equations(Y, t, *, a):
@@ -1080,6 +1085,7 @@ class SprottMore(ContinuousSystem):
1080
1085
  class SprottJerk(ContinuousSystem):
1081
1086
  params = {"mu": 2.017}
1082
1087
  dim = 3
1088
+ _default_method = "LSODA" # explicit default solver fails; use an implicit one
1083
1089
 
1084
1090
  @staticmethod
1085
1091
  def _equations(Y, t, *, mu):
@@ -37,6 +37,9 @@ class Oregonator(ContinuousSystem):
37
37
  "epsilon": 1e-2,
38
38
  }
39
39
  dim = 3 # Three variables: X, Y, Z (reduced forms of the chemical species)
40
+ # Classic stiff system (Field–Noyes); an explicit solver cannot integrate
41
+ # it, so default to an implicit one.
42
+ _default_method = "LSODA"
40
43
 
41
44
  @staticmethod
42
45
  def _equations(Y, t, *, q, f, mu, epsilon):
@@ -134,10 +134,9 @@ def test_ode_full_integration_sweep(ode_entry) -> None:
134
134
  # get a fixed (seeded) draw so the sweep is reproducible, not flaky.
135
135
  np.random.seed(zlib.crc32(ode_entry.name.encode()) & 0xFFFFFFFF)
136
136
  ic = sys.resolve_ic(None)
137
- if ode_entry.name == "Oregonator": # stiff — needs an implicit solver
138
- traj = sys.integrate(ic=ic, final_time=1.0, dt=0.1, method="LSODA", rtol=1e-8, atol=1e-10)
139
- else:
140
- traj = sys.integrate(ic=ic, final_time=2.0, dt=0.1, rtol=1e-5, atol=1e-7)
137
+ # Each system carries its own _default_method (stiff systems default to an
138
+ # implicit solver), so the plain default path must integrate them all.
139
+ traj = sys.integrate(ic=ic, final_time=2.0, dt=0.1, rtol=1e-5, atol=1e-7)
141
140
  assert traj.y.shape[1] == sys.dim
142
141
  assert np.all(np.isfinite(traj.y))
143
142
 
@@ -0,0 +1,81 @@
1
+ """
2
+ Numeric validation of the ``tsdynamics-core`` Rust kernels.
3
+
4
+ Skipped entirely unless the optional accelerator is installed. These tests
5
+ prove the tape VM reproduces the symbolic RHS, that the RK4 stepper matches a
6
+ high-accuracy SciPy reference, and that the rayon ensemble runner is
7
+ deterministic and race-free (parallel == serial, bit for bit).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+ import pytest
14
+
15
+ import tsdynamics as ts
16
+ from tsdynamics.backends import rustcore as rc
17
+
18
+ pytestmark = pytest.mark.skipif(
19
+ not rc.available(), reason="tsdynamics-core (Rust accelerator) is not installed"
20
+ )
21
+
22
+ _EVAL_SAMPLE = ["Lorenz", "Rossler", "Chen", "Thomas", "Halvorsen", "SprottA"]
23
+
24
+
25
+ @pytest.mark.parametrize("name", _EVAL_SAMPLE)
26
+ def test_tape_eval_matches_symbolic_rhs(name: str) -> None:
27
+ """The Rust tape evaluates the exact same RHS as the symbolic core."""
28
+ sys = getattr(ts, name)()
29
+ tape = rc.compile_tape(sys)
30
+ f = sys._rhs_numeric()
31
+ rng = np.random.default_rng(abs(hash(name)) % (2**32))
32
+ for _ in range(8):
33
+ u = rng.standard_normal(sys.dim)
34
+ t = float(rng.uniform(0.0, 5.0))
35
+ got = rc.eval_rhs(sys, u, t, tape=tape)
36
+ ref = f(u, t)
37
+ np.testing.assert_allclose(got, ref, rtol=1e-9, atol=1e-9)
38
+
39
+
40
+ @pytest.mark.parametrize(
41
+ ("name", "ic"),
42
+ [("Lorenz", [1.0, 1.0, 1.0]), ("Rossler", [0.1, 0.0, 0.0])],
43
+ )
44
+ def test_rk4_matches_scipy(name: str, ic: list[float]) -> None:
45
+ """Fixed-step RK4 agrees with a high-accuracy SciPy reference."""
46
+ from scipy.integrate import solve_ivp
47
+
48
+ sys = getattr(ts, name)()
49
+ t_eval = np.linspace(0.0, 8.0, 801)
50
+ y = rc.integrate_dense(sys, ic, t_eval, h=1e-3)
51
+ assert y.shape == (t_eval.size, sys.dim)
52
+ f = sys._rhs_numeric()
53
+ sol = solve_ivp(lambda t, u: f(u, t), (0.0, 8.0), ic, t_eval=t_eval, rtol=1e-11, atol=1e-13)
54
+ # Chaotic divergence over 8 t.u. is bounded for matched truncation; require
55
+ # tight early agreement and finiteness throughout.
56
+ assert np.all(np.isfinite(y))
57
+ early = t_eval <= 3.0
58
+ np.testing.assert_allclose(y[early], sol.y.T[early], atol=1e-4)
59
+
60
+
61
+ def test_ensemble_is_deterministic_and_race_free() -> None:
62
+ """Parallel ensemble == serial per-trajectory, bit for bit; all finite."""
63
+ sys = ts.Lorenz()
64
+ rng = np.random.default_rng(0)
65
+ batch = rng.uniform(-12.0, 12.0, size=(500, 3))
66
+ fin = rc.ensemble_final(sys, batch, 0.0, 4.0, h=2e-3)
67
+ assert fin.shape == batch.shape
68
+ assert np.all(np.isfinite(fin))
69
+ # Re-running must be identical (no nondeterministic reduction).
70
+ again = rc.ensemble_final(sys, batch, 0.0, 4.0, h=2e-3)
71
+ np.testing.assert_array_equal(fin, again)
72
+ # And it must equal the serial single-trajectory result for sampled rows.
73
+ for i in (0, 17, 250, 499):
74
+ serial = rc.integrate_dense(sys, batch[i], np.array([0.0, 4.0]), h=2e-3)[-1]
75
+ np.testing.assert_array_equal(fin[i], serial)
76
+
77
+
78
+ def test_version_is_exposed() -> None:
79
+ import tsdynamics_core
80
+
81
+ assert isinstance(tsdynamics_core._version(), str)
@@ -0,0 +1,49 @@
1
+ """
2
+ Rust-core *tape compiler* coverage — runs without the ``tsdynamics-core`` extra.
3
+
4
+ ``compile_tape`` only needs SymEngine + the jitcode symbols, so this guards the
5
+ symbolic→tape lowering for the whole ODE catalogue on every CI run, regardless
6
+ of whether the Rust accelerator is installed. The numeric cross-validation
7
+ against the symbolic RHS (and the RK4/ensemble kernels) lives in
8
+ ``test_rustcore.py``, which needs the compiled crate and is skipped when absent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ from tsdynamics import registry
16
+ from tsdynamics.backends.rustcore import TapeCompileError, compile_tape
17
+
18
+
19
+ def test_every_ode_compiles_to_tape(ode_entry) -> None:
20
+ """Every built-in ODE must lower to a well-formed instruction tape."""
21
+ try:
22
+ tape = compile_tape(ode_entry.cls())
23
+ except TapeCompileError as exc:
24
+ raise AssertionError(f"{ode_entry.name}: no longer compiles to a tape — {exc}") from exc
25
+
26
+ n = tape.ops.size
27
+ # Parallel instruction arrays agree in length.
28
+ assert tape.a.size == n and tape.b.size == n and tape.imm.size == n
29
+ # One output register per state component.
30
+ assert tape.outputs.size == ode_entry.cls().dim == tape.n_state
31
+ # Every register reference points to an already-emitted instruction.
32
+ assert np.all(tape.outputs < n) and np.all(tape.outputs >= 0)
33
+ # control inputs = the non-structural params (structural ones are folded
34
+ # to constants in the tape, mirroring the DiffSL backend).
35
+ cls = ode_entry.cls
36
+ structural = getattr(cls, "_structural_params", frozenset())
37
+ expected = [k for k in cls().params if k not in structural]
38
+ assert list(tape.control_names) == expected
39
+
40
+
41
+ def test_tape_coverage_is_total() -> None:
42
+ """The whole ODE catalogue lowers to a tape — the Rust-core coverage fact."""
43
+ failed = []
44
+ for e in registry.all_systems(family="ode"):
45
+ try:
46
+ compile_tape(e.cls())
47
+ except Exception as exc: # noqa: BLE001 — record, don't abort
48
+ failed.append((e.name, str(exc).splitlines()[0][:60]))
49
+ assert not failed, f"{len(failed)} ODE systems no longer compile to a tape: {failed}"
File without changes
File without changes
File without changes
File without changes