tsdynamics 2.4.0__tar.gz → 2.5.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.4.0 → tsdynamics-2.5.0}/PKG-INFO +1 -1
  2. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/__init__.py +1 -1
  3. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/backends/rustcore.py +91 -19
  4. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/base/ode_base.py +9 -1
  5. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/climate_geophysics.py +31 -0
  6. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/physical_systems.py +40 -1
  7. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/_sampling.py +11 -16
  8. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_diffsol_backend.py +4 -4
  9. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_rustcore.py +112 -0
  10. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_rustcore_translation.py +29 -0
  11. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/.gitignore +0 -0
  12. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/LICENSE +0 -0
  13. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/README.md +0 -0
  14. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/pyproject.toml +0 -0
  15. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/analysis/__init__.py +0 -0
  16. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/analysis/fixed_points.py +0 -0
  17. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/analysis/lyapunov.py +0 -0
  18. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/analysis/orbit_diagram.py +0 -0
  19. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/analysis/poincare.py +0 -0
  20. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/backends/__init__.py +0 -0
  21. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/backends/diffsol.py +0 -0
  22. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/base/__init__.py +0 -0
  23. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/base/base.py +0 -0
  24. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/base/dde_base.py +0 -0
  25. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/base/map_base.py +0 -0
  26. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/base/protocol.py +0 -0
  27. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/__init__.py +0 -0
  28. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/_base.py +0 -0
  29. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/ensemble.py +0 -0
  30. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/poincare.py +0 -0
  31. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/projected.py +0 -0
  32. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/stroboscopic.py +0 -0
  33. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/tangent.py +0 -0
  34. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/derived/wrapped.py +0 -0
  35. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/py.typed +0 -0
  36. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/registry.py +0 -0
  37. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/sampling.py +0 -0
  38. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/__init__.py +0 -0
  39. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/__init__.py +0 -0
  40. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/chaotic_attractors.py +0 -0
  41. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/chem_bio_systems.py +0 -0
  42. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/coupled_systems.py +0 -0
  43. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/delayed_systems.py +0 -0
  44. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/exotic_systems.py +0 -0
  45. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/oscillatory_systems.py +0 -0
  46. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/continuous/population_dynamics.py +0 -0
  47. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/discrete/__init__.py +0 -0
  48. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/discrete/chaotic_maps.py +0 -0
  49. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/discrete/exotic_maps.py +0 -0
  50. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/discrete/geometric_maps.py +0 -0
  51. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/discrete/polynomial_maps.py +0 -0
  52. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/systems/discrete/population_maps.py +0 -0
  53. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/utils/__init__.py +0 -0
  54. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/utils/general.py +0 -0
  55. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/src/tsdynamics/utils/sagitta_dt.py +0 -0
  56. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/conftest.py +0 -0
  57. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_analysis.py +0 -0
  58. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_base.py +0 -0
  59. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_cache_isolation.py +0 -0
  60. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_dde_systems.py +0 -0
  61. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_derived.py +0 -0
  62. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_diffsol_translation.py +0 -0
  63. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_jacobians.py +0 -0
  64. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_known_values.py +0 -0
  65. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_map_systems.py +0 -0
  66. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_ode_rhs_symbolic.py +0 -0
  67. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_ode_systems.py +0 -0
  68. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_protocol.py +0 -0
  69. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_registry.py +0 -0
  70. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_sampling.py +0 -0
  71. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_signatures.py +0 -0
  72. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_smoke.py +0 -0
  73. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_utils.py +0 -0
  74. {tsdynamics-2.4.0 → tsdynamics-2.5.0}/tests/test_wrapped.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsdynamics
3
- Version: 2.4.0
3
+ Version: 2.5.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.4.0"
51
+ __version__ = "2.5.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.
@@ -12,15 +12,17 @@ optional accelerator is installed::
12
12
 
13
13
  pip install tsdynamics-core # or the [rustcore] extra
14
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`.
15
+ Solvers (select with ``method=``): fixed-step ``RK4``, adaptive Dormand-Prince
16
+ ``RK45`` (default), and an L-stable linearly-implicit ``stiff`` kernel (aliases
17
+ ``Rosenbrock``/``LSODA``/``BDF``) that uses the system's analytic Jacobian —
18
+ also lowered into the tape. Limitations (experimental): ODEs only; the RHS must
19
+ use functions the tape VM provides (the same set the DiffSL backend supports).
20
+ Unsupported constructs raise :class:`TapeCompileError`.
19
21
  """
20
22
 
21
23
  from __future__ import annotations
22
24
 
23
- from dataclasses import dataclass
25
+ from dataclasses import dataclass, field
24
26
  from typing import Any
25
27
 
26
28
  import numpy as np
@@ -101,6 +103,9 @@ class CompiledTape:
101
103
  n_state: int
102
104
  n_param: int
103
105
  control_names: list[str]
106
+ # Register index of each Jacobian entry, row-major dim×dim; empty unless the
107
+ # tape was compiled with_jacobian=True (only the stiff solver needs it).
108
+ jac_outputs: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int32))
104
109
 
105
110
 
106
111
  class _Emitter:
@@ -191,18 +196,24 @@ class _Emitter:
191
196
  return self._push(OP_POW, a=base_reg, b=self.emit(exp))
192
197
 
193
198
 
194
- def compile_tape(system: Any) -> CompiledTape:
199
+ def compile_tape(system: Any, *, with_jacobian: bool = False) -> CompiledTape:
195
200
  """
196
201
  Lower a :class:`~tsdynamics.base.ContinuousSystem`'s RHS to a tape.
197
202
 
198
203
  Structural parameters are folded to constants; control parameters become
199
204
  inputs in ``control_names`` order (the layout the solve-time params vector
200
205
  must follow).
206
+
207
+ With ``with_jacobian=True`` the analytic Jacobian ``∂f_k/∂u_j`` is
208
+ differentiated symbolically and emitted into the *same* tape (sharing
209
+ common subexpressions with the RHS) — the stiff solver consumes it.
201
210
  """
202
211
  import symengine
203
212
  from jitcode import t as t_sym
204
213
  from jitcode import y
205
214
 
215
+ from tsdynamics.base.ode_base import _resolve_derivative_nodes
216
+
206
217
  dim = system.dim
207
218
  struct_vals = system._structural_vals()
208
219
  control_names = list(system._control_params())
@@ -212,11 +223,22 @@ def compile_tape(system: Any) -> CompiledTape:
212
223
  if len(exprs) != dim:
213
224
  raise ValueError(f"_equations must return {dim} expressions, got {len(exprs)}")
214
225
 
215
- subs = {y(i): symengine.Symbol(f"u{i}") for i in range(dim)}
226
+ u_syms = [symengine.Symbol(f"u{i}") for i in range(dim)]
227
+ subs = {y(i): u_syms[i] for i in range(dim)}
216
228
  subs[t_sym] = symengine.Symbol("t")
229
+ rhs = [symengine.sympify(e).subs(subs) for e in exprs]
217
230
 
218
231
  em = _Emitter()
219
- outputs = [em.emit(symengine.sympify(e).subs(subs)._sympy_()) for e in exprs]
232
+ outputs = [em.emit(e._sympy_()) for e in rhs]
233
+ jac_outputs: list[int] = []
234
+ if with_jacobian:
235
+ # Row-major dim×dim: ∂f_k/∂u_j. _resolve_derivative_nodes rewrites the
236
+ # unevaluated d|u|/du → sign(u) and d·sign/du → 0 a.e. (the same a.e.
237
+ # convention the jitcode/diffsol Jacobian autogen uses), so systems with
238
+ # abs/sign still lower cleanly.
239
+ for e in rhs:
240
+ for s in u_syms:
241
+ jac_outputs.append(em.emit(_resolve_derivative_nodes(e.diff(s))._sympy_()))
220
242
 
221
243
  return CompiledTape(
222
244
  ops=np.asarray(em.ops, dtype=np.int32),
@@ -227,6 +249,7 @@ def compile_tape(system: Any) -> CompiledTape:
227
249
  n_state=dim,
228
250
  n_param=len(control_names),
229
251
  control_names=control_names,
252
+ jac_outputs=np.asarray(jac_outputs, dtype=np.int32),
230
253
  )
231
254
 
232
255
 
@@ -253,23 +276,57 @@ def _args(tape: CompiledTape):
253
276
  return (tape.ops, tape.a, tape.b, tape.imm, tape.outputs, tape.n_state, tape.n_param)
254
277
 
255
278
 
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.
279
+ def _args_stiff(tape: CompiledTape):
280
+ # Stiff kernels take jac_outputs after outputs (mirrors the Rust signature).
281
+ return (
282
+ tape.ops,
283
+ tape.a,
284
+ tape.b,
285
+ tape.imm,
286
+ tape.outputs,
287
+ tape.jac_outputs,
288
+ tape.n_state,
289
+ tape.n_param,
290
+ )
291
+
292
+
293
+ # Method names → kernel. RK4 = fixed step; RK45/dopri5 = adaptive Dormand-Prince
294
+ # 5(4); stiff = L-stable linearly-implicit solver with the analytic Jacobian.
295
+ # The SciPy-style stiff names (LSODA/BDF/Radau) route to the stiff kernel so
296
+ # `method="LSODA"` does the expected thing rather than silently downgrading.
259
297
  _FIXED_RK4 = {"RK4", "rk4"}
260
298
  _ADAPTIVE = {"RK45", "rk45", "dopri5", "DP45"}
261
- _METHODS = _FIXED_RK4 | _ADAPTIVE
299
+ _STIFF = {
300
+ "stiff",
301
+ "Rosenbrock",
302
+ "rosenbrock",
303
+ "ROS1",
304
+ "LSODA",
305
+ "lsoda",
306
+ "BDF",
307
+ "bdf",
308
+ "Radau",
309
+ "radau",
310
+ }
311
+ _METHODS = _FIXED_RK4 | _ADAPTIVE | _STIFF
262
312
 
263
313
 
264
314
  def _check_method(method: str) -> None:
265
315
  if method not in _METHODS:
266
316
  raise ValueError(
267
317
  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."
318
+ "(RK4 = fixed step, RK45/dopri5 = adaptive explicit, "
319
+ "stiff/Rosenbrock/LSODA/BDF = L-stable implicit)."
270
320
  )
271
321
 
272
322
 
323
+ def _stiff_tape(system: Any, tape: CompiledTape | None) -> CompiledTape:
324
+ """Return a tape carrying the analytic Jacobian (recompiled if the given one lacks it)."""
325
+ if tape is not None and tape.jac_outputs.size:
326
+ return tape
327
+ return compile_tape(system, with_jacobian=True)
328
+
329
+
273
330
  def _as_state(x: Any, dim: int, name: str) -> np.ndarray:
274
331
  """Coerce to a contiguous float64 1-D vector of length ``dim`` (or raise)."""
275
332
  a = np.ascontiguousarray(x, dtype=np.float64).ravel()
@@ -303,14 +360,16 @@ def integrate_dense(
303
360
 
304
361
  ``method="RK45"`` (default) uses error-controlled adaptive Dormand-Prince
305
362
  5(4) with Hermite dense output (``rtol``/``atol`` set the tolerance);
306
- ``method="RK4"`` uses fixed-step RK4 with internal step ``h``.
363
+ ``method="RK4"`` uses fixed-step RK4 with internal step ``h``;
364
+ ``method="stiff"`` (aliases ``Rosenbrock``/``LSODA``/``BDF``) uses an
365
+ L-stable linearly-implicit solver with the system's analytic Jacobian.
307
366
 
308
367
  Raises ``RuntimeError`` if the trajectory diverges or the step collapses
309
368
  before reaching the final time (matching the jitcode/diffsol backends).
310
369
  """
311
370
  _check_method(method)
312
371
  core = _require()
313
- tape = tape or compile_tape(system)
372
+ tape = _stiff_tape(system, tape) if method in _STIFF else (tape or compile_tape(system))
314
373
  t_eval = np.ascontiguousarray(t_eval, dtype=np.float64)
315
374
  ic = _as_state(ic, tape.n_state, "ic")
316
375
  p = _params_vec(system, tape)
@@ -318,6 +377,12 @@ def integrate_dense(
318
377
  if h is None:
319
378
  h = float(np.min(np.diff(t_eval))) if t_eval.size > 1 else 1e-2
320
379
  y = np.asarray(core.integrate_dense_py(*_args(tape), ic, p, t_eval, float(h)))
380
+ elif method in _STIFF:
381
+ y = np.asarray(
382
+ core.integrate_dense_stiff_py(
383
+ *_args_stiff(tape), ic, p, t_eval, float(rtol), float(atol)
384
+ )
385
+ )
321
386
  else:
322
387
  y = np.asarray(
323
388
  core.integrate_dense_rk45_py(*_args(tape), ic, p, t_eval, float(rtol), float(atol))
@@ -346,8 +411,9 @@ def ensemble_final(
346
411
 
347
412
  ``u0_batch`` is ``(n, dim)``; each row is integrated from ``t0`` to ``t1``
348
413
  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`.
414
+ ``method`` selects the adaptive (``"RK45"``, default), fixed-step
415
+ (``"RK4"``), or stiff (``"stiff"``/``"LSODA"``) kernel, as in
416
+ :func:`integrate_dense`.
351
417
 
352
418
  A trajectory that diverges (escapes to infinity) yields a row of ``NaN``
353
419
  rather than aborting the batch — the basin/ensemble caller classifies those
@@ -355,7 +421,7 @@ def ensemble_final(
355
421
  """
356
422
  _check_method(method)
357
423
  core = _require()
358
- tape = tape or compile_tape(system)
424
+ tape = _stiff_tape(system, tape) if method in _STIFF else (tape or compile_tape(system))
359
425
  u0_batch = np.ascontiguousarray(u0_batch, dtype=np.float64)
360
426
  if u0_batch.ndim != 2 or u0_batch.shape[1] != tape.n_state:
361
427
  raise ValueError(
@@ -368,6 +434,12 @@ def ensemble_final(
368
434
  *_args(tape), u0_batch, p, float(t0), float(t1), float(h)
369
435
  )
370
436
  )
437
+ if method in _STIFF:
438
+ return np.asarray(
439
+ core.integrate_ensemble_final_stiff_py(
440
+ *_args_stiff(tape), u0_batch, p, float(t0), float(t1), float(rtol), float(atol)
441
+ )
442
+ )
371
443
  return np.asarray(
372
444
  core.integrate_ensemble_final_rk45_py(
373
445
  *_args(tape), u0_batch, p, float(t0), float(t1), float(rtol), float(atol)
@@ -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
@@ -47,6 +47,40 @@ class _Twin(ContinuousSystem):
47
47
  return [y(0), y(0)]
48
48
 
49
49
 
50
+ class _LinStiff(ContinuousSystem):
51
+ """y' = -k y — large k stresses L-stability (exact decay to ~0)."""
52
+
53
+ params = {"k": 1000.0}
54
+ dim = 1
55
+
56
+ @staticmethod
57
+ def _equations(y, t, *, k):
58
+ return [-k * y(0)]
59
+
60
+
61
+ class _VdPStiff(ContinuousSystem):
62
+ """Stiff van der Pol oscillator (μ = 1000)."""
63
+
64
+ params = {"mu": 1000.0}
65
+ dim = 2
66
+
67
+ @staticmethod
68
+ def _equations(y, t, *, mu):
69
+ return [y(1), mu * (1 - y(0) ** 2) * y(1) - y(0)]
70
+
71
+
72
+ class _Robertson(ContinuousSystem):
73
+ """Robertson's stiff chemical-kinetics problem."""
74
+
75
+ params: dict = {}
76
+ dim = 3
77
+
78
+ @staticmethod
79
+ def _equations(y, t):
80
+ a, b, c = y(0), y(1), y(2)
81
+ return [-0.04 * a + 1e4 * b * c, 0.04 * a - 1e4 * b * c - 3e7 * b * b, 3e7 * b * b]
82
+
83
+
50
84
  @pytest.mark.parametrize("name", _EVAL_SAMPLE)
51
85
  def test_tape_eval_matches_symbolic_rhs(name: str) -> None:
52
86
  """The Rust tape evaluates the exact same RHS as the symbolic core."""
@@ -214,6 +248,84 @@ def test_empty_t_eval_returns_empty(method: str) -> None:
214
248
  assert y.shape[0] == 0
215
249
 
216
250
 
251
+ # ---------------------------------------------------------------------------
252
+ # Stiff solver — L-stable linearly-implicit kernel with the analytic Jacobian.
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def test_stiff_is_l_stable() -> None:
257
+ """y' = -1000 y integrates accurately to ~0 (an explicit method would blow up)."""
258
+ sys = _LinStiff()
259
+ t_eval = np.linspace(0.0, 1.0, 101)
260
+ y = rc.integrate_dense(sys, [1.0], t_eval, method="stiff", rtol=1e-7, atol=1e-9)
261
+ assert np.all(np.isfinite(y))
262
+ np.testing.assert_allclose(y[:, 0], np.exp(-1000.0 * t_eval), atol=1e-6)
263
+
264
+
265
+ @pytest.mark.parametrize(
266
+ ("sys_factory", "ic", "T", "ref_method"),
267
+ [
268
+ (_VdPStiff, [2.0, 0.0], 30.0, "Radau"),
269
+ (_Robertson, [1.0, 0.0, 0.0], 40.0, "Radau"),
270
+ ],
271
+ )
272
+ def test_stiff_matches_scipy(sys_factory, ic, T, ref_method) -> None:
273
+ """The stiff kernel reproduces SciPy's implicit solvers on classic stiff problems."""
274
+ from scipy.integrate import solve_ivp
275
+
276
+ sys = sys_factory()
277
+ t_eval = np.linspace(0.0, T, 200)
278
+ y = rc.integrate_dense(sys, ic, t_eval, method="stiff", rtol=1e-7, atol=1e-10)
279
+ assert np.all(np.isfinite(y))
280
+ f = sys._rhs_numeric()
281
+ ref = solve_ivp(
282
+ lambda t, u: f(u, t), (0.0, T), ic, t_eval=t_eval, rtol=1e-11, atol=1e-12, method=ref_method
283
+ ).y.T
284
+ np.testing.assert_allclose(y, ref, rtol=1e-3, atol=1e-6)
285
+
286
+
287
+ def test_stiff_handles_abs_sign_systems() -> None:
288
+ """A piecewise-linear system (Chua) integrates via the resolved-sign Jacobian."""
289
+ from scipy.integrate import solve_ivp
290
+
291
+ sys = ts.Chua()
292
+ ic = sys.resolve_ic(0.1 * np.ones(sys.dim))
293
+ t_eval = np.linspace(0.0, 4.0, 201)
294
+ y = rc.integrate_dense(sys, ic, t_eval, method="stiff", rtol=1e-7, atol=1e-9)
295
+ assert np.all(np.isfinite(y))
296
+ f = sys._rhs_numeric()
297
+ ref = solve_ivp(
298
+ lambda t, u: f(u, t), (0.0, 4.0), ic, t_eval=t_eval, rtol=1e-10, atol=1e-12, method="LSODA"
299
+ ).y.T
300
+ early = t_eval <= 2.0
301
+ np.testing.assert_allclose(y[early], ref[early], atol=1e-3)
302
+
303
+
304
+ def test_stiff_method_aliases_route_to_stiff_kernel() -> None:
305
+ """LSODA/BDF names reach the stiff kernel (no silent downgrade to explicit)."""
306
+ sys = _LinStiff()
307
+ t_eval = np.linspace(0.0, 1.0, 51)
308
+ for alias in ("LSODA", "BDF", "Rosenbrock"):
309
+ y = rc.integrate_dense(sys, [1.0], t_eval, method=alias, rtol=1e-7, atol=1e-9)
310
+ np.testing.assert_allclose(y[:, 0], np.exp(-1000.0 * t_eval), atol=1e-6)
311
+
312
+
313
+ def test_stiff_ensemble_deterministic_and_matches_serial() -> None:
314
+ sys = _VdPStiff()
315
+ rng = np.random.default_rng(0)
316
+ batch = rng.uniform(-2.0, 2.0, size=(200, 2))
317
+ fin = rc.ensemble_final(sys, batch, 0.0, 5.0, method="stiff", rtol=1e-6, atol=1e-8)
318
+ assert fin.shape == batch.shape and np.all(np.isfinite(fin))
319
+ np.testing.assert_array_equal(
320
+ fin, rc.ensemble_final(sys, batch, 0.0, 5.0, method="stiff", rtol=1e-6, atol=1e-8)
321
+ )
322
+ for i in (0, 100, 199):
323
+ serial = rc.integrate_dense(
324
+ sys, batch[i], np.array([0.0, 5.0]), method="stiff", rtol=1e-6, atol=1e-8
325
+ )[-1]
326
+ np.testing.assert_array_equal(fin[i], serial)
327
+
328
+
217
329
  def test_version_is_exposed() -> None:
218
330
  import tsdynamics_core
219
331
 
@@ -47,3 +47,32 @@ def test_tape_coverage_is_total() -> None:
47
47
  except Exception as exc: # noqa: BLE001 — record, don't abort
48
48
  failed.append((e.name, str(exc).splitlines()[0][:60]))
49
49
  assert not failed, f"{len(failed)} ODE systems no longer compile to a tape: {failed}"
50
+
51
+
52
+ def test_every_ode_jacobian_compiles_to_tape(ode_entry) -> None:
53
+ """The analytic Jacobian must lower too (stiff solver) — incl. abs/sign systems."""
54
+ cls = ode_entry.cls
55
+ try:
56
+ tape = compile_tape(cls(), with_jacobian=True)
57
+ except TapeCompileError as exc:
58
+ raise AssertionError(
59
+ f"{ode_entry.name}: Jacobian no longer lowers to a tape — {exc}"
60
+ ) from exc
61
+ dim = cls().dim
62
+ # One register per Jacobian entry, row-major dim×dim.
63
+ assert tape.jac_outputs.size == dim * dim
64
+ n = tape.ops.size
65
+ assert tape.jac_outputs.size == 0 or (
66
+ tape.jac_outputs.max() < n and tape.jac_outputs.min() >= 0
67
+ )
68
+
69
+
70
+ def test_jacobian_coverage_is_total() -> None:
71
+ """Every ODE's analytic Jacobian lowers — abs/sign derivatives resolved a.e."""
72
+ failed = []
73
+ for e in registry.all_systems(family="ode"):
74
+ try:
75
+ compile_tape(e.cls(), with_jacobian=True)
76
+ except Exception as exc: # noqa: BLE001 — record, don't abort
77
+ failed.append((e.name, str(exc).splitlines()[0][:60]))
78
+ assert not failed, f"{len(failed)} ODE Jacobians no longer compile to a tape: {failed}"
File without changes
File without changes
File without changes
File without changes
File without changes