Project · Adding a system
Adding a system¶
Adding a system to the catalogue is a one-class contribution. You write the math as a symbolic method; the registry picks the class up at import, the bulk test suite sweeps it, and the docs build generates its page — all for free.
This page is the contributor's recipe. For a gentler, worked introduction to the family contracts, read Defining systems first; this page is the checklist you follow to land a catalogue system.
The three steps¶
- Write the class in the right module under
src/tsdynamics/systems/continuous/or.../discrete/, following the family contract below. Add the class name to the module's__all__. - Add it to the category
__all__—src/tsdynamics/systems/continuous/__init__.pyor.../discrete/__init__.py. (systems/__init__.pyflat-re-exports it automatically, sots.systems.MyAttractorand the lazyts.MyAttractorboth resolve.) A registry consistency test fails loudly if you forget this step. - That's it. The registry sweeps it into the bulk tests, and the docs build
generates its page — equations, parameter table,
reference, and a cached phase portrait — on the next build.
The family contracts¶
Every system subclasses one of the four family bases and holds its dynamics in
one symbolic method. Use only symbolic operations — symengine.sin, cos,
exp, +, *, **, abs, sign, min, max. No NumPy, no math,
no Python control flow over the state; the engine lowers the method to an IR
tape, which cannot capture a numpy ufunc or an if on a state value.
from symengine import sin, cos
import tsdynamics as ts
class MyAttractor(ts.ContinuousSystem):
"""Short one-liner describing the system."""
params = {"a": 1.0, "b": 2.0, "c": 3.0}
dim = 3
@staticmethod
def _equations(y, t, *, a, b, c): # params are KEYWORD-only, matching keys
x, yv, z = y(0), y(1), y(2)
return (
a * (yv - x),
x * (b - z) - yv,
x * yv - c * z,
)
y(i) reads component i of the state; return exactly dim expressions.
t is the time symbol (autonomous systems ignore it). The Jacobian is
generated automatically by symbolic differentiation — you never write one
(a hand-written _jacobian is only cross-checked in tests, never used at
runtime).
import tsdynamics as ts
class MyDDE(ts.DelaySystem):
params = {"beta": 2.0, "gamma": 1.0, "n": 9.65, "tau": 2.0}
dim = 1
_delay_params = ("tau",) # default; override for non-"tau" names
@staticmethod
def _equations(y, t, beta, gamma, n, tau): # params POSITIONAL for DDEs
yd = y(0, t - tau) # the delayed accessor
return [beta * yd / (1 + yd**n) - gamma * y(0)]
y(i, t - tau) reads the delayed state. A delay value is a structural
quantity and is baked into the engine tape, so sweeping it re-lowers. List
every delay parameter's name in _delay_params if it is not called tau.
import tsdynamics as ts
class MyMap(ts.DiscreteMap):
params = {"a": 1.4, "b": 0.3}
dim = 2
@staticmethod
def _step(X, a, b): # ⚠ params arrive POSITIONALLY, in params order
x, y = X
return (1 - a * x**2 + y, b * x)
@staticmethod
def _jacobian(X, a, b):
x, y = X
return ((-2 * a * x, 1.0), (b, 0.0))
Maps take the whole state vector X and their parameters positionally,
in params-dict insertion order — the engine feeds them positionally when it
lowers _step. __init_subclass__ validates the signature order against
params at import, raising TypeError on a mismatch. Maps do provide an
analytic _jacobian (used for stability and Lyapunov exponents).
import tsdynamics as ts
class MyOU(ts.StochasticSystem):
"""Ornstein–Uhlenbeck: dX = θ(μ − X) dt + σ dW."""
params = {"theta": 1.0, "mu": 0.0, "sigma": 0.3}
dim = 1
@staticmethod
def _drift(y, t, *, theta, mu, sigma): # like _equations
return (theta * (mu - y(0)),)
@staticmethod
def _diffusion(y, t, *, theta, mu, sigma): # one coefficient per component
return (sigma,)
A StochasticSystem is a diagonal-Itô SDE \(dX_k = f_k\,dt + g_k\,dW_k\)
with independent Wiener increments. Both _drift and _diffusion are
symbolic and keyword-only; _diffusion returns one coefficient per
component. _drift is the marker that makes the class a concrete sde in
the registry.
It really is that easy — a from-scratch run¶
Because the same contract drives everything, a class you define outside the
package is a full citizen the moment it is imported. Pin an initial condition
(and a seed for the SDE) so the numbers are reproducible:
import numpy as np
import tsdynamics as ts
class MyAttractor(ts.ContinuousSystem):
params = {"a": 0.2, "b": 0.2, "c": 5.7}
dim = 3
variables = ("x", "y", "z")
@staticmethod
def _equations(y, t, *, a, b, c):
x, yv, z = y(0), y(1), y(2)
return (-yv - z, x + a * yv, b + z * (x - c))
traj = MyAttractor(ic=[1.0, 0.0, 0.0]).integrate(final_time=5.0, dt=0.05)
np.round(traj.y[-1], 3) # → [ 0.647, -1.605, 0.037]
exps = ts.lyapunov_spectrum(MyAttractor(ic=[1.0, 0.0, 0.0])) # composes for free
A user-defined class registers as non-builtin
(registry.get("MyAttractor").is_builtin is False), so it stays out of the
built-in-only sweeps — but the whole analysis toolkit,
the derived wrappers, and plotting all work on it
exactly as on a catalogue system.
Optional metadata that improves everything downstream¶
None of these are required, but each unlocks something:
class MyAttractor(ts.ContinuousSystem):
params = {"a": 0.2, "b": 0.2, "c": 5.7}
dim = 3
variables = ("x", "y", "z") # named traj["x"] + labelled figures
reference = "Rössler (1976), Phys. Lett. A 57, 397-398" # shown in the docs
doi = "10.1016/0375-9601(76)90101-8" # bare DOI for the reference
default_ic = (1.0, 0.0, 0.0) # only if random ICs miss the basin
known_lyapunov = { # opt in to the known-value tests
"spectrum": (0.0714, 0.0, -5.39),
"atol": (0.06, 0.06, 1.5),
"kwargs": {"dt": 0.1, "burn_in": 100.0, "final_time": 500.0},
"source": "Sprott (2003), Chaos and Time-Series Analysis",
}
| ClassVar | What it does |
|---|---|
variables |
Named component access (traj["x"]) and labelled axes on the auto-figure |
reference |
Literature citation, surfaced on the docs page and via registry.get(...).reference |
doi |
Bare DOI for the primary reference (used by the bibliography) |
default_ic |
Fallback IC when a random U[0,1)^dim start escapes the basin |
known_lyapunov |
Enrolls the system in test_known_values.py — literature spectra checked continuously |
_structural_params |
For variable-dimension systems (see below) |
_default_method |
"bdf" for a system known to be stiff, so integrate defaults to the implicit kernel |
_field_shape / field_labels |
For a spatially-extended (PDE) system whose state is a flattened field |
Variable-dimension systems¶
Anything whose _equations has a loop length or comprehension driven by a
parameter (Lorenz-96, Kuramoto–Sivashinsky, a coupled-oscillator chain) must
declare that parameter as structural, so it is baked into the engine tape:
class Lorenz96(ts.ContinuousSystem):
params = {"f": 8.0, "N": 20}
_structural_params = frozenset({"N"})
Control parameters are read live from the system on every run, so they can change at runtime with no re-lowering. A structural parameter changes the shape of the tape, so changing it re-lowers (there is no on-disk cache — it just re-lowers in-process).
The extra line for DDEs and SDEs¶
The registry sweep is automatic, with two guard-enforced exceptions:
- A new DDE needs a non-equilibrium history in
tests/_sampling.py::DDE_HISTORIES(a constant past sitting at a fixed point gives Lyapunov exponents \(\approx 0\) and defeats the test). A guard test fails until you add one. - A new built-in SDE needs a
{"seed": ..., "ic": ...}entry intests/_sampling.py::SDE_SAMPLES. Asde-family guard test enforces completeness.
Common pitfalls¶
| Situation | What happens |
|---|---|
_equations uses numpy or math |
The tape can't lower it — use symengine.sin / cos / … |
Variable-dim system without _structural_params |
Lowering-time range(N) fails — declare the dimension parameter structural |
Map param order ≠ _step signature order |
TypeError at import |
Forgot the category __all__ |
A registry consistency test fails |
| New DDE with a fixed-point constant past | Lyapunov exponents \(\approx 0\) — provide a non-equilibrium history |
PR checklist
- Class follows the family contract (symbolic ops only for flows; positional param order for maps).
- Added to the module
__all__and the category__init__.py__all__. -
variablesandreference(anddoiif known) declared. -
make testgreen locally (a new DDE / SDE also has its test-sampling entry). - PR title is a conventional commit (
feat: add …).
See also¶
- Defining systems — the worked, tutorial-paced introduction to the family contracts.
- Contributing — the quality gates and PR flow this recipe lands into.
- Adding a solver — the other extension path: a new integration kernel for the engine.