tsdynamics 2.3.0__tar.gz → 2.4.1__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 (75) hide show
  1. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/PKG-INFO +1 -1
  2. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/__init__.py +1 -1
  3. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/backends/rustcore.py +80 -12
  4. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/base/ode_base.py +9 -1
  5. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/climate_geophysics.py +31 -0
  6. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/physical_systems.py +40 -1
  7. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/_sampling.py +11 -16
  8. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_diffsol_backend.py +4 -4
  9. tsdynamics-2.4.1/tests/test_rustcore.py +220 -0
  10. tsdynamics-2.3.0/tests/test_rustcore.py +0 -81
  11. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/.gitignore +0 -0
  12. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/LICENSE +0 -0
  13. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/README.md +0 -0
  14. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/pyproject.toml +0 -0
  15. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/analysis/__init__.py +0 -0
  16. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/analysis/fixed_points.py +0 -0
  17. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/analysis/lyapunov.py +0 -0
  18. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/analysis/orbit_diagram.py +0 -0
  19. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/analysis/poincare.py +0 -0
  20. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/backends/__init__.py +0 -0
  21. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/backends/diffsol.py +0 -0
  22. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/base/__init__.py +0 -0
  23. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/base/base.py +0 -0
  24. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/base/dde_base.py +0 -0
  25. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/base/map_base.py +0 -0
  26. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/base/protocol.py +0 -0
  27. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/__init__.py +0 -0
  28. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/_base.py +0 -0
  29. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/ensemble.py +0 -0
  30. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/poincare.py +0 -0
  31. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/projected.py +0 -0
  32. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/stroboscopic.py +0 -0
  33. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/tangent.py +0 -0
  34. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/derived/wrapped.py +0 -0
  35. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/py.typed +0 -0
  36. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/registry.py +0 -0
  37. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/sampling.py +0 -0
  38. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/__init__.py +0 -0
  39. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/__init__.py +0 -0
  40. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/chaotic_attractors.py +0 -0
  41. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/chem_bio_systems.py +0 -0
  42. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/coupled_systems.py +0 -0
  43. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/delayed_systems.py +0 -0
  44. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/exotic_systems.py +0 -0
  45. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/oscillatory_systems.py +0 -0
  46. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/continuous/population_dynamics.py +0 -0
  47. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/discrete/__init__.py +0 -0
  48. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/discrete/chaotic_maps.py +0 -0
  49. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/discrete/exotic_maps.py +0 -0
  50. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/discrete/geometric_maps.py +0 -0
  51. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/discrete/polynomial_maps.py +0 -0
  52. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/systems/discrete/population_maps.py +0 -0
  53. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/utils/__init__.py +0 -0
  54. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/utils/general.py +0 -0
  55. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/src/tsdynamics/utils/sagitta_dt.py +0 -0
  56. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/conftest.py +0 -0
  57. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_analysis.py +0 -0
  58. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_base.py +0 -0
  59. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_cache_isolation.py +0 -0
  60. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_dde_systems.py +0 -0
  61. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_derived.py +0 -0
  62. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_diffsol_translation.py +0 -0
  63. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_jacobians.py +0 -0
  64. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_known_values.py +0 -0
  65. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_map_systems.py +0 -0
  66. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_ode_rhs_symbolic.py +0 -0
  67. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_ode_systems.py +0 -0
  68. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_protocol.py +0 -0
  69. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_registry.py +0 -0
  70. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_rustcore_translation.py +0 -0
  71. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_sampling.py +0 -0
  72. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_signatures.py +0 -0
  73. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_smoke.py +0 -0
  74. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_utils.py +0 -0
  75. {tsdynamics-2.3.0 → tsdynamics-2.4.1}/tests/test_wrapped.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsdynamics
3
- Version: 2.3.0
3
+ Version: 2.4.1
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.3.0"
51
+ __version__ = "2.4.1"
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.
@@ -253,13 +253,38 @@ def _args(tape: CompiledTape):
253
253
  return (tape.ops, tape.a, tape.b, tape.imm, tape.outputs, tape.n_state, tape.n_param)
254
254
 
255
255
 
256
+ # Method names: fixed-step RK4, or the adaptive Dormand-Prince 5(4) kernel.
257
+ # These are the only kernels the crate implements; an unknown name is rejected
258
+ # (rather than silently downgraded) — there is no DOP853/LSODA kernel to reach.
259
+ _FIXED_RK4 = {"RK4", "rk4"}
260
+ _ADAPTIVE = {"RK45", "rk45", "dopri5", "DP45"}
261
+ _METHODS = _FIXED_RK4 | _ADAPTIVE
262
+
263
+
264
+ def _check_method(method: str) -> None:
265
+ if method not in _METHODS:
266
+ raise ValueError(
267
+ f"rustcore: unknown method {method!r}; supported: {sorted(_METHODS)} "
268
+ "(RK4 = fixed step, RK45/dopri5 = adaptive). For stiff systems use the "
269
+ "jitcode/diffsol backends."
270
+ )
271
+
272
+
273
+ def _as_state(x: Any, dim: int, name: str) -> np.ndarray:
274
+ """Coerce to a contiguous float64 1-D vector of length ``dim`` (or raise)."""
275
+ a = np.ascontiguousarray(x, dtype=np.float64).ravel()
276
+ if a.size != dim:
277
+ raise ValueError(f"rustcore: {name} has length {a.size}, expected dim={dim}")
278
+ return a
279
+
280
+
256
281
  def eval_rhs(
257
282
  system: Any, u: Any, t: float = 0.0, *, tape: CompiledTape | None = None
258
283
  ) -> np.ndarray:
259
284
  """Evaluate ``du/dt`` once in Rust — used to cross-check the tape."""
260
285
  core = _require()
261
286
  tape = tape or compile_tape(system)
262
- u = np.asarray(u, dtype=np.float64)
287
+ u = _as_state(u, tape.n_state, "u")
263
288
  return np.asarray(core.eval_rhs(*_args(tape), u, _params_vec(system, tape), float(t)))
264
289
 
265
290
 
@@ -268,19 +293,41 @@ def integrate_dense(
268
293
  ic: Any,
269
294
  t_eval: Any,
270
295
  *,
296
+ method: str = "RK45",
297
+ rtol: float = 1e-6,
298
+ atol: float = 1e-9,
271
299
  h: float | None = None,
272
300
  tape: CompiledTape | None = None,
273
301
  ) -> np.ndarray:
274
- """Fixed-step RK4 trajectory at ``t_eval`` (internal step ``h``)."""
302
+ """Integrate a trajectory and sample it at ``t_eval``.
303
+
304
+ ``method="RK45"`` (default) uses error-controlled adaptive Dormand-Prince
305
+ 5(4) with Hermite dense output (``rtol``/``atol`` set the tolerance);
306
+ ``method="RK4"`` uses fixed-step RK4 with internal step ``h``.
307
+
308
+ Raises ``RuntimeError`` if the trajectory diverges or the step collapses
309
+ before reaching the final time (matching the jitcode/diffsol backends).
310
+ """
311
+ _check_method(method)
275
312
  core = _require()
276
313
  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
- )
314
+ t_eval = np.ascontiguousarray(t_eval, dtype=np.float64)
315
+ ic = _as_state(ic, tape.n_state, "ic")
316
+ p = _params_vec(system, tape)
317
+ if method in _FIXED_RK4:
318
+ if h is None:
319
+ h = float(np.min(np.diff(t_eval))) if t_eval.size > 1 else 1e-2
320
+ y = np.asarray(core.integrate_dense_py(*_args(tape), ic, p, t_eval, float(h)))
321
+ else:
322
+ y = np.asarray(
323
+ core.integrate_dense_rk45_py(*_args(tape), ic, p, t_eval, float(rtol), float(atol))
324
+ )
325
+ if not np.all(np.isfinite(y)):
326
+ raise RuntimeError(
327
+ f"{type(system).__name__}: rustcore integration diverged or the step "
328
+ "collapsed before reaching the final time."
329
+ )
330
+ return y
284
331
 
285
332
 
286
333
  def ensemble_final(
@@ -289,19 +336,40 @@ def ensemble_final(
289
336
  t0: float,
290
337
  t1: float,
291
338
  *,
339
+ method: str = "RK45",
340
+ rtol: float = 1e-6,
341
+ atol: float = 1e-9,
292
342
  h: float = 1e-2,
293
343
  tape: CompiledTape | None = None,
294
344
  ) -> np.ndarray:
295
345
  """Integrate a batch of initial conditions in parallel; return final states.
296
346
 
297
347
  ``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.
348
+ and its final state returned as a row of the ``(n, dim)`` result.
349
+ ``method`` selects the adaptive (``"RK45"``, default) or fixed-step
350
+ (``"RK4"``) kernel, as in :func:`integrate_dense`.
351
+
352
+ A trajectory that diverges (escapes to infinity) yields a row of ``NaN``
353
+ rather than aborting the batch — the basin/ensemble caller classifies those
354
+ initial conditions as escaped.
299
355
  """
356
+ _check_method(method)
300
357
  core = _require()
301
358
  tape = tape or compile_tape(system)
302
359
  u0_batch = np.ascontiguousarray(u0_batch, dtype=np.float64)
360
+ if u0_batch.ndim != 2 or u0_batch.shape[1] != tape.n_state:
361
+ raise ValueError(
362
+ f"rustcore: u0_batch must be (n, {tape.n_state}); got shape {u0_batch.shape}"
363
+ )
364
+ p = _params_vec(system, tape)
365
+ if method in _FIXED_RK4:
366
+ return np.asarray(
367
+ core.integrate_ensemble_final_py(
368
+ *_args(tape), u0_batch, p, float(t0), float(t1), float(h)
369
+ )
370
+ )
303
371
  return np.asarray(
304
- core.integrate_ensemble_final_py(
305
- *_args(tape), u0_batch, _params_vec(system, tape), float(t0), float(t1), float(h)
372
+ core.integrate_ensemble_final_rk45_py(
373
+ *_args(tape), u0_batch, p, float(t0), float(t1), float(rtol), float(atol)
306
374
  )
307
375
  )
@@ -153,6 +153,14 @@ class ContinuousSystem(SystemBase, ABC):
153
153
 
154
154
  _default_method: ClassVar[str] = "RK45"
155
155
 
156
+ #: Whether JiTCODE should run SymEngine's ``simplify(ratio=1)`` on each RHS
157
+ #: expression before emitting C. ``None`` keeps JiTCODE's own default
158
+ #: (enabled for ``dim <= 10``). Set ``False`` on systems whose RHS is a
159
+ #: large rational expression: the simplify pass is super-linear and can
160
+ #: effectively hang at compile time, while the C compiler optimises the
161
+ #: unsimplified code just as well (see ``BlinkingRotlet``).
162
+ _compile_simplify: ClassVar[bool | None] = None
163
+
156
164
  #: Parameters whose values affect the symbolic *structure* of _equations
157
165
  #: (e.g. integer loop bounds). These are baked in at compile time.
158
166
  _structural_params: ClassVar[frozenset[str]] = frozenset()
@@ -390,7 +398,7 @@ class ContinuousSystem(SystemBase, ABC):
390
398
  ode = cls_jitc(
391
399
  f_sym, n=self.dim, control_pars=control_par_list, verbose=False, **lyap_kwargs
392
400
  )
393
- ode.generate_f_C()
401
+ ode.generate_f_C(simplify=type(self)._compile_simplify)
394
402
  so = pathlib.Path(ode.save_compiled(destination=str(dest), overwrite=True))
395
403
 
396
404
  if for_lyap:
@@ -75,6 +75,28 @@ class DoubleGyre(ContinuousSystem):
75
75
 
76
76
 
77
77
  class BlinkingRotlet(ContinuousSystem):
78
+ r"""Blinking-rotlet flow — a model of chaotic advection in Stokes mixing.
79
+
80
+ A passive tracer in a circular cell stirred by two off-centre rotlets at
81
+ radius ``+b`` and ``-b`` that alternate being active with period ``tau``,
82
+ the switch being a steep ``tanh`` ramp. The periodic blinking folds and
83
+ stretches material lines, producing chaotic advection (Aref-type *blinking*
84
+ flow). State is ``(r, theta, t)`` in polar coordinates with an explicit
85
+ clock; ``dtheta`` carries a ``1/r`` factor singular at the cell centre, so
86
+ ``default_ic`` sits on a bounded orbit away from ``r = 0``.
87
+
88
+ Compile note
89
+ ------------
90
+ The rotlet velocity field is a large rational expression. JiTCODE's
91
+ default ``simplify(ratio=1)`` codegen pass is super-linear in expression
92
+ size and effectively hangs while *compiling* this RHS — which is what made
93
+ the system look un-integrable. ``_compile_simplify = False`` skips that
94
+ pass (the C compiler optimises the unsimplified code), after which the flow
95
+ integrates in well under a millisecond at every tolerance, even at the
96
+ original steep ``tanh`` switch.
97
+ """
98
+
99
+ reference = "Meleshko & Aref (1996), Phys. Fluids 8, 3215-3217"
78
100
  params = {
79
101
  "a": 1.0,
80
102
  "b": 0.5298833894399929,
@@ -83,6 +105,15 @@ class BlinkingRotlet(ContinuousSystem):
83
105
  "tau": 3.0,
84
106
  }
85
107
  dim = 3
108
+ variables = ("r", "theta", "t")
109
+ #: On a bounded orbit (``r`` stays in ``[0.8, 0.91]``) away from the
110
+ #: ``r = 0`` angular singularity, so default sweeps stay bounded.
111
+ default_ic = [0.8, 4.887, 0.0]
112
+
113
+ # The rotlet RHS is a large rational expression; JiTCODE's default
114
+ # simplify(ratio=1) pass is super-linear on it and effectively hangs the
115
+ # compile. Skip it — the C compiler optimises the unsimplified code.
116
+ _compile_simplify = False
86
117
 
87
118
  @staticmethod
88
119
  def _rotlet(r, theta, a, b, bc):
@@ -140,13 +140,52 @@ class JerkCircuit(ContinuousSystem):
140
140
 
141
141
 
142
142
  class WindmiReduced(ContinuousSystem):
143
+ r"""Reduced WINDMI model of the solar-wind–magnetosphere–ionosphere coupling.
144
+
145
+ Three-variable reduction (field-aligned current ``i``, voltage ``v``,
146
+ pressure ``p``) of the WINDMI energy-conserving model. The pressure-loss
147
+ term carries a fractional power ``p**(5/4)`` gated by a steep
148
+ ``tanh(d1*(i-1))`` switch (``d1 = 2200`` makes it a near-step at ``i = 1``,
149
+ the substorm-onset threshold).
150
+
151
+ Numerical guards (no change to the dynamics)
152
+ --------------------------------------------
153
+ Two robustness guards let every backend reproduce the same orbit that
154
+ JiTCODE's explicit dopri5 already handles:
155
+
156
+ * ``abs(p)`` under the fractional power. On the attractor ``p >= 0``, but
157
+ adaptive and implicit solvers probe trial states with ``p < 0`` where the
158
+ real ``p**(5/4)`` is complex. ``abs(p)`` is identical to ``p`` on the
159
+ physical trajectory and real, ``C^1`` everywhere.
160
+
161
+ * The ``tanh`` argument is clamped to ``[-_TANH_CLAMP, _TANH_CLAMP]``.
162
+ ``tanh`` saturates to ``±1`` to machine precision past ``|arg| ≈ 20``, so
163
+ clamping leaves the switch value unchanged, but it stops the diffsol Rust
164
+ kernels' automatic differentiation from overflowing on ``exp`` of an
165
+ enormous ``d1*(i-1)`` (≈ ±1500 on the orbit) — the actual cause of their
166
+ Newton/error-test failures. The clamp uses ``abs`` only, which both the
167
+ JiTCODE and DiffSL paths support.
168
+ """
169
+
170
+ reference = "Horton, Weigel & Sprott (2001), Phys. Plasmas 8, 2946-2952"
143
171
  params = {"a1": 0.247, "b1": 10.8, "b2": 0.0752, "b3": 1.06, "d1": 2200, "vsw": 5}
144
172
  dim = 3
173
+ variables = ("i", "v", "p")
174
+
175
+ #: Bound on the ``tanh`` switch argument; ``tanh(±20)`` already equals ``±1``
176
+ #: to ~1e-18, so clamping is dynamically invisible but autodiff-safe.
177
+ _TANH_CLAMP = 25.0
145
178
 
146
179
  @staticmethod
147
180
  def _equations(Y, t, *, a1, b1, b2, b3, d1, vsw):
148
181
  i, v, p = Y(0), Y(1), Y(2)
182
+ # Clamp d1*(i-1) to [-C, C] via the abs identity
183
+ # clamp(z, -C, C) = (|z + C| - |z - C|) / 2 — smooth-enough (abs only)
184
+ # and translatable by both the JiTCODE and DiffSL backends.
185
+ c = WindmiReduced._TANH_CLAMP
186
+ z = d1 * (i - 1)
187
+ z_clamped = (abs(z + c) - abs(z - c)) / 2
149
188
  idot = a1 * (vsw - v)
150
189
  vdot = b1 * i - b2 * p**1 / 2 - b3 * v
151
- pdot = vsw**2 - p ** (5 / 4) * vsw ** (1 / 2) * (1 + tanh(d1 * (i - 1))) / 2
190
+ pdot = vsw**2 - abs(p) ** (5 / 4) * vsw ** (1 / 2) * (1 + tanh(z_clamped)) / 2
152
191
  return idot, vdot, pdot
@@ -56,25 +56,20 @@ INTEGRATION_SAMPLE: list[str] = [
56
56
  ]
57
57
 
58
58
  # --- ODE systems excluded from the exhaustive integration sweeps ------------
59
- # These cannot be integrated by adaptive solvers in bounded time, so they
60
- # would hang the nightly full sweep rather than fail it.
61
- HARD_TO_INTEGRATE: dict[str, str] = {
62
- "BlinkingRotlet": (
63
- "near-discontinuous blinking protocol (tanh of a steep sine) makes every "
64
- "adaptive solver take vanishingly small steps and stall at any tolerance; "
65
- "needs a dedicated integration recipe — tracked separately"
66
- ),
67
- }
59
+ # Systems that cannot be integrated by adaptive solvers in bounded time would
60
+ # hang the nightly full sweep rather than fail it, so they are skipped here.
61
+ # Currently empty: ``BlinkingRotlet`` (the former entry) only *looked*
62
+ # un-integrable because JiTCODE's simplify(ratio=1) codegen pass hangs on its
63
+ # large rational RHS — fixed with ``_compile_simplify = False`` on the class.
64
+ HARD_TO_INTEGRATE: dict[str, str] = {}
68
65
 
69
66
  # Systems the diffsol backend specifically can't integrate (the default JiTCODE
70
67
  # backend handles them fine). Excluded from the diffsol full-catalogue sweep.
71
- DIFFSOL_SKIP: dict[str, str] = {
72
- "WindmiReduced": (
73
- "RHS has a near-discontinuous tanh(2200*(i-1)) switch and fractional "
74
- "powers p**(5/4) that go complex when the solver probes p<0; diffsol's "
75
- "Rust solvers fail to converge while JiTCODE's dopri5 tolerates it"
76
- ),
77
- }
68
+ # Currently empty: ``WindmiReduced`` (the former entry) now integrates on every
69
+ # diffsol solver after guarding the fractional power with ``abs(p)`` and
70
+ # clamping the steep ``tanh`` argument (autodiff-safe) — both no-ops on the
71
+ # physical trajectory.
72
+ DIFFSOL_SKIP: dict[str, str] = {}
78
73
 
79
74
  # --- Maps excluded from Lyapunov-spectrum shape tests -----------------------
80
75
  MAP_LYAPUNOV_EXCLUDE: dict[str, str] = {
@@ -135,10 +135,10 @@ def test_diffsol_integrates_full_catalogue() -> None:
135
135
  (``-m full``) with the diffsol extra.
136
136
 
137
137
  Numeric agreement with JiTCODE is checked separately on the curated sample
138
- (``test_cross_validation_over_sample``). We deliberately do NOT cross-check
139
- the JiTCODE reference here for the whole catalogue: a couple of systems
140
- (see ``HARD_TO_INTEGRATE``) stall every adaptive solver and can't produce a
141
- bounded reference, which would hang the sweep rather than measure diffsol.
138
+ (``test_cross_validation_over_sample``); here we only assert each diffsol
139
+ trajectory is finite. ``HARD_TO_INTEGRATE | DIFFSOL_SKIP`` is honoured as
140
+ an escape hatch for systems that genuinely cannot be integrated, but both
141
+ sets are currently empty — the whole ODE catalogue integrates on diffsol.
142
142
  """
143
143
  import sys
144
144
  import zlib
@@ -0,0 +1,220 @@
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
+ from tsdynamics.base.ode_base import ContinuousSystem
18
+
19
+ pytestmark = pytest.mark.skipif(
20
+ not rc.available(), reason="tsdynamics-core (Rust accelerator) is not installed"
21
+ )
22
+
23
+ _EVAL_SAMPLE = ["Lorenz", "Rossler", "Chen", "Thomas", "Halvorsen", "SprottA"]
24
+
25
+
26
+ # Tiny user-defined systems for the robustness/regression tests below. They
27
+ # register as non-builtin, so the builtin-only catalogue sweeps never see them.
28
+ class _Blowup(ContinuousSystem):
29
+ """du/dt = u² — finite-time blow-up from u0 = 1 near t = 1."""
30
+
31
+ params: dict = {}
32
+ dim = 1
33
+
34
+ @staticmethod
35
+ def _equations(y, t):
36
+ return [y(0) ** 2]
37
+
38
+
39
+ class _Twin(ContinuousSystem):
40
+ """Both components are the bare symbol u0 → the tape CSEs to n_reg = 1 < dim."""
41
+
42
+ params: dict = {}
43
+ dim = 2
44
+
45
+ @staticmethod
46
+ def _equations(y, t):
47
+ return [y(0), y(0)]
48
+
49
+
50
+ @pytest.mark.parametrize("name", _EVAL_SAMPLE)
51
+ def test_tape_eval_matches_symbolic_rhs(name: str) -> None:
52
+ """The Rust tape evaluates the exact same RHS as the symbolic core."""
53
+ sys = getattr(ts, name)()
54
+ tape = rc.compile_tape(sys)
55
+ f = sys._rhs_numeric()
56
+ rng = np.random.default_rng(abs(hash(name)) % (2**32))
57
+ for _ in range(8):
58
+ u = rng.standard_normal(sys.dim)
59
+ t = float(rng.uniform(0.0, 5.0))
60
+ got = rc.eval_rhs(sys, u, t, tape=tape)
61
+ ref = f(u, t)
62
+ np.testing.assert_allclose(got, ref, rtol=1e-9, atol=1e-9)
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ ("name", "ic"),
67
+ [("Lorenz", [1.0, 1.0, 1.0]), ("Rossler", [0.1, 0.0, 0.0])],
68
+ )
69
+ def test_rk4_matches_scipy(name: str, ic: list[float]) -> None:
70
+ """Fixed-step RK4 agrees with a high-accuracy SciPy reference."""
71
+ from scipy.integrate import solve_ivp
72
+
73
+ sys = getattr(ts, name)()
74
+ t_eval = np.linspace(0.0, 8.0, 801)
75
+ y = rc.integrate_dense(sys, ic, t_eval, method="RK4", h=1e-3)
76
+ assert y.shape == (t_eval.size, sys.dim)
77
+ f = sys._rhs_numeric()
78
+ sol = solve_ivp(lambda t, u: f(u, t), (0.0, 8.0), ic, t_eval=t_eval, rtol=1e-11, atol=1e-13)
79
+ # Chaotic divergence over 8 t.u. is bounded for matched truncation; require
80
+ # tight early agreement and finiteness throughout.
81
+ assert np.all(np.isfinite(y))
82
+ early = t_eval <= 3.0
83
+ np.testing.assert_allclose(y[early], sol.y.T[early], atol=1e-4)
84
+
85
+
86
+ @pytest.mark.parametrize(
87
+ ("name", "ic"),
88
+ [("Lorenz", [1.0, 1.0, 1.0]), ("Rossler", [1.0, 1.0, 1.0]), ("Thomas", [0.1, 0.0, 0.0])],
89
+ )
90
+ def test_rk45_matches_scipy(name: str, ic: list[float]) -> None:
91
+ """Adaptive Dormand-Prince agrees with SciPy's RK45 at the same tolerance."""
92
+ from scipy.integrate import solve_ivp
93
+
94
+ sys = getattr(ts, name)()
95
+ rtol, atol = 1e-8, 1e-10
96
+ t_eval = np.linspace(0.0, 5.0, 501)
97
+ y = rc.integrate_dense(sys, ic, t_eval, method="RK45", rtol=rtol, atol=atol)
98
+ assert y.shape == (t_eval.size, sys.dim)
99
+ assert np.all(np.isfinite(y))
100
+ f = sys._rhs_numeric()
101
+ ref = solve_ivp(
102
+ lambda t, u: f(u, t), (0.0, 5.0), ic, t_eval=t_eval, rtol=rtol, atol=atol, method="RK45"
103
+ ).y.T
104
+ # Two correct integrators on a chaotic flow diverge slowly; require tight
105
+ # agreement in the early window where Lyapunov amplification is small.
106
+ early = t_eval <= 2.0
107
+ np.testing.assert_allclose(y[early], ref[early], atol=1e-3)
108
+
109
+
110
+ def test_rk45_converges_with_tolerance() -> None:
111
+ """Tightening the tolerance drives the error toward a high-accuracy reference."""
112
+ from scipy.integrate import solve_ivp
113
+
114
+ sys = ts.Lorenz()
115
+ ic = [1.0, 1.0, 1.0]
116
+ t_eval = np.linspace(0.0, 3.0, 301)
117
+ f = sys._rhs_numeric()
118
+ ref = solve_ivp(
119
+ lambda t, u: f(u, t), (0.0, 3.0), ic, t_eval=t_eval, rtol=1e-12, atol=1e-14, method="DOP853"
120
+ ).y.T
121
+ err_loose = np.max(np.abs(rc.integrate_dense(sys, ic, t_eval, rtol=1e-6, atol=1e-9) - ref))
122
+ err_tight = np.max(np.abs(rc.integrate_dense(sys, ic, t_eval, rtol=1e-10, atol=1e-12) - ref))
123
+ assert err_tight < err_loose
124
+ assert err_tight < 1e-4 # adaptive at rtol=1e-10 tracks the reference closely
125
+
126
+
127
+ def test_adaptive_ensemble_deterministic_and_matches_serial() -> None:
128
+ """Adaptive ensemble == serial adaptive, bit for bit, and is reproducible."""
129
+ sys = ts.Lorenz()
130
+ rng = np.random.default_rng(3)
131
+ batch = rng.uniform(-12.0, 12.0, size=(400, 3))
132
+ fin = rc.ensemble_final(sys, batch, 0.0, 4.0, method="RK45", rtol=1e-8, atol=1e-10)
133
+ assert fin.shape == batch.shape and np.all(np.isfinite(fin))
134
+ np.testing.assert_array_equal(
135
+ fin, rc.ensemble_final(sys, batch, 0.0, 4.0, method="RK45", rtol=1e-8, atol=1e-10)
136
+ )
137
+ for i in (0, 200, 399):
138
+ serial = rc.integrate_dense(
139
+ sys, batch[i], np.array([0.0, 4.0]), method="RK45", rtol=1e-8, atol=1e-10
140
+ )[-1]
141
+ np.testing.assert_array_equal(fin[i], serial)
142
+
143
+
144
+ def test_ensemble_is_deterministic_and_race_free() -> None:
145
+ """Fixed-step parallel ensemble == serial per-trajectory, bit for bit; all finite."""
146
+ sys = ts.Lorenz()
147
+ rng = np.random.default_rng(0)
148
+ batch = rng.uniform(-12.0, 12.0, size=(500, 3))
149
+ fin = rc.ensemble_final(sys, batch, 0.0, 4.0, method="RK4", h=2e-3)
150
+ assert fin.shape == batch.shape
151
+ assert np.all(np.isfinite(fin))
152
+ # Re-running must be identical (no nondeterministic reduction).
153
+ again = rc.ensemble_final(sys, batch, 0.0, 4.0, method="RK4", h=2e-3)
154
+ np.testing.assert_array_equal(fin, again)
155
+ # And it must equal the serial single-trajectory result for sampled rows.
156
+ for i in (0, 17, 250, 499):
157
+ serial = rc.integrate_dense(sys, batch[i], np.array([0.0, 4.0]), method="RK4", h=2e-3)[-1]
158
+ np.testing.assert_array_equal(fin[i], serial)
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Robustness / regression — failure modes an adversarial review surfaced.
163
+ # Each must terminate (no infinite loop) and signal cleanly, not crash or lie.
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def test_divergent_trajectory_raises_not_hangs() -> None:
168
+ """A blow-up must raise (not spin forever on a NaN error norm)."""
169
+ with pytest.raises(RuntimeError):
170
+ rc.integrate_dense(_Blowup(), [1.0], np.linspace(0.0, 5.0, 101), method="RK45")
171
+
172
+
173
+ def test_ensemble_divergent_ics_yield_nan_rows() -> None:
174
+ """Diverging ICs become NaN rows; the batch finishes and finite ICs survive."""
175
+ batch = np.array([[0.1], [0.2], [5.0], [10.0]])
176
+ fin = rc.ensemble_final(_Blowup(), batch, 0.0, 3.0, method="RK45")
177
+ assert np.isfinite(fin[0, 0]) and np.isfinite(fin[1, 0])
178
+ assert np.isnan(fin[2, 0]) and np.isnan(fin[3, 0])
179
+
180
+
181
+ def test_degenerate_tape_nreg_below_dim_does_not_panic() -> None:
182
+ """A tape with fewer registers than state components must integrate, not panic."""
183
+ y = rc.integrate_dense(_Twin(), [1.0, 1.0], np.linspace(0.0, 1.0, 11), method="RK45")
184
+ assert np.all(np.isfinite(y))
185
+ # Analytic: u0 = e^t, u1 = 1 + (e^t − 1) = e^t.
186
+ np.testing.assert_allclose(y[-1], [np.e, np.e], rtol=1e-4)
187
+
188
+
189
+ def test_noncontiguous_inputs_are_accepted() -> None:
190
+ """Strided (non-C-contiguous) ic / t_eval must be coerced, not panic."""
191
+ lor = ts.Lorenz()
192
+ t_eval = np.linspace(0.0, 2.0, 41)[::2] # strided view
193
+ ic = np.array([1.0, 1.0, 1.0, 9.0])[:3] # strided-ish view
194
+ y = rc.integrate_dense(lor, ic, t_eval, method="RK45")
195
+ assert y.shape == (t_eval.size, 3) and np.all(np.isfinite(y))
196
+
197
+
198
+ def test_unknown_method_raises() -> None:
199
+ with pytest.raises(ValueError, match="unknown method"):
200
+ rc.integrate_dense(ts.Lorenz(), [1.0, 1.0, 1.0], np.linspace(0, 1, 5), method="DOP853")
201
+
202
+
203
+ def test_dimension_mismatch_raises() -> None:
204
+ lor = ts.Lorenz()
205
+ with pytest.raises(ValueError):
206
+ rc.integrate_dense(lor, [1.0, 1.0], np.linspace(0, 1, 5))
207
+ with pytest.raises(ValueError):
208
+ rc.ensemble_final(lor, np.zeros((5, 2)), 0.0, 1.0)
209
+
210
+
211
+ @pytest.mark.parametrize("method", ["RK45", "RK4"])
212
+ def test_empty_t_eval_returns_empty(method: str) -> None:
213
+ y = rc.integrate_dense(ts.Lorenz(), [1.0, 1.0, 1.0], np.array([]), method=method)
214
+ assert y.shape[0] == 0
215
+
216
+
217
+ def test_version_is_exposed() -> None:
218
+ import tsdynamics_core
219
+
220
+ assert isinstance(tsdynamics_core._version(), str)
@@ -1,81 +0,0 @@
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)
File without changes
File without changes
File without changes
File without changes
File without changes