Skip to content

Reference

Analysis

The quantifiers that consume any System. Prose-first treatments live in the Analysis section.

Orbit diagrams

orbit_diagram

orbit_diagram(
    system: Any,
    param: str,
    values: Any,
    *,
    n: int = 200,
    transient: int = 500,
    carry_state: bool = True,
    component: int | str | tuple[Any, ...] = 0,
    ic: Any | None = None,
    seed: int | None = None,
) -> OrbitDiagram

Sweep a parameter and record the asymptotic orbit at each value.

Works on anything discrete: a :class:~tsdynamics.families.DiscreteMap directly, or a flow wrapped in a :class:~tsdynamics.derived.PoincareMap / :class:~tsdynamics.derived.StroboscopicMap — in which case this is the bifurcation diagram of the flow. ODE parameter changes reuse the compiled module (control parameters), so flow sweeps stay cheap; DDE sweeps recompile per value (their structure depends on all parameters).

PARAMETER DESCRIPTION
system

The system to sweep. Never mutated — each value gets a fresh with_params copy.

TYPE: System(discrete)

param

Parameter name to sweep.

TYPE: str

values

Parameter values, in sweep order.

TYPE: iterable of float

n

Points recorded per parameter value.

TYPE: int DEFAULT: 200

transient

Steps discarded before recording, at every value.

TYPE: int DEFAULT: 500

carry_state

Start each value from the previous value's final state (follows the attractor branch; the classic way to draw clean diagrams). When False, every value starts from ic / the system default.

TYPE: bool DEFAULT: True

component

Which state component(s) to record (names allowed when the system declares variables).

TYPE: int, str, or tuple DEFAULT: 0

ic

Initial state for the first value (and every value when carry_state=False).

TYPE: array - like DEFAULT: None

seed

Seed for the random initial condition when the system has none; makes the diagram reproducible.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
OrbitDiagram

The swept values and the per-value recorded points. A value whose orbit diverged carries an empty point set (and emits a :class:RuntimeWarning).

RAISES DESCRIPTION
TypeError

If system is not a discrete-time view (a :class:~tsdynamics.families.DiscreteMap, or a flow wrapped in :class:~tsdynamics.derived.PoincareMap / :class:~tsdynamics.derived.StroboscopicMap).

ValueError

If a named component is requested but the system does not declare variables.

WARNS DESCRIPTION
RuntimeWarning

When a parameter value diverges; that value records an empty set and the sweep continues.

References

May, R. M. (1976). Simple mathematical models with very complicated dynamics. Nature, 261, 459--467.

Examples:

>>> od = orbit_diagram(Logistic(), "r", np.linspace(2.5, 4.0, 600), n=120)
>>> x, y = od.flat()
>>> # bifurcation diagram of a flow:
>>> od = orbit_diagram(PoincareMap(Rossler(), (1, 0.0)), "c", np.linspace(2, 6, 80))
Source code in src/tsdynamics/analysis/orbits/orbit_diagram.py
def orbit_diagram(
    system: Any,
    param: str,
    values: Any,
    *,
    n: int = 200,
    transient: int = 500,
    carry_state: bool = True,
    component: int | str | tuple[Any, ...] = 0,
    ic: Any | None = None,
    seed: int | None = None,
) -> OrbitDiagram:
    """
    Sweep a parameter and record the asymptotic orbit at each value.

    Works on anything discrete: a :class:`~tsdynamics.families.DiscreteMap`
    directly, or a flow wrapped in a
    :class:`~tsdynamics.derived.PoincareMap` /
    :class:`~tsdynamics.derived.StroboscopicMap` — in which case this *is*
    the bifurcation diagram of the flow.  ODE parameter changes reuse the
    compiled module (control parameters), so flow sweeps stay cheap; DDE
    sweeps recompile per value (their structure depends on all parameters).

    Parameters
    ----------
    system : System (discrete)
        The system to sweep.  Never mutated — each value gets a fresh
        ``with_params`` copy.
    param : str
        Parameter name to sweep.
    values : iterable of float
        Parameter values, in sweep order.
    n : int
        Points recorded per parameter value.
    transient : int
        Steps discarded before recording, at every value.
    carry_state : bool
        Start each value from the previous value's final state (follows the
        attractor branch; the classic way to draw clean diagrams).  When
        False, every value starts from ``ic`` / the system default.
    component : int, str, or tuple
        Which state component(s) to record (names allowed when the system
        declares ``variables``).
    ic : array-like, optional
        Initial state for the first value (and every value when
        ``carry_state=False``).
    seed : int, optional
        Seed for the random initial condition when the system has none; makes
        the diagram reproducible.

    Returns
    -------
    OrbitDiagram
        The swept ``values`` and the per-value recorded ``points``.  A value
        whose orbit diverged carries an empty point set (and emits a
        :class:`RuntimeWarning`).

    Raises
    ------
    TypeError
        If ``system`` is not a discrete-time view (a
        :class:`~tsdynamics.families.DiscreteMap`, or a flow wrapped in
        :class:`~tsdynamics.derived.PoincareMap` /
        :class:`~tsdynamics.derived.StroboscopicMap`).
    ValueError
        If a named ``component`` is requested but the system does not declare
        ``variables``.

    Warns
    -----
    RuntimeWarning
        When a parameter value diverges; that value records an empty set and the
        sweep continues.

    References
    ----------
    May, R. M. (1976). Simple mathematical models with very complicated
    dynamics. *Nature*, 261, 459--467.

    Examples
    --------
    >>> od = orbit_diagram(Logistic(), "r", np.linspace(2.5, 4.0, 600), n=120)
    >>> x, y = od.flat()
    >>> # bifurcation diagram of a flow:
    >>> od = orbit_diagram(PoincareMap(Rossler(), (1, 0.0)), "c", np.linspace(2, 6, 80))
    """
    if not system.is_discrete:
        raise TypeError(
            "orbit_diagram needs a discrete-time view: a DiscreteMap, or a flow wrapped "
            "in PoincareMap / StroboscopicMap."
        )

    comp = (component,) if isinstance(component, int | str) else tuple(component)
    # Resolve names via the *instance* (not ``type(sys)``): a derived wrapper
    # exposes ``variables`` as a property, so ``type(sys).variables`` returns the
    # descriptor object (truthy) and short-circuits — breaking named components
    # over a PoincareMap/StroboscopicMap.  Instance lookup returns the ClassVar
    # for families and the resolved names for wrappers alike.
    names = getattr(system, "variables", None)
    idx: list[int] = []
    for c in comp:
        if isinstance(c, str):
            if names is None:
                raise ValueError("named components need the system to declare `variables`")
            idx.append(names.index(c))
        else:
            idx.append(int(c))

    resolved_ic = _seeded_ic(system, ic, seed)
    if resolved_ic is not None:
        ic = resolved_ic

    import warnings

    from tsdynamics.errors import BackendError
    from tsdynamics.families import DiscreteMap

    values_arr = np.asarray(list(values), dtype=float)
    points: list[np.ndarray] = []
    state: np.ndarray | None = None

    # A genuine DiscreteMap sweeps the WHOLE parameter array in a single engine
    # call (stream perf/param-sweep-kernel): the map is lowered once keeping the
    # swept parameter as the tape's single runtime input, and the Rust kernel
    # varies it per value — one FFI round-trip for the entire diagram, instead of
    # the WS-MAPITER path's one ``iterate`` call per value (a 1000-value sweep was
    # ~1000 round-trips; ~410 ms → a few ms).  The per-iterate numerics are
    # byte-for-byte the per-value ``iterate`` path, so the diagram is
    # byte-identical where the engine and NumPy agree bit-for-bit (the logistic
    # map) and the same attractor for a chaotic map.  Flow wrappers (PoincareMap /
    # StroboscopicMap) have no ``_step`` to lower; a map whose ``_step`` will not
    # lower to the IR (``TapeCompileError`` → ``NotImplementedError``) or a
    # wheel-free environment (``EngineNotAvailableError`` → ``BackendError``) fall
    # back to the per-value/per-step protocol loop below — the same answer.
    if isinstance(system, DiscreteMap):
        try:
            points = _sweep_via_kernel(
                system,
                param,
                values_arr,
                transient=transient,
                n=n,
                carry_state=carry_state,
                ic=ic,
                idx=idx,
            )
            meta = {
                "system": type(system).__name__,
                "param": param,
                "n": n,
                "transient": transient,
                "carry_state": carry_state,
                "components": tuple(idx),
            }
            return OrbitDiagram(
                param=param, values=values_arr, points=points, components=tuple(idx), meta=meta
            )
        except (NotImplementedError, BackendError):
            # The map cannot run on the engine sweep (a non-lowerable ``_step`` or
            # no compiled wheel) — catch the PUBLIC bases (not the engine-internal
            # leaf types) and fall back to the per-value/per-step loop below.
            points = []

    # The per-value protocol path: flow wrappers and the engine-sweep fallback.
    for v in values_arr:
        current = system.with_params(**{param: v})
        start = state if (carry_state and state is not None) else ic
        try:
            # Flow wrappers (PoincareMap / StroboscopicMap) and the engine-sweep
            # fallback (a non-lowerable map / wheel-free env) both drive the
            # per-step protocol loop — byte-identical to the engine path on a
            # lowerable map.
            rec, last = _record_via_step(current, start, transient, n, idx)
        except RuntimeError as exc:
            # One divergent value must not discard the whole sweep: record an
            # empty point set and restart the next value from `ic`.
            warnings.warn(
                f"orbit_diagram: {param}={v:g} diverged ({exc}); recording an "
                f"empty set for this value.",
                RuntimeWarning,
                stacklevel=2,
            )
            points.append(np.empty((0, len(idx))))
            state = None
            continue
        points.append(rec)
        if carry_state:
            state = last

    meta = {
        "system": type(system).__name__,
        "param": param,
        "n": n,
        "transient": transient,
        "carry_state": carry_state,
        "components": tuple(idx),
    }
    return OrbitDiagram(
        param=param, values=values_arr, points=points, components=tuple(idx), meta=meta
    )

OrbitDiagram dataclass

OrbitDiagram(
    param: str = "",
    values: ndarray = (lambda: empty(0))(),
    points: list[ndarray] = list(),
    components: tuple[int, ...] = (),
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Result of :func:orbit_diagram.

An :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / .summary() / .to_dict() / the .plot seam. Iterate to get (value, points) pairs, or use :meth:flat for the scatter-ready arrays.

flat

flat(component: int = 0) -> tuple[ndarray, ndarray]

Flatten to scatter-plot arrays (x, y).

x repeats each parameter value once per recorded point; y is the chosen recorded component.

Source code in src/tsdynamics/analysis/orbits/orbit_diagram.py
def flat(self, component: int = 0) -> tuple[np.ndarray, np.ndarray]:
    """
    Flatten to scatter-plot arrays ``(x, y)``.

    ``x`` repeats each parameter value once per recorded point; ``y`` is
    the chosen recorded component.
    """
    x = np.concatenate(
        [np.full(p.shape[0], v) for v, p in zip(self.values, self.points, strict=True)]
    )
    y = np.concatenate([p[:, component] for p in self.points])
    return x, y

periods

periods(
    *,
    component: int = 0,
    max_period: int = 16,
    rtol: float = 0.01,
) -> ndarray

Return the detected period at each swept parameter value.

Counts the distinct asymptotic branches in the recorded orbit — the period of a periodic window — by clustering the points of one component with a scale-free gap test: a new branch starts where the sorted-value gap exceeds rtol times the orbit's range. A finite period p >= 2 is only reported when the recorded iterate sequence actually revisits its values cyclically (v[i] ≈ v[i + p] to rtol); a chaotic band whose finite-sample points merely cluster into p bins fails this repeat test and is reported as aperiodic (0).

PARAMETER DESCRIPTION
component

Which recorded component to count branches in.

TYPE: int DEFAULT: 0

max_period

Periods above this are reported as 0 (treated as aperiodic / chaotic — too many branches to resolve as a cycle).

TYPE: int DEFAULT: 16

rtol

Relative gap (fraction of the per-value range) separating branches.

TYPE: float DEFAULT: 0.01

RETURNS DESCRIPTION
numpy.ndarray of int

One entry per parameter value: the period 1, 2, 4, …, 0 for aperiodic, or -1 where the sweep recorded no points (diverged).

Source code in src/tsdynamics/analysis/orbits/orbit_diagram.py
def periods(
    self, *, component: int = 0, max_period: int = 16, rtol: float = 0.01
) -> np.ndarray:
    """
    Return the detected period at each swept parameter value.

    Counts the distinct asymptotic branches in the recorded orbit — the
    period of a periodic window — by clustering the points of one component
    with a scale-free gap test: a new branch starts where the sorted-value
    gap exceeds ``rtol`` times the orbit's range.  A finite period ``p >= 2``
    is only reported when the recorded iterate sequence actually *revisits*
    its values cyclically (``v[i] ≈ v[i + p]`` to ``rtol``); a chaotic band
    whose finite-sample points merely cluster into ``p`` bins fails this
    repeat test and is reported as aperiodic (``0``).

    Parameters
    ----------
    component : int, default 0
        Which recorded component to count branches in.
    max_period : int, default 16
        Periods above this are reported as ``0`` (treated as aperiodic /
        chaotic — too many branches to resolve as a cycle).
    rtol : float, default 0.01
        Relative gap (fraction of the per-value range) separating branches.

    Returns
    -------
    numpy.ndarray of int
        One entry per parameter value: the period ``1, 2, 4, …``, ``0`` for
        aperiodic, or ``-1`` where the sweep recorded no points (diverged).
    """
    out = np.empty(len(self.values), dtype=int)
    for k, pts in enumerate(self.points):
        if pts.shape[0] == 0:
            out[k] = -1
            continue
        col = pts[:, component]
        p = _count_branches(col, rtol)
        if p > max_period or (p >= 2 and not _is_cyclic(col, p, rtol)):
            # Either too many branches to resolve as a cycle, or the branch
            # count is a finite-sample clustering of a chaotic band that does
            # not actually revisit its values cyclically — aperiodic.
            out[k] = 0
        else:
            out[k] = p
    return out

bifurcation_points

bifurcation_points(
    *,
    component: int = 0,
    max_period: int = 16,
    rtol: float = 0.01,
) -> ndarray

Parameter values where the detected period changes.

Locates the boundaries of the period-doubling cascade (and other bifurcations) as the midpoints between consecutive swept values across which :meth:periods differs. Transitions touching a diverged value (-1) are skipped.

PARAMETER DESCRIPTION
component

Which recorded component to count branches in.

TYPE: int DEFAULT: 0

max_period

Periods above this are treated as aperiodic when detecting changes.

TYPE: int DEFAULT: 16

rtol

Relative gap separating branches in :meth:periods.

TYPE: float DEFAULT: 0.01

RETURNS DESCRIPTION
numpy.ndarray of float

Estimated bifurcation parameter values, in sweep order. Their resolution is the spacing of values.

References

Feigenbaum, M. J. (1978). Quantitative universality for a class of nonlinear transformations. Journal of Statistical Physics, 19, 25--52.

Source code in src/tsdynamics/analysis/orbits/orbit_diagram.py
def bifurcation_points(
    self, *, component: int = 0, max_period: int = 16, rtol: float = 0.01
) -> np.ndarray:
    """
    Parameter values where the detected period changes.

    Locates the boundaries of the period-doubling cascade (and other
    bifurcations) as the midpoints between consecutive swept values across
    which :meth:`periods` differs.  Transitions touching a diverged value
    (``-1``) are skipped.

    Parameters
    ----------
    component : int, default 0
        Which recorded component to count branches in.
    max_period : int, default 16
        Periods above this are treated as aperiodic when detecting changes.
    rtol : float, default 0.01
        Relative gap separating branches in :meth:`periods`.

    Returns
    -------
    numpy.ndarray of float
        Estimated bifurcation parameter values, in sweep order.  Their
        resolution is the spacing of ``values``.

    References
    ----------
    Feigenbaum, M. J. (1978). Quantitative universality for a class of
    nonlinear transformations. *Journal of Statistical Physics*, 19, 25--52.
    """
    p = self.periods(component=component, max_period=max_period, rtol=rtol)
    return self._bifurcation_points_from_periods(p)

to_plot_spec

to_plot_spec(
    kind: str | None = None, *, annotate: bool = False
) -> Any

Describe this orbit diagram as a backend-agnostic :class:PlotSpec.

Builds an ORBIT_DIAGRAM scatter of the asymptotic state (first recorded component) against the swept parameter — the classic bifurcation diagram — via :meth:flat.

The default plot is clean (just the scatter of asymptotic states, the textbook bifurcation picture). A chaotic period-doubling cascade contains dozens of onsets, so drawing them all as labelled vertical reference lines smears the figure into an illegible pile of overlapping text. Pass annotate=True to overlay the :meth:bifurcation_points onsets as "vline" :class:~tsdynamics.viz.spec.Annotation reference lines (each labelled with the period it opens onto) — best kept for a short, low-period sweep where the labels don't collide. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "bifurcation"). None uses ORBIT_DIAGRAM.

TYPE: str DEFAULT: None

annotate

Overlay the detected period-doubling onsets as labelled vertical reference lines. Off by default so the default .plot() is a clean bifurcation scatter; the :meth:periods / :meth:bifurcation_points quantifiers are unaffected either way.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/orbits/orbit_diagram.py
def to_plot_spec(self, kind: str | None = None, *, annotate: bool = False) -> Any:
    """Describe this orbit diagram as a backend-agnostic :class:`PlotSpec`.

    Builds an ``ORBIT_DIAGRAM`` scatter of the asymptotic state (first
    recorded component) against the swept parameter — the classic
    bifurcation diagram — via :meth:`flat`.

    **The default plot is clean** (just the scatter of asymptotic states, the
    textbook bifurcation picture).  A chaotic period-doubling cascade contains
    *dozens* of onsets, so drawing them all as labelled vertical reference
    lines smears the figure into an illegible pile of overlapping text.  Pass
    ``annotate=True`` to overlay the :meth:`bifurcation_points` onsets as
    ``"vline"`` :class:`~tsdynamics.viz.spec.Annotation` reference lines (each
    labelled with the period it opens onto) — best kept for a short, low-period
    sweep where the labels don't collide.  The :mod:`tsdynamics.viz.spec`
    import is lazy, so building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"bifurcation"``).  ``None`` uses
        ``ORBIT_DIAGRAM``.
    annotate : bool, default False
        Overlay the detected period-doubling onsets as labelled vertical
        reference lines.  Off by default so the default ``.plot()`` is a clean
        bifurcation scatter; the :meth:`periods` / :meth:`bifurcation_points`
        quantifiers are unaffected either way.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    x, y = self.flat()
    annotations: list[Any] = []
    if annotate and len(self.values) > 1:
        # Compute the period sweep once and feed it to *both* the onset
        # detection and the per-line period label (instead of recomputing
        # ``periods()`` inside ``bifurcation_points()`` and again here).
        periods = self.periods()
        onsets = self._bifurcation_points_from_periods(periods)
        for onset in np.asarray(onsets, dtype=float).ravel():
            # Label the line with the period the cascade opens *onto* (the
            # period just to the right of the onset).
            j = int(np.searchsorted(self.values, onset))
            p = int(periods[j]) if 0 <= j < periods.size else 0
            label = f"period {p}" if p > 0 else "bifurcation"
            annotations.append(pb.vline(float(onset), text=label))
    return pb.spec(
        kind,
        "orbit_diagram",
        layers=[pb.scatter(x, y, style={"s": 1.0})],
        xlabel=self.param,
        ylabel="asymptotic state",
        title="orbit diagram",
        annotations=annotations,
        meta=self.meta,
    )

Poincaré sections

poincare_section

poincare_section(
    system: Any,
    plane: tuple[Any, ...],
    *,
    direction: int | str = +1,
    n: int = 1000,
    skip_crossings: int = 0,
    dt: float = 0.01,
    max_time: float = 10000.0,
    seed: int | None = None,
) -> PoincareSection

Poincaré surface of section.

Two input modes:

  • System → wraps it in a :class:~tsdynamics.derived.PoincareMap and collects n root-refined crossings on the fast Rust event engine (stream WS-CROSSKERNEL).
  • Trajectory → finds the plane crossings between consecutive samples by linear interpolation (pure data path; accuracy limited by the trajectory's sampling interval).
PARAMETER DESCRIPTION
system

A flow to section, or measured trajectory data (the data overload).

TYPE: System or Trajectory

plane

The section, in any of three spellings:

  • (axis, c)axis a component name (resolved against the system's variables, e.g. "y") or an integer index, for the section y_axis = c;
  • (axis, c, direction) — the same, with the crossing direction ("up" / "down" / "both") as a third element, which overrides the direction argument;
  • (normal, offset) — an arbitrary normal vector, for the section normal · y = offset.

For example plane=("y", 0.0, "up"), plane=(1, 0.0), or plane=([1, 0, 0], 0.0).

TYPE: tuple

direction

Crossing direction filter (+1 / "up" keeps only crossings where the section function is increasing). Ignored when plane carries its own direction (third element).

TYPE: +1, -1, 0} or {"up", "down", "both" DEFAULT: +1

n

Number of crossings to collect (system mode).

TYPE: int DEFAULT: 1000

skip_crossings

Number of leading crossings to discard before recording. (A section transient is a count of crossings, deliberately distinct from the time/step transient of other analyses.)

TYPE: int DEFAULT: 0

dt

Detection step and integration ceiling (system mode) — see :class:~tsdynamics.derived.PoincareMap.

TYPE: float DEFAULT: 0.01

max_time

Detection step and integration ceiling (system mode) — see :class:~tsdynamics.derived.PoincareMap.

TYPE: float DEFAULT: 0.01

seed

Seed for the random initial condition when the system has none (system mode); makes the section reproducible.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
PoincareSection

A :class:~tsdynamics.data.Trajectory of the crossings (t = crossing times, y = full-dimensional crossing states) carrying POINCARE_SECTION plot intent and a .summary() / .to_dict() / .plot result surface.

Examples:

>>> section = poincare_section(Rossler(), plane=("y", 0.0, "up"), n=500)
>>> section = poincare_section(traj, plane=("z", 25.0))     # from data
Source code in src/tsdynamics/analysis/orbits/poincare.py
def poincare_section(
    system: Any,
    plane: tuple[Any, ...],
    *,
    direction: int | str = +1,
    n: int = 1000,
    skip_crossings: int = 0,
    dt: float = 0.01,
    max_time: float = 1e4,
    seed: int | None = None,
) -> PoincareSection:
    """
    Poincaré surface of section.

    Two input modes:

    - **System** → wraps it in a :class:`~tsdynamics.derived.PoincareMap`
      and collects ``n`` root-refined crossings on the fast Rust event engine
      (stream WS-CROSSKERNEL).
    - **Trajectory** → finds the plane crossings between consecutive samples
      by linear interpolation (pure data path; accuracy limited by the
      trajectory's sampling interval).

    Parameters
    ----------
    system : System or Trajectory
        A flow to section, or measured trajectory data (the ``data`` overload).
    plane : tuple
        The section, in any of three spellings:

        - ``(axis, c)`` — ``axis`` a component **name** (resolved against the
          system's ``variables``, e.g. ``"y"``) or an integer index, for the
          section ``y_axis = c``;
        - ``(axis, c, direction)`` — the same, with the crossing direction
          (``"up"`` / ``"down"`` / ``"both"``) as a third element, which
          overrides the ``direction`` argument;
        - ``(normal, offset)`` — an arbitrary normal **vector**, for the section
          ``normal · y = offset``.

        For example ``plane=("y", 0.0, "up")``, ``plane=(1, 0.0)``, or
        ``plane=([1, 0, 0], 0.0)``.
    direction : {+1, -1, 0} or {"up", "down", "both"}, default +1
        Crossing direction filter (``+1`` / ``"up"`` keeps only crossings where
        the section function is increasing).  Ignored when ``plane`` carries its
        own direction (third element).
    n : int, default 1000
        Number of crossings to collect (system mode).
    skip_crossings : int, default 0
        Number of leading crossings to discard before recording.  (A *section*
        transient is a count of crossings, deliberately distinct from the
        time/step ``transient`` of other analyses.)
    dt, max_time : float
        Detection step and integration ceiling (system mode) — see
        :class:`~tsdynamics.derived.PoincareMap`.
    seed : int, optional
        Seed for the random initial condition when the system has none
        (system mode); makes the section reproducible.

    Returns
    -------
    PoincareSection
        A :class:`~tsdynamics.data.Trajectory` of the crossings (``t`` = crossing
        times, ``y`` = full-dimensional crossing states) carrying
        ``POINCARE_SECTION`` plot intent and a ``.summary()`` / ``.to_dict()`` /
        ``.plot`` result surface.

    Examples
    --------
    >>> section = poincare_section(Rossler(), plane=("y", 0.0, "up"), n=500)
    >>> section = poincare_section(traj, plane=("z", 25.0))     # from data
    """
    if isinstance(system, Trajectory):
        return _section_from_data(system, plane, direction)
    seeded = _seeded_ic(system, None, seed)
    if seeded is not None:
        system = system.copy()
        system.reinit(seeded)
    pmap = PoincareMap(system, plane, direction=direction, dt=dt, max_time=max_time)
    return pmap.trajectory(n, transient=skip_crossings)

Return maps

return_map

return_map(
    system: Any,
    component: int | str = 0,
    *,
    method: str = "max",
    plane: tuple[Any, ...] | None = None,
    direction: int = +1,
    n: int = 2000,
    final_time: float = 200.0,
    dt: float = 0.01,
    transient: float = 0.0,
    skip_crossings: int = 0,
    ic: Any | None = None,
    seed: int | None = None,
    **integrate_kwargs: Any,
) -> ReturnMap

First-return map of a recurring observable.

Records a sequence of scalar values :math:v_0, v_1, \dots from the motion and pairs each with its successor, giving the one-dimensional map :math:v_{n+1} = F(v_n) that organises the dynamics.

PARAMETER DESCRIPTION
system

What to read the observable from. A continuous :class:~tsdynamics.families.ContinuousSystem is integrated first; a :class:~tsdynamics.data.Trajectory is read directly; a 1-D array is treated as the observable series itself (method must be "max" or "min"). (Trajectory / array inputs are the data overload.)

TYPE: System, Trajectory, or array-like

component

Which state component to record (names allowed when the system / trajectory declares variables). Ignored when system is a raw 1-D series.

TYPE: int or str DEFAULT: 0

method

"max" / "min" record successive local maxima / minima of the observable (the Lorenz construction); "poincare" records the observable at successive section crossings (needs plane).

TYPE: ('max', 'min', 'poincare') DEFAULT: "max"

plane

(i, c) or (normal, offset) — the section for method="poincare" (see :func:~tsdynamics.analysis.orbits.poincare_section).

TYPE: tuple DEFAULT: None

direction

Crossing-direction filter (method="poincare" only).

TYPE: (+1, -1, 0) DEFAULT: +1

n

Number of section crossings to collect when integrating a system in method="poincare" mode.

TYPE: int DEFAULT: 2000

final_time

Integration horizon and detection / output step used when system is a flow. In extremum mode dt only needs to resolve the peaks; the recorded value is sharpened by parabolic interpolation, so a coarse grid still gives accurate extrema. In method="poincare" mode only dt is used (the section is marched until n crossings); final_time, ic, transient and **integrate_kwargs apply to extremum mode and are ignored.

TYPE: float DEFAULT: 200.0

dt

Integration horizon and detection / output step used when system is a flow. In extremum mode dt only needs to resolve the peaks; the recorded value is sharpened by parabolic interpolation, so a coarse grid still gives accurate extrema. In method="poincare" mode only dt is used (the section is marched until n crossings); final_time, ic, transient and **integrate_kwargs apply to extremum mode and are ignored.

TYPE: float DEFAULT: 200.0

transient

Elapsed time discarded before recording extrema (method="max" / "min", system input).

TYPE: float DEFAULT: 0.0

skip_crossings

Number of leading crossings discarded before recording (method="poincare", system input).

TYPE: int DEFAULT: 0

ic

Initial state when system is a flow.

TYPE: array - like DEFAULT: None

seed

Seed for the random initial condition when the system has none; makes the map reproducible.

TYPE: int DEFAULT: None

**integrate_kwargs

Forwarded to system.integrate (extremum mode, system input).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
ReturnMap

The recorded values and the paired (current, successor) arrays.

Examples:

>>> rm = return_map(Lorenz(), "z", method="max", final_time=400.0, transient=40.0)
>>> x, y = rm.flat()       # the cusp map z_n -> z_{n+1}
>>> rm = return_map(Rossler(), 0, method="poincare", plane=(0, 0.0), n=400)
Source code in src/tsdynamics/analysis/orbits/return_map.py
def return_map(
    system: Any,
    component: int | str = 0,
    *,
    method: str = "max",
    plane: tuple[Any, ...] | None = None,
    direction: int = +1,
    n: int = 2000,
    final_time: float = 200.0,
    dt: float = 0.01,
    transient: float = 0.0,
    skip_crossings: int = 0,
    ic: Any | None = None,
    seed: int | None = None,
    **integrate_kwargs: Any,
) -> ReturnMap:
    r"""
    First-return map of a recurring observable.

    Records a sequence of scalar values :math:`v_0, v_1, \dots` from the motion
    and pairs each with its successor, giving the one-dimensional map
    :math:`v_{n+1} = F(v_n)` that organises the dynamics.

    Parameters
    ----------
    system : System, Trajectory, or array-like
        What to read the observable from.  A continuous
        :class:`~tsdynamics.families.ContinuousSystem` is integrated first; a
        :class:`~tsdynamics.data.Trajectory` is read directly; a 1-D array is
        treated as the observable series itself (``method`` must be ``"max"`` or
        ``"min"``).  (Trajectory / array inputs are the ``data`` overload.)
    component : int or str, default 0
        Which state component to record (names allowed when the system /
        trajectory declares ``variables``).  Ignored when ``system`` is a raw
        1-D series.
    method : {"max", "min", "poincare"}, default "max"
        ``"max"`` / ``"min"`` record successive local maxima / minima of the
        observable (the Lorenz construction); ``"poincare"`` records the
        observable at successive section crossings (needs ``plane``).
    plane : tuple, optional
        ``(i, c)`` or ``(normal, offset)`` — the section for ``method="poincare"``
        (see :func:`~tsdynamics.analysis.orbits.poincare_section`).
    direction : {+1, -1, 0}, default +1
        Crossing-direction filter (``method="poincare"`` only).
    n : int, default 2000
        Number of section crossings to collect when integrating a system in
        ``method="poincare"`` mode.
    final_time, dt : float
        Integration horizon and detection / output step used when ``system`` is
        a flow.  In extremum mode ``dt`` only needs to resolve the peaks; the
        recorded value is sharpened by parabolic interpolation, so a coarse grid
        still gives accurate extrema.  In ``method="poincare"`` mode only ``dt``
        is used (the section is marched until ``n`` crossings); ``final_time``,
        ``ic``, ``transient`` and ``**integrate_kwargs`` apply to extremum mode
        and are ignored.
    transient : float, default 0.0
        Elapsed **time** discarded before recording extrema (``method="max"`` /
        ``"min"``, system input).
    skip_crossings : int, default 0
        Number of leading **crossings** discarded before recording
        (``method="poincare"``, system input).
    ic : array-like, optional
        Initial state when ``system`` is a flow.
    seed : int, optional
        Seed for the random initial condition when the system has none; makes
        the map reproducible.
    **integrate_kwargs
        Forwarded to ``system.integrate`` (extremum mode, system input).

    Returns
    -------
    ReturnMap
        The recorded ``values`` and the paired ``(current, successor)`` arrays.

    Examples
    --------
    >>> rm = return_map(Lorenz(), "z", method="max", final_time=400.0, transient=40.0)
    >>> x, y = rm.flat()       # the cusp map z_n -> z_{n+1}
    >>> rm = return_map(Rossler(), 0, method="poincare", plane=(0, 0.0), n=400)
    """
    method = method.lower()
    if method not in _KINDS:
        raise ValueError(f"method must be one of {_KINDS}, got {method!r}.")

    if method == "poincare":
        values, times, obs_idx = _poincare_observable(
            system, component, plane, direction, n, skip_crossings, dt, seed
        )
    else:
        values, times, obs_idx = _extremum_observable(
            system, component, method, final_time, dt, transient, ic, seed, integrate_kwargs
        )

    current = values[:-1]
    successor = values[1:]
    meta: dict[str, Any] = {"kind": method, "observable": obs_idx, "n": int(values.size)}
    if plane is not None:
        meta["plane"] = plane
    src_name = getattr(type(system), "__name__", None)
    if not isinstance(system, np.ndarray | list | tuple):
        meta["source"] = src_name
    return ReturnMap(
        current=current,
        successor=successor,
        values=values,
        times=times,
        observable=obs_idx,
        kind=method,
        meta=meta,
    )

ReturnMap dataclass

ReturnMap(
    current: ndarray = (lambda: empty(0))(),
    successor: ndarray = (lambda: empty(0))(),
    values: ndarray = (lambda: empty(0))(),
    times: ndarray = (lambda: empty(0))(),
    observable: int = 0,
    kind: str = "max",
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Result of :func:return_map.

An :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / .summary() / .to_dict() / the .plot seam. The recorded observable values are :attr:values; the return map itself is the pair (:attr:current, :attr:successor) = :math:(v_n, v_{n+1}). Iterate for (current, successor) pairs, or use :meth:flat for the scatter-ready arrays.

flat

flat() -> tuple[ndarray, ndarray]

Return the scatter-plot arrays (current, successor).

Source code in src/tsdynamics/analysis/orbits/return_map.py
def flat(self) -> tuple[np.ndarray, np.ndarray]:
    """Return the scatter-plot arrays ``(current, successor)``."""
    return self.current, self.successor

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this return map as a backend-agnostic :class:PlotSpec.

Builds a RETURN_MAP scatter of :math:(v_n, v_{n+1}) with the diagonal :math:v_{n+1} = v_n drawn as a reference line (its fixed-point locus). The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "return_map"). None uses RETURN_MAP.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/orbits/return_map.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe this return map as a backend-agnostic :class:`PlotSpec`.

    Builds a ``RETURN_MAP`` scatter of :math:`(v_n, v_{n+1})` with the
    diagonal :math:`v_{n+1} = v_n` drawn as a reference line (its fixed-point
    locus).  The :mod:`tsdynamics.viz.spec` import is lazy, so building a spec
    never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"return_map"``).  ``None`` uses
        ``RETURN_MAP``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    cur = np.asarray(self.current, dtype=float)
    suc = np.asarray(self.successor, dtype=float)
    layers = [pb.scatter(cur, suc, label=r"$v_{n+1}$ vs $v_n$")]
    if cur.size:
        layers.append(pb.diagonal(cur, suc))
    return pb.spec(
        kind,
        "return_map",
        layers=layers,
        aspect="equal",
        xlabel=r"$v_n$",
        ylabel=r"$v_{n+1}$",
        title=f"{self.kind} return map",
        meta=self.meta,
    )

cobweb

cobweb(kind: str | None = None) -> Any

Describe the return map's cobweb (staircase) as a :class:PlotSpec.

The cobweb diagram traces the iteration :math:v_{n+1} = F(v_n) as a staircase: from a point on the diagonal it steps vertically to the return curve, then horizontally back to the diagonal, and repeats. This emits a COBWEB spec carrying the scatter of the return points :math:(v_n, v_{n+1}), the diagonal :math:v_{n+1} = v_n, and the staircase LINE itself built from the recorded sequence. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind. None uses COBWEB.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/orbits/return_map.py
def cobweb(self, kind: str | None = None) -> Any:
    r"""Describe the return map's cobweb (staircase) as a :class:`PlotSpec`.

    The cobweb diagram traces the iteration :math:`v_{n+1} = F(v_n)` as a
    staircase: from a point on the diagonal it steps vertically to the return
    curve, then horizontally back to the diagonal, and repeats.  This emits a
    ``COBWEB`` spec carrying the scatter of the return points
    :math:`(v_n, v_{n+1})`, the diagonal :math:`v_{n+1} = v_n`, and the
    staircase ``LINE`` itself built from the recorded sequence.  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls
    a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind.  ``None`` uses ``COBWEB``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    cur = np.asarray(self.current, dtype=float)
    suc = np.asarray(self.successor, dtype=float)
    layers = [pb.scatter(cur, suc, label=r"$v_{n+1}$ vs $v_n$")]
    if cur.size:
        layers.append(pb.diagonal(cur, suc))
        stair_x, stair_y = _cobweb_path(cur, suc)
        layers.append(
            pb.line(stair_x, stair_y, label="cobweb", style={"lw": 0.8, "alpha": 0.8})
        )
    return pb.spec(
        kind,
        "cobweb",
        layers=layers,
        aspect="equal",
        xlabel=r"$v_n$",
        ylabel=r"$v_{n+1}$",
        title=f"{self.kind} cobweb",
        legend=len(layers) > 1,
        meta=self.meta,
    )

Lyapunov quantifiers

lyapunov_spectrum

lyapunov_spectrum(
    system: Any,
    *,
    k: int | None = None,
    final_time: float | None = None,
    n: int | None = None,
    transient: float | None = None,
    dt: float | None = None,
    ic: Any | None = None,
    method: str | None = None,
) -> LyapunovSpectrum

Lyapunov spectrum of any system — the uniform, documented entry point.

Dispatches to the family implementation (QR tangent dynamics for maps, the extended variational system on the engine for ODEs, the engine function-space estimator for DDEs), translating this one signature to each family's native keywords. The exponents are obtained by Benettin renormalisation of an evolving orthonormal frame (Benettin et al. 1980).

PARAMETER DESCRIPTION
system

A flow (ODE/DDE) or a discrete map.

TYPE: System

k

Number of exponents to compute (was n_exp). Defaults to system.dim for flows/maps; a DDE may request more than dim (its tangent space is the infinite-dimensional history).

TYPE: int DEFAULT: None

final_time

Averaging-window length for a flow (after the transient). Mutually exclusive with n; a flow uses final_time.

TYPE: float DEFAULT: None

n

Number of iterations for a map. Mutually exclusive with final_time; a map uses n.

TYPE: int DEFAULT: None

transient

Amount discarded before averaging (a flow burn-in time). Maps reorthonormalise from the initial condition and take no transient here.

TYPE: float DEFAULT: None

dt

Sampling / integration step (flows only).

TYPE: float DEFAULT: None

ic

Initial condition. Falls back to system.ic, then random.

TYPE: array - like DEFAULT: None

method

Solver kernel (continuous flows only).

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
LyapunovSpectrum

The exponents (largest first), a drop-in for the bare (k,) array — np.asarray(result), indexing and iteration work — that also carries .meta, .summary() and the .kaplan_yorke dimension.

RAISES DESCRIPTION
TypeError

If system has no lyapunov_spectrum implementation (e.g. a derived wrapper — compute the spectrum on the underlying system).

ValueError

If k <= 0, or a keyword is passed to the wrong family (final_time / dt / transient / method for a map; n for a flow; or a method for a DDE, which selects its engine via backend).

Examples:

>>> lyapunov_spectrum(Lorenz(), final_time=300.0)   # [0.91, ~0, -14.57]
>>> lyapunov_spectrum(Henon(), k=2, n=5000)         # [0.42, -1.62]
References

G. Benettin, L. Galgani, A. Giorgilli & J.-M. Strelcyn, "Lyapunov characteristic exponents for smooth dynamical systems and for Hamiltonian systems; a method for computing all of them", Meccanica 15 (1980) 9--20 (Part 1) and 21--30 (Part 2).

Source code in src/tsdynamics/analysis/lyapunov/__init__.py
def lyapunov_spectrum(
    system: Any,
    *,
    k: int | None = None,
    final_time: float | None = None,
    n: int | None = None,
    transient: float | None = None,
    dt: float | None = None,
    ic: Any | None = None,
    method: str | None = None,
) -> LyapunovSpectrum:
    """Lyapunov spectrum of any system — the uniform, documented entry point.

    Dispatches to the family implementation (QR tangent dynamics for maps, the
    extended variational system on the engine for ODEs, the engine
    function-space estimator for DDEs), translating this one signature to each
    family's native keywords.  The exponents are obtained by Benettin
    renormalisation of an evolving orthonormal frame (Benettin et al. 1980).

    Parameters
    ----------
    system : System
        A flow (ODE/DDE) or a discrete map.
    k : int, optional
        Number of exponents to compute (was ``n_exp``).  Defaults to
        ``system.dim`` for flows/maps; a DDE may request more than ``dim`` (its
        tangent space is the infinite-dimensional history).
    final_time : float, optional
        Averaging-window length for a **flow** (after the transient).  Mutually
        exclusive with ``n``; a flow uses ``final_time``.
    n : int, optional
        Number of iterations for a **map**.  Mutually exclusive with
        ``final_time``; a map uses ``n``.
    transient : float, optional
        Amount discarded before averaging (a flow burn-in **time**).  Maps
        reorthonormalise from the initial condition and take no transient here.
    dt : float, optional
        Sampling / integration step (flows only).
    ic : array-like, optional
        Initial condition.  Falls back to ``system.ic``, then random.
    method : str, optional
        Solver kernel (continuous flows only).

    Returns
    -------
    LyapunovSpectrum
        The exponents (largest first), a drop-in for the bare ``(k,)`` array —
        ``np.asarray(result)``, indexing and iteration work — that also carries
        ``.meta``, ``.summary()`` and the ``.kaplan_yorke`` dimension.

    Raises
    ------
    TypeError
        If ``system`` has no ``lyapunov_spectrum`` implementation (e.g. a derived
        wrapper — compute the spectrum on the underlying system).
    ValueError
        If ``k <= 0``, or a keyword is passed to the wrong family (``final_time``
        / ``dt`` / ``transient`` / ``method`` for a map; ``n`` for a flow; or a
        ``method`` for a DDE, which selects its engine via ``backend``).

    Examples
    --------
    >>> lyapunov_spectrum(Lorenz(), final_time=300.0)   # [0.91, ~0, -14.57]
    >>> lyapunov_spectrum(Henon(), k=2, n=5000)         # [0.42, -1.62]

    References
    ----------
    G. Benettin, L. Galgani, A. Giorgilli & J.-M. Strelcyn, "Lyapunov
    characteristic exponents for smooth dynamical systems and for Hamiltonian
    systems; a method for computing all of them", *Meccanica* **15** (1980)
    9--20 (Part 1) and 21--30 (Part 2).
    """
    method_fn = getattr(system, "lyapunov_spectrum", None)
    if method_fn is None:
        raise TypeError(
            f"{type(system).__name__} has no lyapunov_spectrum implementation. "
            f"For derived wrappers, compute the spectrum on the underlying system."
        )
    if k is not None and k <= 0:
        raise ValueError(f"k (number of exponents) must be a positive integer, got {k!r}.")

    fwd: dict[str, Any] = {}
    if k is not None:
        fwd["n_exp"] = k
    if ic is not None:
        fwd["ic"] = ic

    if getattr(system, "is_discrete", False):
        # Maps: horizon is `n` (iterations); no time, solver or burn-in concept.
        if final_time is not None:
            raise ValueError("lyapunov_spectrum: final_time is for flows; a map uses n.")
        if dt is not None:
            raise ValueError("lyapunov_spectrum: dt has no meaning for a discrete map.")
        if transient is not None:
            raise ValueError(
                "lyapunov_spectrum: transient is not supported for a map spectrum "
                "(the QR iteration reorthonormalises from the initial condition)."
            )
        if method is not None:
            raise ValueError("lyapunov_spectrum: a map spectrum has no solver method.")
        if n is not None:
            fwd["steps"] = n
    else:
        # Flows (ODE/DDE): horizon is `final_time`; transient is a burn-in time.
        if n is not None:
            raise ValueError("lyapunov_spectrum: n is for maps; a flow/DDE uses final_time.")
        if final_time is not None:
            fwd["final_time"] = final_time
        if transient is not None:
            fwd["burn_in"] = transient
        if dt is not None:
            fwd["dt"] = dt
        if method is not None:
            if isinstance(system, DelaySystem):
                raise ValueError(
                    "lyapunov_spectrum: a DDE selects its engine via backend, not method."
                )
            fwd["method"] = method
    exponents = np.asarray(method_fn(**fwd), dtype=float)
    meta = AnalysisResult.build_meta(
        system,
        analysis="lyapunov_spectrum",
        k=int(exponents.size),
        final_time=final_time,
        n=n,
        transient=transient,
    )
    return LyapunovSpectrum(values=exponents, meta=meta)

max_lyapunov

max_lyapunov(
    system: Any,
    *,
    d0: float = 1e-09,
    n: int = 400,
    steps_per: int = 5,
    dt: float | None = None,
    transient: int = 500,
    ic: Any | None = None,
    seed: int | None = None,
) -> ScalarResult

Maximal Lyapunov exponent by two-trajectory rescaling (Benettin et al. 1976).

Runs a reference and a perturbed copy of the system in lockstep through the :class:~tsdynamics.families.System protocol — no Jacobian needed, so it works for any ODE or map (including ones with non-smooth right-hand sides). The separation is rescaled back to d0 after every cycle and the accumulated :math:\ln(d / d_0) is averaged over elapsed time. Not available for DDEs (their state cannot be set_state-ed); use DelaySystem.lyapunov_spectrum instead.

PARAMETER DESCRIPTION
system

ODE or map.

TYPE: System

d0

Perturbation size restored at every rescaling.

TYPE: float DEFAULT: 1e-09

n

Number of rescaling cycles (more → better averaging).

TYPE: int DEFAULT: 400

steps_per

Protocol steps between rescalings.

TYPE: int DEFAULT: 5

dt

Step size for continuous systems (default: the system's step default).

TYPE: float DEFAULT: None

transient

Protocol steps discarded before measuring.

TYPE: int DEFAULT: 500

ic

Initial condition for the reference trajectory.

TYPE: array - like DEFAULT: None

seed

Seed for the random perturbation direction (two-trajectory path) and for the off-basin random-IC retry on the map engine-kernel path, so a result that triggers the retry is reproducible.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
ScalarResult

Estimated maximal exponent (per unit time / per iteration), a drop-in for its float value that also carries .meta / .summary().

RAISES DESCRIPTION
NotImplementedError

If system is a delay system (it has no set_state).

ValueError

If dt is passed for a discrete map.

ConvergenceError

If the two trajectories collapse or diverge (zero / non-finite separation), or a continuous system's clock does not advance.

Examples:

>>> max_lyapunov(Lorenz(ic=[1.0, 1.0, 1.0]), dt=0.05)   # ≈ 0.91
References

G. Benettin, L. Galgani & J.-M. Strelcyn, "Kolmogorov entropy and numerical experiments", Physical Review A 14 (1976) 2338--2345.

Source code in src/tsdynamics/analysis/lyapunov/__init__.py
def max_lyapunov(
    system: Any,
    *,
    d0: float = 1e-9,
    n: int = 400,
    steps_per: int = 5,
    dt: float | None = None,
    transient: int = 500,
    ic: Any | None = None,
    seed: int | None = None,
) -> ScalarResult:
    r"""Maximal Lyapunov exponent by two-trajectory rescaling (Benettin et al. 1976).

    Runs a reference and a perturbed copy of the system in lockstep through
    the :class:`~tsdynamics.families.System` protocol — no Jacobian needed, so it
    works for any ODE or map (including ones with non-smooth right-hand
    sides).  The separation is rescaled back to ``d0`` after every cycle and the
    accumulated :math:`\ln(d / d_0)` is averaged over elapsed time.  Not
    available for DDEs (their state cannot be ``set_state``-ed); use
    ``DelaySystem.lyapunov_spectrum`` instead.

    Parameters
    ----------
    system : System
        ODE or map.
    d0 : float
        Perturbation size restored at every rescaling.
    n : int
        Number of rescaling cycles (more → better averaging).
    steps_per : int
        Protocol steps between rescalings.
    dt : float, optional
        Step size for continuous systems (default: the system's step default).
    transient : int
        Protocol steps discarded before measuring.
    ic : array-like, optional
        Initial condition for the reference trajectory.
    seed : int, optional
        Seed for the random perturbation direction (two-trajectory path) and for
        the off-basin random-IC retry on the map engine-kernel path, so a result
        that triggers the retry is reproducible.

    Returns
    -------
    ScalarResult
        Estimated maximal exponent (per unit time / per iteration), a drop-in for
        its ``float`` value that also carries ``.meta`` / ``.summary()``.

    Raises
    ------
    NotImplementedError
        If ``system`` is a delay system (it has no ``set_state``).
    ValueError
        If ``dt`` is passed for a discrete map.
    ConvergenceError
        If the two trajectories collapse or diverge (zero / non-finite
        separation), or a continuous system's clock does not advance.

    Examples
    --------
    >>> max_lyapunov(Lorenz(ic=[1.0, 1.0, 1.0]), dt=0.05)   # ≈ 0.91

    References
    ----------
    G. Benettin, L. Galgani & J.-M. Strelcyn, "Kolmogorov entropy and numerical
    experiments", *Physical Review A* **14** (1976) 2338--2345.
    """
    if isinstance(system, DelaySystem):
        raise NotImplementedError(
            "max_lyapunov needs set_state, which delay systems cannot support — "
            "use DelaySystem.lyapunov_spectrum (the engine estimator) instead."
        )
    if system.is_discrete and dt is not None:
        raise InvalidParameterError(
            "dt has no meaning for discrete maps — omit it (every step is one iteration)."
        )

    # Maps: the maximal exponent is the leading entry of the QR tangent-map
    # spectrum, run in one Rust engine call (stream perf/map-lyapunov-kernel) —
    # thousands of times faster than the per-iteration two-trajectory rescaling, and
    # more robust (no perturbation-size / collapse tuning).  The continuous-system
    # path below is unchanged.  Only the map path moves to the kernel; a map whose
    # ``_step`` will not lower, or a wheel-free environment, falls back to the
    # two-trajectory loop transparently.
    if system.is_discrete:
        mle = _max_lyapunov_map(
            system, n=n, steps_per=steps_per, transient=transient, ic=ic, seed=seed
        )
        if mle is not None:
            meta = AnalysisResult.build_meta(
                system, analysis="max_lyapunov", n=n, transient=transient
            )
            return ScalarResult(value=mle, meta=meta)

    rng = np.random.default_rng(seed)
    ref = system.copy()
    ref.reinit(ic)
    for _ in range(transient):
        ref.step(dt)

    pert = system.copy()
    direction = rng.normal(size=system.dim)
    direction *= d0 / np.linalg.norm(direction)
    pert.reinit(ref.state() + direction)

    t_start = ref.time()
    log_sum = 0.0
    for _ in range(n):
        for _ in range(steps_per):
            ref.step(dt)
            pert.step(dt)
        delta = pert.state() - ref.state()
        d = float(np.linalg.norm(delta))
        if d == 0.0 or not np.isfinite(d):
            raise ConvergenceError(
                "max_lyapunov: trajectories collapsed or diverged — "
                "try a larger d0 or smaller steps_per."
            )
        log_sum += np.log(d / d0)
        pert.set_state(ref.state() + (d0 / d) * delta)

    if system.is_discrete:
        elapsed = float(n * steps_per)
    else:
        # Normalize by the *actual* elapsed integration time, read from the
        # reference trajectory's clock — robust to whatever per-step advance the
        # system makes when ``dt`` is ``None`` (built-in flows step by their own
        # ``_default_step_dt``; a continuous ``WrappedSystem`` steps by its
        # ``default_dt``). Guessing a step-size attribute name silently rescales
        # the exponent whenever the guess misses the real per-step advance.
        elapsed = float(ref.time() - t_start)
        if elapsed <= 0.0 or not np.isfinite(elapsed):
            raise ConvergenceError(
                "max_lyapunov: the reference clock did not advance — a continuous "
                "system must report elapsed time through time(); pass an explicit dt."
            )
    mle = float(log_sum / elapsed)
    meta = AnalysisResult.build_meta(system, analysis="max_lyapunov", n=n, transient=transient)
    return ScalarResult(value=mle, meta=meta)

lyapunov_from_data

lyapunov_from_data(
    data: ndarray,
    *,
    dt: float = 1.0,
    dimension: int = 3,
    delay: int = 1,
    theiler: int | None = None,
    k_max: int = 20,
    eps: float | None = None,
    n_neighbors: int = 1,
    method: str = "kantz",
    fit: tuple[int, int] | None = None,
) -> LyapunovFromData

Estimate the maximal Lyapunov exponent from a time series.

Reconstructs an m-dimensional delay embedding of series, measures how fast nearby trajectories diverge as a function of the look-ahead k, and reads the exponent off the slope of the resulting log-divergence curve.

PARAMETER DESCRIPTION
data

1-D scalar series, or 2-D (n_samples, n_channels) for a multivariate recording.

TYPE: array_like

dt

Sampling interval (time between consecutive samples). Use 1.0 for a map (the exponent is then per iteration).

TYPE: float DEFAULT: 1.0

dimension

Embedding dimension. Choose it from the data — large enough to unfold the attractor (e.g. a false-nearest-neighbour estimate); too small underestimates the exponent.

TYPE: int DEFAULT: 3

delay

Embedding delay, in samples. For oversampled flows pick it near the first minimum of the mutual information / first zero of the autocorrelation.

TYPE: int DEFAULT: 1

theiler

Theiler window (Theiler 1986): neighbours with |n - j| <= theiler are rejected so temporally-correlated points are not mistaken for dynamical neighbours. Defaults to (dimension - 1) * delay (the embedding span).

TYPE: int DEFAULT: None

k_max

Number of forward samples over which divergence is tracked; the curve spans k = 0 … k_max.

TYPE: int DEFAULT: 20

eps

Neighbour-ball radius for method="kantz". Defaults to 0.1 times the standard deviation pooled over all embedded coordinates. This default assumes the coordinates are comparably scaled: for a strongly anisotropic embedding (channels on very different scales, or a multivariate recording with disparate units) the pooled std is dominated by the largest-variance coordinate and mis-scales the isotropic ball for the others, biasing the neighbour set — standardize/normalize the channels first (or pass an explicit eps) in that case. Ignored by "rosenstein" (which uses the single nearest neighbour).

TYPE: float DEFAULT: None

n_neighbors

Minimum neighbours a reference point needs to contribute (Kantz only).

TYPE: int DEFAULT: 1

method

Divergence estimator (see module docstring).

TYPE: ('kantz', 'rosenstein') DEFAULT: "kantz"

fit

Inclusive sample range (lo, hi) over which the slope is fit. Defaults to an automatic scaling region (the initial linear rise). Inspect the returned curve and set this explicitly for a reliable estimate.

TYPE: tuple[int, int] DEFAULT: None

RETURNS DESCRIPTION
LyapunovFromData

The estimated exponent, the full divergence curve, and the parameters used. Casts to float as the exponent.

RAISES DESCRIPTION
InvalidParameterError

If a reconstruction parameter is invalid (dimension < 1, delay < 1, k_max < 2, n_neighbors < 1, dt <= 0, theiler < 0, an unknown method, eps <= 0 for a constant series, an out-of-bounds fit window, or a k_max too large / series too short to leave any forward image).

ConvergenceError

If no usable neighbour is found (no reference point has a neighbour within eps outside the Theiler window, or no nearest neighbour clears the Theiler window), or the fit window holds too few usable divergence points to fit a slope.

Notes

The estimate is only as good as the embedding and the chosen scaling region. Always look at result.times vs result.divergence: a trustworthy estimate comes from a clear straight segment before the curve saturates.

Examples:

>>> import tsdynamics as ts
>>> traj = ts.Henon().trajectory(6000, transient=500, ic=[0.1, 0.1])
>>> res = ts.lyapunov_from_data(traj.y[:, 0], dimension=4, k_max=12, fit=(0, 6))
>>> 0.30 < float(res) < 0.55      # ≈ 0.42
True
References

H. Kantz, "A robust method to estimate the maximal Lyapunov exponent of a time series", Physics Letters A 185 (1994) 77--87.

M. T. Rosenstein, J. J. Collins & C. J. De Luca, "A practical method for calculating largest Lyapunov exponents from small data sets", Physica D 65 (1993) 117--134.

Source code in src/tsdynamics/analysis/lyapunov/from_data.py
def lyapunov_from_data(
    data: np.ndarray,
    *,
    dt: float = 1.0,
    dimension: int = 3,
    delay: int = 1,
    theiler: int | None = None,
    k_max: int = 20,
    eps: float | None = None,
    n_neighbors: int = 1,
    method: str = "kantz",
    fit: tuple[int, int] | None = None,
) -> LyapunovFromData:
    r"""Estimate the maximal Lyapunov exponent from a time series.

    Reconstructs an ``m``-dimensional delay embedding of ``series``, measures how
    fast nearby trajectories diverge as a function of the look-ahead ``k``, and
    reads the exponent off the slope of the resulting log-divergence curve.

    Parameters
    ----------
    data : array_like
        1-D scalar series, or 2-D ``(n_samples, n_channels)`` for a multivariate
        recording.
    dt : float, default 1.0
        Sampling interval (time between consecutive samples).  Use ``1.0`` for a
        map (the exponent is then per iteration).
    dimension : int, default 3
        Embedding dimension.  Choose it from the data — large enough to unfold
        the attractor (e.g. a false-nearest-neighbour estimate); too small
        underestimates the exponent.
    delay : int, default 1
        Embedding delay, in samples.  For oversampled flows pick it near the
        first minimum of the mutual information / first zero of the
        autocorrelation.
    theiler : int, optional
        Theiler window (Theiler 1986): neighbours with ``|n - j| <= theiler`` are
        rejected so temporally-correlated points are not mistaken for dynamical
        neighbours.  Defaults to ``(dimension - 1) * delay`` (the embedding span).
    k_max : int, default 20
        Number of forward samples over which divergence is tracked; the curve
        spans ``k = 0 … k_max``.
    eps : float, optional
        Neighbour-ball radius for ``method="kantz"``.  Defaults to ``0.1`` times
        the standard deviation pooled over *all* embedded coordinates.  This
        default assumes the coordinates are comparably scaled: for a strongly
        anisotropic embedding (channels on very different scales, or a
        multivariate recording with disparate units) the pooled std is dominated
        by the largest-variance coordinate and mis-scales the isotropic ball for
        the others, biasing the neighbour set — standardize/normalize the
        channels first (or pass an explicit ``eps``) in that case.  Ignored by
        ``"rosenstein"`` (which uses the single nearest neighbour).
    n_neighbors : int, default 1
        Minimum neighbours a reference point needs to contribute (Kantz only).
    method : {"kantz", "rosenstein"}, default "kantz"
        Divergence estimator (see module docstring).
    fit : tuple[int, int], optional
        Inclusive sample range ``(lo, hi)`` over which the slope is fit.  Defaults
        to an automatic scaling region (the initial linear rise).  Inspect the
        returned curve and set this explicitly for a reliable estimate.

    Returns
    -------
    LyapunovFromData
        The estimated exponent, the full divergence curve, and the parameters
        used.  Casts to ``float`` as the exponent.

    Raises
    ------
    InvalidParameterError
        If a reconstruction parameter is invalid (``dimension < 1``,
        ``delay < 1``, ``k_max < 2``, ``n_neighbors < 1``, ``dt <= 0``,
        ``theiler < 0``, an unknown ``method``, ``eps <= 0`` for a constant
        series, an out-of-bounds ``fit`` window, or a ``k_max`` too large / series
        too short to leave any forward image).
    ConvergenceError
        If no usable neighbour is found (no reference point has a neighbour
        within ``eps`` outside the Theiler window, or no nearest neighbour clears
        the Theiler window), or the fit window holds too few usable divergence
        points to fit a slope.

    Notes
    -----
    The estimate is only as good as the embedding and the chosen scaling region.
    Always look at ``result.times`` vs ``result.divergence``: a trustworthy
    estimate comes from a clear straight segment before the curve saturates.

    Examples
    --------
    >>> import tsdynamics as ts
    >>> traj = ts.Henon().trajectory(6000, transient=500, ic=[0.1, 0.1])
    >>> res = ts.lyapunov_from_data(traj.y[:, 0], dimension=4, k_max=12, fit=(0, 6))
    >>> 0.30 < float(res) < 0.55      # ≈ 0.42
    True

    References
    ----------
    H. Kantz, "A robust method to estimate the maximal Lyapunov exponent of a
    time series", *Physics Letters A* **185** (1994) 77--87.

    M. T. Rosenstein, J. J. Collins & C. J. De Luca, "A practical method for
    calculating largest Lyapunov exponents from small data sets", *Physica D*
    **65** (1993) 117--134.
    """
    dimension = int(dimension)
    delay = int(delay)
    k_max = int(k_max)
    n_neighbors = int(n_neighbors)
    dt = float(dt)
    method = method.lower()
    if dimension < 1:
        raise InvalidParameterError("dimension (embedding dimension) must be >= 1.")
    if delay < 1:
        raise InvalidParameterError("delay (embedding delay) must be >= 1.")
    if k_max < 2:
        raise InvalidParameterError("k_max must be >= 2 to fit a slope.")
    if n_neighbors < 1:
        raise InvalidParameterError("n_neighbors must be >= 1.")
    if dt <= 0.0:
        raise InvalidParameterError("dt must be positive.")
    if method not in {"kantz", "rosenstein"}:
        raise InvalidParameterError(f"method must be 'kantz' or 'rosenstein', got {method!r}.")
    theiler = (dimension - 1) * delay if theiler is None else int(theiler)
    if theiler < 0:
        raise InvalidParameterError("theiler must be >= 0.")

    from scipy.spatial import cKDTree

    emb = _delay_embed(data, dimension, delay)
    n_rows = emb.shape[0]
    last = n_rows - 1 - k_max  # references/neighbours need their k-ahead image to exist
    if last < 1:
        raise InvalidParameterError(
            "k_max is too large for the embedded series: no forward images remain. "
            "Use a longer series or reduce k_max, dimension, or delay."
        )
    tree = cKDTree(emb)

    if method == "kantz":
        if eps is None:
            # Pooled std over every embedded coordinate: assumes comparably-scaled
            # channels (see the `eps` docstring; standardize anisotropic input).
            eps = 0.1 * float(np.std(emb))
        eps = float(eps)
        if eps <= 0.0:
            raise InvalidParameterError("eps must be positive (series may be constant).")
        # One batched ball query for *all* candidate reference rows, then filter
        # each candidate list to neighbours inside eps, outside the Theiler window,
        # and whose k-ahead image still exists (j <= last). A reference point with
        # >= n_neighbors survivors contributes. This reproduces the per-point
        # ``query_ball_point`` loop exactly (same eps, same predicate, same order).
        cand_lists = tree.query_ball_point(emb[: last + 1], eps)
        ref_idx_list: list[int] = []
        # Flat (reference, neighbour) pair arrays plus per-reference neighbour
        # counts: the divergence average over each reference's neighbour set is a
        # segment-mean over these flat arrays (np.add.at grouping below), so the
        # per-k double Python loop collapses to one vectorised distance + reduce.
        ref_repeat_blocks: list[np.ndarray] = []
        neigh_blocks: list[np.ndarray] = []
        counts: list[int] = []
        for n in range(last + 1):
            cand = cand_lists[n]
            neigh = np.fromiter(
                (j for j in cand if j <= last and abs(j - n) > theiler),
                dtype=np.intp,
            )
            if neigh.size >= n_neighbors:
                ref_pos = len(ref_idx_list)
                ref_idx_list.append(n)
                ref_repeat_blocks.append(np.full(neigh.size, ref_pos, dtype=np.intp))
                neigh_blocks.append(neigh)
                counts.append(int(neigh.size))
        if not ref_idx_list:
            raise ConvergenceError(
                "no reference point has a neighbour within eps outside the Theiler "
                "window; increase eps, lower dimension, or shorten the Theiler window."
            )
        n_reference = len(ref_idx_list)
        ref_idx_arr = np.asarray(ref_idx_list, dtype=np.intp)
        ref_repeat = np.concatenate(ref_repeat_blocks)  # group id per pair
        neigh_flat = np.concatenate(neigh_blocks)  # neighbour row per pair
        counts_arr = np.asarray(counts, dtype=float)  # neighbours per reference
        divergence = np.empty(k_max + 1)
        for k in range(k_max + 1):
            # Pairwise distances between every reference and its neighbours at the
            # k-ahead image, in one vectorised pass over the flat pair arrays.
            diff = emb[ref_idx_arr[ref_repeat] + k] - emb[neigh_flat + k]
            d = np.sqrt(np.einsum("ij,ij->i", diff, diff))
            # Mean distance per reference (segment-sum / count), matching the
            # per-point ``d.mean()`` exactly.
            sums = np.zeros(n_reference)
            np.add.at(sums, ref_repeat, d)
            means = sums / counts_arr
            divergence[k] = float(np.mean(np.log(np.maximum(means, _TINY))))
    else:  # rosenstein
        # Examine the n_query nearest candidates per point; the cap is a
        # heuristic sized to clear the Theiler window. A reference point whose
        # candidates are *all* rejected (inside the window or past `last`) is
        # skipped rather than matched to a wrong neighbour, lowering n_reference.
        n_query = min(n_rows, 4 * theiler + 20)
        _, idx_all = tree.query(emb, k=n_query)
        idx_all = np.atleast_2d(idx_all)
        # Select, for each reference row, the FIRST candidate column (in the
        # tree's nearest-first order) that clears the Theiler window and whose
        # k-ahead image exists — exactly the inner break-loop, expressed as a
        # masked argmax. ``argmax`` returns the first True (the break target);
        # rows with no valid candidate are dropped (``any`` over the row is False).
        rows = idx_all[: last + 1, 1:]  # drop column 0 (the point itself)
        ref_grid = np.arange(last + 1, dtype=np.intp)[:, None]
        valid = (rows <= last) & (np.abs(rows - ref_grid) > theiler)
        has_neighbour = valid.any(axis=1)
        first_col = valid.argmax(axis=1)  # first valid column per row (0 if none)
        ref_arr = ref_grid[:, 0][has_neighbour]
        nn_arr = rows[ref_arr, first_col[has_neighbour]].astype(np.intp)
        if ref_arr.size == 0:
            raise ConvergenceError(
                "no nearest neighbour outside the Theiler window was found; "
                "use a longer series or shorten the Theiler window."
            )
        # All look-ahead images at once: index the reference / neighbour pairs at
        # every lag k via broadcasting, one norm reduction over the last axis.
        ks = np.arange(k_max + 1, dtype=np.intp)
        ref_at_k = emb[ref_arr[:, None] + ks]  # (n_ref, k_max+1, dim)
        nn_at_k = emb[nn_arr[:, None] + ks]
        diff = ref_at_k - nn_at_k
        d = np.sqrt(np.einsum("ijk,ijk->ij", diff, diff))  # (n_ref, k_max+1)
        divergence = np.mean(np.log(np.maximum(d, _TINY)), axis=0)
        n_reference = int(ref_arr.size)

    times = np.arange(k_max + 1, dtype=float) * dt
    if fit is None:
        lo, hi = _auto_fit_region(divergence, min_len=max(3, (k_max + 1) // 3))
    else:
        lo, hi = int(fit[0]), int(fit[1])
        if not (0 <= lo < hi <= k_max):
            raise InvalidParameterError(
                f"fit region {fit!r} must satisfy 0 <= lo < hi <= k_max ({k_max})."
            )
    xfit = times[lo : hi + 1]
    yfit = divergence[lo : hi + 1]
    # The divergence curve is floored at ``log(_TINY)``, so it is always finite;
    # guard the slope fit anyway against a degenerate (single-point / collinear-x)
    # window so it raises a clean typed error rather than a numpy rank warning.
    if xfit.size < 2 or not np.any(np.isfinite(yfit)):
        raise ConvergenceError(
            "lyapunov_from_data: the fit window holds too few usable divergence points "
            "to fit a slope; widen `fit` or use a longer series / different embedding."
        )
    slope, intercept = (float(c) for c in np.polyfit(xfit, yfit, 1))
    stderr = _slope_stderr(xfit, yfit, slope, intercept)

    return LyapunovFromData(
        estimate=slope,
        stderr=stderr,
        abscissa=times,
        ordinate=divergence,
        fit_region=(lo, hi),
        intercept=intercept,
        embedding_dim=dimension,
        delay=delay,
        theiler=theiler,
        n_reference=n_reference,
        method=method,
        meta={
            "method": method,
            "dimension": dimension,
            "delay": delay,
            "theiler": theiler,
            "n_reference": n_reference,
        },
    )

LyapunovFromData dataclass

LyapunovFromData(
    estimate: float = 0.0,
    stderr: float = 0.0,
    abscissa: ndarray = (lambda: empty(0))(),
    ordinate: ndarray = (lambda: empty(0))(),
    fit_region: tuple[int, int] = (0, 0),
    intercept: float = 0.0,
    embedding_dim: int = 0,
    delay: int = 0,
    theiler: int = 0,
    n_reference: int = 0,
    method: str = "kantz",
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: ScalingResult

Outcome of :func:lyapunov_from_data: the divergence curve and its slope.

A :class:~tsdynamics.analysis._result.ScalingResult — the maximal Lyapunov exponent is read off the slope of the stretching curve, the same shape every fractal dimension and embedding diagnostic share — so it inherits the canonical estimate / abscissa / ordinate / fit_region schema, the result surface (.meta / .summary() / .to_dict() / the .plot seam) and float(result) (the exponent). Domain-named @property aliases (:attr:lyapunov, :attr:times, :attr:divergence) preserve the original field names.

ATTRIBUTE DESCRIPTION
estimate

Estimated maximal Lyapunov exponent (per unit time), the slope of ordinate against abscissa over fit_region. Aliased :attr:lyapunov. float(result) returns it.

TYPE: float

abscissa

Relative times k * dt for k = 0 … k_max. Aliased :attr:times.

TYPE: ndarray

ordinate

The stretching curve S(k) — mean log divergence after k samples. Aliased :attr:divergence. Inspect abscissa vs ordinate to choose a scaling region and refine with an explicit fit=(lo, hi).

TYPE: ndarray

fit_region

Inclusive index range into the curve used for the slope.

TYPE: tuple[int, int]

embedding_dim, delay, theiler

Reconstruction parameters actually used.

TYPE: int

n_reference

Number of reference points that contributed (had a usable neighbour).

TYPE: int

method

"kantz" or "rosenstein".

TYPE: str

lyapunov property

lyapunov: float

The estimated maximal Lyapunov exponent (alias of :attr:estimate).

times property

times: ndarray

Relative times of the stretching curve (alias of :attr:abscissa).

divergence property

divergence: ndarray

The stretching curve S(k) (alias of :attr:ordinate).

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the divergence curve as a backend-agnostic :class:PlotSpec.

Builds a SCALING_FIT spec — the stretching curve :math:S(k) (mean log-divergence) against time as a scatter, the fitted scaling region highlighted, and the line of slope :attr:lyapunov drawn over it — the same schema the fractal-dimension estimators emit, so a single result.plot.scaling() renders it. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "scaling_fit"). None uses SCALING_FIT.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/lyapunov/from_data.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the divergence curve as a backend-agnostic :class:`PlotSpec`.

    Builds a ``SCALING_FIT`` spec — the stretching curve :math:`S(k)` (mean
    log-divergence) against time as a scatter, the fitted scaling region
    highlighted, and the line of slope :attr:`lyapunov` drawn over it — the
    same schema the fractal-dimension estimators emit, so a single
    ``result.plot.scaling()`` renders it.  The :mod:`tsdynamics.viz.spec`
    import is lazy, so building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"scaling_fit"``).  ``None`` uses
        ``SCALING_FIT``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    t = np.asarray(self.times, dtype=float)
    s = np.asarray(self.divergence, dtype=float)
    lo, hi = self.fit_region
    # The line of slope `lyapunov` anchored to the fit-region centroid.
    line_y = None
    if t.size and hi >= lo:
        tc = float(np.mean(t[lo : hi + 1]))
        sc = float(np.mean(s[lo : hi + 1]))
        fit_x = np.array([t[lo], t[hi]], dtype=float)
        line_y = sc + self.lyapunov * (fit_x - tc)
    return pb.scaling_fit(
        kind,
        t,
        s,
        fit_region=self.fit_region,
        slope=self.lyapunov,
        line_y=line_y,
        curve_label="$S(k)$",
        xlabel="time",
        ylabel="mean log divergence $S(k)$",
        title=f"max. Lyapunov ({self.method}) = {self.lyapunov:.3g}",
    )

kaplan_yorke_dimension

kaplan_yorke_dimension(spectrum: Any) -> ScalarResult

Kaplan--Yorke (Lyapunov) dimension from a Lyapunov spectrum.

.. math::

D_{KY} = j + \frac{\lambda_1 + \cdots + \lambda_j}{|\lambda_{j+1}|},

where :math:j is the largest index whose cumulative exponent sum is non-negative (Kaplan & Yorke 1979). Interpolating between the :math:j-th and :math:(j+1)-th exponents gives a fractional estimate of the attractor's information dimension.

PARAMETER DESCRIPTION
spectrum

Lyapunov exponents (any order; sorted descending internally).

TYPE: array - like

RETURNS DESCRIPTION
ScalarResult

The dimension, a drop-in for its float value (float(result) / comparisons work): 0.0 when every exponent is negative; len(spectrum) when the cumulative sum never turns negative (spectrum incomplete).

Examples:

>>> float(kaplan_yorke_dimension([0.906, 0.0, -14.57]))   # Lorenz
2.062...
References

J. L. Kaplan & J. A. Yorke, "Chaotic behavior of multidimensional difference equations", in Functional Differential Equations and Approximation of Fixed Points, Lecture Notes in Mathematics 730, Springer (1979) 204--227.

Source code in src/tsdynamics/analysis/lyapunov/__init__.py
def kaplan_yorke_dimension(spectrum: Any) -> ScalarResult:
    r"""Kaplan--Yorke (Lyapunov) dimension from a Lyapunov spectrum.

    .. math::

        D_{KY} = j + \frac{\lambda_1 + \cdots + \lambda_j}{|\lambda_{j+1}|},

    where :math:`j` is the largest index whose cumulative exponent sum is
    non-negative (Kaplan & Yorke 1979).  Interpolating between the :math:`j`-th
    and :math:`(j+1)`-th exponents gives a fractional estimate of the attractor's
    information dimension.

    Parameters
    ----------
    spectrum : array-like
        Lyapunov exponents (any order; sorted descending internally).

    Returns
    -------
    ScalarResult
        The dimension, a drop-in for its ``float`` value (``float(result)`` /
        comparisons work): ``0.0`` when every exponent is negative;
        ``len(spectrum)`` when the cumulative sum never turns negative (spectrum
        incomplete).

    Examples
    --------
    >>> float(kaplan_yorke_dimension([0.906, 0.0, -14.57]))   # Lorenz
    2.062...

    References
    ----------
    J. L. Kaplan & J. A. Yorke, "Chaotic behavior of multidimensional difference
    equations", in *Functional Differential Equations and Approximation of Fixed
    Points*, Lecture Notes in Mathematics **730**, Springer (1979) 204--227.
    """
    s = np.sort(np.asarray(spectrum, dtype=float))[::-1]
    if s.size == 0 or s[0] < 0.0:
        dky = 0.0
    else:
        cum = np.cumsum(s)
        j = int(np.nonzero(cum >= 0.0)[0][-1])
        # spectrum doesn't close (j is the last index) -> dimension saturates at len
        dky = float(s.size) if j == s.size - 1 else float(j + 1 + cum[j] / abs(s[j + 1]))
    return ScalarResult(value=dky, meta={"analysis": "kaplan_yorke_dimension", "k": int(s.size)})

Fixed points & periodic orbits

fixed_points

fixed_points(
    system: Any,
    *,
    region: Any = None,
    n_seeds: int = 200,
    tol: float = 1e-12,
    max_iter: int = 60,
    dedup_tol: float = 1e-06,
    method: str = "newton",
    lam: float = 0.05,
    beta: float = 1.0,
    max_c: int | None = None,
    seed: int | None = None,
) -> FixedPointSet

Find fixed points of a map (f(x) = x) or equilibria of a flow (f(x) = 0).

Seeds are drawn uniformly from region plus points sampled from a short orbit; each runs the chosen root finder, and converged roots are deduplicated and classified by the Jacobian spectrum (maps: |lambda| < 1; flows: Re lambda < 0).

PARAMETER DESCRIPTION
system

A discrete map (fixed points) or a continuous flow (equilibria). Delay and stochastic systems are not supported.

TYPE: DiscreteMap or ContinuousSystem

region

Search region; defaults to a burn-in orbit's bounding box padded by 50 %, or [-2, 2]^dim if the orbit diverges.

TYPE: Box, Grid, (lo, hi) tuple DEFAULT: None

n_seeds

Random seeds (orbit points are added on top).

TYPE: int DEFAULT: 200

tol

Residual tolerance (‖f(x) − x‖ for maps, ‖f(x)‖ for flows).

TYPE: float DEFAULT: 1e-12

max_iter

Root-finding iterations per seed.

TYPE: int DEFAULT: 60

dedup_tol

Distance below which two roots are merged.

TYPE: float DEFAULT: 1e-06

method

"newton" (default) — Newton on the exact Jacobian. "sd" / "dl" — Schmelcher--Diakonos / Davidchack--Lai stabilising transformations (maps only) for systematically reaching unstable points. "interval" — the rigorous Krawczyk branch-and-prune (maps and flows): it brackets all roots in region with an existence + uniqueness certificate per sub-box, so it cannot silently miss a root. It requires a bounded region (the box it certifies over) and an interval-extensible right-hand side; a system whose kernel uses an op the interval engine cannot enclose raises :class:~tsdynamics.errors.InvalidInputError (use "newton" there). The n_seeds / lam / beta / max_c knobs do not apply.

TYPE: ('newton', 'sd', 'dl', 'interval') DEFAULT: "newton"

lam

Step size of the Schmelcher--Diakonos iteration (method="sd").

TYPE: float DEFAULT: 0.05

beta

Regularisation strength of the Davidchack--Lai iteration (method="dl"); beta=0 is plain Newton, larger beta enlarges the basin at the cost of more iterations.

TYPE: float DEFAULT: 1.0

max_c

Cap on the number of stabilising matrices tried (sd/dl). The full set has 2^dim · dim! members; if capped, a warning is emitted.

TYPE: int DEFAULT: None

seed

RNG seed for the multi-start sampling. All randomness (the box seeds and the burn-in orbit's starting state) is drawn from a local :class:numpy.random.Generator seeded with this value, so a given seed is fully reproducible regardless of the global numpy.random state. seed=None (the default) is non-deterministic — the sampling varies from call to call.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
FixedPointSet

A list-like CollectionResult of :class:FixedPoint, sorted by coordinate.

RAISES DESCRIPTION
NotImplementedError

If system is neither a discrete map nor a continuous flow.

ValueError

If method is not "newton"/"sd"/"dl"/"interval", or "sd"/"dl" is requested for a flow (use "newton" on f(x)=0), or "interval" is requested without a bounded region.

InvalidInputError

If method="interval" and the system's right-hand side uses an operation the interval engine cannot enclose.

Examples:

>>> fixed_points(Henon())              # two saddles of the Hénon map
>>> fixed_points(Lorenz())             # the origin and the two C± equilibria
>>> fixed_points(Henon(), region=([-3, -3], [3, 3]), method="interval")  # rigorous
References

Schmelcher & Diakonos (1997), Phys. Rev. Lett. 78, 4733. Davidchack & Lai (1999), Phys. Rev. E 60, 6172. Krawczyk (1969), Computing 4, 187. Neumaier (1990), Interval Methods for Systems of Equations, CUP.

Source code in src/tsdynamics/analysis/fixedpoints/fixed.py
def fixed_points(
    system: Any,
    *,
    region: Any = None,
    n_seeds: int = 200,
    tol: float = 1e-12,
    max_iter: int = 60,
    dedup_tol: float = 1e-6,
    method: str = "newton",
    lam: float = 0.05,
    beta: float = 1.0,
    max_c: int | None = None,
    seed: int | None = None,
) -> FixedPointSet:
    r"""
    Find fixed points of a map (``f(x) = x``) or equilibria of a flow (``f(x) = 0``).

    Seeds are drawn uniformly from ``region`` plus points sampled from a short
    orbit; each runs the chosen root finder, and converged roots are deduplicated
    and classified by the Jacobian spectrum (maps: ``|lambda| < 1``; flows:
    ``Re lambda < 0``).

    Parameters
    ----------
    system : DiscreteMap or ContinuousSystem
        A discrete map (fixed points) or a continuous flow (equilibria).  Delay
        and stochastic systems are not supported.
    region : Box, Grid, (lo, hi) tuple, optional
        Search region; defaults to a burn-in orbit's bounding box padded by 50 %,
        or ``[-2, 2]^dim`` if the orbit diverges.
    n_seeds : int
        Random seeds (orbit points are added on top).
    tol : float
        Residual tolerance (``‖f(x) − x‖`` for maps, ``‖f(x)‖`` for flows).
    max_iter : int
        Root-finding iterations per seed.
    dedup_tol : float
        Distance below which two roots are merged.
    method : {"newton", "sd", "dl", "interval"}
        ``"newton"`` (default) — Newton on the exact Jacobian.  ``"sd"`` /
        ``"dl"`` — Schmelcher--Diakonos / Davidchack--Lai stabilising
        transformations (maps only) for systematically reaching unstable points.
        ``"interval"`` — the rigorous Krawczyk branch-and-prune (maps **and**
        flows): it brackets *all* roots in ``region`` with an existence +
        uniqueness certificate per sub-box, so it cannot silently miss a root.
        It requires a bounded ``region`` (the box it certifies over) and an
        interval-extensible right-hand side; a system whose kernel uses an op the
        interval engine cannot enclose raises
        :class:`~tsdynamics.errors.InvalidInputError` (use ``"newton"`` there).
        The ``n_seeds`` / ``lam`` / ``beta`` / ``max_c`` knobs do not apply.
    lam : float
        Step size of the Schmelcher--Diakonos iteration (``method="sd"``).
    beta : float
        Regularisation strength of the Davidchack--Lai iteration
        (``method="dl"``); ``beta=0`` is plain Newton, larger ``beta`` enlarges
        the basin at the cost of more iterations.
    max_c : int, optional
        Cap on the number of stabilising matrices tried (``sd``/``dl``).  The full
        set has ``2^dim · dim!`` members; if capped, a warning is emitted.
    seed : int, optional
        RNG seed for the multi-start sampling.  All randomness (the box seeds and
        the burn-in orbit's starting state) is drawn from a *local*
        :class:`numpy.random.Generator` seeded with this value, so a given ``seed``
        is fully reproducible regardless of the global ``numpy.random`` state.
        ``seed=None`` (the default) is non-deterministic — the sampling varies from
        call to call.

    Returns
    -------
    FixedPointSet
        A list-like ``CollectionResult`` of :class:`FixedPoint`, sorted by
        coordinate.

    Raises
    ------
    NotImplementedError
        If ``system`` is neither a discrete map nor a continuous flow.
    ValueError
        If ``method`` is not ``"newton"``/``"sd"``/``"dl"``/``"interval"``, or
        ``"sd"``/``"dl"`` is requested for a flow (use ``"newton"`` on
        ``f(x)=0``), or ``"interval"`` is requested without a bounded ``region``.
    InvalidInputError
        If ``method="interval"`` and the system's right-hand side uses an
        operation the interval engine cannot enclose.

    Examples
    --------
    >>> fixed_points(Henon())              # two saddles of the Hénon map
    >>> fixed_points(Lorenz())             # the origin and the two C± equilibria
    >>> fixed_points(Henon(), region=([-3, -3], [3, 3]), method="interval")  # rigorous

    References
    ----------
    Schmelcher & Diakonos (1997), *Phys. Rev. Lett.* 78, 4733.
    Davidchack & Lai (1999), *Phys. Rev. E* 60, 6172.
    Krawczyk (1969), *Computing* 4, 187.
    Neumaier (1990), *Interval Methods for Systems of Equations*, CUP.
    """
    if isinstance(system, DiscreteMap):
        continuous = False
    elif isinstance(system, ContinuousSystem):
        continuous = True
    else:
        raise NotImplementedError(
            f"fixed_points supports discrete maps and continuous flows, not "
            f"{type(system).__name__}."
        )

    method = method.lower()
    if method not in ("newton", "sd", "dl", "interval"):
        raise ValueError(f"method must be 'newton', 'sd', 'dl', or 'interval', got {method!r}.")
    if continuous and method in ("sd", "dl"):
        raise ValueError(
            "the 'sd'/'dl' stabilising transformations target unstable orbits of "
            "maps; flow equilibria are found with method='newton' on f(x)=0."
        )

    dim = int(system.dim)
    rng = np.random.default_rng(seed)

    if method == "interval":
        return _interval_fixed_points(system, continuous, region, dim, tol)

    if continuous:
        rhs, jac = _c.flow_fns(system)

        def residual(x: np.ndarray) -> np.ndarray:
            return rhs(x, 0.0)

        def jac_resid(x: np.ndarray) -> np.ndarray:
            return jac(x, 0.0)

        def classify(r: np.ndarray) -> FixedPoint:
            eig = np.linalg.eigvals(jac(r, 0.0))
            return FixedPoint(
                x=r, eigenvalues=eig, stable=bool(np.all(eig.real < 0.0)), continuous=True
            )
    else:
        step, jac = _c.map_fns(system)
        eye = np.eye(dim)

        def residual(x: np.ndarray) -> np.ndarray:
            return cast("np.ndarray", step(x) - x)

        def jac_resid(x: np.ndarray) -> np.ndarray:
            return jac(x) - eye

        def classify(r: np.ndarray) -> FixedPoint:
            eig = np.linalg.eigvals(jac(r))
            return FixedPoint(
                x=r, eigenvalues=eig, stable=bool(np.all(np.abs(eig) < 1.0)), continuous=False
            )

    lo, hi = _c.resolve_box(system, region, dim, rng)
    seeds = _build_seeds(system, dim, lo, hi, n_seeds, rng)
    c_mats = _stabilising_matrices(method, dim, max_c)

    # The box only *seeds* the search.  An explicit ``region`` is also a hard
    # search domain, so converged roots outside it are clipped; but when the box
    # is the auto burn-in bounding box (``region is None``) it must not filter
    # results — a flow's equilibria are saddles the on-attractor orbit never
    # visits (e.g. the Lorenz origin and the C± centres sit outside the chaotic
    # attractor's hull), so clipping to that box would silently drop genuine
    # equilibria (the FIX-FPFLOW defect).
    bounds = (lo, hi) if region is not None else None

    roots = _c.solve_roots(
        residual,
        jac_resid,
        dim,
        seeds,
        method=method,
        c_mats=c_mats,
        lam=lam,
        beta=beta,
        tol=tol,
        max_iter=max_iter,
        dedup_tol=dedup_tol,
        bounds=bounds,
    )
    out = [classify(r) for r in roots]
    out.sort(key=lambda fp: tuple(fp.x))
    return FixedPointSet(
        items=tuple(out),
        meta=AnalysisResult.build_meta(system, analysis="fixed_points", method=method),
    )

FixedPoint dataclass

FixedPoint(
    x: ndarray = (lambda: empty(0))(),
    eigenvalues: ndarray = (lambda: empty(0))(),
    stable: bool = False,
    continuous: bool = False,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A fixed point (map) or equilibrium (flow) with its linear stability data.

An :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / .summary() / .to_dict() / the .plot seam alongside its point and stability data.

ATTRIBUTE DESCRIPTION
x

The point, shape (dim,).

TYPE: ndarray

eigenvalues

Eigenvalues of the Jacobian at x — map multipliers (of :math:Df) for a discrete map, or eigenvalues of the vector-field Jacobian for a flow.

TYPE: ndarray

stable

For a map, True iff every |lambda| < 1; for a flow, True iff every Re(lambda) < 0.

TYPE: bool

continuous

True for a flow equilibrium, False for a map fixed point — sets which stability convention stable uses.

TYPE: bool

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this fixed point as a backend-agnostic :class:PlotSpec.

Builds a FIXED_POINTS_OVERLAY: a single SCATTER point at the first two coordinates of :attr:x, styled by stability (a filled marker for a stable point, an open marker for an unstable one). Designed to be drawn over a phase portrait via :meth:AnalysisResult.overlay_on, which keeps the host layers first. For the eigenvalue/multiplier picture use :meth:eigenvalue_plane. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (a :class:~tsdynamics.viz.spec.PlotKind value). None uses FIXED_POINTS_OVERLAY.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/fixedpoints/fixed.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe this fixed point as a backend-agnostic :class:`PlotSpec`.

    Builds a ``FIXED_POINTS_OVERLAY``: a single ``SCATTER`` point at the first
    two coordinates of :attr:`x`, styled by stability (a filled marker for a
    stable point, an open marker for an unstable one).  Designed to be drawn
    *over* a phase portrait via :meth:`AnalysisResult.overlay_on`, which keeps
    the host layers first.  For the eigenvalue/multiplier picture use
    :meth:`eigenvalue_plane`.  The :mod:`tsdynamics.viz.spec` import is lazy,
    so building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (a :class:`~tsdynamics.viz.spec.PlotKind`
        value).  ``None`` uses ``FIXED_POINTS_OVERLAY``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    x = np.asarray(self.x, dtype=float).ravel()
    label = "stable" if self.stable else "unstable"
    style = _fixed_point_style(self.stable)
    if x.size >= 2:
        layer = pb.scatter(np.array([x[0]]), np.array([x[1]]), label=label, style=style)
        ylabel = "$x_1$"
    else:
        layer = pb.scatter(
            np.array([0.0]), np.array([x[0] if x.size else 0.0]), label=label, style=style
        )
        ylabel = "$x$"
    return pb.spec(
        kind,
        "fixed_points_overlay",
        layers=[layer],
        aspect="equal",
        xlabel="$x_0$",
        ylabel=ylabel,
        title=f"{label} fixed point",
        meta=self.meta,
    )

eigenvalue_plane

eigenvalue_plane(kind: str | None = None) -> Any

Describe the Jacobian spectrum as an :class:EIGENVALUE_PLANE spec.

Plots the eigenvalues / multipliers of :attr:eigenvalues in the complex plane. The stability boundary is drawn as a reference geometry: the unit circle for a map (|λ| = 1) or the imaginary axis for a flow (Re λ = 0), per :attr:continuous. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind. None uses EIGENVALUE_PLANE.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/fixedpoints/fixed.py
def eigenvalue_plane(self, kind: str | None = None) -> Any:
    r"""Describe the Jacobian spectrum as an :class:`EIGENVALUE_PLANE` spec.

    Plots the eigenvalues / multipliers of :attr:`eigenvalues` in the complex
    plane.  The stability boundary is drawn as a reference geometry: the unit
    circle for a **map** (``|λ| = 1``) or the imaginary axis for a **flow**
    (``Re λ = 0``), per :attr:`continuous`.  The :mod:`tsdynamics.viz.spec`
    import is lazy, so building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind.  ``None`` uses ``EIGENVALUE_PLANE``.

    Returns
    -------
    PlotSpec
    """
    return _eigenvalue_plane_spec(
        np.asarray(self.eigenvalues),
        continuous=self.continuous,
        title="fixed-point spectrum",
        meta=dict(self.meta) if self.meta else {},
        kind=kind,
    )

periodic_orbits

periodic_orbits(
    system: Any,
    period: int,
    *,
    region: Any = None,
    n_seeds: int = 300,
    method: str = "dl",
    lam: float = 0.05,
    beta: float = 1.0,
    tol: float = 1e-12,
    max_iter: int = 200,
    dedup_tol: float = 1e-06,
    prime: bool = True,
    max_c: int | None = None,
    seed: int | None = None,
) -> OrbitSet

Find period-period orbits of a discrete map.

Solves :math:f^{p}(x) = x by multi-start root finding (Davidchack--Lai by default — the stabilising transformations reach unstable orbits that plain Newton misses), recovers each orbit by forward iteration, filters orbits whose minimal period properly divides period (prime=True), and merges the cyclic shifts of one orbit.

PARAMETER DESCRIPTION
system

The map.

TYPE: DiscreteMap

period

The period p (p=1 returns the fixed points as one-point orbits).

TYPE: int

region

Seeding controls (see :func:~tsdynamics.analysis.fixedpoints.fixed_points).

TYPE: Any DEFAULT: None

n_seeds

Seeding controls (see :func:~tsdynamics.analysis.fixedpoints.fixed_points).

TYPE: Any DEFAULT: None

dedup_tol

Seeding controls (see :func:~tsdynamics.analysis.fixedpoints.fixed_points).

TYPE: Any DEFAULT: None

seed

Seeding controls (see :func:~tsdynamics.analysis.fixedpoints.fixed_points).

TYPE: Any DEFAULT: None

method

Root finder. "dl" (default) = Davidchack--Lai; "sd" = Schmelcher--Diakonos; "newton" = plain Newton on f^p.

TYPE: ('dl', 'sd', 'newton') DEFAULT: "dl"

lam

Stabilising-transformation controls (see fixed_points).

TYPE: float DEFAULT: 0.05

beta

Stabilising-transformation controls (see fixed_points).

TYPE: float DEFAULT: 0.05

max_c

Stabilising-transformation controls (see fixed_points).

TYPE: float DEFAULT: 0.05

tol

Residual tolerance ‖f^p(x) − x‖.

TYPE: float DEFAULT: 1e-12

max_iter

Iterations per seed/matrix.

TYPE: int DEFAULT: 200

prime

Keep only orbits of minimal period p (drop divisor-period orbits).

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
OrbitSet

A list-like CollectionResult of :class:PeriodicOrbit, sorted by the orbit's lexicographically smallest point.

RAISES DESCRIPTION
TypeError

If system is not a :class:~tsdynamics.families.DiscreteMap (use :func:periodic_orbit for flows).

ValueError

If period < 1 or method is not "newton"/"sd"/"dl".

Examples:

>>> periodic_orbits(Logistic(params={"r": 3.2}), 2)   # the stable 2-cycle
>>> periodic_orbits(Logistic(params={"r": 3.83}), 3)  # stable node + saddle
Source code in src/tsdynamics/analysis/fixedpoints/periodic.py
def periodic_orbits(
    system: Any,
    period: int,
    *,
    region: Any = None,
    n_seeds: int = 300,
    method: str = "dl",
    lam: float = 0.05,
    beta: float = 1.0,
    tol: float = 1e-12,
    max_iter: int = 200,
    dedup_tol: float = 1e-6,
    prime: bool = True,
    max_c: int | None = None,
    seed: int | None = None,
) -> OrbitSet:
    r"""
    Find period-``period`` orbits of a discrete map.

    Solves :math:`f^{p}(x) = x` by multi-start root finding (Davidchack--Lai by
    default — the stabilising transformations reach unstable orbits that plain
    Newton misses), recovers each orbit by forward iteration, filters orbits
    whose minimal period properly divides ``period`` (``prime=True``), and merges
    the cyclic shifts of one orbit.

    Parameters
    ----------
    system : DiscreteMap
        The map.
    period : int
        The period ``p`` (``p=1`` returns the fixed points as one-point orbits).
    region, n_seeds, dedup_tol, seed
        Seeding controls (see :func:`~tsdynamics.analysis.fixedpoints.fixed_points`).
    method : {"dl", "sd", "newton"}
        Root finder.  ``"dl"`` (default) = Davidchack--Lai; ``"sd"`` =
        Schmelcher--Diakonos; ``"newton"`` = plain Newton on ``f^p``.
    lam, beta, max_c
        Stabilising-transformation controls (see ``fixed_points``).
    tol : float
        Residual tolerance ``‖f^p(x) − x‖``.
    max_iter : int
        Iterations per seed/matrix.
    prime : bool
        Keep only orbits of *minimal* period ``p`` (drop divisor-period orbits).

    Returns
    -------
    OrbitSet
        A list-like ``CollectionResult`` of :class:`PeriodicOrbit`, sorted by
        the orbit's lexicographically smallest point.

    Raises
    ------
    TypeError
        If ``system`` is not a :class:`~tsdynamics.families.DiscreteMap` (use
        :func:`periodic_orbit` for flows).
    ValueError
        If ``period < 1`` or ``method`` is not ``"newton"``/``"sd"``/``"dl"``.

    Examples
    --------
    >>> periodic_orbits(Logistic(params={"r": 3.2}), 2)   # the stable 2-cycle
    >>> periodic_orbits(Logistic(params={"r": 3.83}), 3)  # stable node + saddle
    """
    if not isinstance(system, DiscreteMap):
        raise TypeError("periodic_orbits is for DiscreteMap systems; use periodic_orbit for flows.")
    period = int(period)
    if period < 1:
        raise ValueError("period must be a positive integer.")
    method = method.lower()
    if method not in ("newton", "sd", "dl"):
        raise ValueError(f"method must be 'newton', 'sd', or 'dl', got {method!r}.")

    dim = int(system.dim)  # type: ignore[arg-type]  # dim resolved at construction
    rng = np.random.default_rng(seed)
    step, jac = _c.map_fns(system)
    eye = np.eye(dim)

    # Both the residual ``g(x) = f^p(x) - x`` and its Jacobian ``Df^p - I`` come
    # from the *same* p-fold orbit + monodromy sweep.  ``converge_root`` evaluates
    # them at the identical iterate ``x`` within one step (e.g. DL calls both), so
    # cache the last ``(x_p, M)`` keyed on the input vector and share it between
    # the two closures — one ``map_orbit_monodromy`` per iterate instead of two.
    # The cache is per-iterate (single-slot), so the value is byte-identical to
    # recomputing.
    cache: dict[bytes, tuple[np.ndarray, np.ndarray]] = {}

    def _orbit_monodromy(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
        key = x.tobytes()
        hit = cache.get(key)
        if hit is None:
            x_p, m, _ = _c.map_orbit_monodromy(step, jac, x, period, dim)
            cache.clear()  # single-slot: only the live iterate is ever reused
            cache[key] = (x_p, m)
            return x_p, m
        return hit

    def residual(x: np.ndarray) -> np.ndarray:
        return cast("np.ndarray", _orbit_monodromy(x)[0] - x)

    def jac_resid(x: np.ndarray) -> np.ndarray:
        return _orbit_monodromy(x)[1] - eye

    lo, hi = _c.resolve_box(system, region, dim, rng)
    seeds = _build_seeds(system, dim, lo, hi, n_seeds, rng)
    c_mats = _stabilising_matrices(method, dim, max_c)

    # Do not box-clip: an unstable orbit may sit outside the attractor's hull; the
    # closure-residual, prime-period and distinctness filters reject spurious roots.
    roots = _c.solve_roots(
        residual,
        jac_resid,
        dim,
        seeds,
        method=method,
        c_mats=c_mats,
        lam=lam,
        beta=beta,
        tol=tol,
        max_iter=max_iter,
        dedup_tol=dedup_tol,
        bounds=None,
    )

    # The minimal-period closure test is deliberately looser than the root tol:
    # re-iterating f^d from a root accurate only to `tol` accumulates round-off, so
    # a too-tight check would mistake a true divisor-period orbit for a prime one.
    orbit_tol = max(tol * 1e4, 1e-8)
    divisors = [d for d in range(1, period) if period % d == 0]
    reps: list[np.ndarray] = []
    orbits: list[PeriodicOrbit] = []
    for r in roots:
        m, points = _minimal_period(step, r, period, dim, divisors, orbit_tol)
        if prime and m != period:
            continue
        rep = points[np.lexsort(points.T[::-1])][0]  # lexicographically smallest point
        if any(np.linalg.norm(rep - q) < dedup_tol for q in reps):
            continue
        reps.append(rep)
        x_end, monodromy, _ = _c.map_orbit_monodromy(step, jac, rep, m, dim)
        eig = np.linalg.eigvals(monodromy)
        closure = float(np.linalg.norm(x_end - rep))
        orbits.append(
            PeriodicOrbit(
                points=points,
                period=int(m),
                multipliers=eig,
                stable=bool(np.all(np.abs(eig) < 1.0)),
                continuous=False,
                residual=closure,
            )
        )
    orbits.sort(key=lambda o: tuple(np.asarray(o.points)[0]))
    return OrbitSet(
        items=tuple(orbits),
        meta=AnalysisResult.build_meta(
            system, analysis="periodic_orbits", period=int(period), method=method
        ),
    )

periodic_orbit

periodic_orbit(
    system: Any,
    *,
    ic: Any | None = None,
    period_guess: float | None = None,
    steps_per_period: int = 2000,
    transient: float = 0.0,
    tol: float = 1e-10,
    max_iter: int = 50,
    n_points: int = 400,
    min_amplitude: float = 1e-06,
    seed: int | None = None,
) -> PeriodicOrbit

Find a periodic orbit of an autonomous flow by single shooting.

Newton iterates the unknowns (x0, T) to solve φ_T(x0) − x0 = 0 with an orthogonality phase condition f(x0)·δx = 0 (which removes the trivial time-shift degeneracy). The monodromy matrix M = dφ_T/dx0 comes from integrating the variational equation alongside the state; the Floquet multipliers eig(M) give stability (one trivial multiplier ≈ 1).

PARAMETER DESCRIPTION
system

An autonomous flow.

TYPE: ContinuousSystem

ic

Initial guess for a point on the orbit (default: the system's IC, after a short burn-in if it is not already near the cycle).

TYPE: array - like DEFAULT: None

period_guess

Initial guess for the period T. If omitted, it is estimated from a burn-in trajectory via :func:estimate_period.

TYPE: float DEFAULT: None

steps_per_period

Fixed RK4 sub-steps used to integrate one period (state + monodromy).

TYPE: int DEFAULT: 2000

transient

Time to forward-integrate ic before shooting (default 0). A few periods of burn-in lands a guess near a stable limit cycle, widening the Newton basin; leave it 0 when targeting an unstable orbit from a precise guess.

TYPE: float DEFAULT: 0.0

tol

Convergence tolerance on ‖φ_T(x0) − x0‖.

TYPE: float DEFAULT: 1e-10

max_iter

Maximum Newton iterations.

TYPE: int DEFAULT: 50

n_points

Number of points sampled along the converged orbit.

TYPE: int DEFAULT: 400

min_amplitude

Minimum orbit extent (bounding-box diagonal) for the result to count as a genuine cycle. Shooting can collapse onto the trivial solution x0 = equilibrium (any T, residual 0) — common for a centre or from a poor guess — which is rejected below this threshold.

TYPE: float DEFAULT: 1e-06

seed

Seed for the burn-in IC, when ic is not given. The random fallback is drawn from a local :class:numpy.random.Generator seeded with this value, so a given seed is reproducible regardless of the global numpy.random state; seed=None (the default) is non-deterministic.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
PeriodicOrbit

With continuous=True, period the converged T and multipliers the Floquet multipliers.

RAISES DESCRIPTION
NotImplementedError

If system is not a continuous flow.

ValueError

If period_guess (or the auto-estimated period) is not positive.

RuntimeError

If the Newton iteration does not converge, or it collapses onto an equilibrium (the target may be a centre — a non-isolated orbit — rather than a hyperbolic cycle); try a better ic / period_guess.

Examples:

>>> periodic_orbit(VanDerPol(params={"mu": 1.0}), ic=[2.0, 0.0], period_guess=6.6)
Source code in src/tsdynamics/analysis/fixedpoints/periodic.py
def periodic_orbit(
    system: Any,
    *,
    ic: Any | None = None,
    period_guess: float | None = None,
    steps_per_period: int = 2000,
    transient: float = 0.0,
    tol: float = 1e-10,
    max_iter: int = 50,
    n_points: int = 400,
    min_amplitude: float = 1e-6,
    seed: int | None = None,
) -> PeriodicOrbit:
    r"""
    Find a periodic orbit of an autonomous flow by single shooting.

    Newton iterates the unknowns ``(x0, T)`` to solve ``φ_T(x0) − x0 = 0`` with an
    orthogonality phase condition ``f(x0)·δx = 0`` (which removes the trivial
    time-shift degeneracy).  The monodromy matrix ``M = dφ_T/dx0`` comes from
    integrating the variational equation alongside the state; the Floquet
    multipliers ``eig(M)`` give stability (one trivial multiplier ``≈ 1``).

    Parameters
    ----------
    system : ContinuousSystem
        An autonomous flow.
    ic : array-like, optional
        Initial guess for a point on the orbit (default: the system's IC, after a
        short burn-in if it is not already near the cycle).
    period_guess : float, optional
        Initial guess for the period ``T``.  If omitted, it is estimated from a
        burn-in trajectory via :func:`estimate_period`.
    steps_per_period : int
        Fixed RK4 sub-steps used to integrate one period (state + monodromy).
    transient : float
        Time to forward-integrate ``ic`` before shooting (default ``0``).  A few
        periods of burn-in lands a guess near a *stable* limit cycle, widening the
        Newton basin; leave it ``0`` when targeting an unstable orbit from a
        precise guess.
    tol : float
        Convergence tolerance on ``‖φ_T(x0) − x0‖``.
    max_iter : int
        Maximum Newton iterations.
    n_points : int
        Number of points sampled along the converged orbit.
    min_amplitude : float
        Minimum orbit extent (bounding-box diagonal) for the result to count as a
        genuine cycle.  Shooting can collapse onto the trivial solution
        ``x0 = equilibrium`` (any ``T``, residual ``0``) — common for a centre or
        from a poor guess — which is rejected below this threshold.
    seed : int, optional
        Seed for the burn-in IC, when ``ic`` is not given.  The random fallback is
        drawn from a *local* :class:`numpy.random.Generator` seeded with this
        value, so a given ``seed`` is reproducible regardless of the global
        ``numpy.random`` state; ``seed=None`` (the default) is non-deterministic.

    Returns
    -------
    PeriodicOrbit
        With ``continuous=True``, ``period`` the converged ``T`` and ``multipliers``
        the Floquet multipliers.

    Raises
    ------
    NotImplementedError
        If ``system`` is not a continuous flow.
    ValueError
        If ``period_guess`` (or the auto-estimated period) is not positive.
    RuntimeError
        If the Newton iteration does not converge, or it collapses onto an
        equilibrium (the target may be a centre — a non-isolated orbit — rather
        than a hyperbolic cycle); try a better ``ic`` / ``period_guess``.

    Examples
    --------
    >>> periodic_orbit(VanDerPol(params={"mu": 1.0}), ic=[2.0, 0.0], period_guess=6.6)
    """
    if not isinstance(system, ContinuousSystem):
        raise NotImplementedError(
            f"periodic_orbit (shooting) is for continuous flows, not {type(system).__name__}; "
            f"use periodic_orbits for maps."
        )
    dim = int(system.dim)  # type: ignore[arg-type]  # dim resolved at construction
    rhs, jac = _c.flow_fns(system)
    rng = np.random.default_rng(seed)

    x0 = (
        np.asarray(_c._orbit_start_ic(system, dim, rng), dtype=float).ravel()
        if ic is None
        else np.asarray(ic, dtype=float).ravel()
    )
    t_period = float(period_guess) if period_guess is not None else _guess_period(system, x0, dim)
    if t_period <= 0.0:
        raise ValueError("period_guess must be positive.")

    if transient > 0.0:  # land near a stable cycle to widen the Newton basin
        n_burn = max(1, int(round(transient / 0.01)))
        x0 = _c.flow_state(rhs, x0, float(transient), n_burn)

    eye = np.eye(dim)
    converged = False
    r_norm = float(np.linalg.norm(_c.flow_state(rhs, x0, t_period, steps_per_period) - x0))
    for _ in range(max_iter):
        x_end, monodromy = _c.flow_monodromy(rhs, jac, x0, t_period, steps_per_period)
        r = x_end - x0
        r_norm = float(np.linalg.norm(r))
        if r_norm < tol:
            converged = True
            break
        f0 = rhs(x0, 0.0)
        f_end = rhs(x_end, 0.0)  # = dφ_T/dT at the orbit
        # Bordered (d+1) Newton system:  [[M - I, f_end], [f0^T, 0]] δ = -[r, 0]
        amat = np.zeros((dim + 1, dim + 1))
        amat[:dim, :dim] = monodromy - eye
        amat[:dim, dim] = f_end
        amat[dim, :dim] = f0
        rhs_vec = np.concatenate([-r, [0.0]])
        try:
            delta = np.linalg.solve(amat, rhs_vec)
        except np.linalg.LinAlgError as exc:
            raise RuntimeError(
                "periodic_orbit: singular shooting Jacobian — the target may be a "
                "centre (non-isolated orbit) or the phase condition is degenerate."
            ) from exc
        if not np.all(np.isfinite(delta)):
            raise RuntimeError("periodic_orbit: non-finite Newton step (diverged).")
        # Backtracking line search: take the largest fraction of the Newton step
        # that keeps T > 0 and strictly reduces the closure residual (shooting has
        # a small basin, so an undamped step can overshoot to T <= 0 or diverge).
        alpha, accepted = 1.0, False
        for _ls in range(30):
            x_try = x0 + alpha * delta[:dim]
            t_try = t_period + alpha * float(delta[dim])
            if t_try > 0.0:
                end_try = _c.flow_state(rhs, x_try, t_try, steps_per_period)
                r_try = float(np.linalg.norm(end_try - x_try))
                if np.isfinite(r_try) and r_try < r_norm:
                    x0, t_period, r_norm, accepted = x_try, t_try, r_try, True
                    break
            alpha *= 0.5
        if not accepted:
            break  # no productive step — report the converged-or-not state below

    x_end, monodromy = _c.flow_monodromy(rhs, jac, x0, t_period, steps_per_period)
    residual = float(np.linalg.norm(x_end - x0))
    if not converged and residual >= tol:
        raise RuntimeError(
            f"periodic_orbit: Newton did not converge (residual {residual:.3e} ≥ tol {tol:.1e}); "
            f"try a better ic/period_guess or a hyperbolic orbit."
        )

    points = _sample_cycle(rhs, x0, t_period, n_points)
    extent = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0)))
    if extent < min_amplitude:
        raise RuntimeError(
            f"periodic_orbit: shooting collapsed onto an equilibrium (orbit extent "
            f"{extent:.2e} < {min_amplitude:.1e}) — the target may be a centre "
            f"(non-isolated orbit), or seed a point on an actual cycle."
        )

    multipliers, eigenvectors = np.linalg.eig(monodromy)
    stable = _flow_orbit_stable(multipliers, eigenvectors, rhs(x0, 0.0))
    return PeriodicOrbit(
        points=points,
        period=float(t_period),
        multipliers=multipliers,
        stable=stable,
        continuous=True,
        residual=residual,
        meta=AnalysisResult.build_meta(system, analysis="periodic_orbit", period=float(t_period)),
    )

PeriodicOrbit dataclass

PeriodicOrbit(
    points: ndarray = (lambda: empty(0))(),
    period: int | float = 0,
    multipliers: ndarray = (lambda: empty(0))(),
    stable: bool = False,
    continuous: bool = False,
    residual: float = 0.0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A periodic orbit with its Floquet/multiplier stability data.

ATTRIBUTE DESCRIPTION
points

The orbit, shape (n_points, dim): the period distinct points of a map cycle, or a dense sampling along one period of a flow cycle.

TYPE: ndarray

period

The (minimal) period — an integer iteration count for a map, the time T for a flow.

TYPE: int or float

multipliers

Stability multipliers: eigenvalues of :math:Df^{p} at an orbit point (map) or the Floquet multipliers (eigenvalues of the monodromy matrix) of the cycle (flow). A flow always carries one trivial multiplier ≈ 1 along the flow direction.

TYPE: ndarray

stable

True iff every non-trivial multiplier lies inside the unit circle.

TYPE: bool

continuous

True for a flow cycle, False for a map cycle.

TYPE: bool

residual

Closure residual ‖f^p(x) − x‖ (map) or ‖φ_T(x0) − x0‖ (flow).

TYPE: float

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this periodic orbit as a backend-agnostic :class:PlotSpec.

Builds a phase portrait of the orbit :attr:points: a closed LINE3D loop for a 3-D-or-higher flow cycle (first three coordinates), a 2-D LINE (flow) / SCATTER (the discrete map cycle's distinct points) for two coordinates, and a 1-D index plot for a scalar map. A flow loop is drawn as a line (the continuous cycle); a map orbit as markers (its period distinct points). The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (a :class:~tsdynamics.viz.spec.PlotKind value). None picks PHASE_PORTRAIT_3D / PHASE_PORTRAIT_2D / TIME_SERIES from the orbit's dimensionality.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/fixedpoints/periodic.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe this periodic orbit as a backend-agnostic :class:`PlotSpec`.

    Builds a phase portrait of the orbit :attr:`points`: a closed ``LINE3D``
    loop for a 3-D-or-higher flow cycle (first three coordinates), a 2-D
    ``LINE`` (flow) / ``SCATTER`` (the discrete map cycle's distinct points)
    for two coordinates, and a 1-D index plot for a scalar map.  A flow loop
    is drawn as a line (the continuous cycle); a map orbit as markers (its
    ``period`` distinct points).  The :mod:`tsdynamics.viz.spec` import is
    lazy, so building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (a :class:`~tsdynamics.viz.spec.PlotKind`
        value).  ``None`` picks ``PHASE_PORTRAIT_3D`` / ``PHASE_PORTRAIT_2D``
        / ``TIME_SERIES`` from the orbit's dimensionality.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    pts = np.atleast_2d(np.asarray(self.points, dtype=float))
    dim = pts.shape[1] if pts.ndim == 2 and pts.size else 1
    per = f"T = {self.period:.4g}" if self.continuous else f"p = {int(self.period)}"
    title = f"{'stable' if self.stable else 'unstable'} orbit ({per})"
    # A flow loop is a continuous line; a map orbit is its distinct points.
    mark2d = pb.line if self.continuous else pb.scatter

    if dim >= 3:
        loop = (
            pb.line3d(pts[:, 0], pts[:, 1], pts[:, 2], label="orbit")
            if self.continuous
            else pb.markers(pts[:, 0], pts[:, 1], z=pts[:, 2], label="orbit")
        )
        return pb.spec(
            kind,
            "phase_portrait_3d",
            layers=[loop],
            aspect="equal",
            xlabel="$x_0$",
            ylabel="$x_1$",
            zlabel="$x_2$",
            title=title,
        )
    if dim == 2:
        return pb.spec(
            kind,
            "phase_portrait_2d",
            layers=[mark2d(pts[:, 0], pts[:, 1], label="orbit")],
            aspect="equal",
            xlabel="$x_0$",
            ylabel="$x_1$",
            title=title,
        )
    y = pts[:, 0] if pts.ndim == 2 else np.ravel(pts).astype(float)
    return pb.spec(
        kind,
        "time_series",
        layers=[mark2d(np.arange(y.size, dtype=float), y, label="orbit")],
        xlabel="index",
        ylabel="$x$",
        title=title,
    )

eigenvalue_plane

eigenvalue_plane(kind: str | None = None) -> Any

Describe the multiplier spectrum as an :class:EIGENVALUE_PLANE spec.

Plots the stability multipliers in the complex plane against the unit circle (a map's eigenvalues of :math:Df^{p}, or a flow's Floquet multipliers, both judged by |μ| < 1). For a flow the trivial multiplier ≈ 1 along the flow direction is split into its own distinctly-marked layer — located here for the plot by argmin|μ − 1| (a presentation heuristic; the stability flag itself uses the more robust eigenvector-alignment test). The :mod:tsdynamics.viz.spec import is lazy.

PARAMETER DESCRIPTION
kind

Override the semantic kind. None uses EIGENVALUE_PLANE.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/fixedpoints/periodic.py
def eigenvalue_plane(self, kind: str | None = None) -> Any:
    r"""Describe the multiplier spectrum as an :class:`EIGENVALUE_PLANE` spec.

    Plots the stability multipliers in the complex plane against the unit
    circle (a map's eigenvalues of :math:`Df^{p}`, or a flow's Floquet
    multipliers, both judged by ``|μ| < 1``).  For a **flow** the trivial
    multiplier ``≈ 1`` along the flow direction is split into its own
    distinctly-marked layer — located here for the plot by ``argmin|μ − 1|``
    (a presentation heuristic; the stability flag itself uses the more robust
    eigenvector-alignment test).  The :mod:`tsdynamics.viz.spec` import is
    lazy.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind.  ``None`` uses ``EIGENVALUE_PLANE``.

    Returns
    -------
    PlotSpec
    """
    mu = np.asarray(self.multipliers).ravel().astype(complex)
    trivial = int(np.argmin(np.abs(mu - 1.0))) if (self.continuous and mu.size) else None
    per = f"T = {self.period:.4g}" if self.continuous else f"p = {int(self.period)}"
    title = f"Floquet multipliers ({per})" if self.continuous else f"multipliers ({per})"
    return _eigenvalue_plane_spec(
        mu,
        continuous=False,  # multipliers live on the unit-circle convention (maps + flows)
        title=title,
        meta=dict(self.meta) if self.meta else {},
        kind=kind,
        trivial_index=trivial,
    )

estimate_period

estimate_period(
    data: Any,
    *,
    dt: float | None = None,
    component: int | str | None = None,
    method: str = "autocorrelation",
    max_delay: int | None = None,
    detrend: bool = True,
) -> ScalarResult

Estimate the dominant period of a sampled signal.

Accepts a :class:~tsdynamics.data.Trajectory (the sampling step is read from its time grid), a 1-D array, or a 2-D array (one row per sample); for multi-component input, component selects the channel (default: the highest-variance one).

PARAMETER DESCRIPTION
data

The signal.

TYPE: Trajectory or array - like

dt

Sampling step. For a bare array it sets the time unit (default 1.0 → period in samples). For a Trajectory the step is read from its time grid; passing dt overrides that grid.

TYPE: float DEFAULT: None

component

Channel to analyse for multi-component input.

TYPE: int or str DEFAULT: None

method

"autocorrelation" — first autocorrelation peak after the first zero-crossing (parabolically refined). "fft" — reciprocal of the dominant spectral frequency, with the peak bin parabolically refined to sub-bin resolution.

TYPE: ('autocorrelation', 'fft') DEFAULT: "autocorrelation"

max_delay

Largest lag considered ("autocorrelation" only); default len // 2.

TYPE: int DEFAULT: None

detrend

Subtract the mean before estimating (default True).

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
ScalarResult

The estimated period in time units (dt units); float(result) returns the number.

RAISES DESCRIPTION
ValueError

If fewer than 8 samples are given, the signal is constant, the autocorrelation is degenerate or has no zero-crossing, the spectrum has no dominant frequency, or method is not "autocorrelation"/ "fft".

Examples:

>>> estimate_period(VanDerPol().integrate(final_time=200, dt=0.01))   # ≈ 6.66
References

Box, G. E. P. & Jenkins, G. M. (1970). Time Series Analysis: Forecasting and Control. Holden-Day (autocorrelation method).

Source code in src/tsdynamics/analysis/fixedpoints/periodic.py
def estimate_period(
    data: Any,
    *,
    dt: float | None = None,
    component: int | str | None = None,
    method: str = "autocorrelation",
    max_delay: int | None = None,
    detrend: bool = True,
) -> ScalarResult:
    r"""
    Estimate the dominant period of a sampled signal.

    Accepts a :class:`~tsdynamics.data.Trajectory` (the sampling step is read from
    its time grid), a 1-D array, or a 2-D array (one row per sample); for
    multi-component input, ``component`` selects the channel (default: the
    highest-variance one).

    Parameters
    ----------
    data : Trajectory or array-like
        The signal.
    dt : float, optional
        Sampling step.  For a bare array it sets the time unit (default ``1.0`` →
        period in samples).  For a Trajectory the step is read from its time grid;
        passing ``dt`` overrides that grid.
    component : int or str, optional
        Channel to analyse for multi-component input.
    method : {"autocorrelation", "fft"}
        ``"autocorrelation"`` — first autocorrelation peak after the first
        zero-crossing (parabolically refined).  ``"fft"`` — reciprocal of the
        dominant spectral frequency, with the peak bin parabolically refined to
        sub-bin resolution.
    max_delay : int, optional
        Largest lag considered (``"autocorrelation"`` only); default ``len // 2``.
    detrend : bool
        Subtract the mean before estimating (default ``True``).

    Returns
    -------
    ScalarResult
        The estimated period in time units (``dt`` units); ``float(result)``
        returns the number.

    Raises
    ------
    ValueError
        If fewer than 8 samples are given, the signal is constant, the
        autocorrelation is degenerate or has no zero-crossing, the spectrum has
        no dominant frequency, or ``method`` is not ``"autocorrelation"``/
        ``"fft"``.

    Examples
    --------
    >>> estimate_period(VanDerPol().integrate(final_time=200, dt=0.01))   # ≈ 6.66

    References
    ----------
    Box, G. E. P. & Jenkins, G. M. (1970). *Time Series Analysis: Forecasting
    and Control*. Holden-Day (autocorrelation method).
    """
    y, step = _coerce_signal(data, dt, component)
    if y.size < 8:
        raise ValueError("estimate_period needs at least 8 samples.")
    if detrend:
        y = y - y.mean()
    if np.allclose(y, 0.0):
        raise ValueError("estimate_period: signal is constant (no period).")

    method = method.lower()
    if method == "autocorrelation":
        lag, abscissa, ordinate, curve_label = _autocorr_period_lag(y, max_delay, step)
    elif method == "fft":
        lag, abscissa, ordinate, curve_label = _fft_period_lag(y, step)
    else:
        raise ValueError(f"method must be 'autocorrelation' or 'fft', got {method!r}.")
    # The diagnostic curve (autocorrelation function or power spectrum) rides on
    # ``meta`` so :func:`period_diagnostic` can draw a ``DIAGNOSTIC_CURVE`` of how
    # the estimate was read off, without changing the result *type* (it stays a
    # plain :class:`ScalarResult`).
    return ScalarResult(
        value=float(lag * step),
        meta={
            "analysis": "estimate_period",
            "method": method,
            "period": float(lag * step),
            "curve_abscissa": abscissa,
            "curve_ordinate": ordinate,
            "curve_xlabel": curve_label[0],
            "curve_ylabel": curve_label[1],
        },
    )

Chaos indicators

Three literature-validated answers to "is this orbit chaotic?": the Generalized Alignment Index (GALI), the 0--1 test, and Hunt--Ott expansion entropy.

gali

gali(
    system: Any,
    k: int = 2,
    *,
    n: int | None = None,
    final_time: float | None = None,
    dt: float | None = None,
    ic: Any | None = None,
    transient: float | None = None,
    seed: int | None = 0,
    n_internal: int = 10,
) -> GALIResult

Compute the GALI\ :sub:k time series of a map or flow.

PARAMETER DESCRIPTION
system

The system whose tangent dynamics to track. Delay/stochastic systems are not supported (their tangent space is not finite-dimensional here).

TYPE: DiscreteMap or ContinuousSystem

k

Number of deviation vectors, 2 <= k <= system.dim. (k = 1 is trivially 1.)

TYPE: int DEFAULT: 2

n

Number of iterations (maps). Default 1000.

TYPE: int DEFAULT: None

final_time

Integration time (flows). Default 100.0.

TYPE: float DEFAULT: None

dt

Sampling/recording step for flows. Default 0.1. Not valid for maps.

TYPE: float DEFAULT: None

ic

Initial condition (defaults to the system's resolved IC).

TYPE: array - like DEFAULT: None

transient

Burn-in discarded before tracking (iterations for maps, time for flows). Defaults: 500 iterations / 20.0 time units.

TYPE: float DEFAULT: None

seed

Seed for the random orthonormal initial deviation frame.

TYPE: int DEFAULT: 0

n_internal

RK4 sub-steps per dt for flows (controls variational accuracy).

TYPE: int DEFAULT: 10

RETURNS DESCRIPTION
GALIResult
RAISES DESCRIPTION
NotImplementedError

If system is neither a discrete map nor a continuous flow.

InvalidInputError

If an explicit ic diverges to a non-finite state (it escapes the attractor's basin). GALI characterises a specific orbit, so a pinned ic is never silently swapped for a random one — pass an ic from a known basin point, shorten transient, or omit ic to roll a random one.

InvalidParameterError

If k is outside [2, dim]; if the step count is degenerate (n < 1 for a map, final_time <= 0 or dt <= 0 for a flow); or if dt is passed for a map (or n for a flow).

ConvergenceError

Only when ic is omitted: if a random initial condition diverges to a non-finite state from every draw after the retry budget — in which case pass an ic from a known basin point (or shorten transient).

Notes

For a chaotic orbit GALI\ :sub:k decays as :math:\exp\!\big[-\sum_{i=2}^{k}(\lambda_1-\lambda_i)\,t\big], so its log-slope measures the leading Lyapunov-exponent gaps; a regular orbit keeps it bounded. See the module docstring for the full statement.

Examples:

>>> gali(Henon(), k=2, n=60).is_chaotic()          # exponential collapse
True
>>> float(gali(Lorenz(), k=2, final_time=25.0))    # ~0: Lorenz is chaotic
0.0...
References

Skokos, Bountis & Antonopoulos, "Geometrical properties of local dynamics in Hamiltonian systems: The Generalized Alignment Index (GALI) method", Physica D 231 (2007) 30--54.

Source code in src/tsdynamics/analysis/chaos/gali.py
def gali(
    system: Any,
    k: int = 2,
    *,
    n: int | None = None,
    final_time: float | None = None,
    dt: float | None = None,
    ic: Any | None = None,
    transient: float | None = None,
    seed: int | None = 0,
    n_internal: int = 10,
) -> GALIResult:
    r"""Compute the GALI\ :sub:`k` time series of a map or flow.

    Parameters
    ----------
    system : DiscreteMap or ContinuousSystem
        The system whose tangent dynamics to track.  Delay/stochastic systems
        are not supported (their tangent space is not finite-dimensional here).
    k : int, default 2
        Number of deviation vectors, ``2 <= k <= system.dim``.  (``k = 1`` is
        trivially ``1``.)
    n : int, optional
        Number of iterations (maps).  Default 1000.
    final_time : float, optional
        Integration time (flows).  Default 100.0.
    dt : float, optional
        Sampling/recording step for flows.  Default 0.1.  Not valid for maps.
    ic : array-like, optional
        Initial condition (defaults to the system's resolved IC).
    transient : float, optional
        Burn-in discarded before tracking (iterations for maps, time for flows).
        Defaults: 500 iterations / 20.0 time units.
    seed : int, optional
        Seed for the random orthonormal initial deviation frame.
    n_internal : int, default 10
        RK4 sub-steps per ``dt`` for flows (controls variational accuracy).

    Returns
    -------
    GALIResult

    Raises
    ------
    NotImplementedError
        If ``system`` is neither a discrete map nor a continuous flow.
    InvalidInputError
        If an **explicit** ``ic`` diverges to a non-finite state (it escapes the
        attractor's basin).  GALI characterises a *specific* orbit, so a pinned
        ``ic`` is never silently swapped for a random one — pass an ``ic`` from a
        known basin point, shorten ``transient``, or omit ``ic`` to roll a random
        one.
    InvalidParameterError
        If ``k`` is outside ``[2, dim]``; if the step count is degenerate
        (``n < 1`` for a map, ``final_time <= 0`` or ``dt <= 0`` for a flow); or
        if ``dt`` is passed for a map (or ``n`` for a flow).
    ConvergenceError
        Only when ``ic`` is omitted: if a random initial condition diverges to a
        non-finite state from every draw after the retry budget — in which case
        pass an ``ic`` from a known basin point (or shorten ``transient``).

    Notes
    -----
    For a chaotic orbit GALI\ :sub:`k` decays as
    :math:`\exp\!\big[-\sum_{i=2}^{k}(\lambda_1-\lambda_i)\,t\big]`, so its
    log-slope measures the leading Lyapunov-exponent gaps; a regular orbit keeps
    it bounded.  See the module docstring for the full statement.

    Examples
    --------
    >>> gali(Henon(), k=2, n=60).is_chaotic()          # exponential collapse
    True
    >>> float(gali(Lorenz(), k=2, final_time=25.0))    # ~0: Lorenz is chaotic
    0.0...

    References
    ----------
    Skokos, Bountis & Antonopoulos, "Geometrical properties of local dynamics in
    Hamiltonian systems: The Generalized Alignment Index (GALI) method",
    *Physica D* **231** (2007) 30--54.
    """
    if isinstance(system, DiscreteMap):
        mode = "map"
    elif isinstance(system, ContinuousSystem):
        mode = "flow"
    else:
        raise NotImplementedError(
            f"gali supports discrete maps and continuous flows, not "
            f"{type(system).__name__} (its tangent space is not finite-dimensional here)."
        )

    dim = int(system.dim)
    k = int(k)
    if not 2 <= k <= dim:
        raise InvalidParameterError(f"k must satisfy 2 <= k <= dim ({dim}); got {k}.")

    rng = np.random.default_rng(seed)
    w0 = _c._orthonormal_frame(dim, k, rng)

    if mode == "map":
        if dt is not None:
            raise InvalidParameterError("dt has no meaning for a discrete map — omit it.")
        n_steps = 1000 if n is None else int(n)
        if n_steps < 1:
            raise InvalidParameterError(f"n (number of iterations) must be >= 1; got {n_steps}.")
        n_burn = 500 if transient is None else int(transient)
        run_args: tuple[Any, ...] = (n_steps, n_burn)
        discrete = True
    else:
        if n is not None:
            raise InvalidParameterError("n applies to maps; use final_time/dt for a flow.")
        t_end = 100.0 if final_time is None else float(final_time)
        step_dt = 0.1 if dt is None else float(dt)
        if step_dt <= 0.0:
            raise InvalidParameterError(f"dt must be positive; got {step_dt}.")
        if t_end <= 0.0 or int(round(t_end / step_dt)) < 1:
            raise InvalidParameterError(
                f"final_time must be positive and span at least one dt step; "
                f"got final_time={t_end}, dt={step_dt}."
            )
        t_burn = 20.0 if transient is None else float(transient)
        run_args = (t_end, step_dt, t_burn, int(n_internal))
        discrete = False

    def _run(x0: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
        """Track the tangent dynamics from ``x0``; ``None`` on a diverged orbit/frame."""
        w = w0.copy()
        return (
            _gali_map(system, x0, w, *run_args)
            if mode == "map"
            else _gali_flow(system, x0, w, *run_args)
        )

    def _wrap(result: tuple[np.ndarray, np.ndarray]) -> GALIResult:
        times, values = result
        return GALIResult(
            k=k,
            times=times,
            values=values,
            is_discrete=discrete,
            meta=AnalysisResult.build_meta(system, analysis="gali", k=k),
        )

    # GALI characterises a *specific* orbit.  When the caller pins the initial
    # condition with an explicit ``ic`` we honour it exactly: if that orbit
    # escapes the basin and blows up to a non-finite state/frame we must NOT
    # silently substitute a different (random) orbit — that would hand back a
    # result for an orbit the caller never asked about.  Raise instead, so the
    # caller learns their ``ic`` does not stay on the attractor.
    if ic is not None:
        x = np.asarray(system.resolve_ic(ic), dtype=float).ravel()
        result = _run(x)
        if result is not None:
            return _wrap(result)
        raise InvalidInputError(
            f"gali: the explicit ic {x.tolist()!r} diverges to a non-finite state for "
            f"{type(system).__name__} (it escapes the attractor's basin), so GALI cannot "
            "be measured for that orbit. Pass an `ic` from a known basin point, shorten "
            "the burn-in via `transient`, or omit `ic` to roll a random one."
        )

    # With ``ic=None`` the initial condition is the system's own resolution (its
    # ``self.ic`` / ``default_ic`` if any, else a random draw — many systems carry
    # no ``default_ic``), so a first attempt landing outside the basin is expected.
    # Retry from a fresh *seeded* random IC, matching the map convention in
    # :class:`~tsdynamics.derived.TangentSystem` (the seed keeps the retries
    # reproducible).  Only the off-basin *default-draw* case re-rolls.
    max_retries = 10
    for attempt in range(max_retries):
        x = (
            np.asarray(system.resolve_ic(None), dtype=float).ravel()
            if attempt == 0
            else rng.random(dim)
        )
        result = _run(x)
        if result is not None:
            return _wrap(result)

    raise ConvergenceError(
        f"gali: {type(system).__name__} orbit diverges from every tried random IC after "
        f"{max_retries} attempts — pass an `ic` from a known basin point, or shorten "
        "the burn-in via `transient`."
    )

GALIResult dataclass

GALIResult(
    k: int = 0,
    times: ndarray = (lambda: empty(0))(),
    values: ndarray = (lambda: empty(0))(),
    is_discrete: bool = False,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A GALI\ :sub:k time series with the tools to read order vs chaos off it.

An :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / .summary() / .to_dict() / the .plot seam. float(result) is the final value — :math:\approx 1 for a regular orbit, :math:\to 0 for a chaotic one — so the result drops straight into a threshold test.

ATTRIBUTE DESCRIPTION
k

Number of deviation vectors.

TYPE: int

times

Time (flows) or iteration index (maps) at each sample.

TYPE: ndarray

values

GALI\ :sub:k at each sample.

TYPE: ndarray

is_discrete

Whether the underlying system is a map.

TYPE: bool

final property

final: float

The last GALI value.

RAISES DESCRIPTION
InvalidParameterError

If the series is empty (no samples were recorded).

decay_rate

decay_rate(
    *, floor: float = 1e-12, t_min: float | None = None
) -> float

Exponential decay rate of GALI\ :sub:k (a positive number for chaos).

Fits a line to :math:\ln \mathrm{GALI}_k over the samples that lie above floor (i.e. before the index reaches the floating-point noise floor) and returns -slope. For a chaotic orbit this estimates the Lyapunov gap sum :math:(\lambda_1-\lambda_2)+\cdots+(\lambda_1-\lambda_k) (Skokos et al. 2008); for a regular orbit it is ~0.

PARAMETER DESCRIPTION
floor

Ignore samples at or below this value (numerical underflow region).

TYPE: float DEFAULT: 1e-12

t_min

Also ignore samples before this time/iteration (skip the initial alignment transient).

TYPE: float DEFAULT: None

RETURNS DESCRIPTION
float

Estimated decay rate; 0.0 if too few usable samples remain.

Source code in src/tsdynamics/analysis/chaos/gali.py
def decay_rate(self, *, floor: float = 1e-12, t_min: float | None = None) -> float:
    r"""Exponential decay rate of GALI\ :sub:`k` (a positive number for chaos).

    Fits a line to :math:`\ln \mathrm{GALI}_k` over the samples that lie
    above ``floor`` (i.e. before the index reaches the floating-point noise
    floor) and returns ``-slope``.  For a chaotic orbit this estimates the
    Lyapunov gap sum :math:`(\lambda_1-\lambda_2)+\cdots+(\lambda_1-\lambda_k)`
    (Skokos et al. 2008); for a regular orbit it is ``~0``.

    Parameters
    ----------
    floor : float, default 1e-12
        Ignore samples at or below this value (numerical underflow region).
    t_min : float, optional
        Also ignore samples before this time/iteration (skip the initial
        alignment transient).

    Returns
    -------
    float
        Estimated decay rate; ``0.0`` if too few usable samples remain.
    """
    t, v = self.times, self.values
    mask = v > floor
    if t_min is not None:
        mask &= t >= t_min
    if int(np.count_nonzero(mask)) < 2:
        return 0.0
    slope, _, _ = _c._linfit(t[mask], np.log(v[mask]))
    return float(-slope)

is_chaotic

is_chaotic(*, threshold: float = 1e-06) -> bool

Whether the final GALI value collapsed below threshold (chaotic).

Source code in src/tsdynamics/analysis/chaos/gali.py
def is_chaotic(self, *, threshold: float = 1e-6) -> bool:
    """Whether the final GALI value collapsed below ``threshold`` (chaotic)."""
    return self.final < threshold

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the GALI\ :sub:k curve as a backend-agnostic :class:PlotSpec.

Builds a DIAGNOSTIC_CURVE of GALI\ :sub:k against time (or iteration index for a map) on a log y-axis — the recommended scale, since GALI\ :sub:k decays exponentially for a chaotic orbit and saturates for a regular one, so a log axis reads the decay rate off as a slope. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "diagnostic_curve"). None uses DIAGNOSTIC_CURVE.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/chaos/gali.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the GALI\ :sub:`k` curve as a backend-agnostic :class:`PlotSpec`.

    Builds a ``DIAGNOSTIC_CURVE`` of GALI\ :sub:`k` against time (or iteration
    index for a map) on a **log y-axis** — the recommended scale, since
    GALI\ :sub:`k` decays exponentially for a chaotic orbit and saturates for
    a regular one, so a log axis reads the decay rate off as a slope.  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls a
    plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"diagnostic_curve"``).  ``None``
        uses ``DIAGNOSTIC_CURVE``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    xlabel = "iteration" if self.is_discrete else "time"
    return pb.spec(
        kind,
        "diagnostic_curve",
        layers=[
            pb.line(np.asarray(self.times), np.asarray(self.values), label=f"GALI$_{self.k}$")
        ],
        xlabel=xlabel,
        ylabel=f"GALI$_{self.k}$",
        yscale="log",
        title=f"GALI$_{self.k}$",
    )

zero_one_test

zero_one_test(
    system: Any,
    *,
    component: int | None = None,
    final_time: float | None = None,
    n: int | None = None,
    dt: float | None = None,
    transient: float | None = None,
    ic: Any | None = None,
    n_c: int = 100,
    c_range: tuple[float, float] = (
        pi / 5.0,
        4.0 * pi / 5.0,
    ),
    n_cut: int | None = None,
    seed: int | None = 0,
    return_distribution: bool = False,
) -> ZeroOneResult | tuple[ZeroOneResult, ndarray]

Run the 0--1 test for chaos on a system or a measured observable.

PARAMETER DESCRIPTION
system

A dynamical system (integrated / iterated internally to produce the observable, like :func:~tsdynamics.analysis.chaos.gali), or a measured 1-D series / :class:~tsdynamics.data.Trajectory used directly (the data overload). For a flow pass a coarse dt — or a Poincaré / stroboscopic view as system — so successive samples are decorrelated.

TYPE: System, Trajectory, or array-like

component

Column to use when a multi-component system / trajectory is passed.

TYPE: int DEFAULT: None

final_time

Integration horizon for a flow (system input). Default 1000.0.

TYPE: float DEFAULT: None

n

Number of iterations for a map / discrete view (system input). Default 5000.

TYPE: int DEFAULT: None

dt

Sampling / integration step for a flow (system input). Default 0.1.

TYPE: float DEFAULT: None

transient

Discarded before recording — a flow burn-in time, a map / discrete burn-in in steps (system input).

TYPE: float DEFAULT: None

ic

Initial condition (system input).

TYPE: array - like DEFAULT: None

n_c

Number of random frequencies :math:c drawn from c_range.

TYPE: int DEFAULT: 100

c_range

Interval the frequencies are drawn from. The default avoids the resonances near :math:c = 0, \pi (Gottwald & Melbourne 2009).

TYPE: (float, float) DEFAULT: ``(pi/5, 4*pi/5)``

n_cut

Largest displacement lag used in the mean-square displacement. Default N // 10 (the rule of thumb: stay well below the series length).

TYPE: int DEFAULT: None

seed

Seed for the frequency draw (makes :math:K reproducible).

TYPE: int DEFAULT: 0

return_distribution

If true, also return the per-frequency :math:K_c array.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
ZeroOneResult or (ZeroOneResult, ndarray)

The median correlation growth indicator :math:K (~0 regular, ~1 chaotic) as a drop-in for its float value (result > 0.9 and float(result) work) carrying .meta and the translation plane :math:(p_c, q_c) (result.plot() renders it); with return_distribution also the K_c values. The correlation method returns a Pearson coefficient, so :math:K \in [-1, 1] in principle (a regular orbit can give a small negative :math:K); it concentrates near 0 (regular) or 1 (chaotic), so K > 0.5 is the usual chaos threshold.

RAISES DESCRIPTION
InvalidParameterError

If the observable is shorter than 200 points (too short for the test to be meaningful); if n_c < 1; if horizon keywords are passed for a measured-series input; or if n is passed for a flow.

Examples:

>>> zero_one_test(Logistic(params={"r": 4.0}), n=5000) > 0.9     # chaotic
True
>>> x = Logistic(params={"r": 4.0}).iterate(steps=5000).component("x")
>>> zero_one_test(x) > 0.9          # the data overload
True
References

Gottwald & Melbourne, "A new test for chaos in deterministic systems", Proc. R. Soc. Lond. A 460 (2004) 603--611.

Gottwald & Melbourne, "On the implementation of the 0--1 test for chaos", SIAM J. Appl. Dyn. Syst. 8 (2009) 129--145.

Source code in src/tsdynamics/analysis/chaos/zero_one.py
def zero_one_test(
    system: Any,
    *,
    component: int | None = None,
    final_time: float | None = None,
    n: int | None = None,
    dt: float | None = None,
    transient: float | None = None,
    ic: Any | None = None,
    n_c: int = 100,
    c_range: tuple[float, float] = (np.pi / 5.0, 4.0 * np.pi / 5.0),
    n_cut: int | None = None,
    seed: int | None = 0,
    return_distribution: bool = False,
) -> ZeroOneResult | tuple[ZeroOneResult, np.ndarray]:
    r"""Run the 0--1 test for chaos on a system or a measured observable.

    Parameters
    ----------
    system : System, Trajectory, or array-like
        A dynamical system (integrated / iterated internally to produce the
        observable, like :func:`~tsdynamics.analysis.chaos.gali`), or a measured
        1-D series / :class:`~tsdynamics.data.Trajectory` used directly (the
        ``data`` overload).  For a flow pass a coarse ``dt`` — or a Poincaré /
        stroboscopic view as ``system`` — so successive samples are decorrelated.
    component : int, optional
        Column to use when a multi-component system / trajectory is passed.
    final_time : float, optional
        Integration horizon for a flow (system input).  Default 1000.0.
    n : int, optional
        Number of iterations for a map / discrete view (system input).
        Default 5000.
    dt : float, optional
        Sampling / integration step for a flow (system input).  Default 0.1.
    transient : float, optional
        Discarded before recording — a flow burn-in **time**, a map / discrete
        burn-in in **steps** (system input).
    ic : array-like, optional
        Initial condition (system input).
    n_c : int, default 100
        Number of random frequencies :math:`c` drawn from ``c_range``.
    c_range : (float, float), default ``(pi/5, 4*pi/5)``
        Interval the frequencies are drawn from.  The default avoids the
        resonances near :math:`c = 0, \pi` (Gottwald & Melbourne 2009).
    n_cut : int, optional
        Largest displacement lag used in the mean-square displacement.  Default
        ``N // 10`` (the rule of thumb: stay well below the series length).
    seed : int, optional
        Seed for the frequency draw (makes :math:`K` reproducible).
    return_distribution : bool, default False
        If true, also return the per-frequency :math:`K_c` array.

    Returns
    -------
    ZeroOneResult or (ZeroOneResult, ndarray)
        The median correlation growth indicator :math:`K` (``~0`` regular, ``~1``
        chaotic) as a drop-in for its ``float`` value (``result > 0.9`` and
        ``float(result)`` work) carrying ``.meta`` and the translation plane
        :math:`(p_c, q_c)` (``result.plot()`` renders it); with
        ``return_distribution`` also the ``K_c`` values.  The correlation method
        returns a Pearson
        coefficient, so :math:`K \in [-1, 1]` in principle (a regular orbit can
        give a small negative :math:`K`); it concentrates near ``0`` (regular) or
        ``1`` (chaotic), so ``K > 0.5`` is the usual chaos threshold.

    Raises
    ------
    InvalidParameterError
        If the observable is shorter than 200 points (too short for the test to
        be meaningful); if ``n_c < 1``; if horizon keywords are passed for a
        measured-series input; or if ``n`` is passed for a flow.

    Examples
    --------
    >>> zero_one_test(Logistic(params={"r": 4.0}), n=5000) > 0.9     # chaotic
    True
    >>> x = Logistic(params={"r": 4.0}).iterate(steps=5000).component("x")
    >>> zero_one_test(x) > 0.9          # the data overload
    True

    References
    ----------
    Gottwald & Melbourne, "A new test for chaos in deterministic systems",
    *Proc. R. Soc. Lond. A* **460** (2004) 603--611.

    Gottwald & Melbourne, "On the implementation of the 0--1 test for chaos",
    *SIAM J. Appl. Dyn. Syst.* **8** (2009) 129--145.
    """
    phi = _observable(
        system, component, final_time=final_time, n=n, dt=dt, transient=transient, ic=ic
    )
    n_pts = phi.size
    if n_pts < 200:
        raise InvalidParameterError(
            f"the 0-1 test needs a long series to be meaningful; got {n_pts} points (need >= 200)."
        )
    if n_cut is None:
        n_cut = n_pts // 10
    n_cut = int(max(1, min(n_cut, n_pts - 1)))
    if n_c < 1:
        raise InvalidParameterError(f"n_c must be >= 1, got {n_c}.")

    rng = np.random.default_rng(seed)
    c_values = rng.uniform(c_range[0], c_range[1], size=int(n_c))

    j = np.arange(1, n_pts + 1, dtype=float)
    lags = np.arange(1, n_cut + 1, dtype=float)
    mean_phi_sq = float(np.mean(phi)) ** 2

    # Drive all frequencies at once: the skew-translation sums for every ``c``
    # are the columns of ``p``/``q`` (shape ``(n_pts, n_c)``). Batching the
    # ``cos``/``sin``/``cumsum`` across frequencies removes the per-frequency
    # Python work; each column is byte-identical to the per-``c`` cumsum.
    phase = np.outer(j, c_values)  # j*c for every (sample, frequency)
    phi_col = phi[:, None]
    p_all = np.cumsum(phi_col * np.cos(phase), axis=0)  # (n_pts, n_c)
    q_all = np.cumsum(phi_col * np.sin(phase), axis=0)

    # Mean-square displacement at each lag, vectorised over all frequencies at
    # once: the per-lag difference ``p[lag:] - p[:-lag]`` and the ``np.mean`` over
    # samples are unchanged (same elements, same reduction axis) — only the
    # per-frequency Python loop is gone.
    msd = np.empty((n_cut, int(n_c)))
    for li in range(n_cut):
        lag = li + 1
        dp = p_all[lag:] - p_all[:-lag]
        dq = q_all[lag:] - q_all[:-lag]
        msd[li] = np.mean(dp * dp + dq * dq, axis=0)

    # Regularised mean-square displacement: subtract the oscillatory term so only
    # the (diffusive) trend remains (Gottwald & Melbourne 2009, eq. 2.6).
    osc = mean_phi_sq * (1.0 - np.cos(np.outer(lags, c_values))) / (1.0 - np.cos(c_values))
    d_all = msd - osc  # (n_cut, n_c)
    k_c = np.array([_c._pearson(lags, d_all[:, idx]) for idx in range(int(n_c))])

    k = float(np.median(k_c))
    # Capture the skew-translation plane (p_c, q_c) at the most representative
    # frequency — the one whose K_c is closest to the reported median K — purely
    # for the diagnostic figure (it does not enter K).  The representative
    # column already lives in the batched ``p_all``/``q_all`` (byte-identical to a
    # standalone ``cumsum`` for that single c), so slice it instead of recomputing.
    idx_rep = int(np.argmin(np.abs(k_c - k)))
    p_rep = p_all[:, idx_rep]
    q_rep = q_all[:, idx_rep]
    result = ZeroOneResult(
        value=k,
        p=p_rep,
        q=q_rep,
        meta=AnalysisResult.build_meta(system, analysis="zero_one_test"),
    )
    return (result, k_c) if return_distribution else result

expansion_entropy

expansion_entropy(
    system: Any,
    region: Any = None,
    *,
    n_samples: int = 1000,
    n: int | None = None,
    final_time: float | None = None,
    dt: float | None = None,
    fit_range: tuple[int, int] | None = None,
    seed: int | None = 0,
    n_internal: int = 5,
) -> ExpansionEntropyResult

Estimate the expansion entropy :math:H of a map or flow on a region.

PARAMETER DESCRIPTION
system

The system whose expansion to measure.

TYPE: DiscreteMap or ContinuousSystem

region

The restricting region :math:S. None uses the (10%-expanded) bounding box of a burn-in orbit.

TYPE: Box, (lo, hi), or None DEFAULT: None

n_samples

Number of initial conditions sampled uniformly in the region.

TYPE: int DEFAULT: 1000

n

Number of iterations (maps). Default 15. (Kept modest: the raw tangent product is not renormalised, so very long horizons overflow.)

TYPE: int DEFAULT: None

final_time

Integration time (flows). Default 5.0.

TYPE: float DEFAULT: None

dt

Recording step for flows. Default 0.1. Not valid for maps.

TYPE: float DEFAULT: None

fit_range

Inclusive index range in the :math:t grid to fit the slope over. Default skips :math:t = 0 and uses the rest.

TYPE: (int, int) DEFAULT: None

seed

Seed for the initial-condition sampling.

TYPE: int DEFAULT: 0

n_internal

RK4 sub-steps per dt for flows.

TYPE: int DEFAULT: 5

RETURNS DESCRIPTION
ExpansionEntropyResult
RAISES DESCRIPTION
NotImplementedError

If system is neither a discrete map nor a continuous flow.

InvalidParameterError

If n_samples < 1; if the step count is degenerate (n < 1 for a map, final_time <= 0 or dt <= 0 for a flow); if dt is passed for a map (or n for a flow); if the region dimension does not match the system; or if fewer than two finite :math:\ln E(t) points remain to fit (too few survivors stayed in the region — enlarge it or shorten the horizon).

Examples:

>>> float(expansion_entropy(Tent(params={"mu": 1.0}), Box([0.0], [1.0])))
0.69...                                              # ln 2, exact
References

Hunt & Ott, "Defining chaos", Chaos 25 (2015) 097618.

Source code in src/tsdynamics/analysis/chaos/expansion.py
def expansion_entropy(
    system: Any,
    region: Any = None,
    *,
    n_samples: int = 1000,
    n: int | None = None,
    final_time: float | None = None,
    dt: float | None = None,
    fit_range: tuple[int, int] | None = None,
    seed: int | None = 0,
    n_internal: int = 5,
) -> ExpansionEntropyResult:
    r"""Estimate the expansion entropy :math:`H` of a map or flow on a region.

    Parameters
    ----------
    system : DiscreteMap or ContinuousSystem
        The system whose expansion to measure.
    region : Box, (lo, hi), or None
        The restricting region :math:`S`.  ``None`` uses the (10%-expanded)
        bounding box of a burn-in orbit.
    n_samples : int, default 1000
        Number of initial conditions sampled uniformly in the region.
    n : int, optional
        Number of iterations (maps).  Default 15.  (Kept modest: the raw tangent
        product is not renormalised, so very long horizons overflow.)
    final_time : float, optional
        Integration time (flows).  Default 5.0.
    dt : float, optional
        Recording step for flows.  Default 0.1.  Not valid for maps.
    fit_range : (int, int), optional
        Inclusive index range in the :math:`t` grid to fit the slope over.
        Default skips :math:`t = 0` and uses the rest.
    seed : int, optional
        Seed for the initial-condition sampling.
    n_internal : int, default 5
        RK4 sub-steps per ``dt`` for flows.

    Returns
    -------
    ExpansionEntropyResult

    Raises
    ------
    NotImplementedError
        If ``system`` is neither a discrete map nor a continuous flow.
    InvalidParameterError
        If ``n_samples < 1``; if the step count is degenerate (``n < 1`` for a
        map, ``final_time <= 0`` or ``dt <= 0`` for a flow); if ``dt`` is passed
        for a map (or ``n`` for a flow); if the region dimension does not match
        the system; or if fewer than two finite :math:`\ln E(t)` points remain to
        fit (too few survivors stayed in the region — enlarge it or shorten the
        horizon).

    Examples
    --------
    >>> float(expansion_entropy(Tent(params={"mu": 1.0}), Box([0.0], [1.0])))
    0.69...                                              # ln 2, exact

    References
    ----------
    Hunt & Ott, "Defining chaos", *Chaos* **25** (2015) 097618.
    """
    if isinstance(system, DiscreteMap):
        mode = "map"
    elif isinstance(system, ContinuousSystem):
        mode = "flow"
    else:
        raise NotImplementedError(
            f"expansion_entropy supports discrete maps and continuous flows, not "
            f"{type(system).__name__}."
        )

    n_samples = int(n_samples)
    if n_samples < 1:
        raise InvalidParameterError(f"n_samples must be >= 1; got {n_samples}.")

    box = _c._resolve_region(system, region)
    if box.dim != system.dim:
        raise InvalidParameterError(
            f"region dimension ({box.dim}) does not match system dimension ({system.dim})."
        )
    draw = sampler(box, seed=seed)
    ics = np.array([draw() for _ in range(n_samples)])

    if mode == "map":
        if dt is not None:
            raise InvalidParameterError("dt has no meaning for a discrete map — omit it.")
        n_steps = 15 if n is None else int(n)
        if n_steps < 1:
            raise InvalidParameterError(f"n (number of iterations) must be >= 1; got {n_steps}.")
        times, log_growth, survivors = _expansion_map(system, ics, box, n_steps)
    else:
        if n is not None:
            raise InvalidParameterError("n applies to maps; use final_time/dt for a flow.")
        t_end = 5.0 if final_time is None else float(final_time)
        step_dt = 0.1 if dt is None else float(dt)
        if step_dt <= 0.0:
            raise InvalidParameterError(f"dt must be positive; got {step_dt}.")
        if t_end <= 0.0 or int(round(t_end / step_dt)) < 1:
            raise InvalidParameterError(
                f"final_time must be positive and span at least one dt step; "
                f"got final_time={t_end}, dt={step_dt}."
            )
        times, log_growth, survivors = _expansion_flow(
            system, ics, box, t_end, step_dt, int(n_internal)
        )

    lo, hi = _resolve_fit_range(times, log_growth, fit_range)
    # Fit only over finite ln E(t): once every sample has left S, E = 0 and ln E
    # = -inf (a contiguous tail in practice, but mask defensively rather than
    # rely on that invariant).
    t_fit, y_fit = times[lo : hi + 1], log_growth[lo : hi + 1]
    finite = np.isfinite(y_fit)
    if int(np.count_nonzero(finite)) < 2:
        raise InvalidParameterError(
            "expansion entropy: fewer than two finite ln E(t) points in the fit range "
            "(too few survivors stayed in the region — enlarge it or shorten the horizon)."
        )
    slope, intercept, stderr = _c._linfit(t_fit[finite], y_fit[finite])
    return ExpansionEntropyResult(
        estimate=slope,
        stderr=stderr,
        abscissa=times,
        ordinate=log_growth,
        fit_region=(lo, hi),
        intercept=intercept,
        n_samples=int(n_samples),
        n_survivors=int(survivors),
        meta=AnalysisResult.build_meta(
            system, analysis="expansion_entropy", n_samples=int(n_samples)
        ),
    )

ExpansionEntropyResult dataclass

ExpansionEntropyResult(
    estimate: float = 0.0,
    stderr: float = 0.0,
    abscissa: ndarray = (lambda: empty(0))(),
    ordinate: ndarray = (lambda: empty(0))(),
    fit_region: tuple[int, int] = (0, 0),
    intercept: float = 0.0,
    n_samples: int = 0,
    n_survivors: int = 0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: ScalingResult

An expansion-entropy estimate with the growth curve it was read from.

A :class:~tsdynamics.analysis._result.ScalingResult — the entropy is the slope of :math:\ln E(t) against :math:t — so it inherits the canonical estimate / abscissa / ordinate / fit_region schema, the result surface (.meta / .summary() / .to_dict() / the .plot seam) and float(result) (the entropy :math:H). Domain-named @property aliases (:attr:entropy, :attr:times, :attr:log_growth, :attr:fit_slice) preserve the original field names.

ATTRIBUTE DESCRIPTION
estimate

The estimated expansion entropy :math:H. Aliased :attr:entropy.

TYPE: float

abscissa

The :math:t grid (iterations for maps, time for flows). Aliased :attr:times.

TYPE: ndarray

ordinate

:math:\ln E(t) at each :math:t. Aliased :attr:log_growth.

TYPE: ndarray

fit_region

Inclusive (lo, hi) indices of the fitted range. Aliased :attr:fit_slice.

TYPE: tuple[int, int]

n_samples

Number of initial conditions sampled in the region.

TYPE: int

n_survivors

How many of them stayed in the region for the whole run.

TYPE: int

entropy property

entropy: float

The estimated expansion entropy (alias of :attr:estimate).

times property

times: ndarray

The :math:t grid (alias of :attr:abscissa).

log_growth property

log_growth: ndarray

The :math:\ln E(t) curve (alias of :attr:ordinate).

fit_slice property

fit_slice: tuple[int, int]

The fitted index range (alias of :attr:fit_region).

Entropy & complexity

Composable estimation — an OutcomeSpace (how a series is symbolised), a probability estimator, and an information measure — plus the named measures built on it.

entropy

entropy(
    data: Any,
    *,
    outcomes: OutcomeSpace,
    prob: ProbabilityEstimator | None = None,
    measure: InformationMeasure | None = None,
    normalize: bool = False,
    component: int | str | None = None,
) -> ScalarResult

Entropy of data — the composable entry point.

Symbolise with outcomes, estimate probabilities with prob, then apply the information measure. With normalize=True the result is divided by the measure's maximum over the outcome alphabet, mapping it to [0, 1].

PARAMETER DESCRIPTION
data

The series (see :func:as_series).

TYPE: array - like or Trajectory

outcomes

Symbolisation scheme (e.g. :class:OrdinalPatterns, :class:Dispersion).

TYPE: OutcomeSpace

prob

Defaults to :class:MLE.

TYPE: ProbabilityEstimator DEFAULT: None

measure

Defaults to :class:Shannon (base 2, i.e. bits).

TYPE: InformationMeasure DEFAULT: None

normalize

Divide by the maximum (uniform) value over the alphabet.

TYPE: bool DEFAULT: False

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
float

The (optionally normalised) entropy.

Examples:

>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> float(entropy(rng.random(5000), outcomes=OrdinalPatterns(3), normalize=True))  # ≈ 1
0.99...
Source code in src/tsdynamics/analysis/entropy/core.py
def entropy(
    data: Any,
    *,
    outcomes: OutcomeSpace,
    prob: ProbabilityEstimator | None = None,
    measure: InformationMeasure | None = None,
    normalize: bool = False,
    component: int | str | None = None,
) -> ScalarResult:
    """
    Entropy of ``data`` — the composable entry point.

    Symbolise with ``outcomes``, estimate probabilities with ``prob``, then
    apply the information ``measure``.  With ``normalize=True`` the result is
    divided by the measure's maximum over the outcome alphabet, mapping it to
    ``[0, 1]``.

    Parameters
    ----------
    data : array-like or Trajectory
        The series (see :func:`as_series`).
    outcomes : OutcomeSpace
        Symbolisation scheme (e.g. :class:`OrdinalPatterns`, :class:`Dispersion`).
    prob : ProbabilityEstimator, optional
        Defaults to :class:`MLE`.
    measure : InformationMeasure, optional
        Defaults to :class:`Shannon` (base 2, i.e. bits).
    normalize : bool, default False
        Divide by the maximum (uniform) value over the alphabet.
    component : int or str, optional
        Component selector for multi-component input.

    Returns
    -------
    float
        The (optionally normalised) entropy.

    Examples
    --------
    >>> import numpy as np
    >>> rng = np.random.default_rng(0)
    >>> float(entropy(rng.random(5000), outcomes=OrdinalPatterns(3), normalize=True))  # ≈ 1
    0.99...
    """
    series = as_series(data, component)
    prob = prob if prob is not None else MLE()
    measure = measure if measure is not None else Shannon()
    counts = outcomes.counts(series)
    p = prob.probabilities(counts)
    h = measure.apply(p)
    if normalize:
        hmax = measure.maximum(outcomes.cardinality)
        value = h / hmax if hmax > 0 else 0.0
    else:
        value = h
    return ScalarResult(
        value=float(value),
        meta={"analysis": "entropy", "normalize": bool(normalize)},
    )

permutation_entropy

permutation_entropy(
    data: Any,
    dimension: int = 3,
    delay: int = 1,
    *,
    base: float = 2.0,
    normalize: bool = True,
    component: int | str | None = None,
) -> ScalarResult

Permutation entropy of a time series (Bandt & Pompe 2002).

The Shannon entropy of the distribution of ordinal patterns — the relative rankings within every length-dimension embedded window. It is invariant under any strictly monotonic transform of the data and needs no amplitude thresholds, which makes it a robust complexity measure for noisy experimental series.

PARAMETER DESCRIPTION
data

Scalar time series (or a component of a multivariate one).

TYPE: array - like or Trajectory

dimension

Ordinal (embedding) order. Typical choices are 3 ≤ dimension ≤ 7; the series should satisfy n ≫ dimension! for the estimate to be meaningful.

TYPE: int DEFAULT: 3

delay

Embedding delay.

TYPE: int DEFAULT: 1

base

Logarithm base (2 → bits).

TYPE: float DEFAULT: 2.0

normalize

Divide by log_base(dimension!) so the result lies in [0, 1] (0 for a perfectly ordered signal, 1 for uniform pattern usage).

TYPE: bool DEFAULT: True

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
float

Permutation entropy.

Examples:

>>> permutation_entropy(np.arange(1000))          # monotone → 0
0.0
>>> rng = np.random.default_rng(0)
>>> float(permutation_entropy(rng.random(10000)))        # white noise → ≈ 1
0.99...
Source code in src/tsdynamics/analysis/entropy/permutation.py
def permutation_entropy(
    data: Any,
    dimension: int = 3,
    delay: int = 1,
    *,
    base: float = 2.0,
    normalize: bool = True,
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    Permutation entropy of a time series (Bandt & Pompe 2002).

    The Shannon entropy of the distribution of *ordinal patterns* — the relative
    rankings within every length-``dimension`` embedded window.  It is invariant
    under any strictly monotonic transform of the data and needs no amplitude
    thresholds, which makes it a robust complexity measure for noisy
    experimental series.

    Parameters
    ----------
    data : array-like or Trajectory
        Scalar time series (or a component of a multivariate one).
    dimension : int, default 3
        Ordinal (embedding) order.  Typical choices are ``3 ≤ dimension ≤ 7``;
        the series should satisfy ``n ≫ dimension!`` for the estimate to be
        meaningful.
    delay : int, default 1
        Embedding delay.
    base : float, default 2.0
        Logarithm base (``2`` → bits).
    normalize : bool, default True
        Divide by ``log_base(dimension!)`` so the result lies in ``[0, 1]``
        (``0`` for a perfectly ordered signal, ``1`` for uniform pattern usage).
    component : int or str, optional
        Component selector for multi-component input.

    Returns
    -------
    float
        Permutation entropy.

    Examples
    --------
    >>> permutation_entropy(np.arange(1000))          # monotone → 0
    0.0
    >>> rng = np.random.default_rng(0)
    >>> float(permutation_entropy(rng.random(10000)))        # white noise → ≈ 1
    0.99...
    """
    h = entropy(
        data,
        outcomes=OrdinalPatterns(dimension, delay),
        measure=Shannon(base),
        normalize=normalize,
        component=component,
    )
    return ScalarResult(
        value=float(h),
        meta={
            "analysis": "permutation_entropy",
            "dimension": dimension,
            "delay": delay,
            "normalize": bool(normalize),
        },
    )

weighted_permutation_entropy

weighted_permutation_entropy(
    data: Any,
    dimension: int = 3,
    delay: int = 1,
    *,
    base: float = 2.0,
    normalize: bool = True,
    component: int | str | None = None,
) -> ScalarResult

Weighted permutation entropy (Fadlallah et al. 2013).

Like :func:permutation_entropy, but each ordinal pattern is weighted by the variance of its embedding window before the probabilities are formed. This restores sensitivity to amplitude — high-variance (often more dynamically relevant) windows count more, so abrupt large-amplitude events are no longer treated like tiny fluctuations sharing the same ordinal pattern.

PARAMETER DESCRIPTION
data

As in :func:permutation_entropy.

TYPE: Any

dimension

As in :func:permutation_entropy.

TYPE: Any

delay

As in :func:permutation_entropy.

TYPE: Any

base

As in :func:permutation_entropy.

TYPE: Any

normalize

As in :func:permutation_entropy.

TYPE: Any

component

As in :func:permutation_entropy.

TYPE: Any

RETURNS DESCRIPTION
float

Weighted permutation entropy.

Source code in src/tsdynamics/analysis/entropy/permutation.py
def weighted_permutation_entropy(
    data: Any,
    dimension: int = 3,
    delay: int = 1,
    *,
    base: float = 2.0,
    normalize: bool = True,
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    Weighted permutation entropy (Fadlallah et al. 2013).

    Like :func:`permutation_entropy`, but each ordinal pattern is weighted by the
    variance of its embedding window before the probabilities are formed.  This
    restores sensitivity to amplitude — high-variance (often more dynamically
    relevant) windows count more, so abrupt large-amplitude events are no longer
    treated like tiny fluctuations sharing the same ordinal pattern.

    Parameters
    ----------
    data, dimension, delay, base, normalize, component
        As in :func:`permutation_entropy`.

    Returns
    -------
    float
        Weighted permutation entropy.
    """
    series = as_series(data, component)
    space = OrdinalPatterns(dimension, delay)
    labels, weights = space.window_variance(series)
    weighted = np.bincount(labels, weights=weights, minlength=space.cardinality)
    total = weighted.sum()
    if total == 0:
        # All windows are flat (zero variance) → no amplitude information.
        value = 0.0
    else:
        p = weighted / total
        h = Shannon(base).apply(p)
        if normalize:
            hmax = Shannon(base).maximum(space.cardinality)
            value = h / hmax if hmax > 0 else 0.0
        else:
            value = h
    return ScalarResult(
        value=float(value),
        meta={
            "analysis": "weighted_permutation_entropy",
            "dimension": dimension,
            "delay": delay,
            "normalize": bool(normalize),
        },
    )

dispersion_entropy

dispersion_entropy(
    data: Any,
    c: int = 6,
    dimension: int = 2,
    delay: int = 1,
    *,
    base: float = 2.0,
    normalize: bool = True,
    component: int | str | None = None,
) -> ScalarResult

Dispersion entropy of a time series (Rostaghi & Azami 2016).

The series is mapped through the normal CDF onto c amplitude classes, embedded with order dimension and delay τ, and the Shannon entropy of the resulting dispersion patterns is returned. Unlike permutation entropy it keeps amplitude information and is markedly faster than sample entropy, while remaining robust to noise.

Notes

When normalize=True the entropy is divided by log_base(c**dimension) — the log of the full dispersion-pattern alphabet (the canonical Rostaghi & Azami 2016 normalisation). Because a finite series can populate at most n − (dimension − 1)·delay of those c**dimension patterns, even ideal white noise sits slightly below 1 at finite length (it approaches 1 only as n → ∞); this is expected, not a defect.

PARAMETER DESCRIPTION
data

Scalar time series (or a component of a multivariate one).

TYPE: array - like or Trajectory

c

Number of amplitude classes. 4 ≤ c ≤ 8 is typical; n ≫ c**dimension is recommended.

TYPE: int DEFAULT: 6

dimension

Embedding order.

TYPE: int DEFAULT: 2

delay

Embedding delay.

TYPE: int DEFAULT: 1

base

Logarithm base (2 → bits).

TYPE: float DEFAULT: 2.0

normalize

Divide by log_base(c**dimension) so the result lies in [0, 1].

TYPE: bool DEFAULT: True

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
float

Dispersion entropy.

Examples:

>>> rng = np.random.default_rng(0)
>>> float(dispersion_entropy(rng.random(10000)))         # white noise → ≈ 1
0.99...
Source code in src/tsdynamics/analysis/entropy/dispersion.py
def dispersion_entropy(
    data: Any,
    c: int = 6,
    dimension: int = 2,
    delay: int = 1,
    *,
    base: float = 2.0,
    normalize: bool = True,
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    Dispersion entropy of a time series (Rostaghi & Azami 2016).

    The series is mapped through the normal CDF onto ``c`` amplitude classes,
    embedded with order ``dimension`` and delay ``τ``, and the Shannon entropy of
    the resulting *dispersion patterns* is returned.  Unlike permutation entropy
    it keeps amplitude information and is markedly faster than sample entropy,
    while remaining robust to noise.

    Notes
    -----
    When ``normalize=True`` the entropy is divided by ``log_base(c**dimension)`` —
    the log of the *full* dispersion-pattern alphabet (the canonical Rostaghi &
    Azami 2016 normalisation).  Because a finite series can populate at most
    ``n − (dimension − 1)·delay`` of those ``c**dimension`` patterns, even ideal
    white noise sits *slightly below* 1 at finite length (it approaches 1 only as
    ``n → ∞``); this is expected, not a defect.

    Parameters
    ----------
    data : array-like or Trajectory
        Scalar time series (or a component of a multivariate one).
    c : int, default 6
        Number of amplitude classes.  ``4 ≤ c ≤ 8`` is typical; ``n ≫
        c**dimension`` is recommended.
    dimension : int, default 2
        Embedding order.
    delay : int, default 1
        Embedding delay.
    base : float, default 2.0
        Logarithm base (``2`` → bits).
    normalize : bool, default True
        Divide by ``log_base(c**dimension)`` so the result lies in ``[0, 1]``.
    component : int or str, optional
        Component selector for multi-component input.

    Returns
    -------
    float
        Dispersion entropy.

    Examples
    --------
    >>> rng = np.random.default_rng(0)
    >>> float(dispersion_entropy(rng.random(10000)))         # white noise → ≈ 1
    0.99...
    """
    h = entropy(
        data,
        outcomes=Dispersion(c, dimension, delay),
        measure=Shannon(base),
        normalize=normalize,
        component=component,
    )
    return ScalarResult(
        value=float(h),
        meta={
            "analysis": "dispersion_entropy",
            "c": c,
            "dimension": dimension,
            "delay": delay,
            "normalize": bool(normalize),
        },
    )

sample_entropy

sample_entropy(
    data: Any,
    dimension: int = 2,
    r: float | None = None,
    delay: int = 1,
    *,
    component: int | str | None = None,
) -> ScalarResult

Sample entropy (Richman & Moorman 2000).

SampEn = -ln(A / B), where B and A count template pairs (excluding self-matches) that stay within Chebyshev tolerance r over windows of length dimension and dimension+1 respectively. Excluding self-matches removes the bias of approximate entropy, making the statistic largely independent of series length. Larger values mean less regularity.

PARAMETER DESCRIPTION
data

Scalar time series (or a component of a multivariate one).

TYPE: array - like or Trajectory

dimension

Template length.

TYPE: int DEFAULT: 2

r

Tolerance (Chebyshev radius). Defaults to 0.2 · std(data) — the standalone Richman & Moorman (2000) convention. Note that :func:~tsdynamics.analysis.entropy.multiscale.multiscale_entropy deliberately fixes r = 0.15 · std instead (Costa et al. 2002), so its scale-1 entry does not equal sample_entropy at defaults.

TYPE: float DEFAULT: None

delay

Embedding delay.

TYPE: int DEFAULT: 1

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
float

Sample entropy in nats. Returns inf when no length-dimension+1 template pair matches (no regularity detected at that scale).

RAISES DESCRIPTION
ValueError

If the series is too short or no length-dimension template pair matches (r too small).

References

Richman, J. S. & Moorman, J. R. (2000). Physiological time-series analysis using approximate entropy and sample entropy. Am. J. Physiol. Heart Circ. Physiol. 278, H2039–H2049.

Examples:

>>> rng = np.random.default_rng(0)
>>> float(sample_entropy(rng.random(2000)))   # white noise → high
2.2...
Source code in src/tsdynamics/analysis/entropy/sample.py
def sample_entropy(
    data: Any,
    dimension: int = 2,
    r: float | None = None,
    delay: int = 1,
    *,
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    Sample entropy (Richman & Moorman 2000).

    ``SampEn = -ln(A / B)``, where ``B`` and ``A`` count template pairs (excluding
    self-matches) that stay within Chebyshev tolerance ``r`` over windows of
    length ``dimension`` and ``dimension+1`` respectively.  Excluding self-matches
    removes the bias of approximate entropy, making the statistic largely
    independent of series length.  Larger values mean less regularity.

    Parameters
    ----------
    data : array-like or Trajectory
        Scalar time series (or a component of a multivariate one).
    dimension : int, default 2
        Template length.
    r : float, optional
        Tolerance (Chebyshev radius).  Defaults to ``0.2 · std(data)`` — the
        standalone Richman & Moorman (2000) convention.  Note that
        :func:`~tsdynamics.analysis.entropy.multiscale.multiscale_entropy`
        deliberately fixes ``r = 0.15 · std`` instead (Costa et al. 2002), so its
        scale-1 entry does not equal ``sample_entropy`` at defaults.
    delay : int, default 1
        Embedding delay.
    component : int or str, optional
        Component selector for multi-component input.

    Returns
    -------
    float
        Sample entropy in nats.  Returns ``inf`` when no length-``dimension+1``
        template pair matches (no regularity detected at that scale).

    Raises
    ------
    ValueError
        If the series is too short or no length-``dimension`` template pair
        matches (``r`` too small).

    References
    ----------
    Richman, J. S. & Moorman, J. R. (2000). Physiological time-series analysis
    using approximate entropy and sample entropy. *Am. J. Physiol. Heart Circ.
    Physiol.* **278**, H2039–H2049.

    Examples
    --------
    >>> rng = np.random.default_rng(0)
    >>> float(sample_entropy(rng.random(2000)))   # white noise → high
    2.2...
    """
    series = as_series(data, component)
    n = series.size
    n_templates = n - dimension * delay  # common index set for dimension and dimension+1
    if n_templates <= 1:
        raise ValueError(
            f"series too short: need > {dimension * delay + 1} samples "
            f"for dimension={dimension}, delay={delay}."
        )
    rad = _resolve_r(series, r)

    emb_m = _embed(series, dimension, delay, n_templates)
    emb_m1 = _embed(series, dimension + 1, delay, n_templates)

    # Brute-force template matching is O(n_templates^2); a Chebyshev (L_inf)
    # cKDTree counts the same template pairs in O(n_templates log n_templates).
    # ``count_neighbors`` returns ordered pairs (i, j) with max-norm distance
    # <= rad *including* the n_templates self-pairs (i, i), so subtracting
    # n_templates removes exactly the self-matches the loop excluded.
    #
    # For the length-(m+1) count: Chebyshev distance is monotone in the number
    # of columns, so dist over m+1 columns >= dist over m columns.  Hence every
    # (m+1)-template pair within rad is automatically within rad over its first
    # m columns — the loop's ``within`` pre-filter is therefore redundant, and
    # counting (m+1)-template pairs directly reproduces ``a_count`` exactly.
    tree_m = cKDTree(emb_m)
    b_count = int(tree_m.count_neighbors(tree_m, rad, p=np.inf)) - n_templates
    tree_m1 = cKDTree(emb_m1)
    a_count = int(tree_m1.count_neighbors(tree_m1, rad, p=np.inf)) - n_templates

    if b_count == 0:
        raise ValueError("no length-m template matches — increase r or lengthen the series.")
    value = float("inf") if a_count == 0 else float(-np.log(a_count / b_count))
    return ScalarResult(
        value=value,
        meta={"analysis": "sample_entropy", "dimension": dimension, "delay": delay},
    )

approximate_entropy

approximate_entropy(
    data: Any,
    dimension: int = 2,
    r: float | None = None,
    delay: int = 1,
    *,
    component: int | str | None = None,
) -> ScalarResult

Approximate entropy (Pincus 1991).

ApEn = Φ^m(r) − Φ^{m+1}(r), where Φ^m averages ln C_i^m and C_i^m is the fraction of length-dimension templates within tolerance r of template i (including the self-match). Self-matching keeps the logarithms finite but biases the estimate downward for short series — prefer :func:sample_entropy when that bias matters.

PARAMETER DESCRIPTION
data

Scalar time series (or a component of a multivariate one).

TYPE: array - like or Trajectory

dimension

Template length.

TYPE: int DEFAULT: 2

r

Tolerance (Chebyshev radius). Defaults to 0.2 · std(data).

TYPE: float DEFAULT: None

delay

Embedding delay.

TYPE: int DEFAULT: 1

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
float

Approximate entropy in nats.

RAISES DESCRIPTION
ValueError

If the series is too short for the requested dimension/delay. The estimate compares an m-dimensional embedding with an (m + 1)-dimensional one, so the binding constraint is the longer pass: the series must satisfy N - m * delay > 0 (the dimension + 1 embedding sets the minimum length).

Notes

The result can be +inf when a template has no within-radius neighbour other than itself at the (m + 1) dimension (a zero count yields log(0)), which happens for short or near-deterministic series; widen r or lengthen the series to obtain a finite estimate.

References

Pincus, S. M. (1991). Approximate entropy as a measure of system complexity. Proc. Natl. Acad. Sci. USA 88, 2297–2301.

Source code in src/tsdynamics/analysis/entropy/sample.py
def approximate_entropy(
    data: Any,
    dimension: int = 2,
    r: float | None = None,
    delay: int = 1,
    *,
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    Approximate entropy (Pincus 1991).

    ``ApEn = Φ^m(r) − Φ^{m+1}(r)``, where ``Φ^m`` averages ``ln C_i^m`` and
    ``C_i^m`` is the fraction of length-``dimension`` templates within tolerance
    ``r`` of template ``i`` (*including* the self-match).  Self-matching keeps the
    logarithms finite but biases the estimate downward for short series — prefer
    :func:`sample_entropy` when that bias matters.

    Parameters
    ----------
    data : array-like or Trajectory
        Scalar time series (or a component of a multivariate one).
    dimension : int, default 2
        Template length.
    r : float, optional
        Tolerance (Chebyshev radius).  Defaults to ``0.2 · std(data)``.
    delay : int, default 1
        Embedding delay.
    component : int or str, optional
        Component selector for multi-component input.

    Returns
    -------
    float
        Approximate entropy in nats.

    Raises
    ------
    ValueError
        If the series is too short for the requested ``dimension``/``delay``.
        The estimate compares an ``m``-dimensional embedding with an
        ``(m + 1)``-dimensional one, so the binding constraint is the longer
        pass: the series must satisfy ``N - m * delay > 0`` (the
        ``dimension + 1`` embedding sets the minimum length).

    Notes
    -----
    The result can be ``+inf`` when a template has no within-radius neighbour
    other than itself at the ``(m + 1)`` dimension (a zero count yields
    ``log(0)``), which happens for short or near-deterministic series; widen
    ``r`` or lengthen the series to obtain a finite estimate.

    References
    ----------
    Pincus, S. M. (1991). Approximate entropy as a measure of system complexity.
    *Proc. Natl. Acad. Sci. USA* **88**, 2297–2301.
    """
    series = as_series(data, component)
    n = series.size
    rad = _resolve_r(series, r)

    def phi(mm: int) -> float:
        n_templates = n - (mm - 1) * delay
        if n_templates <= 0:
            raise ValueError(f"series too short for dimension={mm}, delay={delay}.")
        emb = _embed(series, mm, delay, n_templates)
        # ``C_i^m`` is the number of templates within Chebyshev radius ``rad`` of
        # template ``i`` *including* the self-match.  A cKDTree ball query returns
        # exactly that per-point neighbour count (radius is inclusive, the point
        # itself is always within distance 0), turning the O(n^2) per-template
        # scan into O(n log n).  ``return_length=True`` yields the integer counts
        # directly, identical to ``np.count_nonzero(dist <= rad)`` for each i.
        tree = cKDTree(emb)
        counts = tree.query_ball_point(emb, rad, p=np.inf, return_length=True)
        return float(np.sum(np.log(counts / n_templates)) / n_templates)

    value = float(phi(dimension) - phi(dimension + 1))
    return ScalarResult(
        value=value,
        meta={"analysis": "approximate_entropy", "dimension": dimension, "delay": delay},
    )

multiscale_entropy

multiscale_entropy(
    data: Any,
    scales: int | Iterable[int] = 20,
    *,
    entropy_fn: Callable[..., Any] = sample_entropy,
    r: float | None = None,
    r_factor: float = 0.15,
    component: int | str | None = None,
    **kwargs: Any,
) -> ArrayResult

Multiscale entropy: an entropy measured on coarse-grained copies of a series.

For each scale the series is coarse-grained (:func:coarse_grain) and entropy_fn is applied. Following Costa et al. (2002), when the chosen entropy takes a tolerance r (e.g. :func:sample_entropy, :func:approximate_entropy) it is fixed across all scales from the original series' standard deviation, so the profile reflects structure rather than the shrinking variance of the coarse-grained signal.

Pure (1/f-like) processes hold a roughly flat profile while uncorrelated white noise decays with scale — the discriminating feature multiscale entropy was designed to expose.

Notes

The default r_factor is 0.15, not the 0.2 that :func:sample_entropy uses on its own. This is the deliberate multiscale convention (Costa et al. 2002 sweep r in the 0.1–0.25 · std band and report 0.15): a tolerance fixed once from the original series keeps the comparison across scales honest. A direct consequence is that, at defaults, multiscale_entropy(x)[0] (scale 1) does not equal sample_entropy(x) — the former uses r = 0.15 · std(x) while the latter uses 0.2 · std(x). Pass r_factor=0.2 (or an explicit r) to make scale 1 coincide with the bare estimator.

PARAMETER DESCRIPTION
data

Scalar time series.

TYPE: array - like or Trajectory

scales

An int S expands to 1, 2, …, S; an iterable is used verbatim.

TYPE: int or iterable of int DEFAULT: 20

entropy_fn

Single-series entropy applied at each scale. Any of this package's scalar entropies works (sample, approximate, permutation, dispersion).

TYPE: callable DEFAULT: :func:`sample_entropy`

r

Explicit tolerance for r-based entropies (overrides r_factor).

TYPE: float DEFAULT: None

r_factor

When r is not given and entropy_fn accepts an r argument, use r_factor · std(original). Defaults to the multiscale convention 0.15 (see Notes), distinct from :func:sample_entropy's standalone 0.2.

TYPE: float DEFAULT: 0.15

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

**kwargs

Forwarded to entropy_fn (e.g. dimension=, delay=).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
ArrayResult

Entropy at each requested scale (same order as scales), a drop-in for the bare array (mse[0], mse.shape, np.asarray(mse)) that also carries .meta.

References

Costa, M., Goldberger, A. L. & Peng, C.-K. (2002). Multiscale entropy analysis of complex physiologic time series. Phys. Rev. Lett. 89, 068102.

Examples:

>>> rng = np.random.default_rng(0)
>>> mse = multiscale_entropy(rng.standard_normal(5000), scales=5)
>>> bool(mse[0] > mse[-1])    # white noise decays with scale
True
Source code in src/tsdynamics/analysis/entropy/multiscale.py
def multiscale_entropy(
    data: Any,
    scales: int | Iterable[int] = 20,
    *,
    entropy_fn: Callable[..., Any] = sample_entropy,
    r: float | None = None,
    r_factor: float = 0.15,
    component: int | str | None = None,
    **kwargs: Any,
) -> ArrayResult:
    r"""
    Multiscale entropy: an entropy measured on coarse-grained copies of a series.

    For each scale the series is coarse-grained (:func:`coarse_grain`) and
    ``entropy_fn`` is applied.  Following Costa et al. (2002), when the chosen
    entropy takes a tolerance ``r`` (e.g. :func:`sample_entropy`,
    :func:`approximate_entropy`) it is **fixed across all scales** from the
    *original* series' standard deviation, so the profile reflects structure
    rather than the shrinking variance of the coarse-grained signal.

    Pure (1/f-like) processes hold a roughly flat profile while uncorrelated
    white noise decays with scale — the discriminating feature multiscale
    entropy was designed to expose.

    Notes
    -----
    The default ``r_factor`` is ``0.15``, not the ``0.2`` that
    :func:`sample_entropy` uses on its own.  This is the deliberate multiscale
    convention (Costa et al. 2002 sweep ``r`` in the ``0.1–0.25 · std`` band and
    report ``0.15``): a tolerance fixed once from the *original* series keeps the
    comparison across scales honest.  A direct consequence is that, **at defaults,
    ``multiscale_entropy(x)[0]`` (scale 1) does not equal ``sample_entropy(x)``**
    — the former uses ``r = 0.15 · std(x)`` while the latter uses
    ``0.2 · std(x)``.  Pass ``r_factor=0.2`` (or an explicit ``r``) to make scale 1
    coincide with the bare estimator.

    Parameters
    ----------
    data : array-like or Trajectory
        Scalar time series.
    scales : int or iterable of int, default 20
        An ``int`` ``S`` expands to ``1, 2, …, S``; an iterable is used verbatim.
    entropy_fn : callable, default :func:`sample_entropy`
        Single-series entropy applied at each scale.  Any of this package's
        scalar entropies works (sample, approximate, permutation, dispersion).
    r : float, optional
        Explicit tolerance for ``r``-based entropies (overrides ``r_factor``).
    r_factor : float, default 0.15
        When ``r`` is not given and ``entropy_fn`` accepts an ``r`` argument, use
        ``r_factor · std(original)``.  Defaults to the multiscale convention
        ``0.15`` (see Notes), distinct from :func:`sample_entropy`'s standalone
        ``0.2``.
    component : int or str, optional
        Component selector for multi-component input.
    **kwargs
        Forwarded to ``entropy_fn`` (e.g. ``dimension=``, ``delay=``).

    Returns
    -------
    ArrayResult
        Entropy at each requested scale (same order as ``scales``), a drop-in for
        the bare array (``mse[0]``, ``mse.shape``, ``np.asarray(mse)``) that also
        carries ``.meta``.

    References
    ----------
    Costa, M., Goldberger, A. L. & Peng, C.-K. (2002). Multiscale entropy
    analysis of complex physiologic time series. *Phys. Rev. Lett.* **89**,
    068102.

    Examples
    --------
    >>> rng = np.random.default_rng(0)
    >>> mse = multiscale_entropy(rng.standard_normal(5000), scales=5)
    >>> bool(mse[0] > mse[-1])    # white noise decays with scale
    True
    """
    series = as_series(data, component)
    scale_list = list(range(1, int(scales) + 1)) if isinstance(scales, int) else list(scales)

    # Fix the tolerance from the original series if the entropy uses one.
    call_kwargs = dict(kwargs)
    takes_r = "r" in inspect.signature(entropy_fn).parameters
    if takes_r and "r" not in call_kwargs:
        call_kwargs["r"] = r if r is not None else r_factor * float(series.std())

    out = np.empty(len(scale_list), dtype=float)
    for k, s in enumerate(scale_list):
        out[k] = float(entropy_fn(coarse_grain(series, s), **call_kwargs))
    return ArrayResult(
        values=out,
        meta={"analysis": "multiscale_entropy", "scales": [int(s) for s in scale_list]},
    )

lz76_complexity

lz76_complexity(
    data: Any,
    *,
    symbolize: Any = "auto",
    threshold: str | float = "median",
    normalize: bool = False,
    provider: str = "native",
    component: int | str | None = None,
) -> ScalarResult

Lempel–Ziv (LZ76) complexity of a sequence.

PARAMETER DESCRIPTION
data

The sequence. Strings and integer arrays are treated as already symbolic; a real-valued series is symbolised first (see symbolize).

TYPE: array-like, str, or Trajectory

symbolize

How to turn the input into symbols. "auto" passes strings and integer arrays through unchanged and median-binarises floating-point series; "median"/"mean" force binarisation; a callable is applied to the (float) series and must return symbols; None insists the input is already symbolic.

TYPE: ('auto', 'median', 'mean', None) DEFAULT: "auto"

threshold

Threshold used when binarising (see :func:binarize).

TYPE: ('median', 'mean') DEFAULT: "median"

normalize

Return the normalised complexity c·log_k(n)/n (= :func:lz76_entropy) instead of the raw factor count.

TYPE: bool DEFAULT: False

provider

"native" uses the built-in parser; "lzcomplexity" delegates to the optional C++ lzcomplexity package (faster for very long sequences).

TYPE: ('native', 'lzcomplexity') DEFAULT: "native"

component

Component selector for multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
float

The LZ76 complexity (an integer count, or the normalised density when normalize=True).

Examples:

>>> float(lz76_complexity("0001101001000101", symbolize=None))   # Kaspar–Schuster
6.0
>>> float(lz76_complexity("aaaaaaaa", symbolize=None))            # constant → 2
2.0
Source code in src/tsdynamics/analysis/entropy/lz.py
def lz76_complexity(
    data: Any,
    *,
    symbolize: Any = "auto",
    threshold: str | float = "median",
    normalize: bool = False,
    provider: str = "native",
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    Lempel–Ziv (LZ76) complexity of a sequence.

    Parameters
    ----------
    data : array-like, str, or Trajectory
        The sequence.  Strings and integer arrays are treated as already
        symbolic; a real-valued series is symbolised first (see ``symbolize``).
    symbolize : {"auto", "median", "mean", None} or callable, default "auto"
        How to turn the input into symbols.  ``"auto"`` passes strings and
        integer arrays through unchanged and median-binarises floating-point
        series; ``"median"``/``"mean"`` force binarisation; a callable is applied
        to the (float) series and must return symbols; ``None`` insists the input
        is already symbolic.
    threshold : {"median", "mean"} or float, default "median"
        Threshold used when binarising (see :func:`binarize`).
    normalize : bool, default False
        Return the normalised complexity ``c·log_k(n)/n`` (= :func:`lz76_entropy`)
        instead of the raw factor count.
    provider : {"native", "lzcomplexity"}, default "native"
        ``"native"`` uses the built-in parser; ``"lzcomplexity"`` delegates to the
        optional C++ ``lzcomplexity`` package (faster for very long sequences).
    component : int or str, optional
        Component selector for multi-component input.

    Returns
    -------
    float
        The LZ76 complexity (an integer count, or the normalised density when
        ``normalize=True``).

    Examples
    --------
    >>> float(lz76_complexity("0001101001000101", symbolize=None))   # Kaspar–Schuster
    6.0
    >>> float(lz76_complexity("aaaaaaaa", symbolize=None))            # constant → 2
    2.0
    """
    codes = _to_codes(data, symbolize, threshold, component)
    n = codes.size
    k = int(np.unique(codes).size) if n else 0

    if provider == "native":
        c = _lz76_parse(codes)[0]
    elif provider == "lzcomplexity":
        c = _lz76_via_lzcomplexity(codes, k)
    else:
        raise ValueError(f"unknown provider {provider!r}; use 'native' or 'lzcomplexity'.")

    value = _normalized_density(c, n, k) if normalize else float(c)
    return ScalarResult(
        value=float(value),
        meta={"analysis": "lz76_complexity", "normalize": bool(normalize), "n_symbols": k},
    )

lz76_entropy

lz76_entropy(
    data: Any,
    *,
    symbolize: Any = "auto",
    threshold: str | float = "median",
    provider: str = "native",
    component: int | str | None = None,
) -> ScalarResult

LZ76 entropy-rate estimate h ≈ c(S)·log_k(n)/n.

The normalised LZ76 complexity, which converges to the entropy rate of an ergodic source (Lempel & Ziv 1976). Equivalent to :func:lz76_complexity with normalize=True. Arguments are as in :func:lz76_complexity.

RETURNS DESCRIPTION
float

Entropy density in units of log_k (i.e. normalised to [0, ~1] for a k-symbol source).

Source code in src/tsdynamics/analysis/entropy/lz.py
def lz76_entropy(
    data: Any,
    *,
    symbolize: Any = "auto",
    threshold: str | float = "median",
    provider: str = "native",
    component: int | str | None = None,
) -> ScalarResult:
    r"""
    LZ76 entropy-rate estimate ``h ≈ c(S)·log_k(n)/n``.

    The normalised LZ76 complexity, which converges to the entropy rate of an
    ergodic source (Lempel & Ziv 1976).  Equivalent to
    :func:`lz76_complexity` with ``normalize=True``.  Arguments are as in
    :func:`lz76_complexity`.

    Returns
    -------
    float
        Entropy density in units of ``log_k`` (i.e. normalised to ``[0, ~1]`` for
        a ``k``-symbol source).
    """
    value = float(
        lz76_complexity(
            data,
            symbolize=symbolize,
            threshold=threshold,
            normalize=True,
            provider=provider,
            component=component,
        )
    )
    return ScalarResult(value=value, meta={"analysis": "lz76_entropy"})

Composable building blocks

OutcomeSpace

Bases: ABC

A scheme that maps a series to counts over a finite set of outcomes.

Subclasses implement :meth:counts, returning a length-:attr:cardinality integer vector (zeros for outcomes that never occurred), so downstream estimators and the normalisation step both know the full outcome alphabet.

cardinality abstractmethod property

cardinality: int

Total number of distinguishable outcomes (the alphabet size).

counts abstractmethod

counts(x: ndarray) -> ndarray

Return the integer count of every outcome for series x.

Source code in src/tsdynamics/analysis/entropy/core.py
@abstractmethod
def counts(self, x: np.ndarray) -> np.ndarray:
    """Return the integer count of every outcome for series ``x``."""

OrdinalPatterns

OrdinalPatterns(m: int = 3, tau: int = 1)

Bases: OutcomeSpace

Ordinal (permutation) patterns of an embedded series (Bandt & Pompe 2002).

Each length-m window (x_i, x_{i+τ}, …, x_{i+(m-1)τ}) is encoded by the permutation that sorts it — its ordinal pattern. There are m! patterns.

PARAMETER DESCRIPTION
m

Embedding (pattern) order, m ≥ 2.

TYPE: int DEFAULT: 3

tau

Embedding delay, τ ≥ 1.

TYPE: int DEFAULT: 1

Source code in src/tsdynamics/analysis/entropy/core.py
def __init__(self, m: int = 3, tau: int = 1) -> None:
    if m < 2:
        raise ValueError("ordinal order m must be ≥ 2.")
    if tau < 1:
        raise ValueError("delay tau must be ≥ 1.")
    self.m = int(m)
    self.tau = int(tau)
    # Stable mapping permutation-tuple -> dense index over all m! patterns.
    self._index = {p: i for i, p in enumerate(itertools.permutations(range(self.m)))}

labels

labels() -> list[str]

Return a readable label per ordinal pattern, in dense-index order.

Each label is the rank tuple that sorts a window, rendered compactly (e.g. "012" for the increasing pattern, "210" for decreasing) — the category names for a :func:outcome_distribution_plot_spec bar plot. Falls back to comma separation when the order is m > 9 (ranks become multi-digit).

RETURNS DESCRIPTION
list of str

One label per outcome, length :attr:cardinality.

Source code in src/tsdynamics/analysis/entropy/core.py
def labels(self) -> list[str]:
    """Return a readable label per ordinal pattern, in dense-index order.

    Each label is the rank tuple that sorts a window, rendered compactly
    (e.g. ``"012"`` for the increasing pattern, ``"210"`` for decreasing) —
    the category names for a :func:`outcome_distribution_plot_spec` bar plot.
    Falls back to comma separation when the order is ``m > 9`` (ranks become
    multi-digit).

    Returns
    -------
    list of str
        One label per outcome, length :attr:`cardinality`.
    """
    ordered = sorted(self._index, key=lambda p: self._index[p])
    sep = "" if self.m <= 9 else ","
    return [sep.join(str(r) for r in pattern) for pattern in ordered]

window_variance

window_variance(x: ndarray) -> tuple[ndarray, ndarray]

Return (labels, per-window variance) — the weighting for WPE.

Source code in src/tsdynamics/analysis/entropy/core.py
def window_variance(self, x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Return ``(labels, per-window variance)`` — the weighting for WPE."""
    n = x.size
    span = (self.m - 1) * self.tau
    n_windows = n - span
    idx = np.arange(n_windows)[:, None] + np.arange(self.m)[None, :] * self.tau
    windows = x[idx]
    patterns = np.argsort(windows, axis=1, kind="stable")
    labels = self._labels_from_patterns(patterns)
    weights = windows.var(axis=1)
    return labels, weights

Dispersion

Dispersion(c: int = 6, m: int = 2, tau: int = 1)

Bases: OutcomeSpace

Dispersion patterns (Rostaghi & Azami 2016).

The series is mapped through the normal CDF (fitted to the sample mean/std) onto c amplitude classes, then embedded with order m and delay τ; each window becomes a dispersion pattern over c**m possibilities.

The :attr:cardinality is the full c**m alphabet, so a normalised entropy (:func:entropy with normalize=True) divides by log(c**m) — the canonical Rostaghi & Azami (2016) convention. A finite series can occupy at most n − (m − 1)·τ of those patterns, so even white noise normalises to slightly below 1 at finite length.

PARAMETER DESCRIPTION
c

Number of amplitude classes, c ≥ 2.

TYPE: int DEFAULT: 6

m

Embedding order, m ≥ 2.

TYPE: int DEFAULT: 2

tau

Embedding delay, τ ≥ 1.

TYPE: int DEFAULT: 1

Source code in src/tsdynamics/analysis/entropy/core.py
def __init__(self, c: int = 6, m: int = 2, tau: int = 1) -> None:
    if c < 2:
        raise ValueError("number of classes c must be ≥ 2.")
    if m < 2:
        raise ValueError("embedding order m must be ≥ 2.")
    if tau < 1:
        raise ValueError("delay tau must be ≥ 1.")
    self.c = int(c)
    self.m = int(m)
    self.tau = int(tau)

Shannon

Shannon(base: float = 2.0)

Bases: InformationMeasure

Shannon entropy H = -∑ p log_b p (Shannon 1948).

PARAMETER DESCRIPTION
base

Logarithm base b (> 1); 2 gives bits, e gives nats.

TYPE: float DEFAULT: 2.0

References

Shannon, C. E. (1948). A mathematical theory of communication. Bell Syst. Tech. J. 27, 379–423.

Source code in src/tsdynamics/analysis/entropy/core.py
def __init__(self, base: float = 2.0) -> None:
    if base <= 1:
        raise ValueError("log base must be > 1.")
    self.base = float(base)

Renyi

Renyi(q: float = 2.0, base: float = 2.0)

Bases: InformationMeasure

Rényi entropy of order q (Rényi 1961).

H_q = (1/(1-q)) log_b ∑ p^q; q → 1 recovers Shannon.

PARAMETER DESCRIPTION
q

Order, q ≥ 0, q ≠ 1 (use :class:Shannon for q = 1).

TYPE: float DEFAULT: 2.0

base

Logarithm base.

TYPE: float DEFAULT: 2.0

Source code in src/tsdynamics/analysis/entropy/core.py
def __init__(self, q: float = 2.0, base: float = 2.0) -> None:
    if q < 0:
        raise ValueError("Rényi order q must be ≥ 0.")
    if base <= 1:
        raise ValueError("log base must be > 1.")
    self.q = float(q)
    self.base = float(base)

Tsallis

Tsallis(q: float = 2.0)

Bases: InformationMeasure

Tsallis entropy of order q (Tsallis 1988).

S_q = (1 - ∑ p^q) / (q - 1); q → 1 recovers Shannon (nats).

PARAMETER DESCRIPTION
q

Entropic index, q ≠ 1 (use :class:Shannon for q = 1).

TYPE: float DEFAULT: 2.0

Source code in src/tsdynamics/analysis/entropy/core.py
def __init__(self, q: float = 2.0) -> None:
    self.q = float(q)

Fractal dimensions

correlation_dimension

correlation_dimension(
    data: Any,
    *,
    theiler: int = 0,
    metric: str | float = "euclidean",
    radii: ndarray | None = None,
    n_radii: int = 24,
    min_window: int = 5,
    tol: float = 1.5,
) -> DimensionResult

Grassberger--Procaccia correlation dimension :math:D_2.

Computes the correlation sum, then reads :math:D_2 off the slope of :math:\log C(r) vs :math:\log r in the automatically selected scaling region (:func:~tsdynamics.analysis.dimensions._scaling.fit_scaling_region).

PARAMETER DESCRIPTION
data

The point set.

TYPE: (Trajectory or array - like, shape(N, dim))

theiler

Theiler window — exclude pairs with :math:|i - j| \le w (see :func:correlation_sum). The default 0 suits a point set; flow users on a densely sampled trajectory should set a Theiler window to exclude temporally correlated neighbours, otherwise :math:D_2 is biased downward.

TYPE: int DEFAULT: 0

metric

Distance metric.

TYPE: str or float DEFAULT: "euclidean"

radii

Explicit radii; default is a data-adaptive log-spaced grid.

TYPE: ndarray DEFAULT: None

n_radii

Number of radii when radii is not given.

TYPE: int DEFAULT: 24

min_window

Minimum number of radii in the fitted scaling region.

TYPE: int DEFAULT: 5

tol

Scaling-region residual tolerance (see :func:~tsdynamics.analysis.dimensions._scaling.fit_scaling_region).

TYPE: float DEFAULT: 1.5

RETURNS DESCRIPTION
DimensionResult

float(result) is :math:D_2; the curve and selected window are carried for inspection.

References

P. Grassberger and I. Procaccia, "Characterization of strange attractors", Phys. Rev. Lett. 50, 346 (1983).

Examples:

>>> d = correlation_dimension(lorenz_traj, theiler=50)
>>> float(d)
2.05...
RAISES DESCRIPTION
InvalidParameterError

If fewer than :data:_MIN_CORR_POINTS points are given — too few to resolve a scaling region (it previously returned a spurious D_2 ~= 0).

Source code in src/tsdynamics/analysis/dimensions/correlation.py
def correlation_dimension(
    data: Any,
    *,
    theiler: int = 0,
    metric: str | float = "euclidean",
    radii: np.ndarray | None = None,
    n_radii: int = 24,
    min_window: int = 5,
    tol: float = 1.5,
) -> DimensionResult:
    r"""Grassberger--Procaccia correlation dimension :math:`D_2`.

    Computes the correlation sum, then reads :math:`D_2` off the slope of
    :math:`\log C(r)` vs :math:`\log r` in the automatically selected scaling
    region (:func:`~tsdynamics.analysis.dimensions._scaling.fit_scaling_region`).

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    theiler : int, default 0
        Theiler window — exclude pairs with :math:`|i - j| \le w` (see
        :func:`correlation_sum`).  The default ``0`` suits a point set; flow
        users on a densely sampled trajectory should set a Theiler window to
        exclude temporally correlated neighbours, otherwise :math:`D_2` is biased
        downward.
    metric : str or float, default "euclidean"
        Distance metric.
    radii : ndarray, optional
        Explicit radii; default is a data-adaptive log-spaced grid.
    n_radii : int, default 24
        Number of radii when ``radii`` is not given.
    min_window : int, default 5
        Minimum number of radii in the fitted scaling region.
    tol : float, default 1.5
        Scaling-region residual tolerance (see
        :func:`~tsdynamics.analysis.dimensions._scaling.fit_scaling_region`).

    Returns
    -------
    DimensionResult
        ``float(result)`` is :math:`D_2`; the curve and selected window are
        carried for inspection.

    References
    ----------
    P. Grassberger and I. Procaccia, "Characterization of strange attractors",
    *Phys. Rev. Lett.* **50**, 346 (1983).

    Examples
    --------
    >>> d = correlation_dimension(lorenz_traj, theiler=50)   # doctest: +SKIP
    >>> float(d)                                                    # doctest: +SKIP
    2.05...

    Raises
    ------
    InvalidParameterError
        If fewer than :data:`_MIN_CORR_POINTS` points are given — too few to
        resolve a scaling region (it previously returned a spurious ``D_2 ~= 0``).
    """
    # ``_as_points`` rejects <2 points / non-finite first (keeping those messages);
    # then reject a too-short-but-finite handful rather than fabricating a slope.
    # Coerce once and reuse the array for the correlation sum (no double scan).
    points = _as_points(data)
    n = points.shape[0]
    if n < _MIN_CORR_POINTS:
        raise invalid_value(
            "data length",
            n,
            rule=f"must be >= {_MIN_CORR_POINTS} points for a correlation-dimension estimate",
            hint=(
                "the Grassberger-Procaccia correlation sum cannot resolve a scaling "
                "region from so few points; pass a longer trajectory / series."
            ),
        )
    radii, c = _correlation_sum_from_points(
        points,
        radii=radii,
        theiler=theiler,
        metric=metric,
        n_radii=n_radii,
    )
    mask = (c > 0.0) & (c < 1.0)
    if mask.sum() < min_window:
        raise ValueError(
            f"only {int(mask.sum())} usable radii (need >= {min_window}); the correlation sum is "
            "saturated or empty over this grid. Pass a wider/denser `radii` or more data."
        )
    # ``radii`` is returned in the caller's original order (which need not be
    # monotone, since ``radii`` is a public parameter), so sort the masked pair
    # by ascending log-radius: ``fit_scaling_region`` scans contiguous index
    # windows and requires inputs ordered by increasing scale.  Mirrors the
    # ``np.argsort`` pattern in ``generalized.py`` / ``fixedmass.py``.
    x = np.log(radii[mask])
    y = np.log(c[mask])
    order = np.argsort(x)
    x = x[order]
    y = y[order]
    fit = fit_scaling_region(x, y, min_window=min_window, tol=tol)
    return DimensionResult(
        estimate=fit.slope,
        stderr=fit.stderr,
        kind="correlation",
        abscissa=x,
        ordinate=y,
        fit_region=(fit.lo, fit.hi),
        intercept=fit.intercept,
        q=2.0,
        meta={"analysis": "correlation_dimension", "kind": "correlation", "q": 2.0},
    )

correlation_sum

correlation_sum(
    data: Any,
    radii: ndarray | None = None,
    *,
    theiler: int = 0,
    metric: str | float = "euclidean",
    n_radii: int = 24,
) -> tuple[ndarray, ndarray]

Correlation sum :math:C(r) over a grid of radii.

PARAMETER DESCRIPTION
data

The point set (a :class:~tsdynamics.data.Trajectory or a raw array; a 1-D series is treated as a single component).

TYPE: (Trajectory or array - like, shape(N, dim))

radii

Radii at which to evaluate :math:C(r). Default: a data-adaptive log-spaced grid (:func:~tsdynamics.analysis.dimensions._common._default_radii).

TYPE: ndarray DEFAULT: None

theiler

Exclude pairs with :math:|i - j| \le w. Use a few autocorrelation times for densely sampled flows; 0 for already-decorrelated point sets.

TYPE: int DEFAULT: 0

metric

Distance metric ("euclidean", "chebyshev", "manhattan", or a Minkowski exponent).

TYPE: str or float DEFAULT: "euclidean"

n_radii

Number of radii when radii is not given.

TYPE: int DEFAULT: 24

RETURNS DESCRIPTION
(radii, C) : tuple[ndarray, ndarray]

The radii and the corresponding correlation-sum values, normalised so C lies in [0, 1].

RAISES DESCRIPTION
ValueError

If the Theiler window leaves no valid pairs.

Source code in src/tsdynamics/analysis/dimensions/correlation.py
def correlation_sum(
    data: Any,
    radii: np.ndarray | None = None,
    *,
    theiler: int = 0,
    metric: str | float = "euclidean",
    n_radii: int = 24,
) -> tuple[np.ndarray, np.ndarray]:
    r"""Correlation sum :math:`C(r)` over a grid of radii.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set (a :class:`~tsdynamics.data.Trajectory` or a raw array; a
        1-D series is treated as a single component).
    radii : ndarray, optional
        Radii at which to evaluate :math:`C(r)`.  Default: a data-adaptive
        log-spaced grid (:func:`~tsdynamics.analysis.dimensions._common._default_radii`).
    theiler : int, default 0
        Exclude pairs with :math:`|i - j| \le w`.  Use a few autocorrelation
        times for densely sampled flows; 0 for already-decorrelated point sets.
    metric : str or float, default "euclidean"
        Distance metric (``"euclidean"``, ``"chebyshev"``, ``"manhattan"``, or a
        Minkowski exponent).
    n_radii : int, default 24
        Number of radii when ``radii`` is not given.

    Returns
    -------
    (radii, C) : tuple[ndarray, ndarray]
        The radii and the corresponding correlation-sum values, normalised so
        ``C`` lies in ``[0, 1]``.

    Raises
    ------
    ValueError
        If the Theiler window leaves no valid pairs.
    """
    return _correlation_sum_from_points(
        _as_points(data), radii, theiler=theiler, metric=metric, n_radii=n_radii
    )

generalized_dimension

generalized_dimension(
    data: Any,
    q: float = 2.0,
    *,
    scales: ndarray | None = None,
    n_scales: int = 18,
    sat_frac: float = 0.85,
    min_window: int = 5,
    tol: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
) -> DimensionResult

Generalized (Rényi) dimension :math:D_q by box counting.

PARAMETER DESCRIPTION
data

The point set.

TYPE: (Trajectory or array - like, shape(N, dim))

q

Rényi order. q=0 is box-counting, q=1 information, q=2 correlation; non-integer q is allowed. Must be >= 0 — negative orders are rejected (the box-counting estimator is unreliable there).

TYPE: float DEFAULT: 2.0

scales

Box sizes :math:\epsilon. Default: a log-spaced grid spanning the attractor diameter (:func:~tsdynamics.analysis.dimensions._common._default_scales).

TYPE: ndarray DEFAULT: None

n_scales

Number of box sizes when scales is not given.

TYPE: int DEFAULT: 18

sat_frac

Drop scales whose occupied-box count exceeds sat_frac * N — the saturated small-box regime where each box holds about one point and the curve flattens spuriously.

TYPE: float DEFAULT: 0.85

min_window

Minimum number of box sizes in the fitted scaling region.

TYPE: int DEFAULT: 5

tol

Scaling-region residual tolerance.

TYPE: float DEFAULT: 1.5

offsets

Grid-origin offsets (as fractions of each box side) swept per scale; the offset with the fewest occupied boxes (the minimal cover) is kept, which removes the alignment bias of a single fixed grid origin. Pass (0.0,) to recover the naive origin-at-minimum partition.

TYPE: tuple of float DEFAULT: ``(0.0, 0.2, 0.4, 0.6, 0.8)``

RETURNS DESCRIPTION
DimensionResult

float(result) is :math:D_q.

References

H. G. E. Hentschel and I. Procaccia, "The infinite number of generalized dimensions of fractals and strange attractors", Physica D 8, 435 (1983).

RAISES DESCRIPTION
InvalidParameterError

If q < 0: the box-counting partition function is unreliable for negative orders (the rarely-visited boxes dominate).

Source code in src/tsdynamics/analysis/dimensions/generalized.py
def generalized_dimension(
    data: Any,
    q: float = 2.0,
    *,
    scales: np.ndarray | None = None,
    n_scales: int = 18,
    sat_frac: float = 0.85,
    min_window: int = 5,
    tol: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
) -> DimensionResult:
    r"""Generalized (Rényi) dimension :math:`D_q` by box counting.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    q : float, default 2.0
        Rényi order.  ``q=0`` is box-counting, ``q=1`` information, ``q=2``
        correlation; non-integer ``q`` is allowed.  Must be ``>= 0`` — negative
        orders are rejected (the box-counting estimator is unreliable there).
    scales : ndarray, optional
        Box sizes :math:`\epsilon`.  Default: a log-spaced grid spanning the
        attractor diameter
        (:func:`~tsdynamics.analysis.dimensions._common._default_scales`).
    n_scales : int, default 18
        Number of box sizes when ``scales`` is not given.
    sat_frac : float, default 0.85
        Drop scales whose occupied-box count exceeds ``sat_frac * N`` — the
        saturated small-box regime where each box holds about one point and the
        curve flattens spuriously.
    min_window : int, default 5
        Minimum number of box sizes in the fitted scaling region.
    tol : float, default 1.5
        Scaling-region residual tolerance.
    offsets : tuple of float, default ``(0.0, 0.2, 0.4, 0.6, 0.8)``
        Grid-origin offsets (as fractions of each box side) swept per scale; the
        offset with the fewest occupied boxes (the minimal cover) is kept, which
        removes the alignment bias of a single fixed grid origin.  Pass
        ``(0.0,)`` to recover the naive origin-at-minimum partition.

    Returns
    -------
    DimensionResult
        ``float(result)`` is :math:`D_q`.

    References
    ----------
    H. G. E. Hentschel and I. Procaccia, "The infinite number of generalized
    dimensions of fractals and strange attractors", *Physica D* **8**, 435
    (1983).

    Raises
    ------
    InvalidParameterError
        If ``q < 0``: the box-counting partition function is unreliable for
        negative orders (the rarely-visited boxes dominate).
    """
    if q < 0.0:
        raise invalid_value("q", q, rule=_NEGATIVE_Q_RULE, hint=_NEGATIVE_Q_HINT)
    points = _as_points(data)
    n = points.shape[0]
    mins = points.min(axis=0)
    if scales is None:
        scales = _default_scales(points, n_scales=n_scales)
    scales = np.asarray(scales, dtype=float)
    if np.any(scales <= 0.0):
        raise ValueError("box sizes (scales) must be positive.")

    order = np.argsort(scales)
    scales = scales[order]
    occ = [_min_cover_occupancy(points, e, mins, offsets) for e in scales]
    x = np.log(scales)
    y = np.array([_partition_ordinate(c, n, q) for c in occ])
    mask = _informative_mask(occ, n, sat_frac)
    fit = _fit_masked(x, y, mask, min_window=min_window, tol=tol, what=f"D_{q:g}")
    where = np.nonzero(mask)[0]
    lo, hi = int(where[fit.lo]), int(where[fit.hi])
    return DimensionResult(
        estimate=fit.slope,
        stderr=fit.stderr,
        kind="generalized",
        abscissa=x,
        ordinate=y,
        fit_region=(lo, hi),
        intercept=fit.intercept,
        q=float(q),
        meta={"analysis": "generalized_dimension", "kind": "generalized", "q": float(q)},
    )

box_counting_dimension

box_counting_dimension(
    data: Any, **kwargs: Any
) -> DimensionResult

Box-counting (capacity) dimension :math:D_0.

Thin wrapper over :func:generalized_dimension with q=0 — the count of occupied boxes scales as :math:N(\epsilon) \sim \epsilon^{-D_0}.

PARAMETER DESCRIPTION
data

The point set.

TYPE: (Trajectory or array - like, shape(N, dim))

**kwargs

Forwarded to :func:generalized_dimension (scales, n_scales, sat_frac, min_window, tol, offsets).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
DimensionResult

float(result) is :math:D_0.

Source code in src/tsdynamics/analysis/dimensions/generalized.py
def box_counting_dimension(data: Any, **kwargs: Any) -> DimensionResult:
    r"""Box-counting (capacity) dimension :math:`D_0`.

    Thin wrapper over :func:`generalized_dimension` with ``q=0`` — the count of
    occupied boxes scales as :math:`N(\epsilon) \sim \epsilon^{-D_0}`.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    **kwargs
        Forwarded to :func:`generalized_dimension` (``scales``, ``n_scales``,
        ``sat_frac``, ``min_window``, ``tol``, ``offsets``).

    Returns
    -------
    DimensionResult
        ``float(result)`` is :math:`D_0`.
    """
    return generalized_dimension(data, 0.0, **kwargs)

information_dimension

information_dimension(
    data: Any, **kwargs: Any
) -> DimensionResult

Information dimension :math:D_1.

Thin wrapper over :func:generalized_dimension with q=1 — the slope of the Shannon information :math:\sum_i p_i \log p_i against :math:\log \epsilon.

PARAMETER DESCRIPTION
data

The point set.

TYPE: (Trajectory or array - like, shape(N, dim))

**kwargs

Forwarded to :func:generalized_dimension (scales, n_scales, sat_frac, min_window, tol, offsets).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
DimensionResult

float(result) is :math:D_1.

Source code in src/tsdynamics/analysis/dimensions/generalized.py
def information_dimension(data: Any, **kwargs: Any) -> DimensionResult:
    r"""Information dimension :math:`D_1`.

    Thin wrapper over :func:`generalized_dimension` with ``q=1`` — the slope of
    the Shannon information :math:`\sum_i p_i \log p_i` against
    :math:`\log \epsilon`.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    **kwargs
        Forwarded to :func:`generalized_dimension` (``scales``, ``n_scales``,
        ``sat_frac``, ``min_window``, ``tol``, ``offsets``).

    Returns
    -------
    DimensionResult
        ``float(result)`` is :math:`D_1`.
    """
    return generalized_dimension(data, 1.0, **kwargs)

dimension_spectrum

dimension_spectrum(
    data: Any,
    qs: Any = None,
    *,
    scales: ndarray | None = None,
    n_scales: int = 18,
    sat_frac: float = 0.85,
    min_window: int = 5,
    tol: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
) -> dict[float, DimensionResult]

Compute the :math:D_q spectrum over several Rényi orders.

Computes box occupancies once per scale and reuses them across every q, so the whole spectrum costs barely more than a single :math:D_q. For a monofractal the spectrum is flat; a decreasing :math:D_q signals multifractality.

PARAMETER DESCRIPTION
data

The point set.

TYPE: (Trajectory or array - like, shape(N, dim))

qs

Rényi orders. Default: [0, 1, 2, 3, 4, 5].

TYPE: array - like DEFAULT: None

scales

Box sizes; default as in :func:generalized_dimension.

TYPE: ndarray DEFAULT: None

n_scales

As in :func:generalized_dimension. The minimal-cover grid origin is chosen once per scale and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale.

TYPE: int DEFAULT: 18

sat_frac

As in :func:generalized_dimension. The minimal-cover grid origin is chosen once per scale and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale.

TYPE: int DEFAULT: 18

min_window

As in :func:generalized_dimension. The minimal-cover grid origin is chosen once per scale and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale.

TYPE: int DEFAULT: 18

tol

As in :func:generalized_dimension. The minimal-cover grid origin is chosen once per scale and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale.

TYPE: int DEFAULT: 18

offsets

As in :func:generalized_dimension. The minimal-cover grid origin is chosen once per scale and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale.

TYPE: int DEFAULT: 18

RETURNS DESCRIPTION
dict[float, DimensionResult]

{q: DimensionResult} in the order of qs.

Source code in src/tsdynamics/analysis/dimensions/generalized.py
def dimension_spectrum(
    data: Any,
    qs: Any = None,
    *,
    scales: np.ndarray | None = None,
    n_scales: int = 18,
    sat_frac: float = 0.85,
    min_window: int = 5,
    tol: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
) -> dict[float, DimensionResult]:
    r"""Compute the :math:`D_q` spectrum over several Rényi orders.

    Computes box occupancies once per scale and reuses them across every ``q``,
    so the whole spectrum costs barely more than a single :math:`D_q`.  For a
    monofractal the spectrum is flat; a decreasing :math:`D_q` signals
    multifractality.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    qs : array-like, optional
        Rényi orders.  Default: ``[0, 1, 2, 3, 4, 5]``.
    scales : ndarray, optional
        Box sizes; default as in :func:`generalized_dimension`.
    n_scales, sat_frac, min_window, tol, offsets
        As in :func:`generalized_dimension`.  The minimal-cover grid origin is
        chosen once per scale and shared across every ``q``, so the spectrum is
        read from one consistent, alignment-debiased partition per scale.

    Returns
    -------
    dict[float, DimensionResult]
        ``{q: DimensionResult}`` in the order of ``qs``.
    """
    points = _as_points(data)
    n = points.shape[0]
    mins = points.min(axis=0)
    if qs is None:
        qs = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
    qs = [float(q) for q in np.atleast_1d(qs)]
    bad = [q for q in qs if q < 0.0]
    if bad:
        raise invalid_value("q", bad[0], rule=_NEGATIVE_Q_RULE, hint=_NEGATIVE_Q_HINT)
    if scales is None:
        scales = _default_scales(points, n_scales=n_scales)
    scales = np.asarray(scales, dtype=float)
    if np.any(scales <= 0.0):
        raise ValueError("box sizes (scales) must be positive.")

    order = np.argsort(scales)
    scales = scales[order]
    occ = [_min_cover_occupancy(points, e, mins, offsets) for e in scales]
    x = np.log(scales)
    mask = _informative_mask(occ, n, sat_frac)
    where = np.nonzero(mask)[0]

    out: dict[float, DimensionResult] = {}
    for q in qs:
        y = np.array([_partition_ordinate(c, n, q) for c in occ])
        fit = _fit_masked(x, y, mask, min_window=min_window, tol=tol, what=f"D_{q:g}")
        out[q] = DimensionResult(
            estimate=fit.slope,
            stderr=fit.stderr,
            kind="generalized",
            abscissa=x,
            ordinate=y,
            fit_region=(int(where[fit.lo]), int(where[fit.hi])),
            intercept=fit.intercept,
            q=q,
            meta={"analysis": "dimension_spectrum", "kind": "generalized", "q": float(q)},
        )
    return out

fixed_mass_dimension

fixed_mass_dimension(
    data: Any,
    *,
    ks: ndarray | None = None,
    theiler: int = 0,
    metric: str | float = "euclidean",
    n_ref: int | None = 1500,
    n_ks: int = 16,
    min_window: int = 5,
    tol: float = 1.5,
    seed: int = 0,
) -> DimensionResult

Fixed-mass (nearest-neighbour) dimension.

PARAMETER DESCRIPTION
data

The point set.

TYPE: (Trajectory or array - like, shape(N, dim))

ks

Neighbour counts (masses) to probe. Default: a log-spaced integer grid from 1 to N // 10.

TYPE: array-like of int DEFAULT: None

theiler

Exclude neighbours with :math:|i - j| \le w (set for dense flows).

TYPE: int DEFAULT: 0

metric

Distance metric.

TYPE: str or float DEFAULT: "euclidean"

n_ref

Number of reference points averaged over (randomly sub-sampled, seeded). None uses every point.

TYPE: int or None DEFAULT: 1500

n_ks

Number of masses when ks is not given.

TYPE: int DEFAULT: 16

min_window

Minimum number of masses in the fitted scaling region.

TYPE: int DEFAULT: 5

tol

Scaling-region residual tolerance.

TYPE: float DEFAULT: 1.5

seed

Seed for the reference sub-sample (keeps the estimate reproducible).

TYPE: int DEFAULT: 0

RETURNS DESCRIPTION
DimensionResult

float(result) is the dimension; x is :math:\langle\log r_k\rangle and y is the digamma :math:\psi(k) (the unbiased abscissa, not :math:\log k).

Notes

The ordinate is the digamma :math:\psi(k) rather than :math:\log k. For a fixed mass the enclosing radius :math:r_k is a random order statistic, and :math:\langle\log r_k\rangle = D^{-1}\psi(k) + \text{const} holds without the :math:O(1/k) bias that :math:\log k carries at small :math:k (Grassberger 1985; the digamma correction of the Kozachenko–Leonenko nearest-neighbour estimators).

References

R. Badii and A. Politi, "Statistical description of chaotic attractors: The dimension function", J. Stat. Phys. 40, 725 (1985).

P. Grassberger, "Generalizations of the Hausdorff dimension of fractal measures", Phys. Lett. A 107, 101 (1985).

Source code in src/tsdynamics/analysis/dimensions/fixedmass.py
def fixed_mass_dimension(
    data: Any,
    *,
    ks: np.ndarray | None = None,
    theiler: int = 0,
    metric: str | float = "euclidean",
    n_ref: int | None = 1500,
    n_ks: int = 16,
    min_window: int = 5,
    tol: float = 1.5,
    seed: int = 0,
) -> DimensionResult:
    r"""Fixed-mass (nearest-neighbour) dimension.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    ks : array-like of int, optional
        Neighbour counts (masses) to probe.  Default: a log-spaced integer grid
        from 1 to ``N // 10``.
    theiler : int, default 0
        Exclude neighbours with :math:`|i - j| \le w` (set for dense flows).
    metric : str or float, default "euclidean"
        Distance metric.
    n_ref : int or None, default 1500
        Number of reference points averaged over (randomly sub-sampled, seeded).
        ``None`` uses every point.
    n_ks : int, default 16
        Number of masses when ``ks`` is not given.
    min_window : int, default 5
        Minimum number of masses in the fitted scaling region.
    tol : float, default 1.5
        Scaling-region residual tolerance.
    seed : int, default 0
        Seed for the reference sub-sample (keeps the estimate reproducible).

    Returns
    -------
    DimensionResult
        ``float(result)`` is the dimension; ``x`` is :math:`\langle\log r_k\rangle`
        and ``y`` is the digamma :math:`\psi(k)` (the unbiased abscissa, *not*
        :math:`\log k`).

    Notes
    -----
    The ordinate is the digamma :math:`\psi(k)` rather than :math:`\log k`.  For
    a fixed mass the enclosing radius :math:`r_k` is a random order statistic, and
    :math:`\langle\log r_k\rangle = D^{-1}\psi(k) + \text{const}` holds without the
    :math:`O(1/k)` bias that :math:`\log k` carries at small :math:`k` (Grassberger
    1985; the digamma correction of the Kozachenko–Leonenko nearest-neighbour
    estimators).

    References
    ----------
    R. Badii and A. Politi, "Statistical description of chaotic attractors: The
    dimension function", *J. Stat. Phys.* **40**, 725 (1985).

    P. Grassberger, "Generalizations of the Hausdorff dimension of fractal
    measures", *Phys. Lett. A* **107**, 101 (1985).
    """
    from scipy.spatial import cKDTree
    from scipy.special import digamma

    points = _as_points(data)
    n = points.shape[0]
    w = int(theiler)
    if w < 0:
        raise ValueError("theiler must be non-negative.")
    p = _metric_p(metric)

    if ks is None:
        k_hi = max(n // 10, min_window + 1)
        ks = np.unique(np.round(np.logspace(0, np.log10(k_hi), n_ks)).astype(int))
    ks = np.asarray(ks, dtype=int)
    ks = ks[(ks >= 1) & (ks < n)]
    if ks.size < min_window:
        raise ValueError(
            f"only {ks.size} usable masses (need >= {min_window}); supply more data or `ks`."
        )
    k_max = int(ks.max())

    rng = np.random.default_rng(seed)
    if n_ref is not None and n_ref < n:
        ref_index = np.sort(rng.choice(n, size=n_ref, replace=False))
    else:
        ref_index = np.arange(n)

    tree = cKDTree(points)
    # Query enough neighbours that k_max valid ones survive removing the <=2w+1
    # self/near-diagonal matches, with headroom for non-adjacent excluded points.
    k_query = min(n, k_max + 2 * w + 5)
    dists, idx = tree.query(points[ref_index], k=k_query, p=p)
    dists = np.atleast_2d(dists)
    idx = np.atleast_2d(idx)

    # The Theiler-validity mask and its row-wise running count are independent of
    # the mass ``k``, so build them once and reuse across every k (the per-k
    # ``_kth_valid_distances`` only re-tests ``csum == k``) instead of recomputing
    # the full (n_ref, k_query) ``abs``+``cumsum`` for each of the ``n_ks`` masses.
    valid = np.abs(idx - ref_index[:, None]) > w
    csum = np.cumsum(valid, axis=1)

    mean_log_r = np.empty(ks.size)
    for m, k in enumerate(ks):
        rk = _kth_valid_distances(dists, valid, csum, int(k))
        rk = rk[np.isfinite(rk) & (rk > 0.0)]
        if rk.size == 0:
            raise ValueError(
                f"no valid {k}-th neighbour found; increase n_ref or reduce the Theiler window."
            )
        mean_log_r[m] = float(np.mean(np.log(rk)))

    # D is the slope of psi(k) vs <log r_k>; <log r_k> increases with k, so it is
    # the (sorted-ascending) abscissa and the index windows are contiguous in scale.
    # The digamma psi(k) is the unbiased ordinate: <log r_k> = (1/D) psi(k) + const
    # exactly, so log(k) would bias the slope at small k (Grassberger 1985).
    x = mean_log_r
    y = digamma(ks.astype(float))
    order = np.argsort(x)
    x, y = x[order], y[order]
    fit = fit_scaling_region(x, y, min_window=min_window, tol=tol)
    return DimensionResult(
        estimate=fit.slope,
        stderr=fit.stderr,
        kind="fixed_mass",
        abscissa=x,
        ordinate=y,
        fit_region=(fit.lo, fit.hi),
        intercept=fit.intercept,
        q=None,
        meta={"analysis": "fixed_mass_dimension", "kind": "fixed_mass", "q": None},
    )

DimensionResult dataclass

DimensionResult(
    estimate: float = 0.0,
    stderr: float = 0.0,
    abscissa: ndarray = (lambda: empty(0))(),
    ordinate: ndarray = (lambda: empty(0))(),
    fit_region: tuple[int, int] = (0, 0),
    intercept: float = 0.0,
    kind: str = "",
    q: float | None = None,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: ScalingResult

A fractal-dimension estimate with the log--log curve it was read from.

Returned by every estimator in this subpackage. A :class:~tsdynamics.analysis._result.ScalingResult — the dimension is the fitted slope of a log--log curve — so it inherits the canonical estimate / abscissa / ordinate / fit_region schema, the result surface (.meta / .summary() / .to_dict() / the .plot seam) and behaves as the dimension number (float(result) and comparisons). Domain-named @property aliases (:attr:dimension, :attr:x, :attr:y, :attr:fit_slice) preserve the original field names.

ATTRIBUTE DESCRIPTION
estimate

The estimated dimension (the fitted slope). Aliased :attr:dimension.

TYPE: float

stderr

Standard error of the slope over the selected scaling region.

TYPE: float

kind

Which estimator produced it ("correlation", "generalized", "fixed_mass").

TYPE: str

abscissa, ordinate

The log--log curve the slope was fitted to (log-radius vs log-C for the correlation sum; log-scale vs partition ordinate for the generalized dimensions; mean-log-radius vs log-mass for fixed mass). Aliased :attr:x, :attr:y.

TYPE: ndarray

fit_region

Inclusive (lo, hi) indices of the selected scaling region. Aliased :attr:fit_slice.

TYPE: tuple[int, int]

intercept

Intercept of the fitted line.

TYPE: float

q

Rényi order, for the generalized dimensions (2.0 for the correlation sum, None for fixed mass).

TYPE: float or None

dimension property

dimension: float

The estimated dimension (alias of :attr:estimate).

x property

The abscissa of the scaling curve (alias of :attr:abscissa; see :attr:kind).

y property

The ordinate of the scaling curve (alias of :attr:ordinate; see :attr:kind).

fit_slice property

fit_slice: tuple[int, int]

The selected scaling region (alias of :attr:fit_region).

local_slopes property

local_slopes: ndarray

Pointwise local slope of the log--log curve (the diagnostic plateau).

scaling_window property

scaling_window: tuple[float, float]

The (x_lo, x_hi) abscissa span of the selected scaling region.

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this dimension estimate as a backend-agnostic :class:PlotSpec.

Builds a SCALING_FIT spec — the log--log curve as a scatter layer, the selected scaling region highlighted, and the fitted line drawn from :attr:intercept and :attr:dimension — the same schema every scaling estimator emits, so a single result.plot.scaling() renders it. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "scaling_fit"). None uses SCALING_FIT.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/dimensions/_common.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe this dimension estimate as a backend-agnostic :class:`PlotSpec`.

    Builds a ``SCALING_FIT`` spec — the log--log curve as a scatter layer, the
    selected scaling region highlighted, and the fitted line drawn from
    :attr:`intercept` and :attr:`dimension` — the same schema every scaling
    estimator emits, so a single ``result.plot.scaling()`` renders it.  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls a
    plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"scaling_fit"``).  ``None`` uses
        ``SCALING_FIT``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    x = np.asarray(self.x, dtype=float)
    y = np.asarray(self.y, dtype=float)
    # Axis/series labels differ by estimator kind (see the abscissa/ordinate
    # field docstring): correlation = log-radius vs log-C(r); generalized =
    # log-scale vs partition ordinate; fixed_mass = mean-log-radius vs the
    # digamma log-mass.  Unknown kinds fall back to neutral labels.
    xlabel, ylabel = {
        "correlation": (r"$\log r$", r"$\log C(r)$"),
        "generalized": (r"$\log \epsilon$", r"partition ordinate"),
        "fixed_mass": (r"$\langle \log r_k \rangle$", r"$\psi(k)$"),
    }.get(self.kind, (r"$\log$ scale", r"$\log$ measure"))
    q = "" if self.q is None else f" (q={self.q:g})"
    return pb.scaling_fit(
        kind,
        x,
        y,
        fit_region=self.fit_slice,
        slope=self.dimension,
        intercept=self.intercept,
        curve_label=ylabel,
        xlabel=xlabel,
        ylabel=ylabel,
        title=f"{self.kind} dimension{q}  D = {self.dimension:.3f}",
    )

Delay embeddings

State-space reconstruction from a scalar (or multivariate) measurement (Takens, 1981): the time-delay map, plus the delay- and dimension-selection heuristics that parameterise it.

embed

embed(
    data: Any,
    dimension: int | Sequence[int],
    delay: int | Sequence[int],
    *,
    component: int | str | None = None,
) -> Embedding

Time-delay embedding of a scalar series (or a multivariate bundle).

PARAMETER DESCRIPTION
data

The source signal. A 1-D series (or a single selected component of a :class:~tsdynamics.data.Trajectory / 2-D array) gives a univariate embedding. Pass a 2-D (N, d) array, a list of equal-length series, or a multi-component trajectory without component to embed every channel jointly (multivariate embedding).

TYPE: array - like or Trajectory

dimension

Embedding dimension :math:m. A single int applies to every channel; a per-channel sequence sets each channel's dimension (multivariate only). Must be >= 1.

TYPE: int or sequence of int

delay

Delay :math:\tau in samples. A single int applies to every channel; a per-channel sequence sets each channel's delay (multivariate only). Must be >= 1.

TYPE: int or sequence of int

component

Select a single channel from a multi-component data for a univariate embedding. When omitted, a multi-component input is embedded across all of its channels.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
Embedding

The delay-coordinate matrix (behaves as an (M, sum(dimension)) ndarray), one reconstructed state per row, in temporal order. M = N - max_c (m_c - 1) * tau_c is the number of rows for which every channel's full delay window is in range. The columns are grouped by channel: channel c contributes m_c consecutive columns [x_c(i), x_c(i+tau_c), ...].

RAISES DESCRIPTION
ValueError

If dimension/delay are not positive, a per-channel sequence is given for a univariate embedding, or the series is too short for the requested window.

Notes

The matrix is consumable directly by the point-set analyses — e.g. correlation_dimension(embed(x, m, tau)) estimates :math:D_2 of the reconstructed attractor. Rows are index-ordered with the original sampling, so an index-based Theiler window still removes temporally-correlated pairs.

References

F. Takens, "Detecting strange attractors in turbulence", in Dynamical Systems and Turbulence, Lecture Notes in Mathematics 898, 366 (1981).

Examples:

>>> import numpy as np
>>> from tsdynamics.analysis.embedding import embed
>>> x = np.arange(10.0)
>>> y = embed(x, dimension=3, delay=2)
>>> y.shape
(6, 3)
>>> y[0]
array([0., 2., 4.])
Source code in src/tsdynamics/analysis/embedding/embed.py
def embed(
    data: Any,
    dimension: int | Sequence[int],
    delay: int | Sequence[int],
    *,
    component: int | str | None = None,
) -> Embedding:
    r"""Time-delay embedding of a scalar series (or a multivariate bundle).

    Parameters
    ----------
    data : array-like or Trajectory
        The source signal.  A 1-D series (or a single selected ``component`` of a
        :class:`~tsdynamics.data.Trajectory` / 2-D array) gives a univariate
        embedding.  Pass a 2-D ``(N, d)`` array, a list of equal-length series, or
        a multi-component trajectory **without** ``component`` to embed every
        channel jointly (multivariate embedding).
    dimension : int or sequence of int
        Embedding dimension :math:`m`.  A single int applies to every channel; a
        per-channel sequence sets each channel's dimension (multivariate only).
        Must be ``>= 1``.
    delay : int or sequence of int
        Delay :math:`\tau` in samples.  A single int applies to every channel; a
        per-channel sequence sets each channel's delay (multivariate only).  Must
        be ``>= 1``.
    component : int or str, optional
        Select a single channel from a multi-component ``data`` for a univariate
        embedding.  When omitted, a multi-component input is embedded across all
        of its channels.

    Returns
    -------
    Embedding
        The delay-coordinate matrix (behaves as an ``(M, sum(dimension))``
        ``ndarray``), one reconstructed state per row, in temporal
        order.  ``M = N - max_c (m_c - 1) * tau_c`` is the number of rows for which
        every channel's full delay window is in range.  The columns are grouped by
        channel: channel ``c`` contributes ``m_c`` consecutive columns
        ``[x_c(i), x_c(i+tau_c), ...]``.

    Raises
    ------
    ValueError
        If ``dimension``/``delay`` are not positive, a per-channel sequence is
        given for a univariate embedding, or the series is too short for the
        requested window.

    Notes
    -----
    The matrix is consumable directly by the point-set analyses — e.g.
    ``correlation_dimension(embed(x, m, tau))`` estimates :math:`D_2` of the
    reconstructed attractor.  Rows are index-ordered with the original sampling,
    so an index-based Theiler window still removes temporally-correlated pairs.

    References
    ----------
    F. Takens, "Detecting strange attractors in turbulence", in *Dynamical
    Systems and Turbulence*, Lecture Notes in Mathematics **898**, 366 (1981).

    Examples
    --------
    >>> import numpy as np
    >>> from tsdynamics.analysis.embedding import embed
    >>> x = np.arange(10.0)
    >>> y = embed(x, dimension=3, delay=2)
    >>> y.shape
    (6, 3)
    >>> y[0]
    array([0., 2., 4.])
    """
    # Univariate path: a 1-D series, or an explicitly selected single component.
    univariate = component is not None or _looks_univariate(data)
    if univariate:
        if not isinstance(dimension, (int, np.integer)):
            raise ValueError("a per-channel `dimension` sequence needs a multivariate input.")
        if not isinstance(delay, (int, np.integer)):
            raise ValueError("a per-channel `delay` sequence needs a multivariate input.")
        series = _as_series(data, component=component)
        embedded = _embed_single(series, int(dimension), int(delay))
        return Embedding(values=embedded, meta=_embed_meta(dimension, delay))

    channels = _as_channels(data)
    n_channels = channels.shape[1]
    dims = _as_per_channel(dimension, n_channels, "dimension")
    delays = _as_per_channel(delay, n_channels, "delay")

    n = channels.shape[0]
    spans = [(m - 1) * tau for m, tau in zip(dims, delays, strict=True)]
    _validate(dims, delays, n, max(spans))
    rows = n - max(spans)

    blocks = [_embed_single(channels[:, c], dims[c], delays[c])[:rows] for c in range(n_channels)]
    embedded = np.ascontiguousarray(np.hstack(blocks))
    return Embedding(values=embedded, meta=_embed_meta(dimension, delay))

Delay selection

optimal_delay

optimal_delay(
    data: Any,
    *,
    method: str = "mi",
    max_delay: int = 50,
    bins: int | None = None,
    component: int | str | None = None,
) -> CountResult

Recommend an embedding delay :math:\tau (in samples).

PARAMETER DESCRIPTION
data

The scalar series (or a selected component).

TYPE: array - like or Trajectory

method
  • "mi" — first local minimum of the time-delayed mutual information (Fraser & Swinney); the recommended nonlinear criterion.
  • "acf" — first lag where the autocorrelation falls to 1/e.
  • "acf_zero" — first lag where the autocorrelation crosses zero.

TYPE: ('mi', 'acf', 'acf_zero') DEFAULT: "mi"

max_delay

Largest lag considered.

TYPE: int DEFAULT: 50

bins

Histogram bins for the mutual-information estimate (method="mi").

TYPE: int DEFAULT: None

component

Component selector for a multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
CountResult

The recommended delay (behaves as an int), always >= 1.

RAISES DESCRIPTION
ValueError

If method is not one of "mi" / "acf" / "acf_zero", or if the underlying curve estimator rejects the series (constant input, bad max_delay / bins).

Notes

If no first minimum / crossing is found within max_delay (e.g. a slowly-decaying curve), the criterion's global fallback is used — the location of the smallest mutual information, or max_delay for the autocorrelation rules — so a usable delay is always returned.

The mutual-information criterion is the more widely recommended nonlinear measure (Fraser & Swinney 1986); the autocorrelation rules are the classic linear alternatives.

The first local minimum is taken at the onset of the dip: on a flat-bottomed (quantised) mutual-information valley the recommended :math:\tau is the first lag of the plateau, not its far edge — so the delay is not over-estimated on coarsely sampled curves.

References

A. M. Fraser and H. L. Swinney, "Independent coordinates for strange attractors from mutual information", Phys. Rev. A 33, 1134 (1986).

Examples:

>>> import numpy as np
>>> from tsdynamics.analysis.embedding import optimal_delay
>>> t = np.linspace(0.0, 100.0, 4000)
>>> x = np.sin(2.0 * np.pi * 0.05 * t)
>>> int(optimal_delay(x, method="mi", max_delay=60)) >= 1
True
Source code in src/tsdynamics/analysis/embedding/delay.py
def optimal_delay(
    data: Any,
    *,
    method: str = "mi",
    max_delay: int = 50,
    bins: int | None = None,
    component: int | str | None = None,
) -> CountResult:
    r"""Recommend an embedding delay :math:`\tau` (in samples).

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``component``).
    method : {"mi", "acf", "acf_zero"}, default "mi"
        - ``"mi"`` — first local minimum of the time-delayed mutual information
          (Fraser & Swinney); the recommended nonlinear criterion.
        - ``"acf"`` — first lag where the autocorrelation falls to ``1/e``.
        - ``"acf_zero"`` — first lag where the autocorrelation crosses zero.
    max_delay : int, default 50
        Largest lag considered.
    bins : int, optional
        Histogram bins for the mutual-information estimate (``method="mi"``).
    component : int or str, optional
        Component selector for a multi-component input.

    Returns
    -------
    CountResult
        The recommended delay (behaves as an ``int``), always ``>= 1``.

    Raises
    ------
    ValueError
        If ``method`` is not one of ``"mi"`` / ``"acf"`` / ``"acf_zero"``, or if
        the underlying curve estimator rejects the series (constant input, bad
        ``max_delay`` / ``bins``).

    Notes
    -----
    If no first minimum / crossing is found within ``max_delay`` (e.g. a
    slowly-decaying curve), the criterion's global fallback is used — the
    location of the smallest mutual information, or ``max_delay`` for the
    autocorrelation rules — so a usable delay is always returned.

    The mutual-information criterion is the more widely recommended nonlinear
    measure (Fraser & Swinney 1986); the autocorrelation rules are the classic
    linear alternatives.

    The first local minimum is taken at the **onset** of the dip: on a
    flat-bottomed (quantised) mutual-information valley the recommended
    :math:`\tau` is the first lag of the plateau, not its far edge — so the
    delay is not over-estimated on coarsely sampled curves.

    References
    ----------
    A. M. Fraser and H. L. Swinney, "Independent coordinates for strange
    attractors from mutual information", *Phys. Rev. A* **33**, 1134 (1986).

    Examples
    --------
    >>> import numpy as np
    >>> from tsdynamics.analysis.embedding import optimal_delay
    >>> t = np.linspace(0.0, 100.0, 4000)
    >>> x = np.sin(2.0 * np.pi * 0.05 * t)
    >>> int(optimal_delay(x, method="mi", max_delay=60)) >= 1
    True
    """
    method = method.lower()
    if method == "mi":
        mi = np.asarray(
            mutual_information(data, max_delay=max_delay, bins=bins, component=component)
        )
        k = _first_local_min(mi)
        if k is None:  # monotone / no interior dip: fall back to the global minimum
            k = int(np.argmin(mi[1:])) + 1 if mi.size > 1 else 1
        tau = max(int(k), 1)
    elif method in ("acf", "acf_zero"):
        acf = autocorrelation(data, max_delay=max_delay, component=component)
        if method == "acf":
            below = np.flatnonzero(acf[1:] <= 1.0 / np.e)
        else:
            below = np.flatnonzero(acf[1:] <= 0.0)
        # never crosses → longest lag available
        tau = int(below[0]) + 1 if below.size else max(int(acf.size) - 1, 1)
    else:
        raise ValueError(f"unknown method {method!r}; use 'mi', 'acf', or 'acf_zero'.")

    return CountResult(value=int(tau), meta={"analysis": "optimal_delay", "method": method})

mutual_information

mutual_information(
    data: Any,
    *,
    max_delay: int = 50,
    bins: int | None = None,
    base: float = e,
    component: int | str | None = None,
) -> MutualInformation

Time-delayed mutual information :math:I(\tau) up to max_delay.

The histogram estimator of

.. math::

I(\tau) = \sum_{a,b} p_{ab}(\tau)\,
          \log\frac{p_{ab}(\tau)}{p_a\, p_b},

where :math:p_{ab} is the joint distribution of :math:(x_i, x_{i+\tau}) over an equal-width 2-D histogram and :math:p_a, p_b its marginals.

PARAMETER DESCRIPTION
data

The scalar series (or a selected component).

TYPE: array - like or Trajectory

max_delay

Largest lag returned. Clamped to N - 2.

TYPE: int DEFAULT: 50

bins

Number of histogram bins per axis. Default: a sample-size-dependent rule, clip(sqrt(N/5), 16, 128).

TYPE: int DEFAULT: None

base

Logarithm base — e for nats, 2 for bits. Only rescales the curve; the location of the first minimum is unaffected.

TYPE: float DEFAULT: ``e``

component

Component selector for a multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
MutualInformation

Behaves as an (max_delay + 1,) ndarray: mi[k] is :math:I(k); mi[0] is the entropy of the (binned) series itself (its self-information). result.optimal_lag reads off the first-minimum delay and result.plot() renders the diagnostic.

RAISES DESCRIPTION
ValueError

If max_delay is negative, bins is less than 2, or the series is constant (its range collapses, leaving the histogram undefined).

References

A. M. Fraser and H. L. Swinney, "Independent coordinates for strange attractors from mutual information", Phys. Rev. A 33, 1134 (1986).

Examples:

>>> import numpy as np
>>> from tsdynamics.analysis.embedding import mutual_information
>>> t = np.linspace(0.0, 100.0, 2000)
>>> x = np.sin(2.0 * np.pi * 0.1 * t)
>>> mi = mutual_information(x, max_delay=40)
>>> int(mi.optimal_lag) >= 1
True
Source code in src/tsdynamics/analysis/embedding/delay.py
def mutual_information(
    data: Any,
    *,
    max_delay: int = 50,
    bins: int | None = None,
    base: float = np.e,
    component: int | str | None = None,
) -> MutualInformation:
    r"""Time-delayed mutual information :math:`I(\tau)` up to ``max_delay``.

    The histogram estimator of

    .. math::

        I(\tau) = \sum_{a,b} p_{ab}(\tau)\,
                  \log\frac{p_{ab}(\tau)}{p_a\, p_b},

    where :math:`p_{ab}` is the joint distribution of :math:`(x_i, x_{i+\tau})`
    over an equal-width 2-D histogram and :math:`p_a, p_b` its marginals.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``component``).
    max_delay : int, default 50
        Largest lag returned.  Clamped to ``N - 2``.
    bins : int, optional
        Number of histogram bins per axis.  Default: a sample-size-dependent
        rule, ``clip(sqrt(N/5), 16, 128)``.
    base : float, default ``e``
        Logarithm base — ``e`` for nats, ``2`` for bits.  Only rescales the
        curve; the location of the first minimum is unaffected.
    component : int or str, optional
        Component selector for a multi-component input.

    Returns
    -------
    MutualInformation
        Behaves as an ``(max_delay + 1,)`` ``ndarray``: ``mi[k]`` is
        :math:`I(k)`; ``mi[0]`` is the entropy of the (binned) series itself
        (its self-information).  ``result.optimal_lag`` reads off the
        first-minimum delay and ``result.plot()`` renders the diagnostic.

    Raises
    ------
    ValueError
        If ``max_delay`` is negative, ``bins`` is less than ``2``, or the series
        is constant (its range collapses, leaving the histogram undefined).

    References
    ----------
    A. M. Fraser and H. L. Swinney, "Independent coordinates for strange
    attractors from mutual information", *Phys. Rev. A* **33**, 1134 (1986).

    Examples
    --------
    >>> import numpy as np
    >>> from tsdynamics.analysis.embedding import mutual_information
    >>> t = np.linspace(0.0, 100.0, 2000)
    >>> x = np.sin(2.0 * np.pi * 0.1 * t)
    >>> mi = mutual_information(x, max_delay=40)
    >>> int(mi.optimal_lag) >= 1
    True
    """
    x = _as_series(data, component=component)
    n = x.size
    max_delay = int(max_delay)
    if max_delay < 0:
        raise ValueError("max_delay must be non-negative.")
    max_delay = min(max_delay, n - 2)
    nbins = int(bins) if bins is not None else _auto_bins(n)
    if nbins < 2:
        raise ValueError("bins must be >= 2.")
    log = np.log if base == np.e else (lambda v: np.log(v) / np.log(base))

    # A shared, fixed bin grid over the series range keeps marginals consistent
    # across lags (Fraser--Swinney use one partition of the data range).
    lo, hi = float(x.min()), float(x.max())
    if hi <= lo:
        raise ValueError("series is constant; mutual information is undefined.")
    edges = np.linspace(lo, hi, nbins + 1)
    # Pre-bin every sample once; the lagged pair (a, b) just indexes shifted views.
    codes = np.clip(np.digitize(x, edges[1:-1]), 0, nbins - 1)

    mi = np.empty(max_delay + 1, dtype=float)
    for tau in range(max_delay + 1):
        a = codes[: n - tau]
        b = codes[tau:] if tau > 0 else codes
        # Flatten the (a, b) integer bin pair to a single linear index and count
        # with bincount — the joint 2-D histogram, no per-element scatter.  a and
        # b are already integer bin codes in [0, nbins), so a*nbins + b is the
        # row-major flat index; this is bit-equivalent to np.add.at but vectorised.
        flat = np.bincount(a * nbins + b, minlength=nbins * nbins)
        joint = flat.reshape(nbins, nbins).astype(float)
        total = joint.sum()
        joint /= total
        p_a = joint.sum(axis=1)
        p_b = joint.sum(axis=0)
        mask = joint > 0.0
        outer = p_a[:, None] * p_b[None, :]
        mi[tau] = float(np.sum(joint[mask] * log(joint[mask] / outer[mask])))
    return MutualInformation(
        values=mi, meta={"analysis": "mutual_information", "max_delay": max_delay}
    )

autocorrelation

autocorrelation(
    data: Any,
    *,
    max_delay: int = 50,
    component: int | str | None = None,
) -> ndarray

Normalised autocorrelation function up to max_delay.

PARAMETER DESCRIPTION
data

The scalar series (or a selected component).

TYPE: array - like or Trajectory

max_delay

Largest lag returned. Clamped to N - 1.

TYPE: int DEFAULT: 50

component

Component selector for a multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
(ndarray, shape(max_delay + 1))

acf[k] is the autocorrelation at lag k (acf[0] == 1).

RAISES DESCRIPTION
ValueError

If max_delay is negative, or the series is constant (zero variance, so the autocorrelation is undefined).

Notes

Computed via FFT (Wiener--Khinchin) on the mean-subtracted series. This is the standard biased autocorrelation estimator — each lag is normalised by the full zero-lag variance (not by the shrinking overlap count at that lag) — which is positive-definite and the conventional choice for delay selection.

Source code in src/tsdynamics/analysis/embedding/delay.py
def autocorrelation(
    data: Any, *, max_delay: int = 50, component: int | str | None = None
) -> np.ndarray:
    r"""Normalised autocorrelation function up to ``max_delay``.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``component``).
    max_delay : int, default 50
        Largest lag returned.  Clamped to ``N - 1``.
    component : int or str, optional
        Component selector for a multi-component input.

    Returns
    -------
    ndarray, shape (max_delay + 1,)
        ``acf[k]`` is the autocorrelation at lag ``k`` (``acf[0] == 1``).

    Raises
    ------
    ValueError
        If ``max_delay`` is negative, or the series is constant (zero variance,
        so the autocorrelation is undefined).

    Notes
    -----
    Computed via FFT (Wiener--Khinchin) on the mean-subtracted series.  This is
    the standard **biased** autocorrelation estimator — each lag is normalised by
    the full zero-lag variance (not by the shrinking overlap count at that lag) —
    which is positive-definite and the conventional choice for delay selection.
    """
    x = _as_series(data, component=component)
    n = x.size
    max_delay = int(max_delay)
    if max_delay < 0:
        raise ValueError("max_delay must be non-negative.")
    max_delay = min(max_delay, n - 1)

    x = x - x.mean()
    var = float(x @ x)
    if var == 0.0:
        raise ValueError("series is constant; autocorrelation is undefined.")

    # Linear (non-circular) autocorrelation via zero-padded FFT.
    size = int(2 ** np.ceil(np.log2(2 * n - 1)))
    f = np.fft.rfft(x, size)
    acf_full = np.fft.irfft(f * np.conj(f), size)[: max_delay + 1]
    return acf_full / var

Dimension selection

embedding_dimension

embedding_dimension(
    data: Any,
    *,
    method: str = "cao",
    delay: int = 1,
    max_dim: int = 10,
    component: int | str | None = None,
    **kwargs: Any,
) -> EmbeddingDimension

Estimate the minimum embedding dimension by the chosen method.

PARAMETER DESCRIPTION
data

The scalar series (or a selected component).

TYPE: array - like or Trajectory

method

"cao" → :func:cao_dimension; "fnn" → :func:false_nearest_neighbors.

TYPE: ('cao', 'fnn') DEFAULT: "cao"

delay

Embedding delay in samples.

TYPE: int DEFAULT: 1

max_dim

Largest dimension evaluated.

TYPE: int DEFAULT: 10

component

Component selector for a multi-component input.

TYPE: int or str DEFAULT: None

**kwargs

Forwarded to the selected estimator (threshold, theiler, and for FNN rtol / atol).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
EmbeddingDimension
RAISES DESCRIPTION
ValueError

If method is neither "cao" nor "fnn", or the selected estimator rejects its arguments (see :func:cao_dimension / :func:false_nearest_neighbors).

See Also

cao_dimension : Cao's averaged false-neighbour estimator. false_nearest_neighbors : Kennel's false-nearest-neighbour estimator.

Source code in src/tsdynamics/analysis/embedding/dimension.py
def embedding_dimension(
    data: Any,
    *,
    method: str = "cao",
    delay: int = 1,
    max_dim: int = 10,
    component: int | str | None = None,
    **kwargs: Any,
) -> EmbeddingDimension:
    """Estimate the minimum embedding dimension by the chosen method.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``component``).
    method : {"cao", "fnn"}, default "cao"
        ``"cao"`` → :func:`cao_dimension`; ``"fnn"`` → :func:`false_nearest_neighbors`.
    delay : int, default 1
        Embedding delay in samples.
    max_dim : int, default 10
        Largest dimension evaluated.
    component : int or str, optional
        Component selector for a multi-component input.
    **kwargs
        Forwarded to the selected estimator (``threshold``, ``theiler``,
        and for FNN ``rtol`` / ``atol``).

    Returns
    -------
    EmbeddingDimension

    Raises
    ------
    ValueError
        If ``method`` is neither ``"cao"`` nor ``"fnn"``, or the selected
        estimator rejects its arguments (see :func:`cao_dimension` /
        :func:`false_nearest_neighbors`).

    See Also
    --------
    cao_dimension : Cao's averaged false-neighbour estimator.
    false_nearest_neighbors : Kennel's false-nearest-neighbour estimator.
    """
    method = method.lower()
    if method == "cao":
        return cao_dimension(data, delay=delay, max_dim=max_dim, component=component, **kwargs)
    if method == "fnn":
        return false_nearest_neighbors(
            data, delay=delay, max_dim=max_dim, component=component, **kwargs
        )
    raise ValueError(f"unknown method {method!r}; use 'cao' or 'fnn'.")

cao_dimension

cao_dimension(
    data: Any,
    *,
    delay: int = 1,
    max_dim: int = 10,
    threshold: float = 0.9,
    theiler: int = 0,
    component: int | str | None = None,
) -> EmbeddingDimension

Cao's averaged-false-neighbour minimum embedding dimension.

PARAMETER DESCRIPTION
data

The scalar series (or a selected component).

TYPE: array - like or Trajectory

delay

Embedding delay :math:\tau in samples (use :func:~tsdynamics.analysis.embedding.delay.optimal_delay).

TYPE: int DEFAULT: 1

max_dim

Largest dimension :math:d at which :math:E_1 is evaluated.

TYPE: int DEFAULT: 10

threshold

Saturation threshold: the estimate is the smallest :math:d with :math:E_1(d) \ge threshold (the onset of the plateau at 1).

TYPE: float DEFAULT: 0.9

theiler

Exclude temporally-close neighbours with :math:|i-j| \le w. Set a few autocorrelation times for densely sampled flows.

TYPE: int DEFAULT: 0

component

Component selector for a multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
EmbeddingDimension

int(result) is the recommended :math:m; result.afn_e1 / result.afn_e2 carry the curves.

RAISES DESCRIPTION
ValueError

If delay is less than 1, max_dim is less than 2, the series is too short for the requested window, or too few valid neighbours survive the Theiler exclusion at some dimension.

Notes

The d = 1 point of the returned :math:E_1 / :math:E_2 curves (afn_e1[0] / afn_e2[0]) is diagnostically unreliable. At :math:d = 1 the base distance :math:R_d is the 1-D nearest-neighbour gap :math:|x_i - x_n|; on a densely sampled series, coincident-but-distinct samples make :math:R_d arbitrarily small, so the per-point ratio :math:a(i, 1) = R_{d+1}/R_d can blow up and inflate the mean by orders of magnitude. This corrupts only the first point of the diagnostic curve and does not affect the recommended dimension :math:m, which is read off the saturation plateau past :math:d = 1 (use a Theiler window of a few autocorrelation times to mitigate the artefact).

References

L. Cao, "Practical method for determining the minimum embedding dimension of a scalar time series", Physica D 110, 43 (1997).

Examples:

>>> import numpy as np
>>> from tsdynamics.analysis.embedding import cao_dimension
>>> t = np.linspace(0.0, 200.0, 4000)
>>> x = np.sin(t) + 0.5 * np.sin(2.0 * t)
>>> int(cao_dimension(x, delay=10, max_dim=8)) >= 2
True
Source code in src/tsdynamics/analysis/embedding/dimension.py
def cao_dimension(
    data: Any,
    *,
    delay: int = 1,
    max_dim: int = 10,
    threshold: float = 0.9,
    theiler: int = 0,
    component: int | str | None = None,
) -> EmbeddingDimension:
    r"""Cao's averaged-false-neighbour minimum embedding dimension.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``component``).
    delay : int, default 1
        Embedding delay :math:`\tau` in samples (use
        :func:`~tsdynamics.analysis.embedding.delay.optimal_delay`).
    max_dim : int, default 10
        Largest dimension :math:`d` at which :math:`E_1` is evaluated.
    threshold : float, default 0.9
        Saturation threshold: the estimate is the smallest :math:`d` with
        :math:`E_1(d) \ge` ``threshold`` (the onset of the plateau at 1).
    theiler : int, default 0
        Exclude temporally-close neighbours with :math:`|i-j| \le w`.  Set a few
        autocorrelation times for densely sampled flows.
    component : int or str, optional
        Component selector for a multi-component input.

    Returns
    -------
    EmbeddingDimension
        ``int(result)`` is the recommended :math:`m`; ``result.afn_e1`` /
        ``result.afn_e2`` carry the curves.

    Raises
    ------
    ValueError
        If ``delay`` is less than ``1``, ``max_dim`` is less than ``2``, the
        series is too short for the requested window, or too few valid
        neighbours survive the Theiler exclusion at some dimension.

    Notes
    -----
    The ``d = 1`` point of the returned :math:`E_1` / :math:`E_2` curves
    (``afn_e1[0]`` / ``afn_e2[0]``) is **diagnostically unreliable**.  At
    :math:`d = 1` the base distance :math:`R_d` is the 1-D nearest-neighbour gap
    :math:`|x_i - x_n|`; on a densely sampled series, coincident-but-distinct
    samples make :math:`R_d` arbitrarily small, so the per-point ratio
    :math:`a(i, 1) = R_{d+1}/R_d` can blow up and inflate the mean by orders of
    magnitude.  This corrupts only the *first* point of the diagnostic curve and
    does **not** affect the recommended dimension :math:`m`, which is read off the
    saturation plateau past :math:`d = 1` (use a Theiler window of a few
    autocorrelation times to mitigate the artefact).

    References
    ----------
    L. Cao, "Practical method for determining the minimum embedding dimension of
    a scalar time series", *Physica D* **110**, 43 (1997).

    Examples
    --------
    >>> import numpy as np
    >>> from tsdynamics.analysis.embedding import cao_dimension
    >>> t = np.linspace(0.0, 200.0, 4000)
    >>> x = np.sin(t) + 0.5 * np.sin(2.0 * t)
    >>> int(cao_dimension(x, delay=10, max_dim=8)) >= 2
    True
    """
    x = _as_series(data, component=component)
    tau, max_dim = int(delay), int(max_dim)
    if tau < 1:
        raise ValueError("delay must be >= 1.")
    if max_dim < 2:
        raise ValueError("max_dim must be >= 2 (E1 compares consecutive dimensions).")
    w = int(theiler)

    big = max_dim + 1  # need E up to d = max_dim + 1 to form E1(max_dim)
    rows = x.size - big * tau
    _require_rows(rows, max_dim, tau)
    cols = _delay_columns(x, big + 1, tau, rows)  # columns 0 .. max_dim+1

    e = np.full(big + 1, np.nan)  # E(d),  d = 1 .. big
    estar = np.full(big + 1, np.nan)  # E*(d), d = 1 .. big
    for d in range(1, big + 1):
        nn, dist_d, has = _nearest_neighbor(cols[:, :d], p=np.inf, theiler=w)
        extra = np.abs(cols[:, d] - cols[nn, d])  # |x_{i+dτ} - x_{n+dτ}|
        valid = has & (dist_d > 0.0)
        if valid.sum() < 5:
            raise ValueError(
                f"too few valid neighbours at dimension {d} "
                f"(Theiler window {w} too large, or degenerate data)."
            )
        dist_d1 = np.maximum(dist_d[valid], extra[valid])
        e[d] = float(np.mean(dist_d1 / dist_d[valid]))
        estar[d] = float(np.mean(extra[valid]))

    dims = np.arange(1, max_dim + 1)
    e1 = np.array([e[d + 1] / e[d] for d in dims])
    e2 = np.array([estar[d + 1] / estar[d] for d in dims])

    reached = np.flatnonzero(e1 >= threshold)
    m_star = int(dims[reached[0]]) if reached.size else int(dims[int(np.argmax(e1))])
    return EmbeddingDimension(
        dimension=m_star,
        dims=dims,
        method="cao",
        delay=tau,
        afn_e1=e1,
        afn_e2=e2,
        meta={"analysis": "cao_dimension", "method": "cao", "delay": int(tau)},
    )

false_nearest_neighbors

false_nearest_neighbors(
    data: Any,
    *,
    delay: int = 1,
    max_dim: int = 10,
    rtol: float = 15.0,
    atol: float = 2.0,
    threshold: float = 0.01,
    theiler: int = 0,
    component: int | str | None = None,
) -> EmbeddingDimension

Kennel's false-nearest-neighbour minimum embedding dimension.

A neighbour found in dimension :math:d is false when extending to :math:d+1 either stretches the pair by more than rtol relative to their :math:d-dimensional distance, or pushes them apart by more than atol relative to the attractor size :math:R_A (the series' standard deviation) — the second test catching neighbours whose :math:d-dimensional distance is essentially zero.

PARAMETER DESCRIPTION
data

The scalar series (or a selected component).

TYPE: array - like or Trajectory

delay

Embedding delay :math:\tau in samples.

TYPE: int DEFAULT: 1

max_dim

Largest dimension evaluated.

TYPE: int DEFAULT: 10

rtol

First-criterion tolerance :math:R_{\text{tol}} (Kennel et al. 1992).

TYPE: float DEFAULT: 15.0

atol

Second-criterion tolerance :math:A_{\text{tol}}.

TYPE: float DEFAULT: 2.0

threshold

The estimate is the smallest :math:d whose false-neighbour fraction is <= threshold.

TYPE: float DEFAULT: 0.01

theiler

Exclude temporally-close neighbours with :math:|i-j| \le w.

TYPE: int DEFAULT: 0

component

Component selector for a multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
EmbeddingDimension

int(result) is the recommended :math:m; result.fnn_fraction carries the decay curve.

RAISES DESCRIPTION
ValueError

If delay is less than 1, max_dim is less than 1, the series is constant, the series is too short for the requested window, or too few valid neighbours survive the Theiler exclusion at some dimension.

References

M. B. Kennel, R. Brown and H. D. I. Abarbanel, "Determining embedding dimension for phase-space reconstruction using a geometrical construction", Phys. Rev. A 45, 3403 (1992).

Examples:

>>> import numpy as np
>>> from tsdynamics.analysis.embedding import false_nearest_neighbors
>>> t = np.linspace(0.0, 200.0, 4000)
>>> x = np.sin(t) + 0.5 * np.sin(2.0 * t)
>>> int(false_nearest_neighbors(x, delay=10, max_dim=8)) >= 2
True
Source code in src/tsdynamics/analysis/embedding/dimension.py
def false_nearest_neighbors(
    data: Any,
    *,
    delay: int = 1,
    max_dim: int = 10,
    rtol: float = 15.0,
    atol: float = 2.0,
    threshold: float = 0.01,
    theiler: int = 0,
    component: int | str | None = None,
) -> EmbeddingDimension:
    r"""Kennel's false-nearest-neighbour minimum embedding dimension.

    A neighbour found in dimension :math:`d` is *false* when extending to
    :math:`d+1` either stretches the pair by more than ``rtol`` relative to their
    :math:`d`-dimensional distance, or pushes them apart by more than ``atol``
    relative to the attractor size :math:`R_A` (the series' standard deviation) —
    the second test catching neighbours whose :math:`d`-dimensional distance is
    essentially zero.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``component``).
    delay : int, default 1
        Embedding delay :math:`\tau` in samples.
    max_dim : int, default 10
        Largest dimension evaluated.
    rtol : float, default 15.0
        First-criterion tolerance :math:`R_{\text{tol}}` (Kennel et al. 1992).
    atol : float, default 2.0
        Second-criterion tolerance :math:`A_{\text{tol}}`.
    threshold : float, default 0.01
        The estimate is the smallest :math:`d` whose false-neighbour fraction is
        ``<= threshold``.
    theiler : int, default 0
        Exclude temporally-close neighbours with :math:`|i-j| \le w`.
    component : int or str, optional
        Component selector for a multi-component input.

    Returns
    -------
    EmbeddingDimension
        ``int(result)`` is the recommended :math:`m`; ``result.fnn_fraction``
        carries the decay curve.

    Raises
    ------
    ValueError
        If ``delay`` is less than ``1``, ``max_dim`` is less than ``1``, the
        series is constant, the series is too short for the requested window, or
        too few valid neighbours survive the Theiler exclusion at some dimension.

    References
    ----------
    M. B. Kennel, R. Brown and H. D. I. Abarbanel, "Determining embedding
    dimension for phase-space reconstruction using a geometrical construction",
    *Phys. Rev. A* **45**, 3403 (1992).

    Examples
    --------
    >>> import numpy as np
    >>> from tsdynamics.analysis.embedding import false_nearest_neighbors
    >>> t = np.linspace(0.0, 200.0, 4000)
    >>> x = np.sin(t) + 0.5 * np.sin(2.0 * t)
    >>> int(false_nearest_neighbors(x, delay=10, max_dim=8)) >= 2
    True
    """
    x = _as_series(data, component=component)
    tau, max_dim = int(delay), int(max_dim)
    if tau < 1:
        raise ValueError("delay must be >= 1.")
    if max_dim < 1:
        raise ValueError("max_dim must be >= 1.")
    w = int(theiler)

    r_attractor = float(np.std(x))
    if r_attractor == 0.0:
        raise ValueError("series is constant; the false-neighbour test is undefined.")

    rows = x.size - max_dim * tau  # need column max_dim → x[max_dim*tau : ...]
    _require_rows(rows, max_dim, tau)
    cols = _delay_columns(x, max_dim + 1, tau, rows)  # columns 0 .. max_dim

    dims = np.arange(1, max_dim + 1)
    fractions = np.empty(dims.size, dtype=float)
    for k, d in enumerate(dims):
        nn, r_d, has = _nearest_neighbor(cols[:, :d], p=2.0, theiler=w)
        extra = np.abs(cols[:, d] - cols[nn, d])
        if has.sum() < 5:
            raise ValueError(
                f"too few valid neighbours at dimension {d} "
                f"(Theiler window {w} too large, or degenerate data)."
            )
        # Criterion 1 written as a product (no division) so R_d == 0 is handled:
        # an extra-coordinate jump with zero base distance is a false neighbour.
        crit1 = extra[has] > rtol * r_d[has]
        r_d1 = np.sqrt(r_d[has] ** 2 + extra[has] ** 2)
        crit2 = r_d1 > atol * r_attractor
        fractions[k] = float(np.mean(crit1 | crit2))

    reached = np.flatnonzero(fractions <= threshold)
    m_star = int(dims[reached[0]]) if reached.size else int(dims[int(np.argmin(fractions))])
    return EmbeddingDimension(
        dimension=m_star,
        dims=dims,
        method="fnn",
        delay=tau,
        fnn_fraction=fractions,
        meta={"analysis": "false_nearest_neighbors", "method": "fnn", "delay": int(tau)},
    )

EmbeddingDimension dataclass

EmbeddingDimension(
    dimension: int = 0,
    dims: ndarray = (lambda: empty(0))(),
    method: str = "",
    delay: int = 0,
    afn_e1: ndarray | None = None,
    afn_e2: ndarray | None = None,
    fnn_fraction: ndarray | None = None,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A minimum-embedding-dimension estimate with the curve it was read from.

An :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / .summary() / .to_dict() / the .plot seam. It also behaves as the dimension integer (int(result) drops it straight into :func:~tsdynamics.analysis.embedding.embed.embed), while carrying the per-dimension diagnostic so the saturation/decay can be inspected.

ATTRIBUTE DESCRIPTION
dimension

The recommended minimum embedding dimension :math:m.

TYPE: int

dims

The dimensions :math:d at which the diagnostic was evaluated.

TYPE: ndarray

method

"cao" or "fnn".

TYPE: str

delay

The delay (in samples) used to build the reconstructions.

TYPE: int

afn_e1, afn_e2

Cao's :math:E_1(d) (saturates to 1 at the right dimension) and :math:E_2(d) (stays near 1 for stochastic data). None for FNN.

TYPE: ndarray or None

fnn_fraction

Kennel's false-nearest-neighbour fraction per dimension (decays to 0). None for Cao.

TYPE: ndarray or None

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the embedding-dimension diagnostic as a :class:PlotSpec.

Builds a DIAGNOSTIC_CURVE of the per-dimension diagnostic against the embedding dimension :math:d, with the selected dimension :math:m annotated as a vertical reference line:

  • for Cao's method, both :math:E_1(d) (saturates to 1 at the right dimension) and :math:E_2(d) (stays near 1 for stochastic data) are drawn, with a reference line at the saturation level :math:1;
  • for Kennel's FNN, the false-nearest-neighbour fraction (decays to 0) is drawn.

The selected :math:m is read straight off :attr:dimension, so the vertical line marks where the curve has saturated/decayed. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "diagnostic_curve"). None uses DIAGNOSTIC_CURVE.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
RAISES DESCRIPTION
VisualizationNotInstalled

If neither the Cao curves nor the FNN fraction is present (nothing to draw) — the generic-fallback contract.

Source code in src/tsdynamics/analysis/embedding/dimension.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the embedding-dimension diagnostic as a :class:`PlotSpec`.

    Builds a ``DIAGNOSTIC_CURVE`` of the per-dimension diagnostic against the
    embedding dimension :math:`d`, with the **selected** dimension :math:`m`
    annotated as a vertical reference line:

    - for Cao's method, both :math:`E_1(d)` (saturates to 1 at the right
      dimension) and :math:`E_2(d)` (stays near 1 for stochastic data) are
      drawn, with a reference line at the saturation level :math:`1`;
    - for Kennel's FNN, the false-nearest-neighbour fraction (decays to 0) is
      drawn.

    The selected :math:`m` is read straight off :attr:`dimension`, so the
    vertical line marks where the curve has saturated/decayed.  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls a
    plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"diagnostic_curve"``).  ``None``
        uses ``DIAGNOSTIC_CURVE``.

    Returns
    -------
    PlotSpec

    Raises
    ------
    VisualizationNotInstalled
        If neither the Cao curves nor the FNN fraction is present (nothing to
        draw) — the generic-fallback contract.
    """
    from .. import _plotbuilder as pb
    from .._result import VisualizationNotInstalled

    dims = np.asarray(self.dims, dtype=float)

    layers = []
    annotations = []
    if self.afn_e1 is not None:
        layers.append(pb.line(dims, np.asarray(self.afn_e1, dtype=float), label="$E_1(d)$"))
        if self.afn_e2 is not None:
            layers.append(pb.line(dims, np.asarray(self.afn_e2, dtype=float), label="$E_2(d)$"))
        annotations.append(pb.hline(1.0, text="saturation = 1"))
        ylabel = r"$E_1$, $E_2$"
    elif self.fnn_fraction is not None:
        layers.append(
            pb.line(dims, np.asarray(self.fnn_fraction, dtype=float), label="FNN fraction")
        )
        ylabel = "false-neighbour fraction"
    else:  # pragma: no cover - both curves absent is a malformed result
        raise VisualizationNotInstalled(
            "EmbeddingDimension carries neither Cao (E1/E2) nor FNN curves, so there is "
            "nothing to draw; export it with .to_dict() instead."
        )

    annotations.append(pb.vline(float(self.dimension), text=f"$m$ = {int(self.dimension)}"))
    return pb.spec(
        kind,
        "diagnostic_curve",
        layers=layers,
        xlabel="dimension $d$",
        ylabel=ylabel,
        title=f"embedding dimension ({self.method}, $m$ = {int(self.dimension)})",
        legend=len(layers) > 1,
        annotations=annotations,
        meta=self.meta,
    )

Recurrence & RQA

Recurrence plots (Eckmann, Kamphorst & Ruelle, 1987) record when a trajectory revisits its own past; recurrence quantification analysis (Marwan et al., 2007) reduces that structure to scalar measures of determinism and laminarity, run globally or in a sliding window.

recurrence_matrix

recurrence_matrix(
    data: Any,
    *,
    threshold: float | None = None,
    recurrence_rate: float | None = None,
    metric: str | float = "euclidean",
    theiler: int = 0,
) -> RecurrenceMatrix

Build a recurrence matrix from a trajectory or point set.

PARAMETER DESCRIPTION
data

The state points (a :class:~tsdynamics.data.Trajectory or a raw array; a 1-D series is treated as a single scalar component). For phase-space recurrence of a scalar measurement, embed it first (:func:tsdynamics.analysis.embed).

TYPE: (Trajectory or array - like, shape(N, dim))

threshold

Fixed distance threshold :math:\varepsilon. Exactly one of threshold or recurrence_rate must be given.

TYPE: float DEFAULT: None

recurrence_rate

Target matrix density in (0, 1); :math:\varepsilon is chosen from the distribution of pairwise distances so the realised :attr:~RecurrenceMatrix.recurrence_rate is close to it. The realised rate can differ slightly because distances are discrete (and sampled for very long series).

TYPE: float DEFAULT: None

metric

Distance metric ("euclidean", "manhattan", "chebyshev", or a numeric Minkowski exponent). "chebyshev" (the maximum norm) is the common RQA choice.

TYPE: str or float DEFAULT: "euclidean"

theiler

Exclude the near-diagonal band :math:|i-j| \le w. 0 keeps every off-diagonal recurrence and drops only the line of identity; raise it to a few autocorrelation times for densely sampled flows.

TYPE: int DEFAULT: 0

RETURNS DESCRIPTION
RecurrenceMatrix
RAISES DESCRIPTION
ValueError

If neither or both of threshold / recurrence_rate are given, if either is out of range, or if the Theiler window leaves no valid pairs.

References

J.-P. Eckmann, S. O. Kamphorst and D. Ruelle, "Recurrence plots of dynamical systems", Europhys. Lett. 4, 973 (1987).

Source code in src/tsdynamics/analysis/recurrence/matrix.py
def recurrence_matrix(
    data: Any,
    *,
    threshold: float | None = None,
    recurrence_rate: float | None = None,
    metric: str | float = "euclidean",
    theiler: int = 0,
) -> RecurrenceMatrix:
    r"""Build a recurrence matrix from a trajectory or point set.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The state points (a :class:`~tsdynamics.data.Trajectory` or a raw array;
        a 1-D series is treated as a single scalar component).  For phase-space
        recurrence of a scalar measurement, embed it first
        (:func:`tsdynamics.analysis.embed`).
    threshold : float, optional
        Fixed distance threshold :math:`\varepsilon`.  Exactly one of
        ``threshold`` or ``recurrence_rate`` must be given.
    recurrence_rate : float, optional
        Target matrix density in ``(0, 1)``; :math:`\varepsilon` is chosen from
        the distribution of pairwise distances so the realised
        :attr:`~RecurrenceMatrix.recurrence_rate` is close to it.  The realised
        rate can differ slightly because distances are discrete (and sampled for
        very long series).
    metric : str or float, default "euclidean"
        Distance metric (``"euclidean"``, ``"manhattan"``, ``"chebyshev"``, or a
        numeric Minkowski exponent).  ``"chebyshev"`` (the maximum norm) is the
        common RQA choice.
    theiler : int, default 0
        Exclude the near-diagonal band :math:`|i-j| \le w`.  ``0`` keeps every
        off-diagonal recurrence and drops only the line of identity; raise it to
        a few autocorrelation times for densely sampled flows.

    Returns
    -------
    RecurrenceMatrix

    Raises
    ------
    ValueError
        If neither or both of ``threshold`` / ``recurrence_rate`` are given, if
        either is out of range, or if the Theiler window leaves no valid pairs.

    References
    ----------
    J.-P. Eckmann, S. O. Kamphorst and D. Ruelle, "Recurrence plots of dynamical
    systems", *Europhys. Lett.* **4**, 973 (1987).
    """
    from scipy import sparse
    from scipy.spatial import cKDTree

    if (threshold is None) == (recurrence_rate is None):
        raise ValueError("pass exactly one of threshold= or recurrence_rate=.")
    points = _as_points(data)
    n = points.shape[0]
    p = _metric_p(metric)
    w = int(theiler)
    if w < 0:
        raise ValueError("theiler must be non-negative.")
    if w >= n - 1:
        raise ValueError(f"theiler={w} excludes every pair for N={n}; reduce it.")

    if threshold is not None:
        eps = float(threshold)
        if not (eps > 0.0):
            raise ValueError(f"threshold must be positive, got {threshold!r}.")
    else:
        assert recurrence_rate is not None  # guaranteed by the exactly-one guard above
        rate = float(recurrence_rate)
        if not (0.0 < rate < 1.0):
            raise ValueError(f"recurrence_rate must be in (0, 1), got {recurrence_rate!r}.")
        eps = _threshold_for_rate(points, rate, p, w)

    tree = cKDTree(points)
    pairs = tree.query_pairs(r=eps, p=p, output_type="ndarray")  # (M, 2), i < j
    if pairs.size:
        i, j = pairs[:, 0], pairs[:, 1]
        keep = (j - i) > w
        i, j = i[keep], j[keep]
    else:
        i = j = np.empty(0, dtype=np.intp)

    # Symmetrise: store both (i, j) and (j, i); the diagonal is never recurrent here.
    rows = np.concatenate([i, j])
    cols = np.concatenate([j, i])
    ones = np.ones(rows.size, dtype=bool)
    mat = sparse.csr_matrix((ones, (rows, cols)), shape=(n, n))
    return RecurrenceMatrix(
        matrix=mat,
        epsilon=eps,
        metric=metric,
        theiler_window=w,
        meta={"analysis": "recurrence_matrix", "epsilon": float(eps), "theiler": int(w)},
    )

RecurrenceMatrix dataclass

RecurrenceMatrix(
    matrix: Any = None,
    epsilon: float = 0.0,
    metric: str | float = "euclidean",
    theiler_window: int = 0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A binary recurrence matrix with the parameters it was built from.

The matrix is symmetric, sparse and excludes the line of identity (plus the Theiler band when theiler_window > 0). Pass it straight to :func:~tsdynamics.analysis.rqa for quantification, or read :attr:recurrence_rate / densify with :meth:toarray for inspection.

ATTRIBUTE DESCRIPTION
matrix

The :math:N \times N boolean recurrence matrix.

TYPE: csr_matrix

epsilon

The distance threshold actually used.

TYPE: float

metric

The metric the threshold is measured in.

TYPE: str or float

theiler_window

Excluded near-diagonal band :math:|i-j| \le w.

TYPE: int

size property

size: int

Number of states N (the matrix is N x N).

recurrence_rate property

recurrence_rate: float

Matrix density :math:RR = \#\{R_{ij}=1\}/N^2.

toarray

toarray() -> ndarray

Return the dense boolean (N, N) matrix (materialises O(N^2)).

Source code in src/tsdynamics/analysis/recurrence/matrix.py
def toarray(self) -> np.ndarray:
    """Return the dense boolean ``(N, N)`` matrix (materialises ``O(N^2)``)."""
    return cast(np.ndarray, self.matrix.toarray().astype(bool))

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this recurrence matrix as a backend-agnostic :class:PlotSpec.

Builds a RECURRENCE_PLOT as a sparse SCATTER of the recurrent pairs: the stored COO row / column indices become the (i, j) point cloud, one marker per recurrence, on a square (aspect="equal") canvas with both axes labelled by the state index. The matrix is never densified — the coordinate arrays have length equal to the number of stored recurrences (nnz), so a recurrence plot of a long, sparse series stays :math:O(\#\text{recurrences}) in memory rather than the :math:O(N^2) a dense image would cost. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "recurrence_plot"). None uses RECURRENCE_PLOT.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/recurrence/matrix.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe this recurrence matrix as a backend-agnostic :class:`PlotSpec`.

    Builds a ``RECURRENCE_PLOT`` as a **sparse** ``SCATTER`` of the recurrent
    pairs: the stored COO row / column indices become the ``(i, j)`` point
    cloud, one marker per recurrence, on a square (``aspect="equal"``) canvas
    with both axes labelled by the state index.  The matrix is **never
    densified** — the coordinate arrays have length equal to the number of
    stored recurrences (``nnz``), so a recurrence plot of a long, sparse
    series stays :math:`O(\#\text{recurrences})` in memory rather than the
    :math:`O(N^2)` a dense image would cost.  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls a
    plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"recurrence_plot"``).  ``None``
        uses ``RECURRENCE_PLOT``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    # Read the COO triplet directly — row/col are the recurrent (i, j) pairs.
    # `.tocoo()` only repackages the already-stored indices (no densification);
    # the coordinate arrays are exactly `nnz` long.
    coo = self.matrix.tocoo()
    i = np.asarray(coo.row, dtype=float)
    j = np.asarray(coo.col, dtype=float)
    return pb.spec(
        kind,
        "recurrence_plot",
        layers=[
            pb.scatter(
                i, j, label="recurrence", style={"color": "black", "s": 1, "marker": "s"}
            )
        ],
        aspect="equal",
        xlabel="$i$",
        xlimits=(0.0, float(self.size)),
        ylabel="$j$",
        ylimits=(0.0, float(self.size)),
        title=f"recurrence plot (RR = {self.recurrence_rate:.3g}, {i.size} pts)",
    )

rqa

rqa(
    data: Any,
    *,
    threshold: float | None = None,
    recurrence_rate: float | None = None,
    metric: str | float = "euclidean",
    theiler: int = 0,
    min_diagonal: int = 2,
    min_vertical: int = 2,
) -> RQAResult

Recurrence quantification of a trajectory, series, or recurrence matrix.

PARAMETER DESCRIPTION
data

A prebuilt :class:~tsdynamics.analysis.RecurrenceMatrix, or a point set / series from which one is built with the parameters below.

TYPE: RecurrenceMatrix, Trajectory, or array-like

threshold

Threshold or target recurrence rate when data is not already a recurrence matrix — exactly one, as in :func:~tsdynamics.analysis.recurrence_matrix. Both must be omitted when data is a :class:~tsdynamics.analysis.RecurrenceMatrix.

TYPE: float DEFAULT: None

recurrence_rate

Threshold or target recurrence rate when data is not already a recurrence matrix — exactly one, as in :func:~tsdynamics.analysis.recurrence_matrix. Both must be omitted when data is a :class:~tsdynamics.analysis.RecurrenceMatrix.

TYPE: float DEFAULT: None

metric

Distance metric (ignored when data is a recurrence matrix).

TYPE: str or float DEFAULT: "euclidean"

theiler

Excluded near-diagonal band (ignored when data is a recurrence matrix).

TYPE: int DEFAULT: 0

min_diagonal

Shortest diagonal line counted toward DET / L / ENTR.

TYPE: int DEFAULT: 2

min_vertical

Shortest vertical line counted toward LAM / TT.

TYPE: int DEFAULT: 2

RETURNS DESCRIPTION
RQAResult

The recurrence-quantification measures (RR, DET, LAM, L, L_max, DIV, ENTR, TT, V_max) plus the raw diagonal / vertical line-length histograms.

RAISES DESCRIPTION
ValueError

If min_diagonal or min_vertical is < 1, or if data is a :class:~tsdynamics.analysis.RecurrenceMatrix and a threshold / recurrence_rate is also given (build the matrix with those instead). Matrix-building errors (no threshold/rate, out-of-range values, an over-wide Theiler window) propagate from :func:~tsdynamics.analysis.recurrence_matrix.

Notes

L_max and V_max are the longest diagonal / vertical lines over the full line-length histograms (no min_* filter), matching Marwan et al. (2007); DIV = 1 / L_max. The min_diagonal / min_vertical cut applies only to the ratio and mean measures (DET / LAM / L / TT / ENTR).

References

N. Marwan, M. C. Romano, M. Thiel and J. Kurths, "Recurrence plots for the analysis of complex systems", Phys. Rep. 438, 237 (2007).

Examples:

>>> import numpy as np
>>> import tsdynamics as ts
>>> t = np.linspace(0.0, 100.0, 1000)
>>> emb = ts.embed(np.sin(t), dimension=2, delay=5)
>>> res = ts.rqa(emb, recurrence_rate=0.05)
>>> 0.0 <= res.determinism <= 1.0
True
Source code in src/tsdynamics/analysis/recurrence/rqa.py
def rqa(
    data: Any,
    *,
    threshold: float | None = None,
    recurrence_rate: float | None = None,
    metric: str | float = "euclidean",
    theiler: int = 0,
    min_diagonal: int = 2,
    min_vertical: int = 2,
) -> RQAResult:
    r"""Recurrence quantification of a trajectory, series, or recurrence matrix.

    Parameters
    ----------
    data : RecurrenceMatrix, Trajectory, or array-like
        A prebuilt :class:`~tsdynamics.analysis.RecurrenceMatrix`, or a point set
        / series from which one is built with the parameters below.
    threshold, recurrence_rate : float, optional
        Threshold or target recurrence rate when ``data`` is not already a
        recurrence matrix — exactly one, as in
        :func:`~tsdynamics.analysis.recurrence_matrix`.  Both must be omitted when
        ``data`` is a :class:`~tsdynamics.analysis.RecurrenceMatrix`.
    metric : str or float, default "euclidean"
        Distance metric (ignored when ``data`` is a recurrence matrix).
    theiler : int, default 0
        Excluded near-diagonal band (ignored when ``data`` is a recurrence
        matrix).
    min_diagonal : int, default 2
        Shortest diagonal line counted toward ``DET`` / ``L`` / ``ENTR``.
    min_vertical : int, default 2
        Shortest vertical line counted toward ``LAM`` / ``TT``.

    Returns
    -------
    RQAResult
        The recurrence-quantification measures (RR, DET, LAM, L, L_max, DIV,
        ENTR, TT, V_max) plus the raw diagonal / vertical line-length histograms.

    Raises
    ------
    ValueError
        If ``min_diagonal`` or ``min_vertical`` is ``< 1``, or if ``data`` is a
        :class:`~tsdynamics.analysis.RecurrenceMatrix` and a ``threshold`` /
        ``recurrence_rate`` is also given (build the matrix with those instead).
        Matrix-building errors (no threshold/rate, out-of-range values, an
        over-wide Theiler window) propagate from
        :func:`~tsdynamics.analysis.recurrence_matrix`.

    Notes
    -----
    ``L_max`` and ``V_max`` are the longest diagonal / vertical lines over the
    *full* line-length histograms (no ``min_*`` filter), matching Marwan et al.
    (2007); ``DIV = 1 / L_max``.  The ``min_diagonal`` / ``min_vertical`` cut
    applies only to the ratio and mean measures (DET / LAM / L / TT / ENTR).

    References
    ----------
    N. Marwan, M. C. Romano, M. Thiel and J. Kurths, "Recurrence plots for the
    analysis of complex systems", *Phys. Rep.* **438**, 237 (2007).

    Examples
    --------
    >>> import numpy as np
    >>> import tsdynamics as ts
    >>> t = np.linspace(0.0, 100.0, 1000)
    >>> emb = ts.embed(np.sin(t), dimension=2, delay=5)
    >>> res = ts.rqa(emb, recurrence_rate=0.05)
    >>> 0.0 <= res.determinism <= 1.0
    True
    """
    if int(min_diagonal) < 1 or int(min_vertical) < 1:
        raise ValueError("min_diagonal and min_vertical must be >= 1.")
    lmin, vmin = int(min_diagonal), int(min_vertical)

    if isinstance(data, RecurrenceMatrix):
        if threshold is not None or recurrence_rate is not None:
            raise ValueError(
                "threshold=/recurrence_rate= do not apply when data is a RecurrenceMatrix; "
                "build the matrix with the desired parameters instead."
            )
        rm = data
    else:
        rm = recurrence_matrix(
            data,
            threshold=threshold,
            recurrence_rate=recurrence_rate,
            metric=metric,
            theiler=theiler,
        )

    diag = _diagonal_run_lengths(rm.matrix)
    vert = _vertical_run_lengths(rm.matrix)

    det, avg_diag, l_max = _line_stats(diag, lmin)
    lam, tt, v_max = _line_stats(vert, vmin)
    entr = _diagonal_entropy(diag, lmin)
    divergence = 1.0 / l_max if l_max > 0 else float("inf")

    return RQAResult(
        recurrence_rate=rm.recurrence_rate,
        determinism=det,
        laminarity=lam,
        avg_diagonal_length=avg_diag,
        max_diagonal_length=l_max,
        divergence=divergence,
        diagonal_entropy=entr,
        trapping_time=tt,
        max_vertical_length=v_max,
        size=rm.size,
        epsilon=rm.epsilon,
        theiler_window=rm.theiler_window,
        min_diagonal=lmin,
        min_vertical=vmin,
        diagonal_lengths=diag,
        vertical_lengths=vert,
        meta={
            "analysis": "rqa",
            "size": int(rm.size),
            "epsilon": float(rm.epsilon),
            "theiler": int(rm.theiler_window),
        },
    )

RQAResult dataclass

RQAResult(
    recurrence_rate: float,
    determinism: float,
    laminarity: float,
    avg_diagonal_length: float,
    max_diagonal_length: int,
    divergence: float,
    diagonal_entropy: float,
    trapping_time: float,
    max_vertical_length: int,
    size: int,
    epsilon: float,
    theiler_window: int,
    min_diagonal: int,
    min_vertical: int,
    diagonal_lengths: ndarray,
    vertical_lengths: ndarray,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Recurrence-quantification measures of one recurrence matrix.

ATTRIBUTE DESCRIPTION
recurrence_rate

RR — density of recurrence points, :math:\#\{R_{ij}=1\}/N^2.

TYPE: float

determinism

DET — fraction of recurrence points that lie on diagonal lines of length :math:\ge min_diagonal.

TYPE: float

laminarity

LAM — fraction of recurrence points that lie on vertical lines of length :math:\ge min_vertical.

TYPE: float

avg_diagonal_length

L — mean length of the diagonal lines counted by DET.

TYPE: float

max_diagonal_length

L_max — longest diagonal line, excluding the line of identity and with no min_diagonal filter (Marwan et al. 2007); 0 only when there are no diagonal recurrence lines at all.

TYPE: int

divergence

DIV :math:= 1/L_{\max} (inf only when there are no diagonal lines, i.e. L_max == 0).

TYPE: float

diagonal_entropy

ENTR — Shannon entropy (nats) of the diagonal line-length distribution.

TYPE: float

trapping_time

TT — mean length of the vertical lines counted by LAM.

TYPE: float

max_vertical_length

V_max — longest vertical line, with no min_vertical filter (the vertical analogue of L_max).

TYPE: int

size

Number of states N.

TYPE: int

epsilon

Threshold the matrix was built with.

TYPE: float

theiler_window

Excluded near-diagonal band.

TYPE: int

min_diagonal, min_vertical

Minimum line lengths counted as diagonal / vertical lines.

TYPE: int

diagonal_lengths, vertical_lengths

Raw line-length histograms (every run, before the min_* cut), kept for inspection / plotting.

TYPE: ndarray

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the scalar RQA measures as a :class:PlotSpec bar readout.

Builds a CATEGORICAL_BAR whose bars are the headline structure quantifiers — RR (recurrence rate), DET (determinism), LAM (laminarity) and ENTR (diagonal-line entropy) — one bar per measure, the category axis carrying the measure labels. This is the at-a-glance readout of "how deterministic / laminar is this trajectory"; the unbounded measures (L_max / V_max / DIV) stay in :meth:summary rather than crushing the bar scale. No line-length histogram is walked — the values are the already-computed scalar fields. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "categorical_bar"). None uses CATEGORICAL_BAR.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/recurrence/rqa.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    """Describe the scalar RQA measures as a :class:`PlotSpec` bar readout.

    Builds a ``CATEGORICAL_BAR`` whose bars are the headline structure
    quantifiers — ``RR`` (recurrence rate), ``DET`` (determinism), ``LAM``
    (laminarity) and ``ENTR`` (diagonal-line entropy) — one bar per measure,
    the category axis carrying the measure labels.  This is the at-a-glance
    readout of "how deterministic / laminar is this trajectory"; the
    unbounded measures (``L_max`` / ``V_max`` / ``DIV``) stay in
    :meth:`summary` rather than crushing the bar scale.  No line-length
    histogram is walked — the values are the already-computed scalar fields.
    The :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never
    pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"categorical_bar"``).  ``None``
        uses ``CATEGORICAL_BAR``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    labels = [lbl for lbl, _ in self._BAR_MEASURES]
    cat = np.arange(len(labels), dtype=float)
    values = np.array([float(getattr(self, attr)) for _, attr in self._BAR_MEASURES])
    return pb.spec(
        kind,
        "categorical_bar",
        layers=[pb.bar(values, cat=cat, label="RQA measures")],
        xlabel="measure",
        xscale="categorical",
        xcategories=labels,
        ylabel="value",
        ylimits=(0.0, 1.0),
        title=f"RQA  DET = {self.determinism:.3g}, LAM = {self.laminarity:.3g}",
    )

windowed_rqa

windowed_rqa(
    data: Any,
    *,
    window: int,
    step: int | None = None,
    threshold: float | None = None,
    recurrence_rate: float | None = None,
    metric: str | float = "euclidean",
    theiler: int = 0,
    min_diagonal: int = 2,
    min_vertical: int = 2,
) -> WindowedRQA

Run :func:~tsdynamics.analysis.rqa in a sliding window.

PARAMETER DESCRIPTION
data

The state points (or a 1-D series).

TYPE: (Trajectory or array - like, shape(N, dim))

window

Window length in samples (>= 2).

TYPE: int

step

Stride between windows (default: window — non-overlapping).

TYPE: int DEFAULT: None

threshold

Exactly one; passed to each window's matrix (see :func:~tsdynamics.analysis.recurrence_matrix).

TYPE: float DEFAULT: None

recurrence_rate

Exactly one; passed to each window's matrix (see :func:~tsdynamics.analysis.recurrence_matrix).

TYPE: float DEFAULT: None

metric

Distance metric.

TYPE: str or float DEFAULT: "euclidean"

theiler

Excluded near-diagonal band, applied within each window.

TYPE: int DEFAULT: 0

min_diagonal

Minimum line lengths (see :func:~tsdynamics.analysis.rqa).

TYPE: int DEFAULT: 2

min_vertical

Minimum line lengths (see :func:~tsdynamics.analysis.rqa).

TYPE: int DEFAULT: 2

RETURNS DESCRIPTION
WindowedRQA
RAISES DESCRIPTION
ValueError

If window is out of range or step < 1.

Source code in src/tsdynamics/analysis/recurrence/windowed.py
def windowed_rqa(
    data: Any,
    *,
    window: int,
    step: int | None = None,
    threshold: float | None = None,
    recurrence_rate: float | None = None,
    metric: str | float = "euclidean",
    theiler: int = 0,
    min_diagonal: int = 2,
    min_vertical: int = 2,
) -> WindowedRQA:
    r"""Run :func:`~tsdynamics.analysis.rqa` in a sliding window.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The state points (or a 1-D series).
    window : int
        Window length in samples (``>= 2``).
    step : int, optional
        Stride between windows (default: ``window`` — non-overlapping).
    threshold, recurrence_rate : float, optional
        Exactly one; passed to each window's matrix (see
        :func:`~tsdynamics.analysis.recurrence_matrix`).
    metric : str or float, default "euclidean"
        Distance metric.
    theiler : int, default 0
        Excluded near-diagonal band, applied within each window.
    min_diagonal, min_vertical : int
        Minimum line lengths (see :func:`~tsdynamics.analysis.rqa`).

    Returns
    -------
    WindowedRQA

    Raises
    ------
    ValueError
        If ``window`` is out of range or ``step < 1``.
    """
    points = _as_points(data)
    n = points.shape[0]
    window = int(window)
    if window < 2:
        raise ValueError(f"window must be >= 2, got {window}.")
    if window > n:
        raise ValueError(f"window={window} exceeds the series length N={n}.")
    step = window if step is None else int(step)
    if step < 1:
        raise ValueError(f"step must be >= 1, got {step}.")

    starts = range(0, n - window + 1, step)
    results = tuple(
        rqa(
            points[s : s + window],
            threshold=threshold,
            recurrence_rate=recurrence_rate,
            metric=metric,
            theiler=theiler,
            min_diagonal=min_diagonal,
            min_vertical=min_vertical,
        )
        for s in starts
    )
    centers = np.array([s + (window - 1) / 2.0 for s in starts], dtype=float)
    return WindowedRQA(
        centers=centers,
        results=results,
        window=window,
        step=step,
        meta={"analysis": "windowed_rqa", "window": int(window), "step": int(step)},
    )

WindowedRQA dataclass

WindowedRQA(
    centers: ndarray = (lambda: empty(0))(),
    results: tuple[RQAResult, ...] = (),
    window: int = 0,
    step: int = 0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

RQA measures over a sliding window.

Each scalar measure of :class:~tsdynamics.analysis.RQAResult is available as a per-window array of the same length as :attr:centers (the window-centre sample indices), e.g. windowed.determinism or windowed.measure("laminarity").

ATTRIBUTE DESCRIPTION
centers

Window-centre positions in sample units (start + (window-1)/2).

TYPE: ndarray

results

The per-window results, in order.

TYPE: tuple[RQAResult, ...]

window

Window length in samples.

TYPE: int

step

Stride between consecutive windows in samples.

TYPE: int

measure

measure(name: str) -> ndarray

Return one RQA measure as an array over windows.

PARAMETER DESCRIPTION
name

Any scalar attribute of :class:~tsdynamics.analysis.RQAResult (e.g. "determinism", "laminarity", "recurrence_rate").

TYPE: str

Source code in src/tsdynamics/analysis/recurrence/windowed.py
def measure(self, name: str) -> np.ndarray:
    """Return one RQA measure as an array over windows.

    Parameters
    ----------
    name : str
        Any scalar attribute of :class:`~tsdynamics.analysis.RQAResult`
        (e.g. ``"determinism"``, ``"laminarity"``, ``"recurrence_rate"``).
    """
    if name not in _MEASURES:
        raise ValueError(f"unknown RQA measure {name!r}; choose from {_MEASURES}.")
    return np.array([getattr(r, name) for r in self.results], dtype=float)

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the windowed RQA as a :class:PlotSpec.

Builds a DIAGNOSTIC_CURVE carrying a LINE of the determinism measure against the window-centre index — the canonical sliding-RQA view for spotting dynamical regime transitions (a drop in determinism flags a shift away from deterministic/periodic behaviour). Read any other measure off :meth:measure. The :mod:tsdynamics.viz.spec import is lazy.

PARAMETER DESCRIPTION
kind

Override the semantic kind. None uses DIAGNOSTIC_CURVE.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/recurrence/windowed.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the windowed RQA as a :class:`PlotSpec`.

    Builds a ``DIAGNOSTIC_CURVE`` carrying a ``LINE`` of the **determinism**
    measure against the window-centre index — the canonical sliding-RQA view
    for spotting dynamical regime transitions (a drop in determinism flags a
    shift away from deterministic/periodic behaviour).  Read any other measure
    off :meth:`measure`.  The :mod:`tsdynamics.viz.spec` import is lazy.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind.  ``None`` uses ``DIAGNOSTIC_CURVE``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    centers = np.asarray(self.centers, dtype=float)
    det = np.asarray(self.measure("determinism"), dtype=float)
    return pb.spec(
        kind,
        "diagnostic_curve",
        layers=[pb.line(centers, det, label="DET")],
        xlabel="window centre",
        ylabel="determinism",
        title="Windowed RQA (determinism)",
    )

Surrogates & nonlinearity tests

The surrogate-data method (Theiler et al., 1992; Schreiber & Schmitz, 1996) tests a series for nonlinear structure against an ensemble of surrogates that reproduce its linear properties (amplitude distribution and/or power spectrum) but are otherwise random.

surrogate_test

surrogate_test(
    data: Any,
    statistic: str | Callable[..., float] = "time_reversal",
    method: str = "iaaft",
    n: int = 39,
    *,
    tail: str = "auto",
    alpha: float = 0.05,
    seed: int | None = None,
    component: int | str | None = None,
    statistic_kwargs: dict[str, Any] | None = None,
    **surrogate_kwargs: Any,
) -> SurrogateTest

Test a series for nonlinearity against linear surrogates.

Evaluates statistic on data and on n surrogates drawn by method, then reports the rank p-value and significance of the data statistic within the surrogate ensemble. The default — time-reversal asymmetry against IAAFT surrogates — rejects the linear-Gaussian null for a dissipative chaotic flow such as Lorenz.

PARAMETER DESCRIPTION
data

The series under test (a component of a multi-component input is selected with component=).

TYPE: array - like or Trajectory

statistic

"time_reversal" or "prediction_error" (see :mod:.statistics), or any callable mapping a 1-D array to a float.

TYPE: str or callable DEFAULT: "time_reversal"

method

Surrogate method — "shuffle", "ft", "aaft" or "iaaft" (see :func:~tsdynamics.analysis.surrogate.generators.surrogates).

TYPE: str DEFAULT: "iaaft"

n

Number of surrogates. Theiler's :math:M = 2/\alpha - 1 makes 39 the smallest ensemble able to reach a two-sided α = 0.05.

TYPE: int DEFAULT: 39

tail

Rejection tail. "auto" resolves to "less" for the prediction-error statistic — whether named "prediction_error" or passed as the :func:~tsdynamics.analysis.surrogate.statistics.nonlinear_prediction_error callable (determinism makes the data more predictable, so it rejects in the lower tail) — and "two" for every other statistic.

TYPE: ('auto', 'two', 'greater', 'less') DEFAULT: "auto"

alpha

Significance level for the rejected decision.

TYPE: float DEFAULT: 0.05

seed

Seed for the surrogate ensemble (makes the whole test reproducible).

TYPE: int DEFAULT: None

component

Component to select from multi-component input.

TYPE: int or str DEFAULT: None

statistic_kwargs

Extra keyword arguments forwarded to the statistic.

TYPE: dict DEFAULT: None

**surrogate_kwargs

Extra keyword arguments forwarded to the generator (e.g. max_iter).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
SurrogateTest

The test outcome.

RAISES DESCRIPTION
ValueError

If statistic or method is an unknown name, if tail is not one of {"auto", "two", "greater", "less"}, or if data cannot be coerced to a finite 1-D series (wrong shape, fewer than three samples, non-finite values, or an ambiguous multi-component input with no component=).

TypeError

If a non-integer component is given for a plain 2-D array.

References

.. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer, "Testing for nonlinearity in time series: the method of surrogate data," Physica D 58, 77-94 (1992).

Examples:

>>> import numpy as np
>>> from tsdynamics.analysis.surrogate import surrogate_test
>>> rng = np.random.default_rng(0)
>>> noise = rng.standard_normal(2000)  # a linear-Gaussian null
>>> res = surrogate_test(noise, "time_reversal", n=39, seed=0)
>>> bool(res.rejected)
False
Source code in src/tsdynamics/analysis/surrogate/hypothesis.py
def surrogate_test(
    data: Any,
    statistic: str | Callable[..., float] = "time_reversal",
    method: str = "iaaft",
    n: int = 39,
    *,
    tail: str = "auto",
    alpha: float = 0.05,
    seed: int | None = None,
    component: int | str | None = None,
    statistic_kwargs: dict[str, Any] | None = None,
    **surrogate_kwargs: Any,
) -> SurrogateTest:
    r"""Test a series for nonlinearity against linear surrogates.

    Evaluates ``statistic`` on ``data`` and on ``n`` surrogates drawn by
    ``method``, then reports the rank p-value and significance of the data
    statistic within the surrogate ensemble.  The default — time-reversal asymmetry
    against IAAFT surrogates — rejects the linear-Gaussian null for a dissipative
    chaotic flow such as Lorenz.

    Parameters
    ----------
    data : array-like or Trajectory
        The series under test (a component of a multi-component input is selected
        with ``component=``).
    statistic : str or callable, default "time_reversal"
        ``"time_reversal"`` or ``"prediction_error"`` (see :mod:`.statistics`), or
        any callable mapping a 1-D array to a float.
    method : str, default "iaaft"
        Surrogate method — ``"shuffle"``, ``"ft"``, ``"aaft"`` or ``"iaaft"`` (see
        :func:`~tsdynamics.analysis.surrogate.generators.surrogates`).
    n : int, default 39
        Number of surrogates.  Theiler's :math:`M = 2/\alpha - 1` makes ``39`` the
        smallest ensemble able to reach a two-sided ``α = 0.05``.
    tail : {"auto", "two", "greater", "less"}, default "auto"
        Rejection tail.  ``"auto"`` resolves to ``"less"`` for the prediction-error
        statistic — whether named ``"prediction_error"`` or passed as the
        :func:`~tsdynamics.analysis.surrogate.statistics.nonlinear_prediction_error`
        callable (determinism makes the data *more* predictable, so it rejects in
        the lower tail) — and ``"two"`` for every other statistic.
    alpha : float, default 0.05
        Significance level for the ``rejected`` decision.
    seed : int, optional
        Seed for the surrogate ensemble (makes the whole test reproducible).
    component : int or str, optional
        Component to select from multi-component input.
    statistic_kwargs : dict, optional
        Extra keyword arguments forwarded to the statistic.
    **surrogate_kwargs
        Extra keyword arguments forwarded to the generator (e.g. ``max_iter``).

    Returns
    -------
    SurrogateTest
        The test outcome.

    Raises
    ------
    ValueError
        If ``statistic`` or ``method`` is an unknown name, if ``tail`` is not one
        of ``{"auto", "two", "greater", "less"}``, or if ``data`` cannot be coerced
        to a finite 1-D series (wrong shape, fewer than three samples, non-finite
        values, or an ambiguous multi-component input with no ``component=``).
    TypeError
        If a non-integer ``component`` is given for a plain 2-D array.

    References
    ----------
    .. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer,
       "Testing for nonlinearity in time series: the method of surrogate data,"
       Physica D 58, 77-94 (1992).

    Examples
    --------
    >>> import numpy as np
    >>> from tsdynamics.analysis.surrogate import surrogate_test
    >>> rng = np.random.default_rng(0)
    >>> noise = rng.standard_normal(2000)  # a linear-Gaussian null
    >>> res = surrogate_test(noise, "time_reversal", n=39, seed=0)
    >>> bool(res.rejected)
    False
    """
    series = _as_series(data, component)
    stat_kw = statistic_kwargs or {}

    if callable(statistic):
        stat_fn: Callable[..., Any] = statistic
        stat_name = getattr(statistic, "__name__", "<callable>")
    else:
        key = statistic.lower()
        if key not in STATISTICS:
            raise ValueError(
                f"unknown statistic {statistic!r}; use {sorted(STATISTICS)} or pass a callable."
            )
        stat_fn = STATISTICS[key]
        stat_name = key

    if tail == "auto":
        # Prediction error is one-sided: determinism makes the data *more*
        # predictable, so it rejects in the LOWER tail.  Resolve it both by the
        # registry key ("prediction_error") and by identity, so passing the
        # callable ``nonlinear_prediction_error`` (whose ``__name__`` differs
        # from the key) still gets the correct one-sided ``"less"`` tail.
        predictive = stat_name == "prediction_error" or stat_fn is nonlinear_prediction_error
        tail = "less" if predictive else "two"

    data_statistic = float(stat_fn(series, **stat_kw))
    ensemble = surrogates(series, method, int(n), seed=seed, **surrogate_kwargs)
    surrogate_statistics = np.array([float(stat_fn(s, **stat_kw)) for s in ensemble], dtype=float)

    p_value = empirical_pvalue(data_statistic, surrogate_statistics, tail)
    z_score = _gaussian_significance(data_statistic, surrogate_statistics)
    return SurrogateTest(
        data_statistic=data_statistic,
        surrogate_statistics=surrogate_statistics,
        p_value=p_value,
        z_score=z_score,
        rejected=p_value <= alpha,
        statistic=stat_name,
        method=method,
        n_surrogates=int(n),
        tail=tail,
        alpha=alpha,
        meta={
            "analysis": "surrogate_test",
            "statistic": stat_name,
            "method": method,
            "n_surrogates": int(n),
        },
    )

SurrogateTest dataclass

SurrogateTest(
    data_statistic: float = 0.0,
    surrogate_statistics: ndarray = (lambda: empty(0))(),
    p_value: float = 1.0,
    z_score: float = 0.0,
    rejected: bool = False,
    statistic: str = "",
    method: str = "",
    n_surrogates: int = 0,
    tail: str = "two",
    alpha: float = 0.05,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Outcome of a surrogate-data nonlinearity test.

An :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / .summary() / .to_dict() / the .plot seam alongside the test outcome.

ATTRIBUTE DESCRIPTION
data_statistic

The discriminating statistic evaluated on the data.

TYPE: float

surrogate_statistics

The same statistic on each surrogate (shape (n_surrogates,)).

TYPE: ndarray

p_value

The rank-based surrogate p-value for the chosen tail.

TYPE: float

z_score

Significance of the data statistic in surrogate standard deviations (the classic "number of sigmas"); positive when the data lies above the mean.

TYPE: float

rejected

Whether the linear null is rejected at alpha (p_value <= alpha); the boundary is inclusive because Theiler's :math:M = 2/\alpha - 1 ensemble makes the most-extreme attainable p-value exactly alpha.

TYPE: bool

statistic

Name of the statistic ("<callable>" for a user-supplied function).

TYPE: str

method

Surrogate method used.

TYPE: str

n_surrogates

Number of surrogates drawn.

TYPE: int

tail

The rejection tail ("two" / "greater" / "less").

TYPE: str

alpha

The significance level the rejection decision used.

TYPE: float

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this surrogate test as a backend-agnostic :class:PlotSpec.

Builds a HISTOGRAM_NULL spec — the surrogate-statistic ensemble as a histogram (the null distribution) with the data statistic marked as a vertical reference line and the alpha-quantile rejection tail(s) shaded (a "span" annotation per tail, picked by :attr:tail) — so the rejection reads off as the data line landing inside a shaded tail. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "histogram_null"). None uses HISTOGRAM_NULL.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/surrogate/hypothesis.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    """Describe this surrogate test as a backend-agnostic :class:`PlotSpec`.

    Builds a ``HISTOGRAM_NULL`` spec — the surrogate-statistic ensemble as a
    histogram (the null distribution) with the data statistic marked as a
    vertical reference line and the ``alpha``-quantile **rejection tail(s)
    shaded** (a ``"span"`` annotation per tail, picked by :attr:`tail`) — so
    the rejection reads off as the data line landing inside a shaded tail.
    The :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never
    pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"histogram_null"``).  ``None`` uses
        ``HISTOGRAM_NULL``.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    surrogate = np.asarray(self.surrogate_statistics, dtype=float)
    verdict = "reject" if self.rejected else "fail to reject"
    annotations: list[Any] = [
        pb.vline(float(self.data_statistic), text="data", style={"color": "lightcoral"})
    ]
    annotations.extend(self._rejection_tail())
    return pb.spec(
        kind,
        "histogram_null",
        layers=[pb.histogram(surrogate, label=f"{self.method} surrogates")],
        xlabel=self.statistic,
        ylabel="count",
        title=f"{self.statistic} surrogate test (p = {self.p_value:.3g}, {verdict})",
        annotations=annotations,
    )

Generators

surrogates

surrogates(
    data: Any,
    method: str = "iaaft",
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
    **kwargs: Any,
) -> SurrogateEnsemble

Generate surrogate series by name — the dispatcher every test goes through.

PARAMETER DESCRIPTION
data

The source series.

TYPE: array - like or Trajectory

method

One of "shuffle" (aliases "random"/"permutation"), "ft" ("fourier"/"phase"), "aaft", or "iaaft".

TYPE: str DEFAULT: "iaaft"

n

Number of surrogates to draw.

TYPE: int DEFAULT: 1

seed

Seed for reproducibility.

TYPE: int DEFAULT: None

component

Component to select from multi-component input.

TYPE: int or str DEFAULT: None

**kwargs

Forwarded to the generator (e.g. max_iter for "iaaft").

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
SurrogateEnsemble

The surrogate ensemble; behaves as the (n, N) numpy.ndarray.

RAISES DESCRIPTION
ValueError

If method is unknown.

Source code in src/tsdynamics/analysis/surrogate/generators.py
def surrogates(
    data: Any,
    method: str = "iaaft",
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
    **kwargs: Any,
) -> SurrogateEnsemble:
    """Generate surrogate series by name — the dispatcher every test goes through.

    Parameters
    ----------
    data : array-like or Trajectory
        The source series.
    method : str, default "iaaft"
        One of ``"shuffle"`` (aliases ``"random"``/``"permutation"``), ``"ft"``
        (``"fourier"``/``"phase"``), ``"aaft"``, or ``"iaaft"``.
    n : int, default 1
        Number of surrogates to draw.
    seed : int, optional
        Seed for reproducibility.
    component : int or str, optional
        Component to select from multi-component input.
    **kwargs
        Forwarded to the generator (e.g. ``max_iter`` for ``"iaaft"``).

    Returns
    -------
    SurrogateEnsemble
        The surrogate ensemble; behaves as the ``(n, N)`` ``numpy.ndarray``.

    Raises
    ------
    ValueError
        If ``method`` is unknown.
    """
    key = _METHOD_ALIASES.get(method.lower())
    if key is None:
        raise ValueError(
            f"unknown surrogate method {method!r}; use 'shuffle', 'ft', 'aaft' or 'iaaft'."
        )
    ensemble = _GENERATORS[key](data, n, seed=seed, component=component, **kwargs)
    return SurrogateEnsemble(
        values=np.asarray(ensemble),
        meta={"analysis": "surrogates", "method": key, "n": int(n)},
    )

random_shuffle

random_shuffle(
    data: Any,
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
) -> ndarray

Random-permutation surrogates (the constrained-realisation i.i.d. null).

Each surrogate is an independent random permutation of the samples, so it keeps the amplitude distribution exactly and destroys every temporal correlation — the appropriate null for "are successive samples independent?".

PARAMETER DESCRIPTION
data

The source series (see :func:~tsdynamics.analysis.surrogate._common._as_series).

TYPE: array - like or Trajectory

n

Number of surrogates to draw.

TYPE: int DEFAULT: 1

seed

Seed for reproducibility.

TYPE: int DEFAULT: None

component

Component to select from multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
(ndarray, shape(n, N))

The surrogate ensemble.

RAISES DESCRIPTION
ValueError

If data cannot be coerced to a finite 1-D series (see :func:~tsdynamics.analysis.surrogate._common._as_series).

References

.. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer, "Testing for nonlinearity in time series: the method of surrogate data," Physica D 58, 77-94 (1992).

Source code in src/tsdynamics/analysis/surrogate/generators.py
def random_shuffle(
    data: Any, n: int = 1, *, seed: int | None = None, component: int | str | None = None
) -> np.ndarray:
    """Random-permutation surrogates (the constrained-realisation i.i.d. null).

    Each surrogate is an independent random permutation of the samples, so it
    keeps the amplitude distribution exactly and destroys every temporal
    correlation — the appropriate null for "are successive samples independent?".

    Parameters
    ----------
    data : array-like or Trajectory
        The source series (see :func:`~tsdynamics.analysis.surrogate._common._as_series`).
    n : int, default 1
        Number of surrogates to draw.
    seed : int, optional
        Seed for reproducibility.
    component : int or str, optional
        Component to select from multi-component input.

    Returns
    -------
    numpy.ndarray, shape (n, N)
        The surrogate ensemble.

    Raises
    ------
    ValueError
        If ``data`` cannot be coerced to a finite 1-D series (see
        :func:`~tsdynamics.analysis.surrogate._common._as_series`).

    References
    ----------
    .. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer,
       "Testing for nonlinearity in time series: the method of surrogate data,"
       Physica D 58, 77-94 (1992).
    """
    series = _as_series(data, component)
    rng = np.random.default_rng(seed)
    N = series.size
    out = np.empty((int(n), N), dtype=float)
    for i in range(int(n)):
        out[i] = series[rng.permutation(N)]
    return out

fourier_surrogate

fourier_surrogate(
    data: Any,
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
) -> ndarray

Phase-randomised (Fourier transform) surrogates of a linear Gaussian null.

Keeps the magnitude spectrum — and therefore the power spectrum and the linear autocorrelation — of data exactly while randomising the Fourier phases (Theiler et al., 1992). The surrogate amplitude distribution drifts toward Gaussian; use :func:aaft_surrogate / :func:iaaft_surrogate when the distribution must be preserved too.

PARAMETER DESCRIPTION
data

The source series.

TYPE: array - like or Trajectory

n

Number of surrogates to draw.

TYPE: int DEFAULT: 1

seed

Seed for reproducibility.

TYPE: int DEFAULT: None

component

Component to select from multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
(ndarray, shape(n, N))

The surrogate ensemble.

RAISES DESCRIPTION
ValueError

If data cannot be coerced to a finite 1-D series.

References

.. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer, "Testing for nonlinearity in time series: the method of surrogate data," Physica D 58, 77-94 (1992).

Source code in src/tsdynamics/analysis/surrogate/generators.py
def fourier_surrogate(
    data: Any, n: int = 1, *, seed: int | None = None, component: int | str | None = None
) -> np.ndarray:
    """Phase-randomised (Fourier transform) surrogates of a linear Gaussian null.

    Keeps the magnitude spectrum — and therefore the power spectrum and the linear
    autocorrelation — of ``data`` exactly while randomising the Fourier phases
    (Theiler et al., 1992).  The surrogate amplitude distribution drifts toward
    Gaussian; use :func:`aaft_surrogate` / :func:`iaaft_surrogate` when the
    distribution must be preserved too.

    Parameters
    ----------
    data : array-like or Trajectory
        The source series.
    n : int, default 1
        Number of surrogates to draw.
    seed : int, optional
        Seed for reproducibility.
    component : int or str, optional
        Component to select from multi-component input.

    Returns
    -------
    numpy.ndarray, shape (n, N)
        The surrogate ensemble.

    Raises
    ------
    ValueError
        If ``data`` cannot be coerced to a finite 1-D series.

    References
    ----------
    .. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer,
       "Testing for nonlinearity in time series: the method of surrogate data,"
       Physica D 58, 77-94 (1992).
    """
    series = _as_series(data, component)
    rng = np.random.default_rng(seed)
    return _phase_randomize(series, int(n), rng)

aaft_surrogate

aaft_surrogate(
    data: Any,
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
) -> ndarray

Amplitude-adjusted Fourier-transform surrogates (Theiler et al., 1992).

Tests the null of a static monotonic nonlinearity acting on a linear Gaussian process: the data is mapped to Gaussian by rank, phase-randomised, then mapped back through the inverse rank transform. The result reproduces the amplitude distribution exactly and the power spectrum approximately (the rank remap distorts the spectrum slightly — :func:iaaft_surrogate removes that bias).

PARAMETER DESCRIPTION
data

The source series.

TYPE: array - like or Trajectory

n

Number of surrogates to draw.

TYPE: int DEFAULT: 1

seed

Seed for reproducibility.

TYPE: int DEFAULT: None

component

Component to select from multi-component input.

TYPE: int or str DEFAULT: None

RETURNS DESCRIPTION
(ndarray, shape(n, N))

The surrogate ensemble.

RAISES DESCRIPTION
ValueError

If data cannot be coerced to a finite 1-D series.

References

.. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer, "Testing for nonlinearity in time series: the method of surrogate data," Physica D 58, 77-94 (1992).

Source code in src/tsdynamics/analysis/surrogate/generators.py
def aaft_surrogate(
    data: Any, n: int = 1, *, seed: int | None = None, component: int | str | None = None
) -> np.ndarray:
    """Amplitude-adjusted Fourier-transform surrogates (Theiler et al., 1992).

    Tests the null of a *static monotonic nonlinearity acting on a linear Gaussian
    process*: the data is mapped to Gaussian by rank, phase-randomised, then mapped
    back through the inverse rank transform.  The result reproduces the amplitude
    distribution exactly and the power spectrum approximately (the rank remap
    distorts the spectrum slightly — :func:`iaaft_surrogate` removes that bias).

    Parameters
    ----------
    data : array-like or Trajectory
        The source series.
    n : int, default 1
        Number of surrogates to draw.
    seed : int, optional
        Seed for reproducibility.
    component : int or str, optional
        Component to select from multi-component input.

    Returns
    -------
    numpy.ndarray, shape (n, N)
        The surrogate ensemble.

    Raises
    ------
    ValueError
        If ``data`` cannot be coerced to a finite 1-D series.

    References
    ----------
    .. [1] J. Theiler, S. Eubank, A. Longtin, B. Galdrikian, and J. D. Farmer,
       "Testing for nonlinearity in time series: the method of surrogate data,"
       Physica D 58, 77-94 (1992).
    """
    series = _as_series(data, component)
    rng = np.random.default_rng(seed)
    N = series.size
    ranks_x = _ranks(series)
    sorted_x = np.sort(series)

    out = np.empty((int(n), N), dtype=float)
    for i in range(int(n)):
        # Gaussian series sharing data's rank order, then phase-randomised.
        gaussian = np.sort(rng.standard_normal(N))[ranks_x]
        gaussian_surrogate = _phase_randomize(gaussian, 1, rng)[0]
        # Re-impose data's amplitude distribution by matching ranks.
        out[i] = sorted_x[_ranks(gaussian_surrogate)]
    return out

iaaft_surrogate

iaaft_surrogate(
    data: Any,
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
    max_iter: int = 1000,
) -> ndarray

Refine AAFT surrogates iteratively (IAAFT; Schreiber & Schmitz, 1996).

Alternates two projections until the sample ordering stops changing: a spectral step that restores the exact magnitude spectrum of data (keeping the current phases), and an amplitude step that restores data's exact sorted values by rank. Because it ends on the amplitude step, the surrogate's amplitude distribution is exact and its power spectrum matches to high accuracy — the standard improvement over plain :func:aaft_surrogate.

PARAMETER DESCRIPTION
data

The source series.

TYPE: array - like or Trajectory

n

Number of surrogates to draw.

TYPE: int DEFAULT: 1

seed

Seed for reproducibility.

TYPE: int DEFAULT: None

component

Component to select from multi-component input.

TYPE: int or str DEFAULT: None

max_iter

Cap on refinement iterations per surrogate (convergence is detected when the rank order repeats; the cap only bites on hard cases).

TYPE: int DEFAULT: 1000

RETURNS DESCRIPTION
(ndarray, shape(n, N))

The surrogate ensemble.

RAISES DESCRIPTION
ValueError

If data cannot be coerced to a finite 1-D series.

References

.. [1] T. Schreiber and A. Schmitz, "Improved surrogate data for nonlinearity tests," Physical Review Letters 77, 635-638 (1996).

Source code in src/tsdynamics/analysis/surrogate/generators.py
def iaaft_surrogate(
    data: Any,
    n: int = 1,
    *,
    seed: int | None = None,
    component: int | str | None = None,
    max_iter: int = 1000,
) -> np.ndarray:
    r"""Refine AAFT surrogates iteratively (IAAFT; Schreiber & Schmitz, 1996).

    Alternates two projections until the sample ordering stops changing: a
    *spectral* step that restores the exact magnitude spectrum of ``data`` (keeping
    the current phases), and an *amplitude* step that restores ``data``'s exact
    sorted values by rank.  Because it ends on the amplitude step, the surrogate's
    amplitude distribution is exact and its power spectrum matches to high accuracy
    — the standard improvement over plain :func:`aaft_surrogate`.

    Parameters
    ----------
    data : array-like or Trajectory
        The source series.
    n : int, default 1
        Number of surrogates to draw.
    seed : int, optional
        Seed for reproducibility.
    component : int or str, optional
        Component to select from multi-component input.
    max_iter : int, default 1000
        Cap on refinement iterations per surrogate (convergence is detected when
        the rank order repeats; the cap only bites on hard cases).

    Returns
    -------
    numpy.ndarray, shape (n, N)
        The surrogate ensemble.

    Raises
    ------
    ValueError
        If ``data`` cannot be coerced to a finite 1-D series.

    References
    ----------
    .. [1] T. Schreiber and A. Schmitz, "Improved surrogate data for nonlinearity
       tests," Physical Review Letters 77, 635-638 (1996).
    """
    series = _as_series(data, component)
    rng = np.random.default_rng(seed)
    N = series.size
    target_magnitude = np.abs(np.fft.rfft(series))
    sorted_x = np.sort(series)

    out = np.empty((int(n), N), dtype=float)
    for i in range(int(n)):
        s = series[rng.permutation(N)]  # random-shuffle start
        prev_ranks: np.ndarray | None = None
        for _ in range(int(max_iter)):
            # Spectral projection: keep phases, impose the target magnitudes.
            phases = np.angle(np.fft.rfft(s))
            s = np.fft.irfft(target_magnitude * np.exp(1j * phases), n=N)
            # Amplitude projection: impose data's exact values by rank order.
            ranks = _ranks(s)
            s = sorted_x[ranks]
            if prev_ranks is not None and np.array_equal(ranks, prev_ranks):
                break
            prev_ranks = ranks
        out[i] = s
    return out

Statistics

time_reversal_asymmetry

time_reversal_asymmetry(
    data: ndarray, delay: int = 1
) -> float

Time-reversal asymmetry statistic of a scalar series.

Computes the dimensionless third-moment ratio of the lagged increments,

.. math::

T_\text{rev} = \frac{\langle (x_{t} - x_{t-\ell})^3 \rangle}
                    {\langle (x_{t} - x_{t-\ell})^2 \rangle^{3/2}},

which changes sign under time reversal and is therefore identically zero (in expectation) for any time-reversible process — including a linear Gaussian process and any static monotonic transform of one, the nulls the Fourier-family surrogates realise. A dissipative deterministic flow breaks that symmetry, so a large :math:|T_\text{rev}| relative to the surrogates flags nonlinearity.

PARAMETER DESCRIPTION
data

The 1-D series.

TYPE: ndarray

delay

The increment lag :math:\ell (in samples), >= 1.

TYPE: int DEFAULT: 1

RETURNS DESCRIPTION
float

The asymmetry ratio (0.0 for a constant series).

RAISES DESCRIPTION
ValueError

If delay < 1 or the series has <= delay samples.

References

.. [1] T. Schreiber and A. Schmitz, "Improved surrogate data for nonlinearity tests," Physical Review Letters 77, 635-638 (1996). .. [2] C. Diks, J. C. van Houwelingen, F. Takens, and J. DeGoede, "Reversibility as a criterion for discriminating time series," Physics Letters A 201, 221-228 (1995).

Source code in src/tsdynamics/analysis/surrogate/statistics.py
def time_reversal_asymmetry(data: np.ndarray, delay: int = 1) -> float:
    r"""Time-reversal asymmetry statistic of a scalar series.

    Computes the dimensionless third-moment ratio of the lagged increments,

    .. math::

        T_\text{rev} = \frac{\langle (x_{t} - x_{t-\ell})^3 \rangle}
                            {\langle (x_{t} - x_{t-\ell})^2 \rangle^{3/2}},

    which changes sign under time reversal and is therefore identically zero (in
    expectation) for any time-reversible process — including a linear Gaussian
    process and any static monotonic transform of one, the nulls the Fourier-family
    surrogates realise.  A dissipative deterministic flow breaks that symmetry, so
    a large :math:`|T_\text{rev}|` relative to the surrogates flags nonlinearity.

    Parameters
    ----------
    data : numpy.ndarray
        The 1-D series.
    delay : int, default 1
        The increment lag :math:`\ell` (in samples), ``>= 1``.

    Returns
    -------
    float
        The asymmetry ratio (``0.0`` for a constant series).

    Raises
    ------
    ValueError
        If ``delay < 1`` or the series has ``<= delay`` samples.

    References
    ----------
    .. [1] T. Schreiber and A. Schmitz, "Improved surrogate data for nonlinearity
       tests," Physical Review Letters 77, 635-638 (1996).
    .. [2] C. Diks, J. C. van Houwelingen, F. Takens, and J. DeGoede,
       "Reversibility as a criterion for discriminating time series," Physics
       Letters A 201, 221-228 (1995).
    """
    data = np.asarray(data, dtype=float)
    if delay < 1:
        raise ValueError(f"delay must be >= 1, got {delay}.")
    if data.size <= delay:
        raise ValueError(f"series too short: need > {delay} samples, got {data.size}.")
    increments = data[delay:] - data[:-delay]
    second = float(np.mean(increments**2))
    if second == 0.0:
        return 0.0
    third = float(np.mean(increments**3))
    return float(third / second**1.5)

nonlinear_prediction_error

nonlinear_prediction_error(
    data: ndarray,
    dimension: int = 3,
    delay: int = 1,
    *,
    horizon: int = 1,
    n_neighbors: int = 4,
    theiler: int = 1,
) -> float

Out-of-sample error of a locally-constant phase-space predictor.

Reconstructs the series in dimension delay coordinates (delay delay) and, for each point, predicts the value horizon steps ahead as the mean of the futures of its n_neighbors nearest phase-space neighbours, excluding temporal neighbours within a Theiler window. The returned error is the root-mean-square prediction residual normalised by the standard deviation of the series, so a perfectly unpredictable series scores :math:\approx 1 and a deterministic one scores well below it (Sugihara & May, 1990; Kantz & Schreiber, 2004).

Because determinism is exactly what the linear surrogates lack, a small error relative to the surrogate ensemble is evidence of nonlinear determinism (use a one-sided tail="less" test).

PARAMETER DESCRIPTION
data

The 1-D series.

TYPE: ndarray

dimension

Embedding dimension, >= 1.

TYPE: int DEFAULT: 3

delay

Embedding delay in samples, >= 1.

TYPE: int DEFAULT: 1

horizon

Prediction horizon in samples, >= 1.

TYPE: int DEFAULT: 1

n_neighbors

Number of nearest neighbours averaged per prediction.

TYPE: int DEFAULT: 4

theiler

Half-width of the temporal-exclusion (Theiler) window; neighbours with |i - j| <= theiler are skipped so the predictor cannot cheat with trivially-adjacent points.

TYPE: int DEFAULT: 1

RETURNS DESCRIPTION
float

The normalised RMS prediction error.

RAISES DESCRIPTION
ValueError

If the parameters are out of range or the series is too short to embed and find the requested neighbours.

References

.. [1] G. Sugihara and R. M. May, "Nonlinear forecasting as a way of distinguishing chaos from measurement error in time series," Nature 344, 734-741 (1990). .. [2] H. Kantz and T. Schreiber, Nonlinear Time Series Analysis, 2nd ed., Cambridge University Press (2004).

Source code in src/tsdynamics/analysis/surrogate/statistics.py
def nonlinear_prediction_error(
    data: np.ndarray,
    dimension: int = 3,
    delay: int = 1,
    *,
    horizon: int = 1,
    n_neighbors: int = 4,
    theiler: int = 1,
) -> float:
    r"""Out-of-sample error of a locally-constant phase-space predictor.

    Reconstructs the series in ``dimension`` delay coordinates (delay ``delay``)
    and, for each point, predicts the value ``horizon`` steps ahead as the mean of
    the futures of its ``n_neighbors`` nearest phase-space neighbours, excluding
    temporal neighbours within a Theiler window.  The returned error is the
    root-mean-square prediction residual normalised by the standard deviation of the
    series, so a perfectly unpredictable series scores :math:`\approx 1` and a
    deterministic one scores well below it (Sugihara & May, 1990; Kantz &
    Schreiber, 2004).

    Because determinism is exactly what the linear surrogates lack, a *small* error
    relative to the surrogate ensemble is evidence of nonlinear determinism (use a
    one-sided ``tail="less"`` test).

    Parameters
    ----------
    data : numpy.ndarray
        The 1-D series.
    dimension : int, default 3
        Embedding dimension, ``>= 1``.
    delay : int, default 1
        Embedding delay in samples, ``>= 1``.
    horizon : int, default 1
        Prediction horizon in samples, ``>= 1``.
    n_neighbors : int, default 4
        Number of nearest neighbours averaged per prediction.
    theiler : int, default 1
        Half-width of the temporal-exclusion (Theiler) window; neighbours with
        ``|i - j| <= theiler`` are skipped so the predictor cannot cheat with
        trivially-adjacent points.

    Returns
    -------
    float
        The normalised RMS prediction error.

    Raises
    ------
    ValueError
        If the parameters are out of range or the series is too short to embed and
        find the requested neighbours.

    References
    ----------
    .. [1] G. Sugihara and R. M. May, "Nonlinear forecasting as a way of
       distinguishing chaos from measurement error in time series," Nature 344,
       734-741 (1990).
    .. [2] H. Kantz and T. Schreiber, *Nonlinear Time Series Analysis*, 2nd ed.,
       Cambridge University Press (2004).
    """
    data = np.asarray(data, dtype=float)
    if dimension < 1 or delay < 1 or horizon < 1:
        raise ValueError("dimension, delay and horizon must all be >= 1.")
    if n_neighbors < 1:
        raise ValueError("n_neighbors must be >= 1.")
    if theiler < 0:
        raise ValueError("theiler must be >= 0.")
    span = (dimension - 1) * delay
    n_points = data.size - span - horizon
    if n_points <= n_neighbors + 2 * theiler + 1:
        raise ValueError(
            f"series too short: need more than {n_neighbors + 2 * theiler + 1 + span + horizon} "
            f"samples for dimension={dimension}, delay={delay}, horizon={horizon}, "
            f"got {data.size}."
        )

    # Delay-coordinate vectors that all have a valid `horizon`-ahead target.
    base = np.arange(n_points)
    embed = data[base[:, None] + np.arange(dimension)[None, :] * delay]
    targets = data[base + span + horizon]

    std = float(data.std())
    if std == 0.0:
        return 0.0

    tree = cKDTree(embed)
    # Over-query so the Theiler band can be filtered and still leave n_neighbors.
    k_query = min(n_points, n_neighbors + 2 * theiler + 1)
    _, idx = tree.query(embed, k=k_query)

    predictions = np.empty(n_points, dtype=float)
    valid = np.zeros(n_points, dtype=bool)
    for i in range(n_points):
        neighbours = [j for j in idx[i] if abs(int(j) - i) > theiler]
        if len(neighbours) < n_neighbors:
            continue
        chosen = neighbours[:n_neighbors]
        predictions[i] = targets[chosen].mean()
        valid[i] = True

    if not np.any(valid):
        raise ValueError(
            "no point retained enough non-Theiler neighbours; relax theiler/dimension/delay."
        )
    residual = predictions[valid] - targets[valid]
    return float(np.sqrt(np.mean(residual**2)) / std)

Attractors & basins

The global stability picture of a multistable system. Attractors are located by following trajectories through a cell tessellation until they recurrently revisit cells (Datseris & Wagemakers, 2022); the basin of each is the set of initial conditions reaching it. Basin stability (Menck et al., 2013) is an attractor's share of a sampled region; basin entropy (Daza et al., 2016) and the uncertainty exponent (Grebogi et al., 1983) quantify how fractal the boundaries are; continuation tracks attractors and their basins across a parameter.

find_attractors

find_attractors(
    system: Any,
    region: Box | Ball | Grid,
    *,
    resolution: int | tuple[int, ...] = 100,
    n_seeds: int = 1000,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    merge_tol: float | None = None,
    **fsm: Any,
) -> AttractorSet

Find the attractors a system has within a region, via recurrences.

Tessellate region into cells, draw n_seeds random initial conditions from it, and follow each until it settles into a recurrent cell set (a new attractor) or inherits one already found (Datseris & Wagemakers, 2022).

PARAMETER DESCRIPTION
system

A discrete map or continuous flow. Delay and stochastic systems are not supported (their state is not a finite-dimensional point).

TYPE: System

region

Where to sample initial conditions and the box the recurrence cells cover.

TYPE: Box, Ball, or Grid

resolution

Recurrence cells per axis when region is a Box/Ball (a Grid carries its own counts). Too coarse merges distinct attractors; too fine stops a chaotic trajectory from recurring — tune it to the attractor scale.

TYPE: int or tuple of int DEFAULT: 100

n_seeds

Number of random initial conditions to classify.

TYPE: int DEFAULT: 1000

seed

Seed for the initial-condition sampler (reproducible).

TYPE: int DEFAULT: 0

dt

Integration step between cell checks for a flow (ignored for a map).

TYPE: float DEFAULT: 1.0

max_steps

Per-seed step cap before declaring divergence.

TYPE: int DEFAULT: 10000

merge_tol

Merge attractors whose centroids lie within this distance (a split-set cleanup). None uses two recurrence-cell diagonals; 0 disables it.

TYPE: float DEFAULT: None

**fsm

Finite-state-machine thresholds forwarded to :class:_AttractorMapper (consecutive_recurrences, attractor_locate_steps, attractor_revisits, basin_revisits, lost_steps).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
AttractorSet

The located attractors plus how many seeds diverged.

RAISES DESCRIPTION
TypeError

If system is a delay or stochastic system (their state is not a finite-dimensional point the cell tessellation can bin).

Notes

The seed march is sequential by design, not a parallelism oversight: each seed is followed cell-by-cell, and the persistent cell labels (_att_cells / _bas_cells) accumulated by earlier seeds let later seeds settle cheaply by reaching an already-labelled cell. That shared, order-dependent labelling state is what makes the sweep amortise — and is exactly why the march cannot be parallelised without changing the result or the determinism.

On a supported engine run (an ODE flow or a map whose _step lowers, on the interp / jit backend) the whole per-IC march now runs in one sequential Rust kernel call (stream perf/basin-march) — stepping, cell-binning and the shared-label early-out all in Rust, with no per-dt Python→FFI round-trip — so it is fast without parallelism. The per-cell-check numerics are byte-for-byte the released system.step(), so the result is bit-identical to the pure-Python loop, which stays the fallback (and the oracle) for reference, a non-lowering _step, and DDE/SDE systems.

Why thread-parallelism is a net loss here, and why the kernel is therefore serial (measured, so the next reader does not re-derive it). The shared early-out is the dominant work-saver: on the two-well Duffing 60×60 grid a serial march takes ~42k engine steps, whereas marching every seed independently to max_steps (the only way to lift the serial label dependency) takes ~1.4M — a ~34× work inflation that 16 cores cannot recover (the independent full-march alone clocked ~5× slower than the whole serial run). A "speculative march in parallel, fold in seed order" scheme would reproduce the labels bit-for-bit (each seed's state stream is a pure function of its IC) but pays exactly that 34× over-march, so it is abandoned. Dense-block FFI batching is not an option either: the FSM checks the cell after every per-dt step() restart, and an adaptive dense-output block over several dt does not reproduce those per-dt restart states bit-for-bit (it drifts at ~5e-7), so it would change the cell sequence and the labels. The march stays serial because that is the algorithm; the Rust kernel makes that serial march cheap.

References

G. Datseris and A. Wagemakers, "Effortless estimation of basins of attraction", Chaos 32, 023104 (2022).

Source code in src/tsdynamics/analysis/basins/attractors.py
def find_attractors(
    system: Any,
    region: Box | Ball | Grid,
    *,
    resolution: int | tuple[int, ...] = 100,
    n_seeds: int = 1000,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    merge_tol: float | None = None,
    **fsm: Any,
) -> AttractorSet:
    r"""
    Find the attractors a system has within a region, via recurrences.

    Tessellate ``region`` into cells, draw ``n_seeds`` random initial conditions
    from it, and follow each until it settles into a recurrent cell set (a new
    attractor) or inherits one already found (Datseris & Wagemakers, 2022).

    Parameters
    ----------
    system : System
        A discrete map or continuous flow.  Delay and stochastic systems are not
        supported (their state is not a finite-dimensional point).
    region : Box, Ball, or Grid
        Where to sample initial conditions and the box the recurrence cells cover.
    resolution : int or tuple of int, default 100
        Recurrence cells per axis when ``region`` is a Box/Ball (a Grid carries
        its own ``counts``).  Too coarse merges distinct attractors; too fine
        stops a chaotic trajectory from recurring — tune it to the attractor scale.
    n_seeds : int, default 1000
        Number of random initial conditions to classify.
    seed : int, optional
        Seed for the initial-condition sampler (reproducible).
    dt : float, default 1.0
        Integration step between cell checks for a flow (ignored for a map).
    max_steps : int, default 10000
        Per-seed step cap before declaring divergence.
    merge_tol : float, optional
        Merge attractors whose centroids lie within this distance (a split-set
        cleanup).  ``None`` uses two recurrence-cell diagonals; ``0`` disables it.
    **fsm
        Finite-state-machine thresholds forwarded to :class:`_AttractorMapper`
        (``consecutive_recurrences``, ``attractor_locate_steps``,
        ``attractor_revisits``, ``basin_revisits``, ``lost_steps``).

    Returns
    -------
    AttractorSet
        The located attractors plus how many seeds diverged.

    Raises
    ------
    TypeError
        If ``system`` is a delay or stochastic system (their state is not a
        finite-dimensional point the cell tessellation can bin).

    Notes
    -----
    The seed march is **sequential by design**, not a parallelism oversight: each
    seed is followed cell-by-cell, and the persistent cell labels (``_att_cells`` /
    ``_bas_cells``) accumulated by earlier seeds let later seeds settle cheaply by
    reaching an already-labelled cell.  That shared, order-dependent labelling state
    is what makes the sweep amortise — and is exactly why the march cannot be
    parallelised without changing the result or the determinism.

    On a supported engine run (an ODE flow or a map whose ``_step`` lowers, on the
    ``interp`` / ``jit`` backend) the whole per-IC march now runs in **one
    sequential Rust kernel call** (stream ``perf/basin-march``) — stepping,
    cell-binning and the shared-label early-out all in Rust, with no per-``dt``
    Python→FFI round-trip — so it is fast *without* parallelism.  The per-cell-check
    numerics are byte-for-byte the released ``system.step()``, so the result is
    bit-identical to the pure-Python loop, which stays the fallback (and the oracle)
    for ``reference``, a non-lowering ``_step``, and DDE/SDE systems.

    Why thread-parallelism is a *net loss* here, and why the kernel is therefore
    serial (measured, so the next reader does not re-derive it).  The shared
    early-out is the dominant work-saver: on the two-well Duffing 60×60 grid a
    serial march takes ~42k engine steps, whereas marching every seed independently
    to ``max_steps`` (the only way to lift the serial label dependency) takes
    ~1.4M — a **~34×** work inflation that 16 cores cannot recover (the independent
    full-march alone clocked ~5× *slower* than the whole serial run).  A
    "speculative march in parallel, fold in seed order" scheme would reproduce the
    labels bit-for-bit (each seed's state stream is a pure function of its IC) but
    pays exactly that 34× over-march, so it is abandoned.  Dense-block FFI batching
    is *not* an option either: the FSM checks the cell after every per-``dt``
    ``step()`` restart, and an adaptive dense-output block over several ``dt`` does
    not reproduce those per-``dt`` restart states bit-for-bit (it drifts at ~5e-7),
    so it would change the cell sequence and the labels.  The march stays serial
    because that is the algorithm; the Rust kernel makes that serial march cheap.

    References
    ----------
    G. Datseris and A. Wagemakers, "Effortless estimation of basins of
    attraction", *Chaos* **32**, 023104 (2022).
    """
    _reject_unsupported(system, "find_attractors")

    grid = _recurrence_grid(region, resolution)
    mapper = _AttractorMapper(system, grid, dt=dt, max_steps=max_steps, **fsm)
    draw = sampler(region, seed=seed)

    # Draw the whole seed cloud up front (the sampler order is unchanged, so the
    # classification order — and thus the shared, order-dependent labelling — is
    # identical to the per-seed draw-then-classify loop), then march it: one
    # sequential Rust kernel call on a supported engine run, else the per-seed
    # Python loop (the oracle).  Either way ``mapper`` carries the same FSM state.
    seeds = np.array([draw() for _ in range(int(n_seeds))], dtype=np.float64).reshape(-1, grid.dim)
    from ...engine.run import resolve_backend

    backend = resolve_backend(getattr(system, "_default_backend", "interp"))
    labels = classify_seeds(mapper, seeds, backend=backend, jit=backend == "jit")
    diverged = int(np.sum(labels == DIVERGED))
    merge = mapper.merge_map(resolve_merge_tol(grid, merge_tol))
    found = mapper.attractor_set(diverged=diverged, seeds=int(n_seeds), merge=merge)
    # Attach provenance without re-allocating the (potentially large) attractor
    # dict — ``replace`` reuses every field but ``meta``.
    return replace(found, meta=AnalysisResult.build_meta(system, analysis="find_attractors"))

AttractorSet dataclass

AttractorSet(
    attractors: dict[int, Attractor] = dict(),
    diverged: int = 0,
    seeds: int = 0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

The attractors found in a region, keyed by integer id.

ATTRIBUTE DESCRIPTION
attractors

Located attractors, id → :class:Attractor.

TYPE: dict[int, Attractor]

diverged

How many seeds left the region / never settled.

TYPE: int

seeds

How many seeds were classified in total.

TYPE: int

ids property

ids: list[int]

Sorted attractor ids.

centers property

centers: ndarray

Stack of attractor representatives, shape (n_attractors, dim).

match

match(
    point: Any, *, method: str = "centroid"
) -> int | None

Return the id of the attractor closest to point (or None if empty).

Uses :func:tsdynamics.data.set_distance; point may be a single state or a point cloud.

Source code in src/tsdynamics/analysis/basins/attractors.py
def match(self, point: Any, *, method: str = "centroid") -> int | None:
    """
    Return the id of the attractor closest to ``point`` (or ``None`` if empty).

    Uses :func:`tsdynamics.data.set_distance`; ``point`` may be a single state
    or a point cloud.
    """
    if not self.attractors:
        return None
    pts = np.atleast_2d(np.asarray(point, dtype=float))  # a single state -> (1, dim)
    dists = {
        k: set_distance(self.attractors[k].points, pts, method=cast("_SetMethod", method))
        for k in self.ids
    }
    return min(dists, key=dists.__getitem__)

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the located attractors as a backend-agnostic :class:PlotSpec.

Builds a PHASE_PORTRAIT_2D of every attractor's point cloud as one SCATTER layer (the first two state coordinates). Each point carries a "cat" channel — the attractor id's swatch index in the shared categorical palette (tab20) — so the same id is drawn the same colour here and on the basin image (:meth:BasinsResult.to_plot_spec). The palette name and the fixed diverged colour are recorded in meta so a renderer can reproduce the mapping; the colorbar is marked :attr:~tsdynamics.viz.spec.Colorbar.discrete.

Each id's representative also seeds a category label, so the colour key reads as attractor 1, attractor 2, …. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "phase_portrait_2d"). None uses PHASE_PORTRAIT_2D.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
RAISES DESCRIPTION
VisualizationNotInstalled

If the set holds no attractor with a ≥ 2-D point cloud to scatter.

Source code in src/tsdynamics/analysis/basins/attractors.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the located attractors as a backend-agnostic :class:`PlotSpec`.

    Builds a ``PHASE_PORTRAIT_2D`` of every attractor's point cloud as one
    ``SCATTER`` layer (the first two state coordinates).  Each point carries
    a ``"cat"`` channel — the attractor id's swatch index in the shared
    categorical palette (``tab20``) — so the same id is drawn the same colour
    here and on the basin image (:meth:`BasinsResult.to_plot_spec`).  The
    palette name and the fixed diverged colour are recorded in ``meta`` so a
    renderer can reproduce the mapping; the colorbar is marked
    :attr:`~tsdynamics.viz.spec.Colorbar.discrete`.

    Each id's representative also seeds a category label, so the colour key
    reads as ``attractor 1``, ``attractor 2``, ….  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls
    a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"phase_portrait_2d"``).  ``None``
        uses ``PHASE_PORTRAIT_2D``.

    Returns
    -------
    PlotSpec

    Raises
    ------
    VisualizationNotInstalled
        If the set holds no attractor with a ≥ 2-D point cloud to scatter.
    """
    from tsdynamics.analysis._result import VisualizationNotInstalled
    from tsdynamics.viz.spec import Colorbar

    from .. import _plotbuilder as pb

    ids = self.ids
    swatch = _palette_indices(ids)

    xs: list[np.ndarray] = []
    ys: list[np.ndarray] = []
    cats: list[np.ndarray] = []
    for aid in ids:
        pts = np.atleast_2d(np.asarray(self.attractors[aid].points, dtype=float))
        if pts.shape[1] < 2 or pts.shape[0] == 0:
            continue
        xs.append(pts[:, 0])
        ys.append(pts[:, 1])
        cats.append(np.full(pts.shape[0], swatch[aid], dtype=int))

    if not xs:
        raise VisualizationNotInstalled(
            "AttractorSet holds no attractor with a 2-D point cloud to scatter; "
            "export it with .to_dict() instead."
        )

    layer = pb.scatter(
        np.concatenate(xs),
        np.concatenate(ys),
        cat=np.concatenate(cats),
        label="attractors",
        style={"cmap": PALETTE},
    )
    meta = dict(self.meta) if self.meta else {}
    meta.update(
        palette=PALETTE,
        diverged_color=DIVERGED_COLOR,
        palette_index=swatch,
        palette_labels=[f"attractor {aid}" for aid in ids],
    )
    return pb.spec(
        kind,
        "phase_portrait_2d",
        layers=[layer],
        aspect="equal",
        xlabel="x1",
        ylabel="x2",
        title=f"attractors ({len(self)})",
        colorbar=Colorbar(label="attractor", cmap=PALETTE, discrete=True),
        meta=meta,
    )

Attractor dataclass

Attractor(
    id: int = 0,
    points: ndarray = (lambda: empty((0, 0)))(),
    cells: int = 0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

One located attractor: a point cloud of states sampled on it.

ATTRIBUTE DESCRIPTION
id

Integer label (>= 1) identifying this attractor within its set.

TYPE: int

points

States sampled while the trajectory was on the attractor. A fixed point collapses to one repeated point; a cycle/chaotic set spreads out.

TYPE: (ndarray, shape(m, dim))

cells

Number of distinct grid cells the attractor occupies (a coarse size).

TYPE: int

center property

center: ndarray

The centroid of the point cloud (an attractor representative).

dim property

dim: int

State-space dimension.

basins_of_attraction

basins_of_attraction(
    system: Any,
    region: Grid,
    *,
    recurrence: Box | Grid | None = None,
    recurrence_resolution: int | tuple[int, ...] = 100,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    merge_tol: float | None = None,
    **fsm: Any,
) -> BasinsResult

Classify every point of a grid region by the attractor it converges to.

Each lattice point is followed until it settles into a recurrent cell set (Datseris & Wagemakers, 2022); by default the region doubles as the recurrence tessellation, so labels accumulate and most points settle cheaply by reaching an already-labelled cell.

For a higher-dimensional flow whose basins are viewed on a slice (e.g. a position grid of a system that also carries velocities), pass a full-dimension recurrence box covering the whole trajectory range and let region be the thin slice of initial conditions (free axes pinned with counts == 1).

PARAMETER DESCRIPTION
system

A discrete map or continuous flow.

TYPE: System

region

The lattice of initial conditions (full state dimension; a slice pins free axes with counts == 1). Build one with :func:tsdynamics.data.region. Its counts set the recurrence resolution when recurrence is not given.

TYPE: Grid

recurrence

Full-dimension region whose tessellation recurrences are detected on. Defaults to region itself (correct for maps and full grids; required when region is a degenerate slice).

TYPE: Box or Grid DEFAULT: None

recurrence_resolution

Recurrence cells per axis when recurrence is a Box.

TYPE: int or tuple of int DEFAULT: 100

seed

Accepted for signature uniformity with :func:find_attractors / :func:basin_fractions; the full-grid scan is deterministic, so seed does not change the labelling (it is recorded in provenance).

TYPE: int DEFAULT: 0

dt

Integration step between cell checks for a flow (ignored for a map).

TYPE: float DEFAULT: 1.0

max_steps

Per-point step cap before declaring divergence.

TYPE: int DEFAULT: 10000

merge_tol

Merge attractors whose centroids lie within this distance. None uses two recurrence-cell diagonals; 0 disables it.

TYPE: float DEFAULT: None

**fsm

Finite-state-machine thresholds forwarded to :class:~tsdynamics.analysis.basins.attractors._AttractorMapper.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
BasinsResult

The labelled basin image and the attractors it refers to.

RAISES DESCRIPTION
TypeError

If system is a delay or stochastic system (unsupported by the recurrence finder).

References

G. Datseris and A. Wagemakers, "Effortless estimation of basins of attraction", Chaos 32, 023104 (2022).

Source code in src/tsdynamics/analysis/basins/basins.py
def basins_of_attraction(
    system: Any,
    region: Grid,
    *,
    recurrence: Box | Grid | None = None,
    recurrence_resolution: int | tuple[int, ...] = 100,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    merge_tol: float | None = None,
    **fsm: Any,
) -> BasinsResult:
    r"""
    Classify every point of a grid region by the attractor it converges to.

    Each lattice point is followed until it settles into a recurrent cell set
    (Datseris & Wagemakers, 2022); by default the region doubles as the recurrence
    tessellation, so labels accumulate and most points settle cheaply by reaching
    an already-labelled cell.

    For a higher-dimensional flow whose basins are viewed on a slice (e.g. a
    position grid of a system that also carries velocities), pass a full-dimension
    ``recurrence`` box covering the whole trajectory range and let ``region`` be the
    thin slice of initial conditions (free axes pinned with ``counts == 1``).

    Parameters
    ----------
    system : System
        A discrete map or continuous flow.
    region : Grid
        The lattice of initial conditions (full state dimension; a slice pins free
        axes with ``counts == 1``).  Build one with
        :func:`tsdynamics.data.region`.  Its ``counts`` set the recurrence
        resolution when ``recurrence`` is not given.
    recurrence : Box or Grid, optional
        Full-dimension region whose tessellation recurrences are detected on.
        Defaults to ``region`` itself (correct for maps and full grids; required
        when ``region`` is a degenerate slice).
    recurrence_resolution : int or tuple of int, default 100
        Recurrence cells per axis when ``recurrence`` is a Box.
    seed : int, optional
        Accepted for signature uniformity with :func:`find_attractors` /
        :func:`basin_fractions`; the full-grid scan is deterministic, so ``seed``
        does not change the labelling (it is recorded in provenance).
    dt : float, default 1.0
        Integration step between cell checks for a flow (ignored for a map).
    max_steps : int, default 10000
        Per-point step cap before declaring divergence.
    merge_tol : float, optional
        Merge attractors whose centroids lie within this distance.  ``None`` uses
        two recurrence-cell diagonals; ``0`` disables it.
    **fsm
        Finite-state-machine thresholds forwarded to
        :class:`~tsdynamics.analysis.basins.attractors._AttractorMapper`.

    Returns
    -------
    BasinsResult
        The labelled basin image and the attractors it refers to.

    Raises
    ------
    TypeError
        If ``system`` is a delay or stochastic system (unsupported by the
        recurrence finder).

    References
    ----------
    G. Datseris and A. Wagemakers, "Effortless estimation of basins of
    attraction", *Chaos* **32**, 023104 (2022).
    """
    _reject_unsupported(system, "basins_of_attraction")
    if recurrence is None:
        # region is a Grid → keeps its own counts (resolution arg is ignored).
        cellgrid = _recurrence_grid(region)
    else:
        cellgrid = _recurrence_grid(recurrence, recurrence_resolution)
    mapper = _AttractorMapper(system, cellgrid, dt=dt, max_steps=max_steps, **fsm)

    # Classify every lattice point.  On a supported engine run (an ODE flow / a map
    # whose ``_step`` lowers, ``interp`` / ``jit``) the whole grid marches in one
    # sequential Rust kernel call (stream ``perf/basin-march``) — bit-identical to,
    # and falling back on, the per-point Python loop.  ``grid_points`` order is the
    # classification order, so the shared labelling accumulates exactly as before.
    points = grid_points(region)
    from ...engine.run import resolve_backend

    backend = resolve_backend(getattr(system, "_default_backend", "interp"))
    labels = classify_seeds(mapper, points, backend=backend, jit=backend == "jit")
    diverged = int(np.sum(labels == DIVERGED))

    merge = mapper.merge_map(resolve_merge_tol(cellgrid, merge_tol))
    labels = _apply_merge(labels.reshape(region.shape), merge)
    attractors = mapper.attractor_set(diverged=diverged, seeds=points.shape[0], merge=merge)
    return BasinsResult(
        labels=labels,
        grid=region,
        attractors=attractors,
        meta=AnalysisResult.build_meta(system, analysis="basins_of_attraction", seed=seed),
    )

BasinsResult dataclass

BasinsResult(
    labels: ndarray = (lambda: empty(0, dtype=int))(),
    grid: Grid | None = None,
    attractors: AttractorSet = AttractorSet(),
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A basin diagram: every grid cell labelled by the attractor it reaches.

ATTRIBUTE DESCRIPTION
labels

Attractor id (>= 1) per grid cell, shaped like grid.shape; -1 marks diverged / unsettled cells.

TYPE: ndarray of int

grid

The lattice the labels are laid out on.

TYPE: Grid

attractors

The attractors the labels refer to.

TYPE: AttractorSet

shape property

shape: tuple[int, ...]

Grid shape of the basin image.

n_attractors property

n_attractors: int

Number of distinct attractors present in the image.

fractions property

fractions: dict[int, float]

Fraction of grid cells in each attractor's basin (diverged cells excluded).

Keyed by attractor id (>= 1); the diverged share is reported separately by :attr:diverged_fraction (so this mirrors :attr:BasinFractions.fractions).

diverged_fraction property

diverged_fraction: float

Fraction of cells that diverged / never settled.

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe this basin diagram as a backend-agnostic :class:PlotSpec.

Builds a BASINS_IMAGE spec — the integer label field as an image on an "equal" canvas, the grid axes giving the extent, plus a marker layer at the attractor representatives (for a 2-D image). A 3-D slice (a label cube with one degenerate counts == 1 axis, as :func:basins_of_attraction paints when imaging a slice of a higher-dimensional flow) is squeezed to its two non-degenerate axes so it renders as a 2-D image; a genuinely 3-D label cube keeps all three axes on the spec.

The image shares the attractor palette (tab20) with :meth:AttractorSet.to_plot_spec: the explicit {id: swatch index} mapping is recorded in meta["palette_index"] (identical to the one the scatter carries), so a given attractor id is the same colour in both views; meta["palette"] / meta["diverged_color"] name the colormap and the fixed escape colour. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "basins_image"). None uses BASINS_IMAGE.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/basins/basins.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    """Describe this basin diagram as a backend-agnostic :class:`PlotSpec`.

    Builds a ``BASINS_IMAGE`` spec — the integer label field as an image on
    an ``"equal"`` canvas, the grid axes giving the extent, plus a marker
    layer at the attractor representatives (for a 2-D image).  A **3-D slice**
    (a label cube with one degenerate ``counts == 1`` axis, as
    :func:`basins_of_attraction` paints when imaging a slice of a
    higher-dimensional flow) is squeezed to its two non-degenerate axes so it
    renders as a 2-D image; a genuinely 3-D label cube keeps all three axes
    on the spec.

    The image shares the attractor palette (``tab20``) with
    :meth:`AttractorSet.to_plot_spec`: the explicit ``{id: swatch index}``
    mapping is recorded in ``meta["palette_index"]`` (identical to the one the
    scatter carries), so a given attractor id is the same colour in both
    views; ``meta["palette"]`` / ``meta["diverged_color"]`` name the colormap
    and the fixed escape colour.  The :mod:`tsdynamics.viz.spec` import is
    lazy, so building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"basins_image"``).  ``None`` uses
        ``BASINS_IMAGE``.

    Returns
    -------
    PlotSpec
    """
    from tsdynamics.viz.spec import Colorbar

    from .. import _plotbuilder as pb

    labels = np.asarray(self.labels)

    assert self.grid is not None  # a populated basin image always carries its grid
    lo, hi = self.grid.lo, self.grid.hi

    # Pick the two axes the image spans.  A degenerate (``counts == 1``) grid
    # axis is a pinned slice coordinate — drop it so a 3-D slice paints as a
    # plain 2-D image on its two free axes.
    axes = [a for a in range(labels.ndim) if labels.shape[a] > 1]
    if labels.ndim == 3 and len(axes) == 2:
        labels = np.squeeze(labels, axis=tuple(a for a in range(labels.ndim) if a not in axes))
    else:
        axes = list(range(min(labels.ndim, 2)))

    layers = [pb.image(labels, style={"cmap": PALETTE})]

    # Mark the attractor representatives on a 2-D image, projected onto the
    # two free axes.  Lower-dim grids skip the overlay.
    if labels.ndim == 2:
        centers = self.attractors.centers
        ax0, ax1 = (axes + [0, 1])[:2]
        if centers.size and centers.shape[1] > max(ax0, ax1):
            layers.append(
                pb.markers(
                    centers[:, ax0],
                    centers[:, ax1],
                    label="attractors",
                    style={"marker": "*", "color": "black"},
                )
            )

    ax0, ax1 = (axes + [0, 1])[:2]
    x_lim = (float(lo[ax0]), float(hi[ax0])) if lo.size > ax0 else None
    y_lim = (float(lo[ax1]), float(hi[ax1])) if lo.size > ax1 else None

    meta = dict(self.meta) if self.meta else {}
    meta.update(
        palette=PALETTE,
        diverged_color=DIVERGED_COLOR,
        palette_index=_palette_indices(self.attractors.ids),
    )
    return pb.spec(
        kind,
        "basins_image",
        layers=layers,
        aspect="equal",
        xlabel=f"x{ax0 + 1}",
        xlimits=x_lim,
        ylabel=f"x{ax1 + 1}",
        ylimits=y_lim,
        title=f"basins ({self.n_attractors} attractors)",
        colorbar=Colorbar(label="attractor", cmap=PALETTE, discrete=True),
        meta=meta,
    )

basin_fractions

basin_fractions(
    system: Any,
    region: Box | Ball | Grid,
    *,
    n: int = 10000,
    resolution: int | tuple[int, ...] = 100,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    merge_tol: float | None = None,
    **fsm: Any,
) -> BasinFractions

Estimate basin stability: each attractor's share of a sampled region.

Draw n random initial conditions from region and classify each; the fraction converging to an attractor estimates its basin stability (Menck et al., 2013). The estimate is dimension-free — its standard error :math:\sqrt{p(1-p)/n} depends only on the fraction and n.

PARAMETER DESCRIPTION
system

A discrete map or continuous flow.

TYPE: System

region

The measure to sample initial conditions from (uniform over a Box/Ball, or a Grid's bounding box).

TYPE: Box, Ball, or Grid

n

Number of random initial conditions.

TYPE: int DEFAULT: 10000

resolution

Recurrence cells per axis (a Grid uses its own counts).

TYPE: int or tuple of int DEFAULT: 100

seed

Seed for the sampler (reproducible).

TYPE: int DEFAULT: 0

dt

Integration step between cell checks for a flow (ignored for a map).

TYPE: float DEFAULT: 1.0

max_steps

Per-sample step cap before declaring divergence.

TYPE: int DEFAULT: 10000

merge_tol

Merge attractors whose centroids lie within this distance. None uses two recurrence-cell diagonals; 0 disables it.

TYPE: float DEFAULT: None

**fsm

Finite-state-machine thresholds forwarded to :class:~tsdynamics.analysis.basins.attractors._AttractorMapper.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
BasinFractions

Fractions per attractor, the diverged share, and the attractors.

RAISES DESCRIPTION
TypeError

If system is a delay or stochastic system (unsupported by the recurrence finder).

References

P. J. Menck, J. Heitzig, N. Marwan and J. Kurths, "How basin stability complements the linear-stability paradigm", Nature Physics 9, 89 (2013).

Source code in src/tsdynamics/analysis/basins/basins.py
def basin_fractions(
    system: Any,
    region: Box | Ball | Grid,
    *,
    n: int = 10000,
    resolution: int | tuple[int, ...] = 100,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    merge_tol: float | None = None,
    **fsm: Any,
) -> BasinFractions:
    r"""
    Estimate basin stability: each attractor's share of a sampled region.

    Draw ``n`` random initial conditions from ``region`` and classify each; the
    fraction converging to an attractor estimates its basin stability (Menck et
    al., 2013).  The estimate is dimension-free — its standard error
    :math:`\sqrt{p(1-p)/n}` depends only on the fraction and ``n``.

    Parameters
    ----------
    system : System
        A discrete map or continuous flow.
    region : Box, Ball, or Grid
        The measure to sample initial conditions from (uniform over a Box/Ball, or
        a Grid's bounding box).
    n : int, default 10000
        Number of random initial conditions.
    resolution : int or tuple of int, default 100
        Recurrence cells per axis (a Grid uses its own ``counts``).
    seed : int, optional
        Seed for the sampler (reproducible).
    dt : float, default 1.0
        Integration step between cell checks for a flow (ignored for a map).
    max_steps : int, default 10000
        Per-sample step cap before declaring divergence.
    merge_tol : float, optional
        Merge attractors whose centroids lie within this distance.  ``None`` uses
        two recurrence-cell diagonals; ``0`` disables it.
    **fsm
        Finite-state-machine thresholds forwarded to
        :class:`~tsdynamics.analysis.basins.attractors._AttractorMapper`.

    Returns
    -------
    BasinFractions
        Fractions per attractor, the diverged share, and the attractors.

    Raises
    ------
    TypeError
        If ``system`` is a delay or stochastic system (unsupported by the
        recurrence finder).

    References
    ----------
    P. J. Menck, J. Heitzig, N. Marwan and J. Kurths, "How basin stability
    complements the linear-stability paradigm", *Nature Physics* **9**, 89 (2013).
    """
    _reject_unsupported(system, "basin_fractions")
    cellgrid = _recurrence_grid(region, resolution)
    mapper = _AttractorMapper(system, cellgrid, dt=dt, max_steps=max_steps, **fsm)
    draw = sampler(region, seed=seed)

    n = int(n)
    # Draw the whole sample up front (the sampler order — and so the labelling
    # order — is unchanged) and march it: one sequential Rust kernel call on a
    # supported engine run, else the per-sample Python loop (the oracle).  This
    # also accelerates :func:`continuation`, which sweeps ``basin_fractions``.
    samples = np.array([draw() for _ in range(n)], dtype=np.float64).reshape(-1, cellgrid.dim)
    from ...engine.run import resolve_backend

    backend = resolve_backend(getattr(system, "_default_backend", "interp"))
    labels = classify_seeds(mapper, samples, backend=backend, jit=backend == "jit")
    diverged = int(np.sum(labels == DIVERGED))
    counts: dict[int, int] = {}
    for lab in labels[labels != DIVERGED]:
        counts[int(lab)] = counts.get(int(lab), 0) + 1

    merge = mapper.merge_map(resolve_merge_tol(cellgrid, merge_tol))
    merged_counts: dict[int, int] = {}
    for k, c in counts.items():
        cid = merge.get(k, k)
        merged_counts[cid] = merged_counts.get(cid, 0) + c

    fractions = {k: c / n for k, c in merged_counts.items()}
    attractors = mapper.attractor_set(diverged=diverged, seeds=n, merge=merge)
    return BasinFractions(
        fractions=fractions,
        diverged=diverged / n,
        n=n,
        attractors=attractors,
        meta=AnalysisResult.build_meta(system, analysis="basin_fractions"),
    )

BasinFractions dataclass

BasinFractions(
    fractions: dict[int, float] = dict(),
    diverged: float = 0.0,
    n: int = 0,
    attractors: AttractorSet = AttractorSet(),
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Monte-Carlo basin stability: each attractor's share of a sampled region.

ATTRIBUTE DESCRIPTION
fractions

Attractor id → fraction of sampled initial conditions converging to it.

TYPE: dict[int, float]

diverged

Fraction of samples that diverged / never settled.

TYPE: float

n

Number of initial conditions sampled.

TYPE: int

attractors

The attractors the ids refer to.

TYPE: AttractorSet

standard_error property

standard_error: dict[int, float]

Binomial standard error :math:\sqrt{p(1-p)/n} per fraction.

dominant property

dominant: int | None

Id of the attractor with the largest basin (None if all diverged).

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the basin fractions as a backend-agnostic :class:PlotSpec.

Builds a CATEGORICAL_BAR — one BAR per attractor id (plus a final bar for the diverged share when it is non-zero) over a categorical x-axis whose :attr:~tsdynamics.viz.spec.Axis.categories carry the labels (attractor 1, …, diverged). The "cat" channel holds the integer category index for each bar and "y" its basin fraction. The bars are coloured from the shared attractor palette (tab20, recorded in meta["palette"]), the diverged bar in the fixed diverged colour, so an id keeps its colour across the basin views. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "categorical_bar"). None uses CATEGORICAL_BAR.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/basins/basins.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the basin fractions as a backend-agnostic :class:`PlotSpec`.

    Builds a ``CATEGORICAL_BAR`` — one ``BAR`` per attractor id (plus a final
    bar for the diverged share when it is non-zero) over a categorical x-axis
    whose :attr:`~tsdynamics.viz.spec.Axis.categories` carry the labels
    (``attractor 1``, …, ``diverged``).  The ``"cat"`` channel holds the
    integer category index for each bar and ``"y"`` its basin fraction.  The
    bars are coloured from the shared attractor palette (``tab20``, recorded
    in ``meta["palette"]``), the diverged bar in the fixed diverged colour, so
    an id keeps its colour across the basin views.  The
    :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never pulls a
    plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"categorical_bar"``).  ``None`` uses
        ``CATEGORICAL_BAR``.

    Returns
    -------
    PlotSpec
    """
    from tsdynamics.viz.spec import Colorbar

    from .. import _plotbuilder as pb

    ids = sorted(self.fractions)
    swatch = _palette_indices(ids)

    categories = [f"attractor {aid}" for aid in ids]
    heights = [float(self.fractions[aid]) for aid in ids]
    if self.diverged > 0.0:
        categories.append("diverged")
        heights.append(float(self.diverged))

    ticks = [float(i) for i in range(len(categories))]
    positions = np.asarray(ticks, dtype=float)
    layer = pb.bar(
        np.asarray(heights, dtype=float),
        cat=positions,
        label="basin fraction",
        style={"cmap": PALETTE},
    )
    meta = dict(self.meta) if self.meta else {}
    meta.update(
        palette=PALETTE,
        diverged_color=DIVERGED_COLOR,
        palette_index=swatch,
    )
    return pb.spec(
        kind,
        "categorical_bar",
        layers=[layer],
        xlabel="attractor",
        xscale="categorical",
        xcategories=categories,
        xticks=ticks,
        ylabel="basin fraction",
        ylimits=(0.0, 1.0),
        title="basin stability",
        colorbar=Colorbar(label="attractor", cmap=PALETTE, discrete=True),
        meta=meta,
    )

Boundary structure

basin_entropy

basin_entropy(
    basins: Any,
    *,
    box_size: int = 5,
    base: float = e,
    include_diverged: bool = False,
) -> BasinEntropy

Basin entropy :math:S_b and boundary basin entropy :math:S_{bb}.

Partition the basin image into non-overlapping boxes of box_size cells per axis. Within box :math:i, with colour fractions :math:p_{ij}, the Gibbs entropy is :math:S_i = -\sum_j p_{ij}\log p_{ij}. Then :math:S_b = \langle S_i\rangle over all boxes and :math:S_{bb} = \langle S_i\rangle over boundary boxes (more than one colour). :math:S_{bb} > \log 2 is sufficient for a fractal boundary, since a box straddling a smooth boundary holds at most two colours.

PARAMETER DESCRIPTION
basins

The basin image (attractor ids >= 1; -1 marks diverged / escape).

TYPE: BasinsResult or array-like of int

box_size

Box side length in cells.

TYPE: int DEFAULT: 5

base

Logarithm base. With the default natural log the fractal threshold is :math:\log 2 \approx 0.693.

TYPE: float DEFAULT: e

include_diverged

If False (the default), diverged cells (-1) are dropped before the per-box colour count — escape is not a basin, so it must not inflate the entropy as a spurious extra colour. A box that is entirely diverged then holds no settled basin and contributes zero entropy, but still counts in the box total :math:N (Daza et al. (2016) average :math:S_b over all :math:N boxes). Set True to count -1 as its own colour (the legacy behaviour).

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
BasinEntropy
RAISES DESCRIPTION
ValueError

If box_size < 1 or the label array yields no boxes (it is empty).

References

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", Scientific Reports 6, 31416 (2016).

Source code in src/tsdynamics/analysis/basins/metrics.py
def basin_entropy(
    basins: Any, *, box_size: int = 5, base: float = np.e, include_diverged: bool = False
) -> BasinEntropy:
    r"""
    Basin entropy :math:`S_b` and boundary basin entropy :math:`S_{bb}`.

    Partition the basin image into non-overlapping boxes of ``box_size`` cells per
    axis.  Within box :math:`i`, with colour fractions :math:`p_{ij}`, the Gibbs
    entropy is :math:`S_i = -\sum_j p_{ij}\log p_{ij}`.  Then
    :math:`S_b = \langle S_i\rangle` over all boxes and
    :math:`S_{bb} = \langle S_i\rangle` over boundary boxes (more than one
    colour).  :math:`S_{bb} > \log 2` is sufficient for a fractal boundary, since
    a box straddling a smooth boundary holds at most two colours.

    Parameters
    ----------
    basins : BasinsResult or array-like of int
        The basin image (attractor ids ``>= 1``; ``-1`` marks diverged / escape).
    box_size : int, default 5
        Box side length in cells.
    base : float, default e
        Logarithm base.  With the default natural log the fractal threshold is
        :math:`\log 2 \approx 0.693`.
    include_diverged : bool, default False
        If ``False`` (the default), diverged cells (``-1``) are dropped before the
        per-box colour count — escape is not a basin, so it must not inflate the
        entropy as a spurious extra colour.  A box that is *entirely* diverged then
        holds no settled basin and contributes zero entropy, but still counts in
        the box total :math:`N` (Daza et al. (2016) average :math:`S_b` over all
        :math:`N` boxes).  Set ``True`` to count ``-1`` as its own colour (the
        legacy behaviour).

    Returns
    -------
    BasinEntropy

    Raises
    ------
    ValueError
        If ``box_size < 1`` or the label array yields no boxes (it is empty).

    References
    ----------
    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",
    *Scientific Reports* **6**, 31416 (2016).
    """
    labels = _as_label_array(basins)
    if box_size < 1:
        raise ValueError(f"box_size must be >= 1, got {box_size}")
    log = np.log(base)

    box_entropies: list[float] = []
    n_boundary = 0
    for block in _iter_blocks(labels, box_size):
        flat = block.reshape(-1)
        if flat.size == 0:
            continue  # a zero-area block can only arise from an empty grid edge.
        if not include_diverged:
            flat = flat[flat != -1]  # escape is not a colour
            if flat.size == 0:
                # A fully diverged box holds no settled basin → zero Gibbs entropy.
                # Daza et al. (2016) normalise S_b over *all* N boxes, so an empty
                # box must still count in N (as a zero), not be skipped.
                box_entropies.append(0.0)
                continue
        _, counts = np.unique(flat, return_counts=True)
        p = counts / flat.size
        s = float(-np.sum(p * np.log(p)) / log)
        box_entropies.append(s)
        if counts.size > 1:
            n_boundary += 1

    if not box_entropies:
        raise ValueError("no boxes to analyse (empty label array).")

    entropies = np.asarray(box_entropies)
    sb = float(entropies.mean())
    boundary_mask = entropies > 0.0
    sbb = float(entropies[boundary_mask].mean()) if n_boundary else float("nan")
    threshold = np.log(2.0) / log
    fractal = bool(np.isfinite(sbb) and sbb > threshold)
    return BasinEntropy(
        sb=sb,
        sbb=sbb,
        n_boxes=len(box_entropies),
        n_boundary_boxes=n_boundary,
        box_size=int(box_size),
        log_base=float(base),
        fractal_boundary=fractal,
        meta={"analysis": "basin_entropy", "box_size": int(box_size)},
    )

BasinEntropy dataclass

BasinEntropy(
    sb: float = 0.0,
    sbb: float = 0.0,
    n_boxes: int = 0,
    n_boundary_boxes: int = 0,
    box_size: int = 0,
    log_base: float = 0.0,
    fractal_boundary: bool = False,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Basin entropy and boundary basin entropy of a basin diagram.

ATTRIBUTE DESCRIPTION
sb

Basin entropy :math:S_b — the mean Gibbs entropy over all boxes.

TYPE: float

sbb

Boundary basin entropy :math:S_{bb} — the mean over boundary boxes only (those holding more than one basin). nan if there is no boundary box.

TYPE: float

n_boxes

Number of boxes the grid was partitioned into.

TYPE: int

n_boundary_boxes

Number of boxes containing more than one basin.

TYPE: int

box_size

Box side length in cells.

TYPE: int

log_base

Base of the logarithm (e by default).

TYPE: float

fractal_boundary

True when :math:S_{bb} > \log 2, the sufficient fractal-boundary criterion of Daza et al. (2016).

TYPE: bool

uncertainty_exponent

uncertainty_exponent(
    basins: Any,
    *,
    radii: tuple[int, ...] = (1, 2, 3, 4, 5),
    cell_size: float | ndarray = 1.0,
    include_diverged: bool = False,
) -> UncertaintyExponent

Estimate the uncertainty exponent of a basin boundary.

A cell is :math:\varepsilon-uncertain when at least one axis-neighbour at distance :math:\varepsilon carries a different basin label. The fraction of such cells scales as :math:f(\varepsilon)\sim\varepsilon^{\alpha} (Grebogi et al., 1983); :math:\alpha is the slope of a log-log fit and the boundary box-counting dimension is :math:D_0 = D - \alpha.

PARAMETER DESCRIPTION
basins

The basin image.

TYPE: BasinsResult or array-like of int

radii

Perturbation radii in cells.

TYPE: tuple of int DEFAULT: (1, 2, 3, 4, 5)

cell_size

Physical cell spacing (a scalar or per-axis); rescales epsilons only — the exponent is invariant to it.

TYPE: float or array - like DEFAULT: 1.0

include_diverged

If False (the default), diverged cells (-1) are excluded: a cell is :math:\varepsilon-uncertain only when a same-distance neighbour carries a different settled basin, and the fraction is taken over settled cells. Escape is not a basin, so a basin/escape interface is not a final-state boundary. Set True to treat -1 as just another label.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
UncertaintyExponent
RAISES DESCRIPTION
ValueError

If fewer than two radii are given, or fewer than two non-zero :math:f(\varepsilon) values are available to fit a slope (no boundary).

References

C. Grebogi, S. W. McDonald, E. Ott and J. A. Yorke, "Final state sensitivity: an obstruction to predictability", Physics Letters A 99, 415 (1983).

Source code in src/tsdynamics/analysis/basins/metrics.py
def uncertainty_exponent(
    basins: Any,
    *,
    radii: tuple[int, ...] = (1, 2, 3, 4, 5),
    cell_size: float | np.ndarray = 1.0,
    include_diverged: bool = False,
) -> UncertaintyExponent:
    r"""
    Estimate the uncertainty exponent of a basin boundary.

    A cell is :math:`\varepsilon`-uncertain when at least one axis-neighbour at
    distance :math:`\varepsilon` carries a different basin label.  The fraction of
    such cells scales as :math:`f(\varepsilon)\sim\varepsilon^{\alpha}`
    (Grebogi et al., 1983); :math:`\alpha` is the slope of a log-log fit and the
    boundary box-counting dimension is :math:`D_0 = D - \alpha`.

    Parameters
    ----------
    basins : BasinsResult or array-like of int
        The basin image.
    radii : tuple of int, default (1, 2, 3, 4, 5)
        Perturbation radii in cells.
    cell_size : float or array-like, default 1.0
        Physical cell spacing (a scalar or per-axis); rescales ``epsilons`` only —
        the exponent is invariant to it.
    include_diverged : bool, default False
        If ``False`` (the default), diverged cells (``-1``) are excluded: a cell is
        :math:`\varepsilon`-uncertain only when a same-distance neighbour carries a
        *different settled basin*, and the fraction is taken over settled cells.
        Escape is not a basin, so a basin/escape interface is not a final-state
        boundary.  Set ``True`` to treat ``-1`` as just another label.

    Returns
    -------
    UncertaintyExponent

    Raises
    ------
    ValueError
        If fewer than two ``radii`` are given, or fewer than two non-zero
        :math:`f(\varepsilon)` values are available to fit a slope (no boundary).

    References
    ----------
    C. Grebogi, S. W. McDonald, E. Ott and J. A. Yorke, "Final state sensitivity:
    an obstruction to predictability", *Physics Letters A* **99**, 415 (1983).
    """
    labels = _as_label_array(basins)
    radii = tuple(int(r) for r in radii)
    if len(radii) < 2:
        raise ValueError("need at least two radii to fit a slope.")

    valid = None if include_diverged else (labels != -1)  # -1 == DIVERGED/escape
    fractions = np.array([_uncertain_fraction(labels, m, valid) for m in radii])
    spacing = float(np.mean(np.atleast_1d(cell_size)))
    epsilons = np.asarray(radii, dtype=float) * spacing

    positive = fractions > 0.0
    if positive.sum() < 2:
        raise ValueError("not enough non-zero f(epsilon) values to fit (no boundary?).")

    log_eps = np.log(epsilons[positive])
    log_f = np.log(fractions[positive])
    slope, intercept = np.polyfit(log_eps, log_f, 1)
    fit = slope * log_eps + intercept
    ss_res = float(np.sum((log_f - fit) ** 2))
    ss_tot = float(np.sum((log_f - log_f.mean()) ** 2))
    r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0

    alpha = float(slope)
    dim = labels.ndim
    return UncertaintyExponent(
        alpha=alpha,
        boundary_dimension=float(dim - alpha),
        state_dimension=dim,
        epsilons=epsilons,
        f=fractions,
        r_squared=float(r2),
        meta={"analysis": "uncertainty_exponent", "state_dimension": int(dim)},
    )

UncertaintyExponent dataclass

UncertaintyExponent(
    alpha: float = 0.0,
    boundary_dimension: float = 0.0,
    state_dimension: int = 0,
    epsilons: ndarray = (lambda: empty(0))(),
    f: ndarray = (lambda: empty(0))(),
    r_squared: float = 0.0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

The uncertainty exponent of a basin boundary.

ATTRIBUTE DESCRIPTION
alpha

Uncertainty exponent :math:\alpha (slope of :math:\log f vs :math:\log\varepsilon); 0 = boundary fills the space (maximally unpredictable), 1 = smooth boundary.

TYPE: float

boundary_dimension

Box-counting dimension of the boundary, :math:D_0 = D - \alpha.

TYPE: float

state_dimension

State-space (grid) dimension :math:D.

TYPE: int

epsilons

Perturbation radii used (in state-space units).

TYPE: ndarray

f

Fraction of :math:\varepsilon-uncertain cells at each radius.

TYPE: ndarray

r_squared

Coefficient of determination of the log-log fit.

TYPE: float

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the uncertainty exponent as its log--log scaling fit.

The uncertainty exponent is a scaling estimate: :math:f(\varepsilon)\sim\varepsilon^{\alpha} (Grebogi et al., 1983), so the natural figure is the SCALING_FIT of :math:\log f against :math:\log\varepsilon with the fitted slope :math:\alpha. This builds that spec directly — a SCATTER of the curve, the fit region marked, and the fit line drawn from the slope :math:\alpha and an intercept recovered from the curve mean — so a single result.plot.scaling() renders it like every other dimension / Lyapunov-from-data scaling result. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind. None uses SCALING_FIT; the .plot.scaling() seam passes "scaling_fit" explicitly, which resolves to the same kind.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/basins/metrics.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the uncertainty exponent as its log--log scaling fit.

    The uncertainty exponent *is* a scaling estimate:
    :math:`f(\varepsilon)\sim\varepsilon^{\alpha}` (Grebogi et al., 1983), so
    the natural figure is the ``SCALING_FIT`` of :math:`\log f` against
    :math:`\log\varepsilon` with the fitted slope :math:`\alpha`.  This builds
    that spec directly — a ``SCATTER`` of the curve, the fit region marked,
    and the fit line drawn from the slope :math:`\alpha` and an intercept
    recovered from the curve mean — so a single ``result.plot.scaling()``
    renders it like every other dimension / Lyapunov-from-data scaling result.
    The :mod:`tsdynamics.viz.spec` import is lazy, so building a spec never
    pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind.  ``None`` uses ``SCALING_FIT``; the
        ``.plot.scaling()`` seam passes ``"scaling_fit"`` explicitly, which
        resolves to the same kind.

    Returns
    -------
    PlotSpec
    """
    from .. import _plotbuilder as pb

    eps = np.asarray(self.epsilons, dtype=float)
    f = np.asarray(self.f, dtype=float)
    positive = (eps > 0.0) & (f > 0.0)
    log_eps = np.log(eps[positive])
    log_f = np.log(f[positive])

    layers = [pb.scatter(log_eps, log_f, label="curve")]
    if log_eps.size:
        # The fitted line: slope alpha, intercept recovered so it passes
        # through the curve's centroid (log_f ≈ intercept + alpha * log_eps).
        intercept = float(np.mean(log_f) - self.alpha * np.mean(log_eps))
        fit_x = np.array([log_eps.min(), log_eps.max()], dtype=float)
        layers.append(
            pb.line(fit_x, intercept + self.alpha * fit_x, label=f"slope = {self.alpha:.3g}")
        )
    return pb.spec(
        kind,
        "scaling_fit",
        layers=layers,
        xlabel=r"$\log\varepsilon$",
        ylabel=r"$\log f$",
        title=type(self).__name__,
        meta=self.meta,
    )

wada_property

wada_property(
    basins: Any,
    *,
    radii: tuple[int, ...] = (1, 2, 3, 4, 5),
    threshold: float = 0.9,
    include_diverged: bool = False,
) -> WadaResult

Test a basin diagram for the Wada property on a grid.

With three or more basins, a boundary cell is "Wada-complete" at radius :math:r when its Chebyshev-:math:r neighbourhood contains every basin colour. The fraction :math:W(r) of such boundary cells tends to one for a genuine Wada boundary (Daza et al., 2015). This is a sufficient grid test, not a topological proof.

PARAMETER DESCRIPTION
basins

The basin image. Diverged cells (-1) are ignored when counting colours.

TYPE: BasinsResult or array-like of int

radii

Chebyshev radii (in cells) at which to grow the neighbourhood.

TYPE: tuple of int DEFAULT: (1, 2, 3, 4, 5)

threshold

Minimum :math:W at the largest radius to call the boundary Wada.

TYPE: float DEFAULT: 0.9

include_diverged

If False (the default), a basin/escape (-1) interface is not counted as a boundary cell — escape is not a basin. Set True to let a cell bordering -1 count as boundary. Wada colours are always the settled basins (>= 1) regardless.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
WadaResult
RAISES DESCRIPTION
ValueError

If basins carries non-integer labels (attractor ids must be integers, with -1 marking escape).

References

A. Daza, A. Wagemakers, M. A. F. Sanjuán and J. A. Yorke, "Testing for basins of Wada", Scientific Reports 5, 16579 (2015).

Source code in src/tsdynamics/analysis/basins/metrics.py
def wada_property(
    basins: Any,
    *,
    radii: tuple[int, ...] = (1, 2, 3, 4, 5),
    threshold: float = 0.9,
    include_diverged: bool = False,
) -> WadaResult:
    r"""
    Test a basin diagram for the Wada property on a grid.

    With three or more basins, a boundary cell is "Wada-complete" at radius
    :math:`r` when its Chebyshev-:math:`r` neighbourhood contains *every* basin
    colour.  The fraction :math:`W(r)` of such boundary cells tends to one for a
    genuine Wada boundary (Daza et al., 2015).  This is a sufficient grid test,
    not a topological proof.

    Parameters
    ----------
    basins : BasinsResult or array-like of int
        The basin image.  Diverged cells (``-1``) are ignored when counting
        colours.
    radii : tuple of int, default (1, 2, 3, 4, 5)
        Chebyshev radii (in cells) at which to grow the neighbourhood.
    threshold : float, default 0.9
        Minimum :math:`W` at the largest radius to call the boundary Wada.
    include_diverged : bool, default False
        If ``False`` (the default), a basin/escape (``-1``) interface is not
        counted as a boundary cell — escape is not a basin.  Set ``True`` to let a
        cell bordering ``-1`` count as boundary.  Wada colours are always the
        settled basins (``>= 1``) regardless.

    Returns
    -------
    WadaResult

    Raises
    ------
    ValueError
        If ``basins`` carries non-integer labels (attractor ids must be integers,
        with ``-1`` marking escape).

    References
    ----------
    A. Daza, A. Wagemakers, M. A. F. Sanjuán and J. A. Yorke, "Testing for basins
    of Wada", *Scientific Reports* **5**, 16579 (2015).
    """
    from scipy.ndimage import maximum_filter

    labels = _as_label_array(basins)
    colors = [int(c) for c in np.unique(labels) if c >= 1]
    radii = tuple(int(r) for r in radii)
    valid = None if include_diverged else (labels != -1)  # -1 == DIVERGED/escape
    boundary = _neighbor_differs(labels, 1, valid=valid)
    n_boundary = int(boundary.sum())

    if len(colors) < 3 or n_boundary == 0:
        return WadaResult(
            is_wada=False,
            n_basins=len(colors),
            radii=np.asarray(radii),
            fractions=np.zeros(len(radii)),
            n_boundary_cells=n_boundary,
            threshold=float(threshold),
            meta={"analysis": "wada_property"},
        )

    presence = {c: (labels == c) for c in colors}
    fractions = []
    for r in radii:
        size = 2 * r + 1
        has_all = np.ones(labels.shape, dtype=bool)
        for c in colors:
            within = maximum_filter(presence[c], size=size, mode="constant", cval=False)
            has_all &= within
        fractions.append(float(has_all[boundary].mean()))

    frac_arr = np.asarray(fractions)
    is_wada = bool(frac_arr[-1] >= threshold)
    return WadaResult(
        is_wada=is_wada,
        n_basins=len(colors),
        radii=np.asarray(radii),
        fractions=frac_arr,
        n_boundary_cells=n_boundary,
        threshold=float(threshold),
        meta={"analysis": "wada_property"},
    )

WadaResult dataclass

WadaResult(
    is_wada: bool = False,
    n_basins: int = 0,
    radii: ndarray = (lambda: empty(0))(),
    fractions: ndarray = (lambda: empty(0))(),
    n_boundary_cells: int = 0,
    threshold: float = 0.0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

A grid test for the Wada property of a basin diagram.

ATTRIBUTE DESCRIPTION
is_wada

True when there are at least three basins and the fraction of boundary cells seeing all basins reaches threshold at the largest radius (a sufficient grid criterion, not a proof).

TYPE: bool

n_basins

Number of attractor basins (colours) considered.

TYPE: int

radii

Chebyshev radii tested.

TYPE: ndarray

fractions

Fraction of boundary cells whose neighbourhood contains every basin, per radius (:math:W of Daza et al., 2015).

TYPE: ndarray

n_boundary_cells

Number of boundary cells.

TYPE: int

threshold

Acceptance fraction at the largest radius.

TYPE: float

resilience

resilience(
    result: BasinsResult, attractor_id: int
) -> ScalarResult

Minimal-fatal-shock resilience of an attractor: its distance to the boundary.

The smallest perturbation that pushes the attractor out of its own basin, estimated as the state-space distance from the attractor's representative to the nearest cell of another basin (Halekotte & Feudel, 2020). Larger means more resilient.

PARAMETER DESCRIPTION
result

A basin image carrying its grid and attractors.

TYPE: BasinsResult

attractor_id

Which attractor (basin label) to measure.

TYPE: int

RETURNS DESCRIPTION
ScalarResult

Distance from the attractor to its basin boundary (behaves as a float), in state-space units.

RAISES DESCRIPTION
TypeError

If result is not a :class:BasinsResult (the grid + attractors are required to measure a state-space distance).

ValueError

If the labels are not laid out on the grid (a pre-squeezed slice), or attractor_id is absent from the basin image.

Notes

A sliced basin image (a label cube with one or more degenerate counts == 1 axes, e.g. a position-plane slice of a higher-dimensional flow) is collapsed to its free axes before the distance transform, so a pinned axis does not inject a spurious one-cell-away boundary that would cap the reported distance.

References

L. Halekotte and U. Feudel, "Minimal fatal shocks in multistable complex networks", Scientific Reports 10, 11374 (2020). Builds on the integral stability of C. Mitra, J. Kurths and R. V. Donner, Scientific Reports 5, 16196 (2015).

Source code in src/tsdynamics/analysis/basins/metrics.py
def resilience(result: BasinsResult, attractor_id: int) -> ScalarResult:
    r"""
    Minimal-fatal-shock resilience of an attractor: its distance to the boundary.

    The smallest perturbation that pushes the attractor out of its own basin,
    estimated as the state-space distance from the attractor's representative to
    the nearest cell of another basin (Halekotte & Feudel, 2020).  Larger means
    more resilient.

    Parameters
    ----------
    result : BasinsResult
        A basin image carrying its grid and attractors.
    attractor_id : int
        Which attractor (basin label) to measure.

    Returns
    -------
    ScalarResult
        Distance from the attractor to its basin boundary (behaves as a
        ``float``), in state-space units.

    Raises
    ------
    TypeError
        If ``result`` is not a :class:`BasinsResult` (the grid + attractors are
        required to measure a state-space distance).
    ValueError
        If the labels are not laid out on the grid (a pre-squeezed slice), or
        ``attractor_id`` is absent from the basin image.

    Notes
    -----
    A *sliced* basin image (a label cube with one or more degenerate ``counts == 1``
    axes, e.g. a position-plane slice of a higher-dimensional flow) is collapsed to
    its free axes before the distance transform, so a pinned axis does not inject a
    spurious one-cell-away boundary that would cap the reported distance.

    References
    ----------
    L. Halekotte and U. Feudel, "Minimal fatal shocks in multistable complex
    networks", *Scientific Reports* **10**, 11374 (2020).  Builds on the integral
    stability of C. Mitra, J. Kurths and R. V. Donner, *Scientific Reports* **5**,
    16196 (2015).
    """
    from scipy.ndimage import distance_transform_edt

    if not isinstance(result, BasinsResult):
        raise TypeError("resilience needs a BasinsResult (it requires the grid + attractors).")
    labels = result.labels
    grid = result.grid
    assert grid is not None  # a BasinsResult fed to resilience always carries its grid
    if labels.shape != tuple(grid.shape):
        raise ValueError(
            f"resilience needs labels laid out on the grid: labels {labels.shape} vs "
            f"grid {tuple(grid.shape)} (do not pre-squeeze a sliced basin image)."
        )
    mask = labels == int(attractor_id)
    if not mask.any():
        raise ValueError(f"attractor id {attractor_id} is absent from the basin image.")

    counts = np.asarray(grid.shape, dtype=float)
    span = grid.hi - grid.lo
    spacing_full = np.where(counts > 1, span / np.maximum(counts - 1, 1), 1.0)

    # Drop degenerate (``counts == 1``) axes — a pinned slice coordinate of a
    # higher-dimensional flow.  Padding/EDT over such an axis would inject a
    # spurious one-cell-away False border (the axis has only one layer), capping
    # the reported distance.  We mirror ``_as_label_array``: collapse the slice to
    # its effective dimension, keeping only the free axes for the distance field
    # and the matching grid origin / spacing entries.
    free = np.flatnonzero(np.asarray(grid.shape) > 1)
    if free.size == 0:  # fully degenerate grid (a single cell): no boundary at all.
        free = np.arange(labels.ndim)
    mask_free = np.squeeze(mask, axis=tuple(a for a in range(labels.ndim) if a not in free))
    lo_free = grid.lo[free]
    spacing = spacing_full[free]

    # Pad the basin mask with a one-cell False border so the computational-domain
    # edge is itself a boundary.  Without this, a basin that runs to the grid edge
    # has no nearby background there and the EDT reports the (large) distance to
    # the far interior boundary instead — *overestimating* the minimal fatal shock
    # (the domain simply ends; we cannot claim resilience past what was computed).
    padded = np.pad(mask_free, 1, mode="constant", constant_values=False)
    edt = distance_transform_edt(padded, sampling=spacing)
    shape = np.asarray(mask_free.shape)

    def _edt_at(points: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
        """(distance-to-boundary, clipped cell index) for each state in ``points``."""
        pts_free = np.atleast_2d(np.asarray(points, dtype=float))[:, free]
        idx = np.clip(np.rint((pts_free - lo_free) / spacing).astype(int), 0, shape - 1)
        return edt[tuple((idx + 1).T)], idx

    # Minimal fatal shock = the closest approach of the attractor to its basin
    # boundary, i.e. the MINIMUM distance-to-boundary over the attractor's spatial
    # extent (its sampled point cloud) — an extended attractor (limit cycle /
    # strange set) can graze the boundary far from its single representative.
    att = result.attractors[int(attractor_id)]
    pts = np.atleast_2d(np.asarray(att.points, dtype=float))
    if pts.size:
        dists, idx = _edt_at(pts)
        on_basin = mask_free[tuple(idx.T)]  # ignore stray points outside the basin
        value = float(np.min(dists[on_basin])) if np.any(on_basin) else float("nan")
    else:
        value = float("nan")
    if not np.isfinite(value):  # empty / off-basin cloud → fall back to the centre
        dist, _ = _edt_at(np.atleast_2d(att.center))
        value = float(dist[0])
    return ScalarResult(
        value=value,
        meta={"analysis": "resilience", "attractor_id": int(attractor_id)},
    )

Continuation & tipping

continuation

continuation(
    system: Any,
    param: str,
    values: Any,
    region: Box | Ball | Grid,
    *,
    n: int = 2000,
    resolution: int | tuple[int, ...] = 100,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    min_fraction: float = 0.0,
    match_method: str = "centroid",
    match_threshold: float | None = None,
    **fsm: Any,
) -> ContinuationResult

Track attractors and basin fractions as param sweeps values.

At each parameter value the attractors are found and their basin fractions estimated (:func:~tsdynamics.analysis.basins.basin_fractions); consecutive value's attractors are matched greedily by nearest state-space distance so a persisting attractor keeps its id and a vanishing one drops out (Datseris, Rossi & Wagemakers, 2023).

PARAMETER DESCRIPTION
system

A discrete map or continuous flow exposing param (with_params).

TYPE: System

param

Name of the parameter to sweep.

TYPE: str

values

Parameter values, in the order to walk them.

TYPE: array - like

region

The region whose basin fractions are measured at each value.

TYPE: Box, Ball, or Grid

n

Initial conditions sampled per value.

TYPE: int DEFAULT: 2000

resolution

Recurrence cells per axis (a Grid uses its own counts).

TYPE: int or tuple of int DEFAULT: 100

seed

Sampler seed (shared across values for a fair comparison).

TYPE: int DEFAULT: 0

dt

Integration step between cell checks for a flow.

TYPE: float DEFAULT: 1.0

max_steps

Per-sample step cap.

TYPE: int DEFAULT: 10000

min_fraction

Drop attractors whose basin fraction is below this at a given value before matching — a filter for the tiny spurious sets the recurrence finder can report near unstable equilibria. 0 keeps everything. The dropped basin mass is folded into the reported diverged share, so the tracked bands plus diverged still tile :math:[0, 1].

TYPE: float DEFAULT: 0.0

match_method

Set distance used to match attractors between values.

TYPE: ('centroid', 'hausdorff', 'minimum') DEFAULT: "centroid"

match_threshold

Reject a match farther apart than this (so an attractor that jumps is not spuriously tied to a different one). None matches the nearest regardless of distance.

TYPE: float DEFAULT: None

**fsm

Finite-state-machine thresholds forwarded to the recurrence finder.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
ContinuationResult
RAISES DESCRIPTION
TypeError

If system is a delay or stochastic system (unsupported by the recurrence finder).

References

G. Datseris, K. L. Rossi and A. Wagemakers, "Framework for global stability analysis of dynamical systems", Chaos 33, 073151 (2023).

Source code in src/tsdynamics/analysis/basins/continuation.py
def continuation(
    system: Any,
    param: str,
    values: Any,
    region: Box | Ball | Grid,
    *,
    n: int = 2000,
    resolution: int | tuple[int, ...] = 100,
    seed: int | None = 0,
    dt: float = 1.0,
    max_steps: int = 10000,
    min_fraction: float = 0.0,
    match_method: str = "centroid",
    match_threshold: float | None = None,
    **fsm: Any,
) -> ContinuationResult:
    r"""
    Track attractors and basin fractions as ``param`` sweeps ``values``.

    At each parameter value the attractors are found and their basin fractions
    estimated (:func:`~tsdynamics.analysis.basins.basin_fractions`); consecutive
    value's attractors are matched greedily by nearest state-space distance so a
    persisting attractor keeps its id and a vanishing one drops out (Datseris,
    Rossi & Wagemakers, 2023).

    Parameters
    ----------
    system : System
        A discrete map or continuous flow exposing ``param`` (``with_params``).
    param : str
        Name of the parameter to sweep.
    values : array-like
        Parameter values, in the order to walk them.
    region : Box, Ball, or Grid
        The region whose basin fractions are measured at each value.
    n : int, default 2000
        Initial conditions sampled per value.
    resolution : int or tuple of int, default 100
        Recurrence cells per axis (a Grid uses its own ``counts``).
    seed : int, optional
        Sampler seed (shared across values for a fair comparison).
    dt : float, default 1.0
        Integration step between cell checks for a flow.
    max_steps : int, default 10000
        Per-sample step cap.
    min_fraction : float, default 0.0
        Drop attractors whose basin fraction is below this at a given value before
        matching — a filter for the tiny spurious sets the recurrence finder can
        report near unstable equilibria.  ``0`` keeps everything.  The dropped
        basin mass is folded into the reported ``diverged`` share, so the tracked
        bands plus ``diverged`` still tile :math:`[0, 1]`.
    match_method : {"centroid", "hausdorff", "minimum"}, default "centroid"
        Set distance used to match attractors between values.
    match_threshold : float, optional
        Reject a match farther apart than this (so an attractor that jumps is not
        spuriously tied to a different one).  ``None`` matches the nearest
        regardless of distance.
    **fsm
        Finite-state-machine thresholds forwarded to the recurrence finder.

    Returns
    -------
    ContinuationResult

    Raises
    ------
    TypeError
        If ``system`` is a delay or stochastic system (unsupported by the
        recurrence finder).

    References
    ----------
    G. Datseris, K. L. Rossi and A. Wagemakers, "Framework for global stability
    analysis of dynamical systems", *Chaos* **33**, 073151 (2023).
    """
    _reject_unsupported(system, "continuation")
    values = np.asarray(values, dtype=float)
    fractions: dict[int, list[float]] = {}
    per_value: list[dict[int, Attractor]] = []
    diverged: list[float] = []

    prev_global: dict[int, Attractor] = {}
    next_global = 1

    for k, v in enumerate(values):
        sys_v = system.with_params(**{param: float(v)})
        bf = basin_fractions(
            sys_v, region, n=n, resolution=resolution, seed=seed, dt=dt, max_steps=max_steps, **fsm
        )
        local = {
            lid: att
            for lid, att in bf.attractors.attractors.items()
            if bf.fractions.get(lid, 0.0) >= min_fraction
        }  # local_id -> Attractor, tiny spurious sets dropped
        # Basin mass of the dropped (sub-``min_fraction``) sets is not tracked as a
        # band, so fold it into the "diverged / other" share — otherwise the
        # stacked bands plus diverged would sum below one (the un-tracked spurious
        # mass would silently vanish from the [0, 1] tiling).
        dropped = sum(
            float(bf.fractions.get(lid, 0.0))
            for lid in bf.attractors.attractors
            if lid not in local
        )

        local_to_global, next_global = _match(
            prev_global, local, next_global, method=match_method, threshold=match_threshold
        )

        value_attractors: dict[int, Attractor] = {}
        for lid, att in local.items():
            gid = local_to_global[lid]
            fractions.setdefault(gid, [float("nan")] * len(values))
            fractions[gid][k] = float(bf.fractions.get(lid, 0.0))
            value_attractors[gid] = att
        per_value.append(value_attractors)
        diverged.append(float(bf.diverged) + dropped)
        prev_global = value_attractors

    frac_arrays = {gid: np.asarray(arr) for gid, arr in fractions.items()}
    return ContinuationResult(
        param=param,
        values=values,
        fractions=frac_arrays,
        attractors=per_value,
        diverged=np.asarray(diverged),
        meta=AnalysisResult.build_meta(system, analysis="continuation", param=param),
    )

ContinuationResult dataclass

ContinuationResult(
    param: str = "",
    values: ndarray = (lambda: empty(0))(),
    fractions: dict[int, ndarray] = dict(),
    attractors: list[dict[int, Attractor]] = list(),
    diverged: ndarray = (lambda: empty(0))(),
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: AnalysisResult

Attractors and basin fractions tracked across a parameter sweep.

ATTRIBUTE DESCRIPTION
param

The swept parameter name.

TYPE: str

values

Parameter values, in sweep order.

TYPE: ndarray

fractions

Global attractor id → basin fraction at each value (nan where the attractor is absent).

TYPE: dict[int, ndarray]

attractors

Per value, the located attractors keyed by their global (matched) id.

TYPE: list[dict[int, Attractor]]

diverged

Diverged-or-untracked fraction at each value: the diverged share plus the basin mass of any attractor dropped by min_fraction, so the tracked bands and this share together tile :math:[0, 1].

TYPE: ndarray

ids property

ids: list[int]

Sorted global attractor ids seen anywhere in the sweep.

tipping_points

tipping_points(
    *, threshold: float = 0.0
) -> CollectionResult

Tipping events along this continuation (see :func:tipping_points).

Source code in src/tsdynamics/analysis/basins/continuation.py
def tipping_points(self, *, threshold: float = 0.0) -> CollectionResult:
    """Tipping events along this continuation (see :func:`tipping_points`)."""
    return tipping_points(self, threshold=threshold)

to_plot_spec

to_plot_spec(kind: str | None = None) -> Any

Describe the continuation as a backend-agnostic :class:PlotSpec.

Builds a CONTINUATION spec — the basin fractions stacked against the swept parameter, one filled AREA band per global attractor id (its "lo" / "hi" channels are the cumulative fraction below and above the band). The attractor bands fill the tracked share; the remaining gap up to 1 is the diverged / untracked mass (:attr:diverged), so the bands plus that gap tile :math:[0, 1]. Each tipping event from :meth:tipping_points — a basin annihilating ("disappear") or being born ("appear") — is drawn as a vertical :class:~tsdynamics.viz.spec.Annotation at the parameter value where it happens. The bands share the attractor palette (tab20, recorded in meta["palette"]) so an id keeps its colour across the basin views. A :class:~tsdynamics.viz.spec.Legend is attached when more than one attractor is tracked. The :mod:tsdynamics.viz.spec import is lazy, so building a spec never pulls a plotting library.

PARAMETER DESCRIPTION
kind

Override the semantic kind (e.g. "continuation"). None uses CONTINUATION.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
PlotSpec
Source code in src/tsdynamics/analysis/basins/continuation.py
def to_plot_spec(self, kind: str | None = None) -> Any:
    r"""Describe the continuation as a backend-agnostic :class:`PlotSpec`.

    Builds a ``CONTINUATION`` spec — the basin fractions **stacked** against
    the swept parameter, one filled ``AREA`` band per global attractor id (its
    ``"lo"`` / ``"hi"`` channels are the cumulative fraction below and above
    the band).  The attractor bands fill the tracked share; the remaining gap
    up to ``1`` is the diverged / untracked mass (:attr:`diverged`), so the
    bands plus that gap tile :math:`[0, 1]`.  Each **tipping** event from
    :meth:`tipping_points` — a basin annihilating (``"disappear"``) or being
    born (``"appear"``) — is drawn as a vertical
    :class:`~tsdynamics.viz.spec.Annotation` at the parameter value where it
    happens.  The bands share the attractor palette (``tab20``, recorded in
    ``meta["palette"]``) so an id keeps its colour across the basin views.  A
    :class:`~tsdynamics.viz.spec.Legend` is attached when more than one
    attractor is tracked.  The :mod:`tsdynamics.viz.spec` import is lazy, so
    building a spec never pulls a plotting library.

    Parameters
    ----------
    kind : str, optional
        Override the semantic kind (e.g. ``"continuation"``).  ``None`` uses
        ``CONTINUATION``.

    Returns
    -------
    PlotSpec
    """
    from tsdynamics.viz.spec import Colorbar

    from .. import _plotbuilder as pb

    values = np.asarray(self.values, dtype=float)
    ids = self.ids
    swatch = _palette_indices(ids)

    # Stack the (nan-as-zero) fractions so the bands tile [0, 1] per value.
    cumulative = np.zeros(values.size, dtype=float)
    layers = []
    for gid in ids:
        frac = np.nan_to_num(np.asarray(self.fractions[gid], dtype=float), nan=0.0)
        lo = cumulative.copy()
        cumulative = cumulative + frac
        layers.append(
            pb.area(
                values,
                cumulative.copy(),
                lo=lo,
                hi=cumulative.copy(),
                label=f"attractor {gid}",
                style={"cmap": PALETTE},
            )
        )

    annotations = [
        pb.vline(
            float(event["value"]),
            text=f"{event['kind']} (attractor {event['attractor']})",
        )
        for event in self.tipping_points()
    ]
    meta = dict(self.meta) if self.meta else {}
    meta.update(palette=PALETTE, diverged_color=DIVERGED_COLOR, palette_index=swatch)
    return pb.spec(
        kind,
        "continuation",
        layers=layers,
        xlabel=self.param or "parameter",
        ylabel="basin fraction",
        ylimits=(0.0, 1.0),
        title=f"continuation over {self.param!r}",
        legend=len(ids) > 1,
        colorbar=Colorbar(label="attractor", cmap=PALETTE, discrete=True),
        annotations=annotations,
        meta=meta,
    )

tipping_points

tipping_points(
    result: ContinuationResult, *, threshold: float = 0.0
) -> CollectionResult

Read tipping events off a continuation.

A tipping event is a basin fraction crossing threshold between consecutive parameter values: a disappear event (fraction falls to/through it — an attractor and its basin annihilating) or an appear event (a new attractor gaining a basin). With the default threshold=0 only true appearance/annihilation is reported; raise it to flag basins shrinking past a safety margin.

PARAMETER DESCRIPTION
result

A continuation from :func:continuation.

TYPE: ContinuationResult

threshold

Basin-fraction level whose crossing counts as a tipping event.

TYPE: float DEFAULT: 0.0

RETURNS DESCRIPTION
CollectionResult

Behaves as a list of dict; each event is {"value", "attractor", "kind", "before", "after"} with kind in {"appear", "disappear"}, sorted by parameter value.

Source code in src/tsdynamics/analysis/basins/continuation.py
def tipping_points(result: ContinuationResult, *, threshold: float = 0.0) -> CollectionResult:
    r"""
    Read tipping events off a continuation.

    A tipping event is a basin fraction crossing ``threshold`` between consecutive
    parameter values: a **disappear** event (fraction falls to/through it — an
    attractor and its basin annihilating) or an **appear** event (a new attractor
    gaining a basin).  With the default ``threshold=0`` only true
    appearance/annihilation is reported; raise it to flag basins shrinking past a
    safety margin.

    Parameters
    ----------
    result : ContinuationResult
        A continuation from :func:`continuation`.
    threshold : float, default 0.0
        Basin-fraction level whose crossing counts as a tipping event.

    Returns
    -------
    CollectionResult
        Behaves as a ``list of dict``; each event is
        ``{"value", "attractor", "kind", "before", "after"}`` with ``kind`` in
        ``{"appear", "disappear"}``, sorted by parameter value.
    """
    events: list[dict[str, Any]] = []
    vals = result.values
    for gid, frac in result.fractions.items():
        present = np.nan_to_num(np.asarray(frac, dtype=float), nan=0.0)
        for i in range(1, present.size):
            before, after = float(present[i - 1]), float(present[i])
            if before > threshold >= after:
                kind = "disappear"
            elif before <= threshold < after:
                kind = "appear"
            else:
                continue
            events.append(
                {
                    "value": float(vals[i]),
                    "attractor": int(gid),
                    "kind": kind,
                    "before": before,
                    "after": after,
                }
            )
    events.sort(key=lambda e: (e["value"], e["attractor"]))
    return CollectionResult(
        items=tuple(events),
        meta={"analysis": "tipping_points", "threshold": float(threshold)},
    )

Sampling

Sagitta-based tools for time-ordered samples: choose an output dt (the largest stride whose mid-point bow off the chord — the sagitta — stays under a geometric tolerance), or read the per-point sagitta as a local "how sharply it bends" field.

estimate_dt_from_sagitta

estimate_dt_from_sagitta(
    y: ndarray,
    dt0: float,
    *,
    epsilon: float,
    percentile: float = 95.0,
    coarsen_only: bool = True,
    use_relative: bool | None = None,
    min_points_per_segment: int = 3,
    search_growth: float = 1.5,
) -> SagittaDt

Sagitta-based output-step :math:\Delta t^\ast selector.

A derivative-free, scale-invariant, idempotent heuristic for the output sampling step. For a range of candidate strides it measures the sagitta — the perpendicular bow of the midpoint of each (i-span, i, i+span) triple off its chord — at a robust percentile, then picks the largest stride whose sagitta still satisfies the geometric tolerance epsilon.

A one-dimensional input is first delay-embedded so the geometric criterion lives in a reconstructed state space: the delay is the first minimum of the time-delayed mutual information (Fraser & Swinney 1986) and the dimension is Kennel's false-nearest-neighbour estimate (Kennel, Brown & Abarbanel 1992), both via the public :mod:tsdynamics.analysis.embedding estimators (:func:~tsdynamics.analysis.embedding.optimal_delay and :func:~tsdynamics.analysis.embedding.embedding_dimension with method="fnn"). Multivariate input is used directly, per-feature σ-normalised (ddof=1).

PARAMETER DESCRIPTION
y

Time-ordered samples. A 1-D (or (n, 1)) series is delay-embedded automatically. Must be finite.

TYPE: (ndarray, shape(n_samples) or (n_samples, n_dim))

dt0

Base sampling step :math:\Delta t_0 > 0.

TYPE: float

epsilon

Geometric tolerance :math:\varepsilon > 0. Absolute (σ-normalised units) when use_relative=False; relative (sagitta / chord_median) when use_relative=True.

TYPE: float

percentile

Robust percentile :math:p \in (0, 100]. Default 95.0.

TYPE: float DEFAULT: 95.0

coarsen_only

If True, never suggest :math:\Delta t^\ast < \Delta t_0 (stride >= 1). Does not affect behaviour when span=1 already exceeds :math:\varepsilon — that case is flagged via notes and stride=1 is returned regardless. Default True.

TYPE: bool DEFAULT: True

use_relative

Whether to use the relative sagitta criterion (sagitta / chord_median <= epsilon). None (default) selects True for 1-D input (post-embedding) and False for multivariate input. The relative criterion is not strictly monotone in stride, so the binary search is approximate for that case.

TYPE: bool or None DEFAULT: None

min_points_per_segment

Minimum number of triples (i-span, i, i+span) required to evaluate a candidate span. Default 3.

TYPE: int DEFAULT: 3

search_growth

Multiplicative growth factor (> 1) for the coarse stride search. Default 1.5.

TYPE: float DEFAULT: 1.5

RETURNS DESCRIPTION
SagittaDt

The chosen :math:\Delta t^\ast, stride, achieved percentile value, decimation indices, and search bookkeeping. For 1-D input the notes field records the embedding parameters (lag, dimension).

RAISES DESCRIPTION
InvalidParameterError

If y is not a NumPy array, is not 1-D/2-D, contains non-finite values, has too few samples, or if dt0 / epsilon / percentile / search_growth / min_points_per_segment violate their bounds.

References

A. M. Fraser and H. L. Swinney, "Independent coordinates for strange attractors from mutual information", Phys. Rev. A 33, 1134 (1986).

M. B. Kennel, R. Brown and H. D. I. Abarbanel, "Determining embedding dimension for phase-space reconstruction using a geometrical construction", Phys. Rev. A 45, 3403 (1992).

Source code in src/tsdynamics/analysis/sampling/sagitta.py
def estimate_dt_from_sagitta(
    y: np.ndarray,
    dt0: float,
    *,
    epsilon: float,
    percentile: float = 95.0,
    coarsen_only: bool = True,
    use_relative: bool | None = None,
    min_points_per_segment: int = 3,
    search_growth: float = 1.5,
) -> SagittaDt:
    r"""Sagitta-based output-step :math:`\Delta t^\ast` selector.

    A derivative-free, scale-invariant, idempotent heuristic for the output
    sampling step.  For a range of candidate strides it measures the *sagitta* —
    the perpendicular bow of the midpoint of each ``(i-span, i, i+span)`` triple
    off its chord — at a robust percentile, then picks the largest stride whose
    sagitta still satisfies the geometric tolerance ``epsilon``.

    A one-dimensional input is first delay-embedded so the geometric criterion
    lives in a reconstructed state space: the delay is the first minimum of the
    time-delayed mutual information (Fraser & Swinney 1986) and the dimension is
    Kennel's false-nearest-neighbour estimate (Kennel, Brown & Abarbanel 1992),
    both via the public :mod:`tsdynamics.analysis.embedding` estimators
    (:func:`~tsdynamics.analysis.embedding.optimal_delay` and
    :func:`~tsdynamics.analysis.embedding.embedding_dimension` with
    ``method="fnn"``).  Multivariate input is used directly, per-feature
    σ-normalised (``ddof=1``).

    Parameters
    ----------
    y : numpy.ndarray, shape (n_samples,) or (n_samples, n_dim)
        Time-ordered samples.  A 1-D (or ``(n, 1)``) series is delay-embedded
        automatically.  Must be finite.
    dt0 : float
        Base sampling step :math:`\Delta t_0 > 0`.
    epsilon : float
        Geometric tolerance :math:`\varepsilon > 0`.  Absolute (σ-normalised
        units) when ``use_relative=False``; relative (``sagitta / chord_median``)
        when ``use_relative=True``.
    percentile : float, optional
        Robust percentile :math:`p \in (0, 100]`.  Default ``95.0``.
    coarsen_only : bool, optional
        If ``True``, never suggest :math:`\Delta t^\ast < \Delta t_0`
        (``stride >= 1``).  Does not affect behaviour when ``span=1`` already
        exceeds :math:`\varepsilon` — that case is flagged via ``notes`` and
        ``stride=1`` is returned regardless.  Default ``True``.
    use_relative : bool or None, optional
        Whether to use the relative sagitta criterion
        (``sagitta / chord_median <= epsilon``).  ``None`` (default) selects
        ``True`` for 1-D input (post-embedding) and ``False`` for multivariate
        input.  The relative criterion is not strictly monotone in stride, so the
        binary search is approximate for that case.
    min_points_per_segment : int, optional
        Minimum number of triples ``(i-span, i, i+span)`` required to evaluate a
        candidate span.  Default ``3``.
    search_growth : float, optional
        Multiplicative growth factor (``> 1``) for the coarse stride search.
        Default ``1.5``.

    Returns
    -------
    SagittaDt
        The chosen :math:`\Delta t^\ast`, stride, achieved percentile value,
        decimation ``indices``, and search bookkeeping.  For 1-D input the
        ``notes`` field records the embedding parameters (lag, dimension).

    Raises
    ------
    InvalidParameterError
        If ``y`` is not a NumPy array, is not 1-D/2-D, contains non-finite
        values, has too few samples, or if ``dt0`` / ``epsilon`` / ``percentile``
        / ``search_growth`` / ``min_points_per_segment`` violate their bounds.

    References
    ----------
    A. M. Fraser and H. L. Swinney, "Independent coordinates for strange
    attractors from mutual information", *Phys. Rev. A* **33**, 1134 (1986).

    M. B. Kennel, R. Brown and H. D. I. Abarbanel, "Determining embedding
    dimension for phase-space reconstruction using a geometrical construction",
    *Phys. Rev. A* **45**, 3403 (1992).
    """
    # -------- input validation and dimensionality handling --------
    if not isinstance(y, np.ndarray):
        raise invalid_value("y", type(y).__name__, rule="must be a numpy array")
    if not np.all(np.isfinite(y)):
        raise invalid_value("y", "non-finite", rule="must be finite (no nan/inf)")

    needs_embedding = False

    if y.ndim == 1:
        needs_embedding = True
        y_original = y.copy()
        n_samples_original = len(y)
    elif y.ndim == 2:
        _, n_dim = y.shape
        if n_dim == 1:
            needs_embedding = True
            y_original = y.squeeze()
            n_samples_original = len(y_original)
    else:
        raise invalid_value("y", y.ndim, rule="must be a 1D or 2D array (got y.ndim)")

    embedding_notes = ""
    if needs_embedding:
        if n_samples_original < 50:
            raise invalid_value(
                "y",
                n_samples_original,
                rule="must have at least 50 samples for 1D embedding (got len(y))",
            )

        # Delegate delay/dimension selection to the public embedding estimators
        # (first-minimum AMI delay; Kennel FNN dimension) — one implementation,
        # no drift.
        embedding_lag = int(optimal_delay(y_original, method="mi"))
        embedding_dim = int(embedding_dimension(y_original, method="fnn", delay=embedding_lag))

        y = _takens_embedding(y_original, embedding_lag, embedding_dim)
        n_samples, n_dim = y.shape

        embedding_notes = f"Applied Takens embedding: lag={embedding_lag}, dim={embedding_dim}. "
    else:
        n_samples, n_dim = y.shape

    if n_samples < 5:
        raise invalid_value(
            "y", n_samples, rule="must have at least 5 samples after embedding (got n_samples)"
        )
    if not (dt0 > 0):
        raise invalid_value("dt0", dt0, rule="must be > 0")
    if not (epsilon > 0):
        raise invalid_value("epsilon", epsilon, rule="must be > 0")
    if not (0.0 < percentile <= 100.0):
        raise invalid_value("percentile", percentile, rule="must be in (0, 100]")
    if search_growth <= 1.0:
        raise invalid_value("search_growth", search_growth, rule="must be > 1.0")
    if min_points_per_segment < 1:
        raise invalid_value("min_points_per_segment", min_points_per_segment, rule="must be >= 1")

    # use_relative is an explicit parameter, not silently tied to needs_embedding
    if use_relative is None:
        use_relative = needs_embedding

    # -------- per-feature σ-normalization (scale invariance) --------
    y = np.asarray(y, dtype=float)
    feature_std = y.std(axis=0, ddof=1)
    feature_std[~np.isfinite(feature_std) | (feature_std == 0.0)] = 1.0
    samples_for_sagitta = y / feature_std

    criterion_note = "relative sagitta" if use_relative else "absolute sagitta"
    notes = embedding_notes + f"Used state-space {criterion_note}."
    if use_relative:
        notes += " NOTE: relative criterion is not strictly monotone; binary search is approximate."

    # -------- determine max span with enough triples --------
    max_span = (n_samples - 1) // 2
    while max_span > 1 and _triple_count(n_samples, max_span) < min_points_per_segment:
        max_span -= 1

    def _make_result(
        stride: int, achieved: float, searched: list[tuple[int, float]], extra_note: str
    ) -> SagittaDt:
        idx = np.arange(0, n_samples, stride, dtype=int)
        if idx[-1] != (n_samples - 1):
            idx = np.concatenate([idx, np.array([n_samples - 1], dtype=int)])
        evs = np.array(sorted(searched, key=lambda x: x[0]), dtype=float)
        spans = evs[:, 0].astype(int) if evs.size else np.array([1], dtype=int)
        return SagittaDt(
            delta_t=float(stride * dt0),
            stride=int(stride),
            percentile_value=float(achieved),
            indices=idx,
            p=float(percentile),
            epsilon=float(epsilon),
            searched_ms=spans,
            notes=notes + extra_note,
        )

    if max_span < 1:
        return _make_result(1, 0.0, [(1, 0.0)], " Too few samples; returning Δt0.")

    # -------- helper to test a span --------
    def span_is_ok(span: int) -> tuple[bool, float]:
        if _triple_count(n_samples, span) < min_points_per_segment:
            return False, np.nan
        s_p, chord_med = _compute_sagitta_stats(samples_for_sagitta, span, percentile)
        if use_relative:
            denom = chord_med if (chord_med > 1e-12 and np.isfinite(chord_med)) else 1.0
            val = s_p / denom
        else:
            val = s_p
        return (val <= epsilon), val

    # -------- coarse search (exponential growth) --------
    evaluated: list[tuple[int, float]] = []

    ok_1, val_1 = span_is_ok(1)
    evaluated.append((1, val_1))

    if not ok_1:
        # fix #1: this is not a coarsen_only decision — span=1 failing means the data
        # is already under-sampled relative to ε. Return stride=1 with a clear note.
        # coarsen_only is irrelevant here since we cannot go finer.
        return _make_result(
            1, val_1, evaluated, " span=1 exceeds ε; data may be under-sampled at dt0."
        )

    # fix #4: chosen_stride always valid from here; no sentinel 0 needed
    chosen_stride = 1
    achieved_percentile = val_1
    current_span = 1
    first_fail_span = None

    while True:
        next_span = int(np.floor(current_span * search_growth))
        if next_span <= current_span:
            next_span = current_span + 1
        if next_span > max_span:
            break
        ok_next, val_next = span_is_ok(next_span)
        evaluated.append((next_span, val_next))
        if ok_next:
            chosen_stride = next_span
            achieved_percentile = val_next
            current_span = next_span
        else:
            first_fail_span = next_span
            break

    # -------- binary search between last ok and first fail (if any) --------
    # fix #6: low_ok is now always a confirmed-ok stride (no sentinel ambiguity)
    low_ok = chosen_stride
    high_fail = first_fail_span if first_fail_span is not None else (max_span + 1)

    while high_fail - low_ok > 1:
        mid = (low_ok + high_fail) // 2
        ok_mid, val_mid = span_is_ok(mid)
        evaluated.append((mid, val_mid))
        if ok_mid:
            low_ok = mid
            chosen_stride = mid
            achieved_percentile = val_mid
        else:
            high_fail = mid

    return _make_result(chosen_stride, achieved_percentile, evaluated, "")

sagitta_profile

sagitta_profile(
    samples: ndarray,
    *,
    span: int = 1,
    relative: bool = True,
    normalize: bool = True,
) -> ndarray

Per-point sagitta along a sampled curve — its local bow off the chord.

For each interior point i the sagitta is the perpendicular distance of samples[i] from the chord through its neighbours (i-span, i+span) (the same geometry the sagitta-based Δt selector uses, here evaluated at every point rather than a strided percentile). Large where the trajectory bends sharply, ~0 on straight runs; the first/last span points are 0.

PARAMETER DESCRIPTION
samples

Time-ordered points (a 1-D series is treated as a column).

TYPE: (ndarray, shape(n) or (n, d))

span

Neighbour offset of the chord (i-span, i, i+span). Default 1.

TYPE: int DEFAULT: 1

relative

Divide each bow by its chord length, giving a dimensionless bend ratio that is (largely) independent of the local speed/step. Default True.

TYPE: bool DEFAULT: True

normalize

σ-normalize each feature first (per-feature std, ddof=1) so no single coordinate dominates the geometry. Default True.

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
(ndarray, shape(n))

The per-point sagitta, aligned to samples.

Source code in src/tsdynamics/analysis/sampling/sagitta.py
def sagitta_profile(
    samples: np.ndarray,
    *,
    span: int = 1,
    relative: bool = True,
    normalize: bool = True,
) -> np.ndarray:
    """Per-point *sagitta* along a sampled curve — its local bow off the chord.

    For each interior point ``i`` the sagitta is the perpendicular distance of
    ``samples[i]`` from the chord through its neighbours ``(i-span, i+span)`` (the
    same geometry the sagitta-based Δt selector uses, here evaluated at *every*
    point rather than a strided percentile).  Large where the trajectory bends
    sharply, ~0 on straight runs; the first/last ``span`` points are 0.

    Parameters
    ----------
    samples : np.ndarray, shape (n,) or (n, d)
        Time-ordered points (a 1-D series is treated as a column).
    span : int, optional
        Neighbour offset of the chord ``(i-span, i, i+span)``.  Default 1.
    relative : bool, optional
        Divide each bow by its chord length, giving a dimensionless *bend* ratio
        that is (largely) independent of the local speed/step.  Default ``True``.
    normalize : bool, optional
        σ-normalize each feature first (per-feature std, ``ddof=1``) so no single
        coordinate dominates the geometry.  Default ``True``.

    Returns
    -------
    np.ndarray, shape (n,)
        The per-point sagitta, aligned to ``samples``.
    """
    s = np.asarray(samples, dtype=float)
    if s.ndim == 1:
        s = s[:, None]
    n = s.shape[0]
    out = np.zeros(n, dtype=float)
    if span < 1 or n < 2 * span + 1:
        return out
    if normalize:
        std = s.std(axis=0, ddof=1)
        std = np.where(np.isfinite(std) & (std > 0.0), std, 1.0)
        s = s / std
    i = np.arange(span, n - span)
    sagitta, chord = _sagitta_chord(s[i - span], s[i], s[i + span])
    if relative:
        sagitta = sagitta / np.where(chord > 0.0, chord, 1.0)
    out[i] = sagitta
    return out