Analysis · Attractors & basins
Attractors & basins¶
A Lyapunov exponent or a fractal dimension describes one attractor. But many systems are multistable: several attractors coexist, and which one you reach depends entirely on where you start. That is a global question — it lives in the whole state space, not on a single orbit — and it is the one this toolkit answers. It locates the attractors a system has in a region, paints the basin of initial conditions feeding each, quantifies how tangled the boundaries between them are, tracks how those basins deform as a parameter moves, and measures how robust an attractor is to a perturbation.
| Function | Answers | Reads |
|---|---|---|
attractors |
what attractors exist | a system + region |
basins |
which initial condition goes where | a system + per-axis bounds |
basin_fractions |
each basin's volume share | a system + region |
basin_entropy |
is the boundary fractal? | a label image |
uncertainty_exponent |
boundary dimension \(D_0\) | a label image |
wada_property |
do ≥ 3 basins share a boundary? | a label image |
continuation |
attractors versus a parameter | a system |
tipping_points |
where a basin dies | a ContinuationResult |
resilience |
distance to the boundary | a BasinsResult |
Everything is built on one engine — a recurrence finite-state machine
(Datseris & Wagemakers 2022) — so the same machinery drives maps and flows
alike. On a supported engine run (an ODE flow, or a map whose _step lowers) the
whole per-initial-condition march runs in one sequential Rust kernel call,
bit-identical to the pure-Python fallback.
A bistable example¶
The running example is the damped two-well Duffing oscillator,
a unit mass in the double-well potential \(V(x) = -\tfrac12 x^2 + \tfrac14 x^4\) with linear damping \(\delta\). The two wells sit at \(x = \pm 1\); with damping, every trajectory eventually rolls into one of them, and which one is exactly the basin question. It is a two-line system:
import numpy as np
import tsdynamics as ts
from tsdynamics import data
class DuffingTwoWell(ts.ContinuousSystem):
"""Damped two-well Duffing: x'' = x - x**3 - delta * x'."""
params = {"delta": 0.3}
dim = 2
variables = ("x", "y")
@staticmethod
def _equations(Y, t, *, delta):
x, y = Y(0), Y(1)
return (y, x - x**3 - delta * y)
sys = DuffingTwoWell()
# two nearby starts, two different wells:
sys.run(final_time=60.0, dt=0.05, ic=[ 1.5, 0.5]).y[-1] # ≈ [ 1.0, 0.0]
sys.run(final_time=60.0, dt=0.05, ic=[-1.5, 0.5]).y[-1] # ≈ [-1.0, 0.0]
That two stable states coexist is what makes the basin question meaningful.
(The delay and stochastic families are not supported here — their state is
not a finite-dimensional point the cell tessellation can bin — and raise a clear
TypeError.)
Locating attractors¶
attractors tessellates a search region into cells, draws random initial
conditions from it, and follows each trajectory cell by cell with a small
finite-state machine: while it keeps landing in new cells it is transient; once
it recurrently re-visits cells it has located an attractor (the recurrent
cell set). Near-coincident attractors are proximity-merged (merge_tol).
A search region is one (lo, hi) pair per state component — plain Python.
A Box / Ball / Grid from ts.data is accepted everywhere a region is, but
never required:
region = [(-2.0, 2.0), (-2.0, 2.0)]
att = ts.analysis.attractors(sys, region, resolution=40, n_seeds=200,
dt=0.5, max_steps=2000, seed=0)
att # AttractorSet(2 attractors, 0/200 diverged)
att[0] # ≈ [-1.0, 0.0] — indexing gives the CENTRE, an array
att.centers # both of them, as an (n_attractors, dim) array
att.details[0].cells # the record, when you want the diagnostics
att.by_id(1).center # ...or by the label you read off a basin image
Flows step by dt between cell checks, maps by one iteration. A raised or
non-finite step is treated as divergence; a finite excursion outside the box is
counted by a lost-counter, and a trajectory that never settles within
max_steps is reported as diverged.
Indexing an AttractorSet gives you numbers, not a class to learn. att[i]
is the (dim,) centre of attractor i and np.asarray(att) is the whole
(n, dim) block, so a located attractor drops straight into arithmetic. The
records are still there, one word away and aligned with the same index:
att.details[i] is the Attractor, carrying its sampled .points, its
.center, and the number of .cells it occupies. att.by_id(k) is the same
record by the label painted into a basin image.
Tune resolution to the attractor scale
Too coarse and the tessellation merges genuinely distinct attractors; too fine and a chaotic trajectory never recurs into the same cell, so it is never recognised as settled. Size the cells to the attractor's own scale. The per-seed march is sequential by design — earlier seeds leave persistent cell labels that let later seeds settle cheaply, and that shared, order-dependent labelling is exactly what makes the sweep amortise.
Painting basins¶
basins runs that finder from every cell of a lattice of initial
conditions and labels each with the attractor it reaches — a colour map of
state space. Add a node count to each axis bound to say how fine the lattice is:
(lo, hi, n) per component.
grid = [(-2.0, 2.0, 60), (-2.0, 2.0, 60)]
basins = ts.analysis.basins(sys, grid, dt=0.5, max_steps=2000)
basins.n_attractors # 2
basins.labels # int array, one attractor id per cell (−1 = diverged)
basins.labels.shape # (60, 60)
basins.fractions # {1: ≈ 0.50, 2: ≈ 0.50} — basin volume share per id
basins.diverged_fraction # 0.0 — nothing escaped the box
basins.labels is a plain integer array, ready to plot (attractor id per cell, with
-1 marking diverged/unsettled cells). For the two-well Duffing the two basins
split the plane roughly in half along the smooth stable manifold of the origin
saddle.
Imaging a slice of a higher-dimensional flow
For a flow whose state space is larger than the 2-D picture you want, pass a
separate full-dimension recurrence box: the basin is painted over the 2-D
lattice of initial conditions while the recurrence FSM runs in the full space
(free axes pinned with a node count of 1). This is how the magnetic pendulum's
famous fractal basins are imaged from its higher-dimensional phase space.
Basin stability¶
basin_fractions skips the image and Monte-Carlo–samples the region instead —
basin stability in the sense of Menck et al. (2013): the probability that a
random initial condition lands on each attractor, with a binomial standard error
\(\sqrt{p(1-p)/n}\) that depends only on the fraction and the sample count, never
on the dimension.
bf = ts.analysis.basin_fractions(sys, region, n_seeds=400, dt=0.5, max_steps=2000, seed=0)
bf.fractions # {1: ≈ 0.53, 2: ≈ 0.47}
bf.dominant # 1 — the id with the largest basin
bf.standard_error # {1: ≈ 0.025, 2: ≈ 0.025}
bf.diverged # 0.0
Because it never forms an image, basin_fractions scales to high dimension where
a full grid would be impossible — and it is the workhorse continuation sweeps
below.
Validation systems¶
These three examples pin the fractions to values you can check by symmetry:
| System | Basins | Each fraction |
|---|---|---|
| Two-well Duffing (above) | 2 | \(\approx 1/2\) |
| Newton's method on \(z^3 = 1\) (a map) | 3 (symmetric) | \(\approx 1/3\) |
| Magnetic pendulum | 3, fractal boundary | Wada (see below) |
Boundary structure¶
Once you have a basin label image — a .labels array — three metrics read
it directly and do no further integration, so they run instantly. They answer
the question a smooth-boundary two-well system and a fractal-boundary map answer
very differently: how predictable is the outcome near the boundary?
For the smooth two-well Duffing basin the answer is "very": the boundary is a clean curve.
be = ts.analysis.basin_entropy(basins.labels)
be.sb, be.sbb # ≈ 0.24, 0.53
be.fractal_boundary # False (Sbb ≈ 0.53 < ln 2 ≈ 0.693)
ue = ts.analysis.uncertainty_exponent(basins.labels)
ue.alpha # ≈ 0.88 — near 1: a thin, nearly-smooth boundary
ue.boundary_dimension # ≈ 1.12 — D₀ = D − α
A fractal basin tells a different story. The figure below is Newton's method
on \(z^3 = 1\) — three roots, three basins, and a Julia-set boundary where every
boundary point touches all three colours. That is the Wada property, and
wada_property detects it:
class NewtonMap(ts.DiscreteMap):
"""Newton on z**3 - 1 = 0 → three roots, Wada basins."""
params: dict = {}
dim = 2
variables = ("re", "im")
_jacobian_fd_check = False
@staticmethod
def _step(X):
z = complex(X[0], X[1])
z = (2.0 * z**3 + 1.0) / (3.0 * z**2)
return (z.real, z.imag)
@staticmethod
def _jacobian(X):
return ((0.0, 0.0), (0.0, 0.0))
res = ts.analysis.basins(
NewtonMap(), [(-1.0, 1.0, 200), (-1.0, 1.0, 200)],
consecutive_recurrences=8, attractor_locate_steps=5, max_steps=200)
res.fractions # {1: ≈ 1/3, 2: ≈ 1/3, 3: ≈ 1/3}
wr = ts.analysis.wada_property(res.labels)
wr.is_wada, wr.n_basins # True, 3
wr.fractions[-1] # 1.0 — every boundary cell sees all 3 basins
The three basins interleave on a fractal boundary: no matter how finely you localise an initial condition near the boundary, its fate stays uncertain — final-state sensitivity in its purest form.
The three metrics, in one place:
The basin entropy \(S_b\) and boundary basin entropy \(S_{bb}\) (Daza et al.
2016) measure label diversity per box; \(S_{bb} > \ln 2\) is a sufficient
condition for a fractal boundary, exposed as .fractal_boundary.
The fraction of \(\varepsilon\)-uncertain cells scales as \(f(\varepsilon)\sim\varepsilon^{\alpha}\) (Grebogi et al. 1983); a small \(\alpha\) means a thick, fractal boundary where the outcome is rarely predictable, and the boundary box-counting dimension is \(D_0 = D - \alpha\).
The grid test of Daza et al. (2015) for the Wada property: with three or more basins, a boundary cell is Wada-complete when its neighbourhood contains every basin colour, and the fraction of such cells tends to one for a genuine Wada boundary. A sufficient grid criterion, not a topological proof.
Continuation, tipping, resilience¶
Multistability is not static — as a parameter drifts, basins grow, shrink, and
sometimes vanish outright. continuation re-finds the attractors at each value
of a swept parameter and matches them across values by state-space distance
(greedy nearest; Datseris, Rossi & Wagemakers 2023), so a single attractor keeps
its id along its branch. min_fraction drops the tiny spurious sets the
recurrence finder can report near unstable equilibria.
Tilt the two-well Duffing with a constant force \(F\) — as \(F\) grows past a fold, the shallower well disappears, and one basin annihilates:
class TiltedDuffing(ts.ContinuousSystem):
"""x'' = x - x**3 - delta * x' + F — a fold as F grows."""
params = {"delta": 0.3, "F": 0.0}
dim = 2
variables = ("x", "y")
@staticmethod
def _equations(Y, t, *, delta, F):
x, y = Y(0), Y(1)
return (y, x - x**3 - delta * y + F)
cont = ts.analysis.continuation(TiltedDuffing(), "F", np.linspace(0.0, 0.6, 13),
region, n_seeds=300, resolution=60, dt=0.5, max_steps=1500, seed=0)
cont.fractions # {1: [0.52, 0.45, …, nan, nan], 2: [0.48, 0.55, …, 1.0]}
# attractor 1 vanishes past the fold → nan (basin gone)
tips = ts.analysis.tipping_points(cont)
[(e["kind"], e["attractor"], round(e["value"], 2)) for e in tips]
# → [("disappear", 1, 0.4)] — one well's basin annihilates at F ≈ 0.4
tipping_points reads off the parameter values where a basin's share crosses a
threshold (default \(0\) — true appearance / annihilation); raise the threshold to
flag basins shrinking past a safety margin. Each event is a dict with
"value", "attractor", "kind" ("appear" / "disappear"), and the
"before" / "after" fractions.
resilience measures, for a chosen attractor in a painted BasinsResult, the
distance from the attractor to the nearest cell of another basin — the largest
perturbation it can absorb without tipping (Halekotte & Feudel 2020):
r = ts.analysis.resilience(basins, attractor_id=1) # from the two-well basin image
float(r) # ≈ 0.61 — distance to the boundary
Larger means more resilient. A resilient attractor sits deep inside its own basin; one hugging the boundary can be tipped by a small kick.
See also¶
- Tutorials — runnable end-to-end walkthroughs
- Fixed & periodic points — the equilibria and limit cycles the attractors often are
- Lyapunov spectra — the local stretching rate on a single attractor
- Systems — the built-in catalogue of maps and flows
References¶
- C. Grebogi, S. W. McDonald, E. Ott and J. A. Yorke, "Final state sensitivity: an obstruction to predictability", Phys. Lett. A 99, 415 (1983).
- P. J. Menck, J. Heitzig, N. Marwan and J. Kurths, "How basin stability complements the linear-stability paradigm", Nat. Phys. 9, 89 (2013).
- A. Daza, A. Wagemakers, M. A. F. Sanjuán and J. A. Yorke, "Testing for basins of Wada", Sci. Rep. 5, 16579 (2015).
- A. Daza, A. Wagemakers, B. Georgeot, D. Guéry-Odelin and M. A. F. Sanjuán, "Basin entropy: a new tool to analyze uncertainty in dynamical systems", Sci. Rep. 6, 31416 (2016).
- L. Halekotte and U. Feudel, "Minimal fatal shocks in multistable complex networks", Sci. Rep. 10, 11783 (2020).
- G. Datseris and A. Wagemakers, "Effortless estimation of basins of attraction", Chaos 32, 023104 (2022).
- G. Datseris, K. L. Rossi and A. Wagemakers, "Framework for global stability analysis of dynamical systems", Chaos 33, 073151 (2023).