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,
    *,
    points_per_value: int = 200,
    transient: int = 500,
    carry_state: bool = True,
    components: int | str | tuple[Any, ...] = 0,
    section: Any | None = None,
    ic: Any | None = None,
    seed: int | None = 0,
    dt: float = _FLOW_DT,
    max_time: float = _FLOW_MAX_TIME,
) -> OrbitDiagram

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

Pass any system — this is the one-liner::

ts.analysis.orbit_diagram(ts.systems.Lorenz(), "rho", np.linspace(0.0, 50.0, 200))

A :class:~tsdynamics.families.DiscreteMap is swept directly. A flow is reduced to a discrete view automatically, because a bifurcation diagram of a flow is the common case and a section always has to be chosen:

  • by default, successive maxima of the recorded component — the next-amplitude (peak) map, the view Lorenz (1963) used and the one every textbook bifurcation diagram of a flow is drawn from. It needs no parameter-dependent constant, so it keeps producing points as the attractor moves across the sweep, and a value whose flow has settled on an equilibrium records that single state (the fixed-point branch);
  • or the section you name with section= (any :class:~tsdynamics.derived.PoincareMap plane spelling, e.g. section=("z", 27.0, "up")).

The choice is never silent: it is recorded in meta["section"] and printed under the figure's title. A flow already wrapped in a :class:~tsdynamics.derived.PoincareMap / :class:~tsdynamics.derived.StroboscopicMap is used as given.

orbit_diagram is the one spelling: bifurcation_diagram was the same object under a second name and is gone (a shared implementation can name only one of its spellings in a message, so half of all callers were answered about a function they had never typed). The picture is still called a bifurcation diagram — PlotKind.BIFURCATION_DIAGRAM is untouched.

ODE parameter changes reuse the cached lowered tape (control parameters), so flow sweeps stay cheap; DDE sweeps re-lower per value (their structure depends on all parameters).

Notes

How each value is run. A genuine :class:~tsdynamics.families.DiscreteMap sweeps the whole parameter array in one Rust kernel call. A :class:~tsdynamics.derived.PoincareMap collects each value's section in one engine call through :meth:~tsdynamics.derived.PoincareMap.trajectory (the wired Rust event march) — ~70x faster than the per-dt step() loop it replaces. Everything else (StroboscopicMap, a map whose _step will not lower, a wheel-free environment) drives the per-step protocol loop.

The Poincaré fast path marches the fixed-step rk4 kernel at the map's detection dt (see :mod:tsdynamics.derived._crossings), which is the same discretisation :func:~tsdynamics.analysis.poincare_section and :func:~tsdynamics.analysis.return_map already use — so an orbit diagram and a section of the same flow are now consistent with each other. It is a different discretisation from the flow's adaptive default that the old step() loop used. Measured over a 40-value Rössler c-cascade at the default dt=0.01: in a periodic window the branch values shift by ~5e-8 (up to ~3e-3 for a value sitting right at a window edge, where a tiny shift in the orbit is amplified), and on a chaotic band the two are the same attractor rather than the same points (support bounds agree to ~6e-2). The detected branch structure — OrbitDiagram.periods() and bifurcation_points() — is unchanged, at both a short and a long transient.

rk4 is fixed-step, so its stability region is bounded (|λh| ≲ 2.785). A sweep that runs into a regime where the flow is stiff relative to the detection dt therefore records an empty point set with a RuntimeWarning for those values, where the old adaptive step() loop would have shrunk its step and carried on (on the Rössler c-sweep at dt=0.01 this starts at c ≈ 200). Pass a smaller dt to :class:~tsdynamics.derived.PoincareMap when sweeping into a fast-timescale regime.

The flow path. With no section=, each parameter value is integrated (output step dt, horizon grown from 100 time units up to max_time) until transient + n maxima of the recorded component have been collected; the last n are the column. transient and n therefore count peaks, exactly as they count iterates for a map and crossings for a section. Each peak is sharpened by the same parabolic interpolation :func:~tsdynamics.analysis.return_map uses, so a coarse dt still gives a crisp period-1 branch.

PARAMETER DESCRIPTION
system

The system to sweep — a map, a flow, or a discrete view of a flow. Never mutated: each value gets a fresh with_params copy.

TYPE: System

param

Parameter name to sweep.

TYPE: str

values

Parameter values, in sweep order.

TYPE: iterable of float

points_per_value

Points recorded per parameter value (map iterates / section crossings / peaks, according to the view) — the height of one vertical stripe of the diagram.

.. versionchanged:: 6.0 Named n before v6. n was carrying four different meanings across the public surface (map iterations, points kept per parameter value, number of crossings, Monte-Carlo sample count); this is one of the two that misled, so it says what it counts.

TYPE: int DEFAULT: 200

transient

Points discarded before recording, at every value — a count, in the unit the sweep advances in: map iterations for a map, crossings for a Poincaré/stroboscopic view, recorded peaks for the flow peak map. (The subject of an orbit diagram is always a discrete view, so this word never means time here, unlike run(transient=) on a flow.)

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

components

Which state component(s) to record (names allowed when the system declares variables). On the flow path the first of them also defines the peak map.

TYPE: int, str, or tuple DEFAULT: 0

section

Slice a flow with this Poincaré section instead of the default peak map. Any :class:~tsdynamics.derived.PoincareMap plane spelling: ("z", 27.0, "up"), ("y", 0.0), or (normal, offset). Invalid for a system that is already discrete.

TYPE: tuple DEFAULT: None

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, so the diagram is reproducible. Pass seed=None for an unseeded draw.

.. versionchanged:: 6.0 Was None: a system with no declared initial condition drew a fresh random one on every call, so the same line of code drew a different diagram each time it ran.

TYPE: int DEFAULT: 0

dt

Output sampling step of the flow path's integration, in time units. Ignored for a map / an already-discrete view, which iterate.

TYPE: float DEFAULT: 0.01

max_time

Ceiling on the flow path's integration horizon per parameter value.

TYPE: float DEFAULT: 1e4

RETURNS DESCRIPTION
OrbitDiagram

The swept values and the per-value recorded points. meta records the discrete view that was used under "section". A value whose orbit diverged carries an empty point set (and emits a :class:RuntimeWarning).

RAISES DESCRIPTION
InvalidInputError

If system is not a dynamical system, or is stochastic (an SDE has no single asymptotic orbit).

InvalidParameterError

If section= is given for a system that is already discrete.

ValueError

If a named components 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), or when the flow path exhausts max_time before collecting n peaks.

References

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

Lorenz, E. N. (1963). Deterministic nonperiodic flow. Journal of the Atmospheric Sciences, 20, 130--141. (Successive maxima as the discrete view of a flow.)

Examples:

>>> # a flow, straight up — the section is chosen for you and reported:
>>> od = orbit_diagram(Lorenz(), "rho", np.linspace(0.0, 50.0, 200))
>>> od.meta["section"]
'successive maxima of x'
>>> # ... or name the section yourself:
>>> od = orbit_diagram(Rossler(), "c", np.linspace(2, 6, 80),
...                          section=("y", 0.0, "up"))
>>> # a map:
>>> od = orbit_diagram(Logistic(), "r", np.linspace(2.5, 4.0, 600), points_per_value=120)
>>> x, y = od.flat()
Source code in src/tsdynamics/analysis/orbits/orbit_diagram.py
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
def orbit_diagram(
    system: Any,
    param: str,
    values: Any,
    *,
    points_per_value: int = 200,
    transient: int = 500,
    carry_state: bool = True,
    components: int | str | tuple[Any, ...] = 0,
    section: Any | None = None,
    ic: Any | None = None,
    seed: int | None = 0,
    dt: float = _FLOW_DT,
    max_time: float = _FLOW_MAX_TIME,
) -> OrbitDiagram:
    """
    Sweep a parameter and record the asymptotic orbit at each value.

    Pass **any** system — this is the one-liner::

        ts.analysis.orbit_diagram(ts.systems.Lorenz(), "rho", np.linspace(0.0, 50.0, 200))

    A :class:`~tsdynamics.families.DiscreteMap` is swept directly.  A **flow** is
    reduced to a discrete view automatically, because a bifurcation diagram of a
    flow is the common case and a section always has to be chosen:

    - by default, **successive maxima of the recorded component** — the
      next-amplitude (peak) map, the view Lorenz (1963) used and the one every
      textbook bifurcation diagram of a flow is drawn from.  It needs no
      parameter-dependent constant, so it keeps producing points as the attractor
      moves across the sweep, and a value whose flow has settled on an
      **equilibrium** records that single state (the fixed-point branch);
    - or the section you name with ``section=`` (any
      :class:`~tsdynamics.derived.PoincareMap` ``plane`` spelling, e.g.
      ``section=("z", 27.0, "up")``).

    **The choice is never silent**: it is recorded in ``meta["section"]`` and
    printed under the figure's title.  A flow already wrapped in a
    :class:`~tsdynamics.derived.PoincareMap` /
    :class:`~tsdynamics.derived.StroboscopicMap` is used as given.

    ``orbit_diagram`` is the **one** spelling: ``bifurcation_diagram`` was the
    same object under a second name and is gone (a shared implementation can name
    only one of its spellings in a message, so half of all callers were answered
    about a function they had never typed).  The *picture* is still called a
    bifurcation diagram — ``PlotKind.BIFURCATION_DIAGRAM`` is untouched.

    ODE parameter changes reuse the cached lowered tape (control parameters), so
    flow sweeps stay cheap; DDE sweeps re-lower per value (their structure depends
    on all parameters).

    Notes
    -----
    **How each value is run.**  A genuine :class:`~tsdynamics.families.DiscreteMap`
    sweeps the *whole* parameter array in one Rust kernel call.  A
    :class:`~tsdynamics.derived.PoincareMap` collects each value's section in one
    engine call through :meth:`~tsdynamics.derived.PoincareMap.trajectory` (the
    wired Rust event march) — ~70x faster than the per-``dt`` ``step()`` loop it
    replaces.  Everything else (``StroboscopicMap``, a map whose ``_step`` will not
    lower, a wheel-free environment) drives the per-step protocol loop.

    The Poincaré fast path marches the fixed-step ``rk4`` kernel at the map's
    detection ``dt`` (see :mod:`tsdynamics.derived._crossings`), which is the same
    discretisation :func:`~tsdynamics.analysis.poincare_section` and
    :func:`~tsdynamics.analysis.return_map` already use — so an orbit diagram and
    a section of the same flow are now consistent with each other.  It is a
    *different* discretisation from the flow's adaptive default that the old
    ``step()`` loop used.  Measured over a 40-value Rössler ``c``-cascade at the
    default ``dt=0.01``: in a periodic window the branch values shift by ~5e-8
    (up to ~3e-3 for a value sitting right at a window edge, where a tiny shift
    in the orbit is amplified), and on a chaotic band the two are the same
    attractor rather than the same points (support bounds agree to ~6e-2).  The
    detected branch structure — ``OrbitDiagram.periods()`` and
    ``bifurcation_points()`` — is unchanged, at both a short and a long
    ``transient``.

    ``rk4`` is fixed-step, so its stability region is bounded (``|λh| ≲ 2.785``).
    A sweep that runs into a regime where the flow is stiff *relative to* the
    detection ``dt`` therefore records an empty point set with a
    ``RuntimeWarning`` for those values, where the old adaptive ``step()`` loop
    would have shrunk its step and carried on (on the Rössler ``c``-sweep at
    ``dt=0.01`` this starts at ``c ≈ 200``).  Pass a smaller ``dt`` to
    :class:`~tsdynamics.derived.PoincareMap` when sweeping into a fast-timescale
    regime.

    **The flow path.**  With no ``section=``, each parameter value is integrated
    (output step ``dt``, horizon grown from 100 time units up to ``max_time``)
    until ``transient + n`` maxima of the recorded component have been collected;
    the last ``n`` are the column.  ``transient`` and ``n`` therefore count
    **peaks**, exactly as they count iterates for a map and crossings for a
    section.  Each peak is sharpened by the same parabolic interpolation
    :func:`~tsdynamics.analysis.return_map` uses, so a coarse ``dt`` still gives a
    crisp period-1 branch.

    Parameters
    ----------
    system : System
        The system to sweep — a map, a flow, or a discrete view of a flow.  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.
    points_per_value : int
        Points recorded per parameter value (map iterates / section crossings /
        peaks, according to the view) — the *height* of one vertical stripe of
        the diagram.

        .. versionchanged:: 6.0
            Named ``n`` before v6.  ``n`` was carrying four different meanings
            across the public surface (map iterations, points kept per parameter
            value, number of crossings, Monte-Carlo sample count); this is one of
            the two that misled, so it says what it counts.
    transient : int
        Points discarded before recording, at every value — a **count**, in the
        unit the sweep advances in: map iterations for a map, crossings for a
        Poincaré/stroboscopic view, recorded peaks for the flow peak map.  (The
        subject of an orbit diagram is always a discrete view, so this word never
        means time here, unlike ``run(transient=)`` on a flow.)
    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.
    components : int, str, or tuple
        Which state component(s) to record (names allowed when the system
        declares ``variables``).  On the flow path the **first** of them also
        defines the peak map.
    section : tuple, optional
        Slice a flow with this Poincaré section instead of the default peak map.
        Any :class:`~tsdynamics.derived.PoincareMap` ``plane`` spelling:
        ``("z", 27.0, "up")``, ``("y", 0.0)``, or ``(normal, offset)``.  Invalid
        for a system that is already discrete.
    ic : array-like, optional
        Initial state for the first value (and every value when
        ``carry_state=False``).
    seed : int, default 0
        Seed for the random initial condition when the system has none, so the
        diagram is reproducible.  Pass ``seed=None`` for an unseeded draw.

        .. versionchanged:: 6.0
            Was ``None``: a system with no declared initial condition drew a
            fresh random one on every call, so the same line of code drew a
            different diagram each time it ran.
    dt : float, default 0.01
        Output sampling step of the flow path's integration, in **time units**.
        Ignored for a map / an already-discrete view, which iterate.
    max_time : float, default 1e4
        Ceiling on the flow path's integration horizon per parameter value.

    Returns
    -------
    OrbitDiagram
        The swept ``values`` and the per-value recorded ``points``.  ``meta``
        records the discrete view that was used under ``"section"``.  A value
        whose orbit diverged carries an empty point set (and emits a
        :class:`RuntimeWarning`).

    Raises
    ------
    tsdynamics.errors.InvalidInputError
        If ``system`` is not a dynamical system, or is stochastic (an SDE has no
        single asymptotic orbit).
    tsdynamics.errors.InvalidParameterError
        If ``section=`` is given for a system that is already discrete.
    ValueError
        If a named ``components`` 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), or when the flow path exhausts ``max_time`` before
        collecting ``n`` peaks.

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

    Lorenz, E. N. (1963). Deterministic nonperiodic flow. *Journal of the
    Atmospheric Sciences*, 20, 130--141.  (Successive maxima as the discrete view
    of a flow.)

    Examples
    --------
    >>> # a flow, straight up — the section is chosen for you and reported:
    >>> od = orbit_diagram(Lorenz(), "rho", np.linspace(0.0, 50.0, 200))
    >>> od.meta["section"]
    'successive maxima of x'
    >>> # ... or name the section yourself:
    >>> od = orbit_diagram(Rossler(), "c", np.linspace(2, 6, 80),
    ...                          section=("y", 0.0, "up"))
    >>> # a map:
    >>> od = orbit_diagram(Logistic(), "r", np.linspace(2.5, 4.0, 600), points_per_value=120)
    >>> x, y = od.flat()
    """
    # Measured data first, through the shared guard.  A ``Trajectory`` carries a
    # ``_is_discrete`` flag, so it walked straight past the system check below and
    # failed ~300 lines later on ``view.with_params`` — an internal name, in an
    # AttributeError, about an object the caller never reached for.
    reject_data(system, analysis="orbit_diagram")
    comp = (components,) if isinstance(components, int | str) else tuple(components)
    # 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))

    label = names[idx[0]] if names is not None else f"component {idx[0]}"
    # Reduce whatever was passed to a discrete-time view.  A flow with no explicit
    # ``section=`` comes back as ``None`` — the peak map, which is not a wrapper
    # object but a way of *reading* the flow, so it is driven directly below.
    resolved, section_label = _discrete_view(system, section, label, param)
    is_peak_map = resolved is None
    view = system if is_peak_map else resolved

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

    import warnings

    from tsdynamics.derived.poincare import PoincareMap
    from tsdynamics.errors import BackendError
    from tsdynamics.families import DiscreteMap

    # One short local alias: the sweep machinery below (and the helpers it calls
    # positionally) reads better with the terse name, while the *signature* says
    # what the number counts.
    n = points_per_value
    values_arr = np.asarray(list(values), dtype=float)
    points: list[np.ndarray] = []
    state: np.ndarray | None = None
    horizon = _FLOW_FINAL_TIME

    def _meta() -> dict[str, Any]:
        return {
            "system": type(system).__name__,
            "param": param,
            "points_per_value": n,
            "transient": transient,
            "carry_state": carry_state,
            "components": tuple(idx),
            # The NAME of the recorded observable, not merely its index: it is
            # what the figure's y axis is a picture of.  Without it the axis was
            # labelled with the *section description* — identical for
            # ``components=0`` and ``components='z'``, so the one label that had
            # to distinguish them could not.
            "observable": label,
            # The discrete view this diagram was read through — recorded so an
            # auto-chosen section is never a silent choice (it is also printed
            # under the figure's title by ``__plot_spec__``).
            "section": section_label,
            "section_auto": is_peak_map,
        }

    # 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(view, DiscreteMap):
        try:
            points = _sweep_via_kernel(
                view,
                param,
                values_arr,
                transient=transient,
                n=n,
                carry_state=carry_state,
                ic=ic,
                idx=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 = []

    # A ``PoincareMap`` collects each value's section in ONE engine call through
    # ``trajectory`` (the wired Rust event march) instead of re-entering the flow
    # integrator per detection ``dt`` — the flagship "bifurcation diagram of a flow"
    # path, previously ~70x slower than necessary (the gap CLAUDE.md and
    # ``derived/_crossings`` both call out).  ``trajectory`` owns the eligibility
    # decision and falls back to the very ``_advance_to_crossing`` loop
    # ``_record_via_step`` drives, so the answer is unchanged where the fast path
    # declines.  A degenerate ``n == 0`` keeps the step loop: ``trajectory`` records
    # nothing, so it cannot leave ``state()`` at the last *discarded* transient
    # crossing the way the step loop does, and ``carry_state`` would drift.
    use_trajectory = isinstance(view, PoincareMap) and n > 0

    # The per-value protocol path: flow wrappers, the raw-flow peak map, and the
    # engine-sweep fallback.
    equilibria: list[bool] = []
    for v in values_arr:
        current = view.with_params(**{param: v})
        start = state if (carry_state and state is not None) else ic
        try:
            if is_peak_map:
                # A raw flow: read it as the successive-maxima (peak) map.  The
                # horizon that satisfied the previous value seeds the next one, so
                # only the first value pays for discovering it.
                rec, last, horizon, settled = _record_via_peaks(
                    current, start, transient, n, idx, dt=dt, final_time=horizon, max_time=max_time
                )
                equilibria.append(settled)
            else:
                # Flow wrappers (PoincareMap / StroboscopicMap) and the
                # engine-sweep fallback (a non-lowerable map / wheel-free env)
                # drive the per-step protocol loop — byte-identical to the engine
                # path on a lowerable map.
                record = _record_via_trajectory if use_trajectory else _record_via_step
                rec, last = record(current, start, transient, n, idx)
                # A discrete view cannot reach an equilibrium silently: a flow
                # that settles stops crossing its section, which raises below.
                equilibria.append(False)
        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))))
            if len(equilibria) < len(points):
                equilibria.append(False)
            state = None
            continue
        points.append(rec)
        if carry_state:
            state = last

    return OrbitDiagram(
        param=param,
        values=values_arr,
        points=points,
        components=tuple(idx),
        equilibria=np.asarray(equilibria, dtype=bool),
        meta=_meta(),
    )

OrbitDiagram dataclass

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

Bases: AnalysisResult

Result of :func:orbit_diagram.

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

ATTRIBUTE DESCRIPTION
param

Name of the swept control parameter.

TYPE: str

values

The swept parameter axis — see the note below.

TYPE: ndarray

points

The asymptotic orbit recorded at each value, points[k] of shape (n, k_components); one entry per entry of :attr:values.

TYPE: list of ndarray

.. note

values is the parameter axis here, not the answer. The name means three different things across the result classes — on a Lyapunov spectrum or an embedding it is the measurement, on this class and on :class:~tsdynamics.analysis.results.ContinuationResult it is the control parameter that was swept, and on :class:~tsdynamics.analysis.results.ReturnMap it is the observable series. Always read the field's own line below before using it.

TYPE: :

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,
    labelled: bool = False,
) -> 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

labelled

Return a structured array carrying the period on each side of every transition, so a genuine period-doubling can be told from a band split inside the chaotic regime.

Measured on the logistic map, the plain array holds 20 values of which the first (r = 3.000) is the period-doubling and the other 19 are branch splittings inside the chaotic band — successive gaps give ratios like 11.7, nothing near Feigenbaum's 4.669 — with nothing to say which is which. With labelled=True the rows carry value / before / after / kind, and rows["kind"] == "period-doubling" selects the cascade.

.. versionadded:: 6.0

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
ndarray

labelled=False (the default): the estimated bifurcation parameter values, in sweep order, resolved to the spacing of values. labelled=True: a structured array with fields value (float), before / after (int periods, 0 = aperiodic) and kind ("period-doubling" / "period-halving" / "onset of chaos" / "band split" / "periodic window" / "period change").

Examples:

>>> import numpy as np, tsdynamics as ts
>>> od = ts.analysis.orbit_diagram(
...     ts.systems.Logistic(), "r", np.linspace(2.8, 3.6, 120)
... )
>>> rows = od.bifurcation_points(labelled=True)
>>> float(rows["value"][rows["kind"] == "period-doubling"][0])
3.0033613445378155
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,
    labelled: bool = False,
) -> 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`.
    labelled : bool, default False
        Return a structured array carrying the **period on each side** of
        every transition, so a genuine period-doubling can be told from a
        band split inside the chaotic regime.

        Measured on the logistic map, the plain array holds 20 values of
        which the first (``r = 3.000``) is the period-doubling and the other
        19 are branch splittings inside the chaotic band — successive gaps
        give ratios like 11.7, nothing near Feigenbaum's 4.669 — with nothing
        to say which is which.  With ``labelled=True`` the rows carry
        ``value`` / ``before`` / ``after`` / ``kind``, and
        ``rows["kind"] == "period-doubling"`` selects the cascade.

        .. versionadded:: 6.0

    Returns
    -------
    numpy.ndarray
        ``labelled=False`` (the default): the estimated bifurcation parameter
        values, in sweep order, resolved to the spacing of ``values``.
        ``labelled=True``: a structured array with fields ``value`` (float),
        ``before`` / ``after`` (int periods, ``0`` = aperiodic) and ``kind``
        (``"period-doubling"`` / ``"period-halving"`` / ``"onset of chaos"``
        / ``"band split"`` / ``"periodic window"`` / ``"period change"``).

    Examples
    --------
    >>> import numpy as np, tsdynamics as ts
    >>> od = ts.analysis.orbit_diagram(
    ...     ts.systems.Logistic(), "r", np.linspace(2.8, 3.6, 120)
    ... )
    >>> rows = od.bifurcation_points(labelled=True)
    >>> float(rows["value"][rows["kind"] == "period-doubling"][0])  # doctest: +SKIP
    3.0033613445378155

    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)
    if not labelled:
        return self._bifurcation_points_from_periods(p)
    return self._labelled_bifurcations(p)

Poincaré sections

poincare_section

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

Poincaré surface of section.

Two input modes:

  • System → wraps it in a :class:~tsdynamics.derived.PoincareMap and collects crossings 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).

Omit it and one is chosen, from a short probe orbit: the widest-spread component (largest interquartile range) among those the orbit repeatedly returns to, sectioned at its median — an offset the probe orbit provably straddles (so the auto section cannot miss it; on a system with no default_ic the section run starts from a different random draw, which only matters when the two orbits need not share an attractor — see :func:~tsdynamics.derived.poincare.auto_plane), and one it comes back to, so the map has crossings to return to. A monotone coordinate (a carried drive phase) has the widest spread of all and is not a section; it is skipped for that reason. See :func:tsdynamics.derived.poincare.auto_plane for the rule and why it is that one. The choice is recorded in result.meta["plane"] with meta["plane_auto"] = True and printed by the section's repr; name a plane whenever you have one. In data mode (a Trajectory in) the same rule applies, read off the data itself.

TYPE: tuple DEFAULT: None

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

crossings

Number of crossings to collect (system mode) — the number of points the returned section holds.

.. versionchanged:: 6.0 Named n before v6. n meant four different things across the public surface; here it counts crossings, so it says so — and it now reads in the same unit as its neighbour skip_crossings.

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

ic

Where to start the section run — dim numbers, one per state component (system mode). Falls back to system.ic, the declared default, then the seeded random draw.

.. versionadded:: 6.0 Every sibling that runs a system took ic= — orbit_diagram, lyapunov_spectrum, zero_one_test — and this one refused it with a bare TypeError, leaving seed= as the only control over where the orbit starts. On a system with a finite basin that is not a control at all: 3 of the first 4 seeds can land off-attractor.

TYPE: array - like DEFAULT: None

dt

Crossing-detection step, in time units (system mode). The march is fixed-step rk4 at this step, so dt bounds how finely a crossing is bracketed before the Hermite refinement — it is not an output grid and not a tolerance. Ignored for a Trajectory, whose sampling interval is already fixed.

TYPE: float DEFAULT: 0.01

max_time

Ceiling on the integration horizon, in time units (system mode): how long to march before giving up on collecting crossings crossings.

TYPE: float DEFAULT: 1e4

seed

Seed for the random initial condition when the system has none (system mode), so the section is reproducible. Pass seed=None for an explicitly unseeded draw.

.. versionchanged:: 6.0 Was None: the same call drew a different starting point, and so a different set of crossings, every time it ran.

TYPE: int DEFAULT: 0

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 repr / .to_dict() / .plot result surface.

Examples:

>>> section = poincare_section(Rossler(), plane=("y", 0.0, "up"), crossings=500)
>>> section = poincare_section(Rossler(), crossings=500)     # section chosen + recorded
>>> 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, ...] | None = None,
    *,
    direction: int | str = +1,
    crossings: int = 1000,
    skip_crossings: int = 0,
    ic: Any | None = None,
    dt: float = 0.01,
    max_time: float = 1e4,
    seed: int | None = 0,
) -> PoincareSection:
    """
    Poincaré surface of section.

    Two input modes:

    - **System** → wraps it in a :class:`~tsdynamics.derived.PoincareMap`
      and collects ``crossings`` 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, optional
        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)``.

        **Omit it and one is chosen**, from a short probe orbit: the
        widest-spread component (largest interquartile range) *among those the
        orbit repeatedly returns to*, sectioned at its median — an offset the
        probe orbit provably straddles (so the auto section cannot miss *it*; on
        a system with no ``default_ic`` the section run starts from a different
        random draw, which only matters when the two orbits need not share an
        attractor — see :func:`~tsdynamics.derived.poincare.auto_plane`), and one
        it comes back to, so the map has crossings to return to.  A
        monotone coordinate (a carried drive phase) has the widest spread of all
        and is not a section; it is skipped for that reason.  See
        :func:`tsdynamics.derived.poincare.auto_plane` for the rule and why it is
        that one.  The choice is recorded in ``result.meta["plane"]`` with
        ``meta["plane_auto"] = True`` and printed by the section's repr; name a
        plane whenever you have one.  In **data** mode (a ``Trajectory`` in) the
        same rule applies, read off the data itself.
    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).
    crossings : int, default 1000
        Number of crossings to collect (system mode) — the number of points the
        returned section holds.

        .. versionchanged:: 6.0
            Named ``n`` before v6.  ``n`` meant four different things across the
            public surface; here it counts crossings, so it says so — and it now
            reads in the same unit as its neighbour ``skip_crossings``.
    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.)
    ic : array-like, optional
        Where to start the section run — ``dim`` numbers, one per state
        component (system mode).  Falls back to ``system.ic``, the declared
        default, then the seeded random draw.

        .. versionadded:: 6.0
            Every sibling that runs a system took ``ic=`` — ``orbit_diagram``,
            ``lyapunov_spectrum``, ``zero_one_test`` — and this
            one refused it with a bare ``TypeError``, leaving ``seed=`` as the
            only control over where the orbit starts.  On a system with a finite
            basin that is not a control at all: 3 of the first 4 seeds can land
            off-attractor.

    dt : float, default 0.01
        Crossing-**detection** step, in **time units** (system mode).  The march
        is fixed-step ``rk4`` at this step, so ``dt`` bounds how finely a crossing
        is bracketed before the Hermite refinement — it is not an output grid and
        not a tolerance.  Ignored for a ``Trajectory``, whose sampling interval is
        already fixed.
    max_time : float, default 1e4
        Ceiling on the integration horizon, in **time units** (system mode):
        how long to march before giving up on collecting ``crossings`` crossings.
    seed : int, default 0
        Seed for the random initial condition when the system has none
        (system mode), so the section is reproducible.  Pass ``seed=None`` for an
        explicitly unseeded draw.

        .. versionchanged:: 6.0
            Was ``None``: the same call drew a different starting point, and so a
            different set of crossings, every time it ran.

    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 repr / ``.to_dict()`` /
        ``.plot`` result surface.

    Examples
    --------
    >>> section = poincare_section(Rossler(), plane=("y", 0.0, "up"), crossings=500)
    >>> section = poincare_section(Rossler(), crossings=500)     # section chosen + recorded
    >>> section = poincare_section(traj, plane=("z", 25.0))     # from data
    """
    _reject_axes_pair(system, plane)
    if isinstance(system, Trajectory):
        if ic is not None:
            from tsdynamics.errors import InvalidParameterError

            raise InvalidParameterError(
                "ic= chooses where to START a run, and a Trajectory has already been "
                "run — its section is cut from the samples it holds."
                + remedy(
                    "ts.analysis.poincare_section(system, plane, ic=[1.0, 1.0, 1.0])",
                    "ts.analysis.poincare_section(traj[100:], plane)   # ...or cut later data",
                )
            )
        return _section_from_data(system, plane, direction)
    start = _seeded_ic(system, ic, seed) if ic is None else np.asarray(ic, dtype=float)
    if start is not None:
        system = system.copy()
        system.reinit(start)
    pmap = PoincareMap(system, plane, direction=direction, dt=dt, max_time=max_time)
    return pmap.run(crossings, transient=skip_crossings)

Return maps

return_map

return_map(
    system: Any,
    components: int | str = 0,
    *,
    kind: 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 = 0,
    **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 (kind must be "max" or "min"). (Trajectory / array inputs are the data overload.)

TYPE: System, Trajectory, or array-like

components

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

kind

"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 kind="poincare" (see :func:~tsdynamics.analysis.orbits.poincare_section).

TYPE: tuple DEFAULT: None

direction

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

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

n

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

TYPE: int DEFAULT: 2000

final_time

Integration horizon, in time units, when system is a flow (extremum mode only).

TYPE: float DEFAULT: 200.0

dt

Step, in time units, when system is a flow. In extremum mode it is the output sampling step and only needs to resolve the peaks — the recorded value is sharpened by parabolic interpolation, so a coarse grid still gives accurate extrema. In kind="poincare" mode it is the crossing-detection step of the section march. Ignored for a Trajectory / a bare series, which are already sampled. In kind="poincare" mode final_time, ic, transient and **integrate_kwargs do not apply and are ignored (the section is marched until n crossings).

TYPE: float DEFAULT: 0.01

transient

Dynamics discarded before recording extrema, in time units — the unit final_time and run(transient=) use (kind="max" / "min", system input). The section transient is the separate skip_crossings, because a count of crossings is a different quantity from an elapsed time.

TYPE: float DEFAULT: 0.0

skip_crossings

Number of leading crossings discarded before recording (kind="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, so the map is reproducible. Pass seed=None for an unseeded draw.

.. versionchanged:: 6.0 Was None — the same call returned a different map each run.

TYPE: int DEFAULT: 0

**integrate_kwargs

Forwarded to system.run (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", kind="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, kind="poincare", plane=(0, 0.0), n=400)
Source code in src/tsdynamics/analysis/orbits/return_map.py
def return_map(
    system: Any,
    components: int | str = 0,
    *,
    kind: 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 = 0,
    **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 (``kind`` must be ``"max"`` or
        ``"min"``).  (Trajectory / array inputs are the ``data`` overload.)
    components : 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.
    kind : {"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 ``kind="poincare"``
        (see :func:`~tsdynamics.analysis.orbits.poincare_section`).
    direction : {+1, -1, 0}, default +1
        Crossing-direction filter (``kind="poincare"`` only).
    n : int, default 2000
        Number of section crossings to collect when integrating a system in
        ``kind="poincare"`` mode.
    final_time : float, default 200.0
        Integration horizon, in **time units**, when ``system`` is a flow
        (extremum mode only).
    dt : float, default 0.01
        Step, in **time units**, when ``system`` is a flow.  In extremum mode it
        is the *output sampling* step and only needs to resolve the peaks — the
        recorded value is sharpened by parabolic interpolation, so a coarse grid
        still gives accurate extrema.  In ``kind="poincare"`` mode it is the
        crossing-**detection** step of the section march.  Ignored for a
        ``Trajectory`` / a bare series, which are already sampled.
        In ``kind="poincare"`` mode ``final_time``, ``ic``, ``transient`` and
        ``**integrate_kwargs`` do not apply and are ignored (the section is
        marched until ``n`` crossings).
    transient : float, default 0.0
        Dynamics discarded before recording extrema, in **time units** — the unit
        ``final_time`` and ``run(transient=)`` use (``kind="max"`` / ``"min"``,
        system input).  The *section* transient is the separate
        ``skip_crossings``, because a count of crossings is a different quantity
        from an elapsed time.
    skip_crossings : int, default 0
        Number of leading **crossings** discarded before recording
        (``kind="poincare"``, system input).
    ic : array-like, optional
        Initial state when ``system`` is a flow.
    seed : int, default 0
        Seed for the random initial condition when the system has none, so the
        map is reproducible.  Pass ``seed=None`` for an unseeded draw.

        .. versionchanged:: 6.0
            Was ``None`` — the same call returned a different map each run.
    **integrate_kwargs
        Forwarded to ``system.run`` (extremum mode, system input).

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

    Examples
    --------
    >>> rm = return_map(Lorenz(), "z", kind="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, kind="poincare", plane=(0, 0.0), n=400)
    """
    kind = kind.lower()
    if kind not in _KINDS:
        raise ValueError(f"kind must be one of {_KINDS}, got {kind!r}.")

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

    current = values[:-1]
    successor = values[1:]
    meta: dict[str, Any] = {"kind": kind, "observable": obs_idx, "n": int(values.size)}
    if plane is not None:
        meta["plane"] = plane
    names = tuple(getattr(system, "variables", ()) or ())
    if names:
        # The repr says "26 returns of z", not "of component 2" (S3 inbox d).
        meta["variables"] = names
    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=kind,
        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 / the readout repr / .to_dict() / the .plot seam. Iterate for (current, successor) pairs, or use :meth:flat for the scatter-ready arrays.

ATTRIBUTE DESCRIPTION
current, successor

The return map itself — :math:(v_n, v_{n+1}), the pair you plot.

TYPE: ndarray

values

The raw observable series the map was built from: every recorded :math:v_n in time order, including the last one (which has no successor and so appears in :attr:current for no pair). It is the input to the map, not the map. (values is the one name in the result layer that means different things on different classes: the answer on a spectrum or an embedding, the swept parameter on :class:~tsdynamics.analysis.results.OrbitDiagram and :class:~tsdynamics.analysis.results.ContinuationResult, this series here.)

TYPE: ndarray

times

When each entry of :attr:values was recorded.

TYPE: ndarray

kind

"max", "min" or "poincare" — how successive values were picked off the orbit.

TYPE: str

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

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

    xlabel, ylabel, legend_label = self._axis_labels()
    cur = np.asarray(self.current, dtype=float)
    suc = np.asarray(self.successor, dtype=float)
    layers = [pb.scatter(cur, suc, label=legend_label)]
    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=xlabel,
        ylabel=ylabel,
        title=f"{self.kind} cobweb of {self._observable_label()}",
        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,
    solver: str | None = None,
    rtol: float | None = None,
    atol: float | None = None,
    backend: str | None = None,
    reortho_interval: int | None = None,
    d0: float | None = None,
    steps_per: int | None = None,
    **aliases: Any,
) -> LyapunovSpectrum

Lyapunov spectrum — lambda_1 > 0 is the chaos test.

The one entry point for every family, and since v6 the one entry point for the maximal exponent too: ask for k=1.

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). A system with no right-hand side to differentiate — a :class:~tsdynamics.families.WrappedSystem around an opaque external stepper — is answered instead by Benettin two-trajectory rescaling, which forms no Jacobian; it resolves one exponent, so k > 1 is refused there.

.. versionchanged:: 6.0 Absorbed max_lyapunov, which was a second public door onto the same question and answered it with a different number: on Hénon at one nominal horizon it returned 0.4233 where this function returned 0.4160. The two things it had that this one lacked came with it — the map burn-in (see transient, now on by default) and the two-trajectory machine for a system with no Jacobian.

PARAMETER DESCRIPTION
system

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

TYPE: System

k

Number of exponents to compute. 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

**aliases

steps= only — the alias of n that a map's :meth:~tsdynamics.families.discrete.DiscreteMap.iterate already accepts, taken here too so the flat function speaks the same horizon vocabulary as the method it wraps. Passing both n and steps raises, as does any other keyword.

TYPE: Any DEFAULT: {}

rtol

Solver tolerances for the flow / DDE variational integration (they have no meaning for a map, whose tangent iteration is exact arithmetic).

TYPE: float DEFAULT: None

atol

Solver tolerances for the flow / DDE variational integration (they have no meaning for a map, whose tangent iteration is exact arithmetic).

TYPE: float DEFAULT: None

transient

Dynamics discarded before the exponents are accumulated — time units for a flow (the same unit as final_time, and the unit run(transient=) uses), a count of ITERATIONS for a map.

A Lyapunov exponent is a property of the attractor, so the orbit has to be on it first; a map's default is :data:_DEFAULT_BURN_ITERATIONS iterations, and transient=0 disables it for a caller who has already landed their own ic.

.. versionchanged:: 6.0 A map used to refuse this word — "its QR iteration reorthonormalises from the initial condition, so there is nothing to discard", which confuses the tangent frame with the base orbit. The burn-in is folded in from the retired max_lyapunov, which always did it and was measurably the more accurate door because of it.

TYPE: float DEFAULT: None

dt

Integration step for the variational system, in time units (flows only; a map iterates). Defaults to the family's own step.

TYPE: float DEFAULT: None

ic

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

TYPE: array - like DEFAULT: None

solver

Numerical kernel for the variational integration (continuous flows only), e.g. "rk45" / "bdf". solver= picks a kernel; method= picks an estimator, and this analysis has one — so method= raises, naming this word.

.. versionchanged:: 6.0 Was method=. v6 renamed the kernel word to solver= at run() for exactly this reason, and this door had not followed — so obeying the library's own rule here used to be a dead end.

TYPE: str DEFAULT: None

backend

Which engine runs the tangent dynamics. Named here because this free function is the ONLY door in v6 (ruling A2): a keyword the removed method accepted and this one refuses is a signature bug, not a curation (C1).

TYPE: ('jit', 'interp', 'reference') DEFAULT: "jit"

reortho_interval

Iterations between Gram--Schmidt reorthonormalisations (maps only).

TYPE: int DEFAULT: None

d0

Separation the two trajectories are reset to after every rescaling — only for a system with no right-hand side to differentiate, which is the one case answered that way. Default 1e-9. A system whose variational machine exists refuses it by name: nothing is being perturbed there.

It is a real knob, not a formality: on an exactly-linear expanding flow the growth rate is independent of the perturbation size, and a d0 that small against a growing reference loses the separation to floating-point cancellation — so that measurement needs d0=1e-4.

.. versionadded:: 6.0 Carried over from the retired max_lyapunov, whose two-trajectory machine this is.

TYPE: float DEFAULT: None

steps_per

Protocol steps between rescalings of that same two-trajectory loop (default 10). Refused, by name, for a system that has a tangent frame — a frame is reorthonormalised on its own cadence (reortho_interval), not rescaled.

.. versionadded:: 6.0 Carried over from the retired max_lyapunov.

TYPE: int 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
InvalidInputError

If system is measured data (a Trajectory / array — estimate the exponent from the series with :func:lyapunov_from_data instead), or offers neither a right-hand side to differentiate nor a set_state for two trajectories to be rescaled through. A TypeError subclass, so an existing except TypeError keeps catching it.

ValueError

If k <= 0, or a keyword is passed to the wrong family (final_time / dt / solver for a map; n for a flow; or a solver for a DDE, which selects its engine via backend), or k > 1 is asked of a system with no Jacobian (two trajectories resolve one exponent).

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,
    solver: str | None = None,
    rtol: float | None = None,
    atol: float | None = None,
    backend: str | None = None,
    reortho_interval: int | None = None,
    d0: float | None = None,
    steps_per: int | None = None,
    **aliases: Any,
) -> LyapunovSpectrum:
    """Lyapunov spectrum — lambda_1 > 0 is the chaos test.

    The **one** entry point for every family, and since v6 the one entry point
    for the maximal exponent too: ask for ``k=1``.

    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).
    A system with **no right-hand side to differentiate** — a
    :class:`~tsdynamics.families.WrappedSystem` around an opaque external
    stepper — is answered instead by Benettin *two-trajectory* rescaling, which
    forms no Jacobian; it resolves one exponent, so ``k > 1`` is refused there.

    .. versionchanged:: 6.0
        Absorbed ``max_lyapunov``, which was a second public door onto the same
        question and answered it with a different number: on Hénon at one
        nominal horizon it returned 0.4233 where this function returned 0.4160.
        The two things it had that this one lacked came with it — the **map
        burn-in** (see ``transient``, now on by default) and the
        **two-trajectory** machine for a system with no Jacobian.

    Parameters
    ----------
    system : System
        A flow (ODE/DDE) or a discrete map.
    k : int, optional
        Number of exponents to compute.  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``.
    **aliases
        ``steps=`` only — the alias of ``n`` that a map's
        :meth:`~tsdynamics.families.discrete.DiscreteMap.iterate` already
        accepts, taken here too so the flat function speaks the same horizon
        vocabulary as the method it wraps.  Passing both ``n`` and ``steps``
        raises, as does any other keyword.
    rtol, atol : float, optional
        Solver tolerances for the flow / DDE variational integration (they have
        no meaning for a map, whose tangent iteration is exact arithmetic).
    transient : float, optional
        Dynamics discarded before the exponents are accumulated — **time units**
        for a flow (the same unit as ``final_time``, and the unit
        ``run(transient=)`` uses), a count of **ITERATIONS** for a map.

        A Lyapunov exponent is a property of the *attractor*, so the orbit has
        to be on it first; a map's default is
        :data:`_DEFAULT_BURN_ITERATIONS` iterations, and ``transient=0``
        disables it for a caller who has already landed their own ``ic``.

        .. versionchanged:: 6.0
            A map used to *refuse* this word — "its QR iteration
            reorthonormalises from the initial condition, so there is nothing to
            discard", which confuses the tangent frame with the base orbit.  The
            burn-in is folded in from the retired ``max_lyapunov``, which always
            did it and was measurably the more accurate door because of it.
    dt : float, optional
        Integration step for the variational system, in **time units** (flows
        only; a map iterates).  Defaults to the family's own step.
    ic : array-like, optional
        Initial condition.  Falls back to ``system.ic``, then random.
    solver : str, optional
        Numerical kernel for the variational integration (continuous flows
        only), e.g. ``"rk45"`` / ``"bdf"``.  ``solver=`` picks a *kernel*;
        ``method=`` picks an *estimator*, and this analysis has one — so
        ``method=`` raises, naming this word.

        .. versionchanged:: 6.0
            Was ``method=``.  v6 renamed the kernel word to ``solver=`` at
            ``run()`` for exactly this reason, and this door had not followed —
            so obeying the library's own rule here used to be a dead end.
    backend : {"jit", "interp", "reference"}, optional
        Which engine runs the tangent dynamics.  Named here because this free
        function is the ONLY door in v6 (ruling A2): a keyword the removed method
        accepted and this one refuses is a signature bug, not a curation (C1).
    reortho_interval : int, optional
        Iterations between Gram--Schmidt reorthonormalisations (maps only).
    d0 : float, optional
        Separation the two trajectories are reset to after every rescaling —
        **only** for a system with no right-hand side to differentiate, which is
        the one case answered that way.  Default ``1e-9``.  A system whose
        variational machine exists refuses it by name: nothing is being
        perturbed there.

        It is a real knob, not a formality: on an exactly-linear expanding flow
        the growth rate is independent of the perturbation size, and a ``d0``
        that small against a growing reference loses the separation to
        floating-point cancellation — so that measurement needs ``d0=1e-4``.

        .. versionadded:: 6.0
            Carried over from the retired ``max_lyapunov``, whose two-trajectory
            machine this is.
    steps_per : int, optional
        Protocol steps between rescalings of that same two-trajectory loop
        (default 10).  Refused, by name, for a system that has a tangent frame —
        a frame is reorthonormalised on its own cadence
        (``reortho_interval``), not rescaled.

        .. versionadded:: 6.0
            Carried over from the retired ``max_lyapunov``.

    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
    ------
    InvalidInputError
        If ``system`` is measured data (a ``Trajectory`` / array — estimate the
        exponent from the series with :func:`lyapunov_from_data` instead), or
        offers neither a right-hand side to differentiate nor a ``set_state``
        for two trajectories to be rescaled through.  A ``TypeError`` subclass,
        so an existing ``except TypeError`` keeps catching it.
    ValueError
        If ``k <= 0``, or a keyword is passed to the wrong family (``final_time``
        / ``dt`` / ``solver`` for a map; ``n`` for a flow; or a ``solver`` for a
        DDE, which selects its engine via ``backend``), or ``k > 1`` is asked of
        a system with no Jacobian (two trajectories resolve one exponent).

    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).
    """
    reject_data(system, analysis="lyapunov_spectrum", sibling=_FROM_DATA_LINE)
    method_fn = getattr(system, "_lyapunov_spectrum", None)
    if k is not None:
        # Typed, and checked BEFORE the tape is built — ``k`` is an option value,
        # so the library's ``InvalidParameterError`` (a ``ValueError``) is the
        # class, and a non-integer must not reach a bare ``'<=' not supported``.
        if isinstance(k, bool) or not isinstance(k, (int, np.integer)):
            raise InvalidParameterError(
                f"k (number of exponents) must be a positive integer, got {k!r}."
                + remedy("ts.analysis.lyapunov_spectrum(system, k=2)")
            )
        if k <= 0:
            raise InvalidParameterError(
                f"k (number of exponents) must be a positive integer, got {k!r}."
                + remedy("ts.analysis.lyapunov_spectrum(system, k=2)")
            )

    # ``steps`` is the alias of ``n`` a map's ``iterate`` / ``run`` accept, so it
    # reaches this door too: the flat function must take the same horizon words
    # as the method it wraps, or ``hen.lyapunov_spectrum(steps=2000)`` working
    # while ``ts.analysis.lyapunov_spectrum(hen, steps=2000)`` raises is a signature bug
    # a user has no way to predict.  It is accepted through ``**aliases`` rather
    # than named in the signature on purpose: ``n`` is the declared vocabulary
    # (the naming gate in ``test_polish_standards.py`` bans ``steps`` as a
    # *parameter spelling*), and an alias is a courtesy, not a second word.
    steps = aliases.pop("steps", None)
    if "method" in aliases:
        # v6 split the two words everywhere else — ``solver=`` selects a numerical
        # kernel, ``method=`` selects an *estimator* — and ``run()`` already says
        # so in its own refusal.  This door kept the old spelling, so a user who
        # obeyed the rule they had just been taught hit "unexpected keyword
        # 'solver'" and had nowhere to go.  Same sentence, same fix, both doors.
        raise InvalidParameterError(
            "method= selects an *estimator*, and lyapunov_spectrum has only one "
            "(Benettin QR); the numerical kernel is solver=."
            + remedy('ts.analysis.lyapunov_spectrum(system, solver="bdf")')
        )
    if aliases:
        # An unknown keyword is a call-shape error, so it must stay catchable as
        # the ``TypeError`` the interpreter would have raised (``InvalidInputError``
        # subclasses it); ``InvalidParameterError`` is a ``ValueError`` and is for
        # a keyword that exists with a bad value.
        raise InvalidInputError(
            f"lyapunov_spectrum got an unexpected keyword argument {sorted(aliases)[0]!r}. "
            f"It takes k, final_time (a flow) / n (a map), transient, dt, ic, solver, "
            f"rtol, atol, backend and reortho_interval."
            + remedy("ts.analysis.lyapunov_spectrum(system, k=2, final_time=200.0)")
        )
    if steps is not None:
        if n is not None:
            raise InvalidParameterError(
                f"n and steps are the same argument (the iteration count), so pass "
                f"only one; got n={n!r} and steps={steps!r}."
                + remedy("ts.analysis.lyapunov_spectrum(system, n=5000)")
            )
        n = steps

    if method_fn is None:
        # No variational machine — an adapted external stepper (a
        # ``WrappedSystem``) has no right-hand side to differentiate and no tape
        # to lower.  The Benettin TWO-TRAJECTORY loop needs neither, so it is not
        # a second implementation of anything here: it is the only one, and it
        # lives behind this door rather than behind a second public name.  That
        # is the whole of the v6 fold — ``max_lyapunov`` was a second door onto
        # the same question that answered it with a different number (Hénon:
        # 0.4233 against this function's 0.4160 at one nominal horizon).
        return _two_trajectory_spectrum(
            system,
            k=k,
            final_time=final_time,
            n=n,
            dt=dt,
            transient=transient,
            ic=ic,
            solver=solver,
            reortho_interval=reortho_interval,
            d0=d0,
            steps_per=steps_per,
        )

    for word, value, why in (
        ("d0", d0, "the separation two rescaled trajectories are reset to"),
        ("steps_per", steps_per, "the number of protocol steps between those rescalings"),
    ):
        if value is not None:
            raise InvalidParameterError(
                f"{word} is {why}, and {type(system).__name__} has a right-hand side to "
                f"differentiate — so its exponents come from a tangent FRAME carried "
                f"alongside the orbit, with nothing perturbed and nothing rescaled."
                + remedy("ts.analysis.lyapunov_spectrum(system, k=2)")
            )

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

    if getattr(system, "family", None) == "map":
        # Maps: horizon is `n` (iterations); no time, solver or burn-in concept.
        # Every refusal below names the unit, because the mistake is never a typo
        # — it is the right word for the other kind of dynamics.
        if final_time is not None:
            raise InvalidParameterError(
                "final_time is a horizon in TIME UNITS and a map has no continuous time; "
                "a map's horizon is n, a count of ITERATIONS."
                + remedy("ts.analysis.lyapunov_spectrum(system, n=5000)")
            )
        if dt is not None:
            raise InvalidParameterError(
                "dt is an integration step in TIME UNITS and a map has no continuous time "
                "— every map step is exactly one iteration."
                + remedy("ts.analysis.lyapunov_spectrum(system, n=5000)")
            )
        # ``transient`` is a burn-in in ITERATIONS here, and it defaults to
        # :data:`_DEFAULT_BURN_ITERATIONS` rather than to nothing.  This door used
        # to REFUSE the word ("a map spectrum has nothing to discard: the QR
        # iteration reorthonormalises from the initial condition"), which
        # confuses reorthonormalisation — a property of the tangent frame — with
        # landing on the attractor, a property of the base orbit: iterates spent
        # falling onto the attractor are stretched by the *transient* dynamics,
        # and averaging them in is exactly why the two Lyapunov doors returned
        # two numbers.  The retired ``max_lyapunov`` burnt in and was the more
        # accurate of the pair; that burn-in now lives here.
        transient = transient_n = _resolve_map_transient(transient)
        if transient_n:
            fwd["ic"] = _burn_in_map(system, ic=ic, iterations=transient_n)
        if solver is not None:
            raise InvalidParameterError(
                "solver names a numerical kernel for a differential equation, and a map is "
                "iterated exactly — there is no kernel to choose."
                + remedy("ts.analysis.lyapunov_spectrum(system, n=5000)")
            )
        if n is not None:
            fwd["n"] = n
        if reortho_interval is not None:
            fwd["reortho_interval"] = reortho_interval
    else:
        if reortho_interval is not None:
            raise InvalidParameterError(
                "reortho_interval counts ITERATIONS between reorthonormalisations, so it "
                "is a map keyword; a flow reorthonormalises once per dt chunk."
                + remedy("ts.analysis.lyapunov_spectrum(system, dt=0.01)")
            )
        # Flows (ODE/DDE): horizon is `final_time`; transient is a burn-in time.
        if n is not None:
            raise InvalidParameterError(
                "n is a count of ITERATIONS and a flow advances in continuous time; its "
                "horizon is final_time, a length in TIME UNITS."
                + remedy("ts.analysis.lyapunov_spectrum(system, final_time=200.0)")
            )
        if final_time is not None:
            fwd["final_time"] = final_time
        if transient is not None:
            fwd["transient"] = transient
        if dt is not None:
            fwd["dt"] = dt
        if solver is not None:
            if isinstance(system, DelaySystem):
                raise InvalidParameterError(
                    "a DDE's variational system is integrated by the engine's method of "
                    "steps, which has no selectable kernel; choose the engine with backend=."
                    + remedy('ts.analysis.lyapunov_spectrum(system, backend="jit")')
                )
            fwd["method"] = solver
        # The solver tolerances the family estimator has always taken.  They are
        # named here because the free function is the ONLY door in v6 (ruling
        # A2), so a keyword the removed method accepted and this one refuses is a
        # signature bug, not a curation (C1).
        if rtol is not None:
            fwd["rtol"] = rtol
        if atol is not None:
            fwd["atol"] = atol
    estimate = method_fn(**fwd)
    exponents = np.asarray(estimate, dtype=float)
    meta = _build_meta(
        system,
        analysis="lyapunov_spectrum",
        k=int(exponents.size),
        final_time=final_time,
        n=n,
        transient=transient,
        # Carry the family estimator's record of where the orbit LANDED, so a
        # spectrum measured on an escaping orbit can refuse a verdict instead of
        # hedging about the horizon.  This function rebuilds ``meta`` from
        # scratch, so anything the family recorded has to be re-read here.
        orbit_peak=getattr(estimate, "meta", {}).get("orbit_peak"),
    )
    return LyapunovSpectrum(values=exponents, meta=meta)

lyapunov_from_data

lyapunov_from_data(
    data: ndarray,
    *,
    dt: float | None = None,
    dimension: int = 5,
    delay: int | None = None,
    theiler: int | None = None,
    k_max: int | None = None,
    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); the exponent is reported per unit of dt. Left unset it is read from the data when the data knows it — a :class:~tsdynamics.data.Trajectory carries its output spacing, so lyapunov_from_data(traj) answers in the system's own time units and is directly comparable with :func:lyapunov_spectrum. A bare array has no time axis, so it keeps dt = 1.0: an exponent per sample, which is also the right reading for a map (per iteration).

TYPE: float DEFAULT: None

dimension

Embedding dimension. Should be large enough to unfold the attractor (Takens' sufficient condition is m > 2 D; a false-nearest-neighbour estimate is the data-driven choice) — too small underestimates the exponent and roughens the scaling region.

TYPE: int DEFAULT: 5

delay

Embedding delay, in samples. None (the default) reads it from the data: the first lag at which the autocorrelation has fallen to :math:1/e (see :func:_auto_delay, which also explains why that rule rather than the first zero crossing or the mutual-information minimum). That is 1 for a map and grows with the oversampling factor of a flow; the old fixed delay=1 default produced a near-collinear embedding for any finely-sampled flow and, with it, a badly biased exponent.

TYPE: int DEFAULT: None

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. None (the default) uses 25 * delay (at least 20, never more than the series can support, and never more than :data:_KMAX_CEILING — both estimators cost time, and Rosenstein memory, linearly in k_max, so an oversampling-driven value is capped with a :class:ScalingRegionWarning): the scaling region lives a few decorrelation times out, so a look-ahead fixed in samples is meaningless while one fixed in delays transfers between systems.

TYPE: int DEFAULT: None

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. None (the default) locates the linear scaling region automatically (:func:_auto_fit_region: the longest stable-local-slope plateau, above the transient and below saturation). Passing fit explicitly takes ownership of the choice — no plateau search runs and no :class:ScalingRegionWarning is raised for it, whatever the window contains.

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.

WARNS DESCRIPTION
ScalingRegionWarning

When the requested delay is far below the series' own decorrelation time — a near-collinear embedding, whose exponent is badly biased — or when the series is so heavily oversampled that the automatic k_max had to be capped at :data:_KMAX_CEILING to stay affordable.

The other untrustworthy outcome, a curve with no stable-slope plateau (all transient and/or all saturation, so the slope is a fallback fit over the whole curve), is reported through result.trusted / repr(result) / result.meta["scaling_region"] rather than a warning — see :class:ScalingRegionWarning for why. Either way, do not use an untrusted number: inspect the curve and pass an explicit fit, or improve the reconstruction.

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. Measured at the defaults against independent truth (logistic r = 4, where the exponent is exactly :math:\ln 2; Hénon, Lorenz, Chen and Rössler against variational spectra), the automatic region lands within 1 % on a map and within −22 % to +9 % on a flow, depending on the observable and the sampling rate — a Kantz/Rosenstein reading of a scalar flow observable is a ~20 %-accurate quantity, not a precision one. Treat it as "is there a positive exponent, and roughly how large", and use :func:~tsdynamics.analysis.lyapunov.lyapunov_spectrum when the system (not just a measured series) is in hand.

Examples:

>>> import tsdynamics as ts
>>> traj = ts.systems.Henon().run(6000, transient=500, ic=[0.1, 0.1])
>>> res = ts.analysis.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
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
def lyapunov_from_data(
    data: np.ndarray,
    *,
    dt: float | None = None,
    dimension: int = 5,
    delay: int | None = None,
    theiler: int | None = None,
    k_max: int | None = None,
    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, optional
        Sampling interval (time between consecutive samples); the exponent is
        reported per unit of ``dt``.  Left unset it is **read from the data
        when the data knows it** — a
        :class:`~tsdynamics.data.Trajectory` carries its output spacing, so
        ``lyapunov_from_data(traj)`` answers in the system's own time units and
        is directly comparable with :func:`lyapunov_spectrum`.  A bare array has
        no time axis, so it keeps ``dt = 1.0``: an exponent per sample, which is
        also the right reading for a map (per iteration).
    dimension : int, default 5
        Embedding dimension.  Should be large enough to unfold the attractor
        (Takens' sufficient condition is ``m > 2 D``; a false-nearest-neighbour
        estimate is the data-driven choice) — too small underestimates the
        exponent and roughens the scaling region.
    delay : int, optional
        Embedding delay, in samples.  ``None`` (the default) reads it from the
        data: the first lag at which the autocorrelation has fallen to
        :math:`1/e` (see :func:`_auto_delay`, which also explains why that rule
        rather than the first zero crossing or the mutual-information minimum).
        That is ``1`` for a map and grows with the oversampling factor of a
        flow; the old fixed
        ``delay=1`` default produced a near-collinear embedding for any
        finely-sampled flow and, with it, a badly biased exponent.
    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, optional
        Number of forward samples over which divergence is tracked; the curve
        spans ``k = 0 … k_max``.  ``None`` (the default) uses ``25 * delay``
        (at least 20, never more than the series can support, and never more than
        :data:`_KMAX_CEILING` — both estimators cost time, and Rosenstein memory,
        linearly in ``k_max``, so an oversampling-driven value is capped with a
        :class:`ScalingRegionWarning`): the scaling region lives a few
        decorrelation times out, so a look-ahead fixed in *samples* is
        meaningless while one fixed in *delays* transfers between systems.
    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.  ``None``
        (the default) locates the linear scaling region automatically
        (:func:`_auto_fit_region`: the longest stable-local-slope plateau, above
        the transient and below saturation).  Passing ``fit`` explicitly takes
        ownership of the choice — no plateau search runs and no
        :class:`ScalingRegionWarning` is raised for it, whatever the window
        contains.

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

    Warns
    -----
    ScalingRegionWarning
        When the requested ``delay`` is far below the series' own decorrelation
        time — a near-collinear embedding, whose exponent is badly biased — or
        when the series is so heavily oversampled that the automatic ``k_max``
        had to be capped at :data:`_KMAX_CEILING` to stay affordable.

        The other untrustworthy outcome, a curve with **no** stable-slope plateau
        (all transient and/or all saturation, so the slope is a fallback fit over
        the whole curve), is reported through ``result.trusted`` /
        ``repr(result)`` / ``result.meta["scaling_region"]`` rather than a
        warning — see :class:`ScalingRegionWarning` for why.  Either way, do not
        use an untrusted number: inspect the curve and pass an explicit ``fit``,
        or improve the reconstruction.

    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.
    Measured at the defaults against independent truth (logistic ``r = 4``, where
    the exponent is exactly :math:`\ln 2`; Hénon, Lorenz, Chen and Rössler
    against variational spectra), the automatic region lands within **1 % on a
    map** and within **−22 % to +9 % on a flow**, depending on the observable and
    the sampling rate — a Kantz/Rosenstein reading of a scalar flow observable is
    a ~20 %-accurate quantity, not a precision one.  Treat it as "is there a
    positive exponent, and roughly how large", and use
    :func:`~tsdynamics.analysis.lyapunov.lyapunov_spectrum` when the system (not
    just a measured series) is in hand.

    Examples
    --------
    >>> import tsdynamics as ts
    >>> traj = ts.systems.Henon().run(6000, transient=500, ic=[0.1, 0.1])
    >>> res = ts.analysis.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.
    """
    reject_system(data, analysis="lyapunov_from_data")
    # Read the escape off the DATA before anything is reconstructed from it, so
    # a runaway that reached the estimator as a bare array (``traj["x"][::10]``
    # — the spelling that laundered the trajectory's own flag) is caught too.
    escape = runaway_meta(getattr(data, "y", data), analysis="lyapunov_from_data")
    dimension = int(dimension)
    n_neighbors = int(n_neighbors)
    if dt is None:
        # Unset: read it off the data when the data knows it, else per sample.
        measured = _sampling_interval_of(data)
        dt = 1.0 if measured is None else measured
    else:
        dt = float(dt)
    method = method.lower()
    if dimension < 1:
        raise InvalidParameterError("dimension (embedding dimension) must be >= 1.")
    # Reconstruction defaults are read from the data, not fixed in samples: a
    # delay and a look-ahead that are right for a map are wrong for the same
    # dynamics sampled 100x finer (see the `delay` / `k_max` docstrings).
    auto_delay, delay_measured = _auto_delay(data)
    delay = auto_delay if delay is None else int(delay)
    if delay < 1:
        raise InvalidParameterError("delay (embedding delay) must be >= 1.")
    degenerate_embedding = delay_measured and delay * _DEGENERATE_DELAY_FACTOR < auto_delay
    if degenerate_embedding:
        warnings.warn(
            f"lyapunov_from_data: delay={delay} is far below this series' own "
            f"decorrelation time (~{auto_delay} samples), so the delay embedding is "
            "near-collinear: its 'neighbours' are consecutive samples of one "
            "trajectory rather than dynamical neighbours, and the exponent read "
            "off it is badly biased (result.trusted is False). Leave delay unset "
            "to use the data-driven value, or pass a delay of that order.",
            ScalingRegionWarning,
            stacklevel=2,
        )
    if k_max is None:
        # The look-ahead is set by the *dynamics'* own decorrelation time, not by
        # whatever embedding delay the caller chose: an over-long delay on a map
        # (e.g. delay=5 on the logistic map, whose stretching curve saturates by
        # k = 10) would otherwise stretch k_max to 125 and drown the scaling
        # region in saturation.
        scale = auto_delay if delay_measured else delay
        span = (dimension - 1) * delay
        rows = int(np.shape(np.asarray(data))[0]) - span
        wanted = max(_KMAX_FLOOR, _KMAX_DELAYS * scale)
        # Every row within k_max of the end is unusable as a reference OR as a
        # neighbour (its k-ahead image does not exist), so a look-ahead of half
        # the rows throws away half the point cloud — and on a short, heavily
        # oversampled series (whose Theiler window is ``(m - 1) * delay``, itself
        # large) the survivors are too sparse for any of them to have a neighbour
        # inside ``eps``: a 1000-sample Lorenz x-series at dt = 0.02 raised
        # ConvergenceError at ``(rows - 2) // 2`` and estimates fine at a quarter.
        k_max = int(min(wanted, _KMAX_CEILING, max(2, (rows - 1) // 4)))
        if wanted > _KMAX_CEILING and k_max == _KMAX_CEILING:
            # The cap bound: say so, because it is the sampling rate that made the
            # automatic look-ahead unaffordable, and decimating is the real remedy.
            warnings.warn(
                f"lyapunov_from_data: this series decorrelates only after ~{auto_delay} "
                f"samples, so the automatic look-ahead would be k_max={wanted}; it is "
                f"capped at {_KMAX_CEILING} because the cost of both estimators grows "
                "with k_max (and the Rosenstein path's memory with it). The series is "
                "heavily oversampled for this estimator: decimating it "
                f"(data[::{max(1, auto_delay // 10)}], with dt scaled by the same "
                "factor) usually sharpens the scaling region, and passing k_max "
                "explicitly buys the full look-ahead at the full cost. "
                "The capped estimate is NOT automatically wrong — read "
                "result.trusted and the repr before acting: if the fit is clean "
                "and the record is long, the capped answer stands.",
                ScalingRegionWarning,
                stacklevel=2,
            )
    else:
        k_max = int(k_max)
    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.")
    if fit is not None and not (0 <= int(fit[0]) < int(fit[1]) <= k_max):
        # Validated up front: an out-of-range window is a caller error and must
        # not be reported only after the (potentially minutes-long) neighbour search.
        raise InvalidParameterError(
            f"fit region {fit!r} must satisfy 0 <= lo < hi <= k_max ({k_max})."
        )

    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(
                f"lyapunov_from_data: no reference point on the reconstructed "
                f"attractor has a neighbour within eps={eps:.3g} outside the Theiler "
                f"window ({theiler} samples), so there is no divergence to measure. "
                f"The series is too short or too sparsely sampled for a "
                f"{dimension}-dimensional reconstruction."
                + remedy(
                    f"ts.analysis.lyapunov_from_data(data, dimension=3, eps={4 * eps:.3g},"
                    f" theiler={max(1, theiler // 2)})",
                    lead="Widen the neighbourhood and lower the embedding dimension:",
                )
            )
        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).
        # ``candidates``, not ``rows``: ``rows`` already means the COUNT of usable
        # reference rows in this function (bound far above), and rebinding it to
        # the candidate-neighbour INDEX ARRAY gave one name two types — which is
        # what the type checker was objecting to, and is worth not doing anyway.
        candidates: np.ndarray = idx_all[: last + 1, 1:]  # drop column 0 (the point itself)
        ref_grid: np.ndarray = np.arange(last + 1, dtype=np.intp)[:, None]
        valid = (candidates <= last) & (np.abs(candidates - 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 = candidates[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
    trusted = not degenerate_embedding
    if fit is None:
        region, _peak_slope = _auto_fit_region(times, divergence)
        if region is None:
            # No plateau: the curve is all transient and/or all saturation, so no
            # slope read off it is a scaling-region reading.  Fall back to the
            # whole curve and flag the result untrusted.
            trusted = False
            lo, hi = 0, k_max
            region_source = "none (fallback: fitted over the whole curve)"
        else:
            lo, hi = region
            region_source = "auto (stable-slope plateau)"
    else:
        lo, hi = int(fit[0]), int(fit[1])  # bounds already validated above
        region_source = "explicit"
    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,
        # The series' OWN decorrelation lag, carried so the record-length test
        # cannot be gamed by shrinking the embedding the caller chose.  ``0``
        # when the autocorrelation never reached 1/e — no measurement, so no
        # floor, rather than a floor invented from a capped search.
        decorrelation=auto_delay if delay_measured else 0,
        trusted=trusted,
        meta={
            "method": method,
            "dimension": dimension,
            "delay": delay,
            "theiler": theiler,
            "k_max": k_max,
            "n_reference": n_reference,
            "decorrelation": auto_delay if delay_measured else 0,
            "trusted": trusted,
            "scaling_region": region_source,
            **escape,
        },
    )

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",
    decorrelation: int = 0,
    trusted: bool = True,
    *,
    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 / the readout repr / .to_dict() / the .plot seam) and float(result) (the exponent). The domain names for the curve are :attr:lyapunov, :attr:times and :attr:divergence.

ATTRIBUTE DESCRIPTION
lyapunov

Estimated maximal Lyapunov exponent (per unit time), the slope of :attr:divergence against :attr:times over fit_region. The domain name for the inherited estimate field; float(result) returns it.

TYPE: float

times

Relative times k * dt for k = 0 … k_max — the domain name for the inherited abscissa field.

TYPE: ndarray

divergence

The stretching curve S(k) — mean log divergence after k samples; the domain name for the inherited ordinate field. Inspect :attr:times vs :attr:divergence 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

method

"kantz" or "rosenstein".

TYPE: str

trusted

False when the estimate is not a reading of a scaling region. Four ways to lose it, and the repr says which:

  • the automatic search found no stable-slope plateau (the slope was then fitted over the whole curve as a fallback), or the delay embedding is near-collinear (which also raises a :class:ScalingRegionWarning);
  • the fit is not believable — too few points, or too crooked;
  • the record is too short — fewer than :data:_MIN_INDEPENDENT_WINDOWS independent windows;
  • the fitted window is too narrow — the divergence climbs fewer than :data:_MIN_FIT_EFOLDINGS e-folds across it, so there is no growth rate to read.

meta["scaling_region"] records which happened. Check this flag before believing an exponent from an unattended run. An explicit fit=(lo, hi) takes ownership of the region; it does not take ownership of the record length or of the window's width, so those two still apply.

TYPE: bool

Notes

Four further quantities are carried and readable but kept off dir() (contract §11.3): abscissa / ordinate (R2 — :attr:times and :attr:divergence are the names this estimator's curve is known by, and the ones the docs use), and n_reference / decorrelation, the two estimator-internal counts whose readable combination is the public :attr:independent_windows. All four remain in to_dict().

independent_windows property

independent_windows: float

How many independent embedding windows the record holds.

n_reference / max((m - 1) * τ, τ_dec) — the reference points divided by the span one embedding vector covers, floored at the series' own decorrelation time. This is the record-length half of :attr:trusted, and it is the half that was missing: measured on 2000 samples of Lorenz x (truth λ = 0.9056) the estimator returned 1.2414 — 37% high — with R² = 0.99872 and trusted = True, because :math:R^2 measures how straight the fitted line is and says nothing at all about whether there was enough data behind it.

Why the floor. Without it the denominator is entirely the caller's to choose, so the one thing the library hands a reader whose call was refused — "reduce dimension/delay" — also silently restored the trust flag: on 500 Lorenz samples, dimension=2, delay=1 takes the span from 48 to 1 and the count from 2.2 to 358.0, a 160x jump in apparent record length bought by shrinking the reconstruction rather than by measuring anything. :attr:decorrelation is read off the data, so it cannot be bought. Where the embedding is auto-chosen the floor never binds (τ ≈ τ_dec and m = 5, so (m-1)τ is already 4 τ_dec) and no shipped number moves.

fit_efoldings property

fit_efoldings: float

How many e-folds of separation the fitted window covers.

:math:\lambda \cdot \Delta t_{fit} — the exponent times the width of the window it was fitted over. A Lyapunov exponent is a growth rate, so a window the separation barely grows across carries no rate to read; this is the diagnostic neither :math:R^2 nor the record length can be. See :data:_MIN_FIT_EFOLDINGS for the measurement that sets the floor.

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).

chaotic property

chaotic: bool | None

Is this chaotic? None when the fit cannot support a verdict.

The one adjective-named verdict (contract §4.2 rule 10), three-valued on purpose: None exactly when :attr:trusted is False, so an unreadable scaling region can never be read as a confident "no".

This estimator is billed as the data-side twin of the "one number that says chaotic", and it used to be the only one of the three that refused to say it — verdict was None on every result, while zero_one_test, which carries no trust flag at all, answered chaotic (K ~ 1). For a user who came because they could not make the call themselves, that is backwards.

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.

When you already hold a :class:LyapunovSpectrum, spectrum.kaplan_yorke is the same number and the shorter spelling — it calls this. Reach for the function when the exponents came from somewhere else: a literature spectrum, a hand-written array, another library's output. That is what it takes, and why both exist::

ts.analysis.kaplan_yorke_dimension([0.906, 0.0, -14.572])   # Lorenz, from the paper
PARAMETER DESCRIPTION
spectrum

Lyapunov exponents (any order; sorted descending internally). Must be non-empty and finite — an empty spectrum has no dimension to report and a nan/inf exponent means the estimator did not converge, so both raise rather than return a plausible number.

TYPE: array - like

RETURNS DESCRIPTION
ScalarResult

The dimension, a drop-in for its float value (float(result) / comparisons work). The documented conventions at the edges of the definition:

  • every exponent negative (:math:\lambda_1 < 0) — a stable fixed point: 0.0;
  • the cumulative sum never turns negative — the spectrum is incomplete (a truncated k < dim spectrum, or a conservative system whose exponents sum to zero, e.g. [1, -1]): saturates at len(spectrum), since :math:D_{KY} cannot exceed the number of directions supplied.

:math:\lambda_{j+1} cannot be zero at the interpolation step: if it were, the cumulative sum at j + 1 would equal the one at j and still be non-negative, so j would not have been the last such index.

RAISES DESCRIPTION
InvalidParameterError

If spectrum is empty, is not one-dimensional, or holds a non-finite exponent.

Examples:

>>> float(kaplan_yorke_dimension([0.906, 0.0, -14.57]))   # Lorenz
2.062...
>>> float(kaplan_yorke_dimension([-0.5, -1.0]))           # stable fixed point
0.0
>>> float(kaplan_yorke_dimension([1.0, -1.0]))            # conservative: saturates
2.0
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.

    When you already hold a :class:`LyapunovSpectrum`, ``spectrum.kaplan_yorke``
    is the same number and the shorter spelling — it calls this.  Reach for the
    function when the exponents came from somewhere else: a literature spectrum,
    a hand-written array, another library's output.  That is what it takes, and
    why both exist::

        ts.analysis.kaplan_yorke_dimension([0.906, 0.0, -14.572])   # Lorenz, from the paper

    Parameters
    ----------
    spectrum : array-like
        Lyapunov exponents (any order; sorted descending internally).  Must be
        non-empty and finite — an empty spectrum has no dimension to report and a
        ``nan``/``inf`` exponent means the estimator did not converge, so both
        raise rather than return a plausible number.

    Returns
    -------
    ScalarResult
        The dimension, a drop-in for its ``float`` value (``float(result)`` /
        comparisons work).  The documented conventions at the edges of the
        definition:

        * every exponent negative (:math:`\lambda_1 < 0`) — a stable fixed point:
          ``0.0``;
        * the cumulative sum never turns negative — the spectrum is *incomplete*
          (a truncated ``k < dim`` spectrum, or a conservative system whose
          exponents sum to zero, e.g. ``[1, -1]``): saturates at
          ``len(spectrum)``, since :math:`D_{KY}` cannot exceed the number of
          directions supplied.

        :math:`\lambda_{j+1}` cannot be zero at the interpolation step: if it
        were, the cumulative sum at ``j + 1`` would equal the one at ``j`` and
        still be non-negative, so ``j`` would not have been the last such index.

    Raises
    ------
    InvalidParameterError
        If ``spectrum`` is empty, is not one-dimensional, or holds a non-finite
        exponent.

    Examples
    --------
    >>> float(kaplan_yorke_dimension([0.906, 0.0, -14.57]))   # Lorenz
    2.062...
    >>> float(kaplan_yorke_dimension([-0.5, -1.0]))           # stable fixed point
    0.0
    >>> float(kaplan_yorke_dimension([1.0, -1.0]))            # conservative: saturates
    2.0

    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.
    """
    # No ``hint=``: the shared builder already knows this is a *result*-first
    # analysis and which analysis produces its subject.  The bespoke hint opened
    # with "expects measured data" — which this function does not — and that
    # wrong clause is the one CONTRACT §5.6 names.
    reject_system(spectrum, analysis="kaplan_yorke_dimension")
    s = np.asarray(spectrum, dtype=float)
    if s.ndim == 0:
        # A single number is never a spectrum: the formula needs a negative
        # exponent to interpolate against, so a scalar always saturates and
        # returns a confident 1.0.  ``kaplan_yorke_dimension(spec[0])`` is the
        # natural way to make this mistake -- both are "Lyapunov things" -- so
        # refuse it rather than answer it.
        raise InvalidParameterError(
            f"kaplan_yorke_dimension needs the whole Lyapunov spectrum, not a single "
            f"exponent (got {float(s):.6g}). The dimension is read off where the "
            f"running sum of the exponents changes sign, so one number carries no "
            f"dimension — it would saturate at 1.0 whatever its value."
            + remedy(
                "exps = ts.analysis.lyapunov_spectrum(system)",
                "ts.analysis.kaplan_yorke_dimension(exps)",
                lead="Compute the full spectrum first:",
            )
        )
    s = np.atleast_1d(s)
    if s.ndim != 1:
        raise InvalidParameterError(
            f"kaplan_yorke_dimension: spectrum must be one-dimensional, got shape {s.shape}."
        )
    if s.size == 0:
        raise InvalidParameterError(
            "kaplan_yorke_dimension: the spectrum is empty — there is no dimension to "
            "report. Pass at least one Lyapunov exponent."
        )
    if not np.all(np.isfinite(s)):
        raise InvalidParameterError(
            f"kaplan_yorke_dimension: the spectrum holds a non-finite exponent "
            f"({np.array2string(s, precision=4)}); the estimator did not converge, so "
            "any dimension read off it would be meaningless."
        )
    s = np.sort(s)[::-1]
    if 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 = 0,
) -> FixedPointSet

Find the fixed points of a map, or the equilibria of a flow.

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. An explicit region is a hard search domain: seeds are drawn in it and converged roots outside it are discarded. None (the default) is a pure seeding heuristic instead — nothing is clipped — and seeds two boxes derived from a 20-time-unit burn-in orbit: its bare bounding box (n_seeds draws, where equilibria cluster) and the same box padded by 50 % (a further n_seeds / 2, which reaches the ones outside the attractor), plus 20 on-orbit points. The padded box is what finds equilibria the orbit never approaches: Rossler's second one is at (5.69, -28.47, 28.47) while its attractor never leaves |y| < 12, and before v6 (a 2.0-time-unit hull) fixed_points(Rossler()) returned 1 of 2 for every seed, with no warning. If the burn-in orbit escapes instead of settling, both boxes fall back to [-2, 2]^dim.

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

n_seeds

Random seeds in the hull box; the padded box takes half as many again, and 20 orbit points are added on top. This is a completeness knob, and the cheap one: multi-start Newton is a heuristic that can always miss a root, so raise it when the count matters. For a guaranteed complete set use method="interval" (which recovers all 27 of Thomas's equilibria with a per-box existence certificate).

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. Pass seed=None for an explicitly non-deterministic search.

.. versionchanged:: 6.0 Was None. Multi-start is a sampling method, so an unseeded default made the shipped answer vary run to run: measured, three identical fixed_points(Thomas(), region=[(-5, 5)] * 3, n_seeds=60) calls found 16, 19 and 17 equilibria.

TYPE: int DEFAULT: 0

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 = 0,
) -> FixedPointSet:
    r"""
    Find the fixed points of a map, or the equilibria of a flow.

    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.  An explicit region is a hard search **domain**: seeds are
        drawn in it and converged roots outside it are discarded.  ``None`` (the
        default) is a pure *seeding* heuristic instead — nothing is clipped — and
        seeds two boxes derived from a 20-time-unit burn-in orbit: its bare
        bounding box (``n_seeds`` draws, where equilibria cluster) *and* the same
        box padded by 50 % (a further ``n_seeds / 2``, which reaches the ones
        outside the attractor), plus 20 on-orbit points.  The padded box is what
        finds equilibria the orbit never approaches: Rossler's second one is at
        ``(5.69, -28.47, 28.47)`` while its attractor never leaves ``|y| < 12``,
        and before v6 (a 2.0-time-unit hull) ``fixed_points(Rossler())`` returned
        1 of 2 for **every** seed, with no warning.  If the burn-in orbit escapes
        instead of settling, both boxes fall back to ``[-2, 2]^dim``.
    n_seeds : int
        Random seeds in the hull box; the padded box takes half as many again,
        and 20 orbit points are added on top.  This is a
        completeness knob, and the cheap one: multi-start Newton is a heuristic
        that can always miss a root, so raise it when the count matters.  For a
        *guaranteed* complete set use ``method="interval"`` (which recovers all
        27 of Thomas's equilibria with a per-box existence certificate).
    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, default 0
        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.
        Pass ``seed=None`` for an explicitly non-deterministic search.

        .. versionchanged:: 6.0
            Was ``None``.  Multi-start is a *sampling* method, so an unseeded
            default made the shipped answer vary run to run: measured, three
            identical ``fixed_points(Thomas(), region=[(-5, 5)] * 3,
            n_seeds=60)`` calls found 16, 19 and 17 equilibria.

    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.
    """
    reject_data(system, analysis="fixed_points")
    if isinstance(system, DiscreteMap):
        continuous = False
    elif isinstance(system, ContinuousSystem):
        continuous = True
    else:
        raise NotImplementedError(
            f"fixed_points solves f(x)=x for a map and f(x)=0 for a flow; "
            f"{type(system).__name__} is neither. A delay system has no "
            f"finite-dimensional root to solve for, and a stochastic one has no "
            f"fixed point at all — but the *drift* of an SDE, and the flow a delay "
            f"system reduces to at zero delay, do."
            + remedy(
                "ts.analysis.fixed_points(ts.systems.Lorenz())",
                lead="Pass a map or a flow:",
            )
        )

    method = method.lower()
    if method not in ("newton", "sd", "dl", "interval"):
        raise invalid_value(
            "method",
            method,
            options=["newton", "sd", "dl", "interval"],
            hint=(
                "'newton' is multi-start Newton; 'sd'/'dl' add stabilising "
                "transformations that reach unstable orbits of maps; 'interval' "
                "is the rigorous Krawczyk search, which cannot miss a root."
                + remedy(
                    "ts.analysis.fixed_points(system, method='newton')",
                    lead="The default is the one to start from:",
                )
            ),
        )
    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
            )

    # One burn-in orbit serves both the automatic box and the on-orbit seeds (it
    # is 2500 RK4 steps of pure Python — sampling it twice was pure waste).
    orbit = _c.sample_orbit_box(system, dim, rng=rng) if region is None else None
    lo, hi = (
        _c.hull_box(orbit, dim, _c.HULL_PAD)
        if orbit is not None
        else _c.resolve_box(system, region, dim, rng)
    )
    seeds = _build_seeds(dim, lo, hi, n_seeds, rng, orbit=orbit)
    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=_build_meta(
            system,
            analysis="fixed_points",
            method=method,
            variables=_variable_names(system),
        ),
    )

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: _ArrayBacked, AnalysisResult

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

It IS its point (v6): np.asarray(fp) is the (dim,) state, fp[0] is a float, fp - other and np.linalg.norm(fp) work, and len(fp) is the state-space dimension — so fixed_points(sys)[0] hands back numbers and the class stays invisible, showing itself only in the repr, which states the answer. The stability data is one dot away and never in the way: :attr:eigenvalues, :attr:stable, :attr:continuous.

It is also an :class:~tsdynamics.analysis._result.AnalysisResult, so it carries .meta / the readout repr / .to_dict() / .to_frame() / the .plot seam.

ATTRIBUTE DESCRIPTION
x

The point, shape (dim,). np.asarray(fp) returns it.

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

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 | float | None = None,
    region: Any = None,
    *,
    n_seeds: int = 300,
    method: str = "newton",
    lam: float = 0.05,
    beta: float = 1.0,
    tol: float | None = None,
    max_iter: int | None = None,
    dedup_tol: float = 1e-06,
    prime: bool = True,
    max_c: int | None = None,
    seed: int | None = 0,
    ic: Any = None,
    transient: float = 0.0,
    steps_per_period: int = 2000,
    n_points: int = 400,
    min_amplitude: float = 1e-06,
) -> OrbitSet

Periodic orbits of a map, or a flow's limit cycle.

Solves :math:f^{p}(x) = x by multi-start root finding, recovers each orbit by forward iteration, filters orbits whose minimal period properly divides period (prime=True), and merges the cyclic shifts of one orbit.

The default is plain Newton on :math:f^{p}. It was Davidchack--Lai until v6, on the reasoning that the stabilising transformations reach unstable orbits Newton misses; measured, that is not what happens at the periods users actually explore. On the logistic map at r = 4, where the prime-cycle counts are known exactly (2, 1, 2, 3, 6, 9, 18 for :math:p = 1 \ldots 7), Newton at the default n_seeds recovers all of them, and so do "sd" and "dl" — at up to 19x the cost. On Henon(1.4, 0.3) the two agree on the count at every :math:p \le 7 while Newton is 112-323x faster (p=7: 0.09 s against 29.0 s), because "dl" runs every seed against each of the :math:2^d d! stabilising matrices and each residual is a p-fold monodromy sweep. The cheap axis for completeness is n_seeds, not the transformation: raise it first, and reach for "dl" when a high period still comes up short.

PARAMETER DESCRIPTION
system

The map, or the flow whose limit cycle to shoot for.

TYPE: DiscreteMap or ContinuousSystem

period

A map: the period p, a count of ITERATIONS (p=1 returns the fixed points as one-point orbits) — required, since a map's orbits are the fixed points of f^p and p picks which root problem to solve. A flow: the period guess T, in TIME UNITS — a flow's period is a real unknown that shooting solves for, so this only has to be close.

TYPE: int or float DEFAULT: None

region

Search region, one (lo, hi) pair per state component — the same reading every region= door in the library uses. Positional, like fixed_points' and basins'.

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

seed

RNG seed for the multi-start / burn-in sampling, so the orbits found are reproducible. Pass seed=None for an explicitly unseeded search.

.. versionchanged:: 6.0 Was None, so the same call could find a different set of orbits on each run.

TYPE: int DEFAULT: 0

n_seeds

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

TYPE: int DEFAULT: 300

dedup_tol

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

TYPE: int DEFAULT: 300

method

Root finder. "newton" (default) = Newton on f^p; "sd" = Schmelcher--Diakonos; "dl" = Davidchack--Lai. The two stabilising transformations cost :math:2^d d! root-finding passes per seed (384 matrices at dim=4) — see above for the measured trade.

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

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: None

max_iter

Iterations per seed/matrix.

TYPE: int DEFAULT: None

prime

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

TYPE: bool DEFAULT: True

PARAMETER DESCRIPTION
ic

Starting point. A flow: the shooting guess for x0 (and the burn-in start when it is not on the cycle). A map: unused.

TYPE: array - like

transient

Dynamics discarded before the shooting burn-in lands, in time units (flows only — the unit run(transient=) uses for a flow). A map takes none: its orbits are roots, not the tail of a run.

TYPE: float

steps_per_period

Integration steps per period guess in the shooting/monodromy march (flows only). Higher is more accurate and slower.

TYPE: int

n_points

How many points of the converged cycle to return in :attr:PeriodicOrbit.points (flows only) — a sampling resolution, not an accuracy knob.

TYPE: int

min_amplitude

Below this peak-to-peak amplitude a converged "cycle" is an equilibrium the shooting collapsed onto, and is rejected (flows only).

.. versionchanged:: 6.0 These five were on the signature and in no docstring at all.

TYPE: float

RETURNS DESCRIPTION
OrbitSet

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

RAISES DESCRIPTION
InvalidInputError

If system is measured data, or is not a a :class:~tsdynamics.families.DiscreteMap, when a period is named. A TypeError subclass, so except TypeError keeps working.

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 | float | None = None,
    region: Any = None,
    *,
    n_seeds: int = 300,
    method: str = "newton",
    lam: float = 0.05,
    beta: float = 1.0,
    tol: float | None = None,
    max_iter: int | None = None,
    dedup_tol: float = 1e-6,
    prime: bool = True,
    max_c: int | None = None,
    seed: int | None = 0,
    ic: Any = None,
    transient: float = 0.0,
    steps_per_period: int = 2000,
    n_points: int = 400,
    min_amplitude: float = 1e-6,
) -> OrbitSet:
    r"""
    Periodic orbits of a map, or a flow's limit cycle.

    Solves :math:`f^{p}(x) = x` by multi-start root finding, recovers each orbit
    by forward iteration, filters orbits whose minimal period properly divides
    ``period`` (``prime=True``), and merges the cyclic shifts of one orbit.

    The default is plain **Newton** on :math:`f^{p}`.  It was Davidchack--Lai
    until v6, on the reasoning that the stabilising transformations reach
    unstable orbits Newton misses; measured, that is not what happens at the
    periods users actually explore.  On the logistic map at ``r = 4``, where the
    prime-cycle counts are known exactly (2, 1, 2, 3, 6, 9, 18 for
    :math:`p = 1 \ldots 7`), Newton at the default ``n_seeds`` recovers **all of
    them**, and so do ``"sd"`` and ``"dl"`` — at up to 19x the cost.  On
    Henon(1.4, 0.3) the two agree on the count at every :math:`p \le 7` while
    Newton is **112-323x** faster (``p=7``: 0.09 s against 29.0 s), because
    ``"dl"`` runs every seed against each of the :math:`2^d d!` stabilising
    matrices and each residual is a ``p``-fold monodromy sweep.  The cheap axis
    for completeness is ``n_seeds``, not the transformation: raise it first, and
    reach for ``"dl"`` when a high period still comes up short.

    Parameters
    ----------
    system : DiscreteMap or ContinuousSystem
        The map, or the flow whose limit cycle to shoot for.
    period : int or float
        A **map**: the period ``p``, a count of ITERATIONS (``p=1`` returns the
        fixed points as one-point orbits) — required, since a map's orbits are the
        fixed points of ``f^p`` and ``p`` picks which root problem to solve.
        A **flow**: the period *guess* ``T``, in TIME UNITS — a flow's period is a
        real unknown that shooting solves for, so this only has to be close.
    region : Box, Grid, (lo, hi) pairs, optional
        Search region, one ``(lo, hi)`` pair per state component — the same
        reading every ``region=`` door in the library uses.  Positional, like
        ``fixed_points``' and ``basins``'.
    seed : int, default 0
        RNG seed for the multi-start / burn-in sampling, so the orbits found are
        reproducible.  Pass ``seed=None`` for an explicitly unseeded search.

        .. versionchanged:: 6.0
            Was ``None``, so the same call could find a different set of orbits
            on each run.
    n_seeds, dedup_tol
        Seeding controls (see :func:`~tsdynamics.analysis.fixedpoints.fixed_points`).
    method : {"newton", "sd", "dl"}
        Root finder.  ``"newton"`` (default) = Newton on ``f^p``;
        ``"sd"`` = Schmelcher--Diakonos; ``"dl"`` = Davidchack--Lai.  The two
        stabilising transformations cost :math:`2^d d!` root-finding passes per
        seed (384 matrices at ``dim=4``) — see above for the measured trade.
    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).

    Other Parameters
    ----------------
    ic : array-like, optional
        Starting point.  A **flow**: the shooting guess for ``x0`` (and the
        burn-in start when it is not on the cycle).  A **map**: unused.
    transient : float, default 0.0
        Dynamics discarded before the shooting burn-in lands, in **time units**
        (flows only — the unit ``run(transient=)`` uses for a flow).  A map takes
        none: its orbits are roots, not the tail of a run.
    steps_per_period : int, default 2000
        Integration steps per period guess in the shooting/monodromy march
        (flows only).  Higher is more accurate and slower.
    n_points : int, default 400
        How many points of the converged cycle to return in
        :attr:`PeriodicOrbit.points` (flows only) — a sampling resolution, not an
        accuracy knob.
    min_amplitude : float, default 1e-6
        Below this peak-to-peak amplitude a converged "cycle" is an equilibrium
        the shooting collapsed onto, and is rejected (flows only).

        .. versionchanged:: 6.0
            These five were on the signature and in no docstring at all.

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

    Raises
    ------
    InvalidInputError
        If ``system`` is measured data, or is not a
        a :class:`~tsdynamics.families.DiscreteMap`, when a period is named.  A ``TypeError`` subclass, so ``except TypeError`` keeps working.
    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
    """
    reject_data(system, analysis="periodic_orbits")
    if not isinstance(system, DiscreteMap):
        # A flow's periodic orbit is a closed curve with a REAL period, so it is
        # found by single shooting on ``(x0, T)`` rather than by rooting f^p.
        # One verb, one return type: v6 absorbed the old ``periodic_orbit``
        # (singular) here, and ``period`` is the period guess for a flow.
        return _flow_periodic_orbits(
            system,
            period_guess=None if period is None else float(period),
            ic=ic,
            transient=transient,
            steps_per_period=steps_per_period,
            tol=1e-10 if tol is None else float(tol),
            max_iter=50 if max_iter is None else int(max_iter),
            n_points=n_points,
            min_amplitude=min_amplitude,
            seed=seed,
        )
    if period is None:
        raise InvalidInputError(
            "periodic_orbits needs the period p to look for on a map — a map's "
            "orbits are the fixed points of f^p, one root problem per p."
            + remedy(
                "ts.analysis.periodic_orbits(system, 2)   # the 2-cycles",
                lead="Name the period:",
            )
        )
    tol = 1e-12 if tol is None else float(tol)
    max_iter = 200 if max_iter is None else int(max_iter)
    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

    # One burn-in orbit serves both the automatic box and the on-orbit seeds.
    orbit = _c.sample_orbit_box(system, dim, rng=rng) if region is None else None
    lo, hi = (
        _c.hull_box(orbit, dim, _c.HULL_PAD)
        if orbit is not None
        else _c.resolve_box(system, region, dim, rng)
    )
    seeds = _build_seeds(dim, lo, hi, n_seeds, rng, orbit=orbit)
    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=_build_meta(system, analysis="periodic_orbits", period=int(period), method=method),
    )

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: _ArrayBacked, AnalysisResult

A periodic orbit with its Floquet/multiplier stability data.

It IS its orbit (v6): np.asarray(orbit) is the (n_points, dim) point array, orbit[0] is the first point, len(orbit) is how many points it is stored as, and iteration walks them — so periodic_orbits(sys)[0] hands back numbers and the class stays invisible, showing itself only in the repr. The stability data is one dot away: :attr:period, :attr:multipliers, :attr:stable, :attr:residual.

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. np.asarray(orbit) returns it.

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

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,
    components: 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, components 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

components

Which column of a multi-component input to read, by index or by name (default: the highest-variance one). It selects a channel, never a sample: estimate_period(traj, components=0) reads traj.y[:, 0].

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().run(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,
    components: 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, ``components`` 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.
    components : int or str, optional
        Which **column** of a multi-component input to read, by index or by
        name (default: the highest-variance one).  It selects a channel, never
        a sample: ``estimate_period(traj, components=0)`` reads ``traj.y[:, 0]``.
    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().run(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, components)
    if y.size < 8:
        raise ValueError("estimate_period needs at least 8 samples.")
    # Finiteness FIRST, before anything reads the shape of the curve.  A series
    # carrying NaNs has no autocorrelation zero-crossing, so it used to be
    # reported as "no autocorrelation zero-crossing — signal may be aperiodic or
    # too short for its period" — an accurate description of the symptom and a
    # false diagnosis of the cause, sending the caller to `method='fft'` or to
    # collect more data when the data they have is unusable.  Same sentence as
    # every sibling estimator uses.
    if not np.all(np.isfinite(y)):
        raise ValueError("estimate_period: series contains non-finite values (nan/inf).")
    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}.")
    del abscissa, ordinate, curve_label  # recomputed by period_diagnostic; see below
    # ``meta`` is PROVENANCE, not payload (contract §4.2 rule 8).  The diagnostic
    # curve (autocorrelation function or power spectrum) used to ride here so
    # :func:`period_diagnostic` could draw it without changing the result type —
    # which made ``to_dict()`` for ONE float 30 824 characters of JSON, almost
    # all of it two arrays nobody asked for.  The diagnostic *builder* recomputes
    # the curve instead: one autocorrelation, on a plotting path.
    #
    # The unit is not a property of the analysis, it is a property of the CALL.
    # The value is ``lag * step``: with a sampling interval it is a time, without
    # one it is a count of samples.  A constant "samples" in the result layer's
    # table made the repr wrong by a factor of 1/dt for every ``Trajectory``
    # caller — the docstring's own example printed "6.65372 samples" for 6.65372
    # TIME UNITS (665 samples).  Record which one this call measured.
    return ScalarResult(
        value=float(lag * step),
        meta={
            "analysis": "estimate_period",
            "method": method,
            "unit": "samples" if step == 1.0 else "time units",
            "dt": float(step),
        },
    )

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

Dynamics discarded before tracking, in the unit the family advances in: iterations for a map, time units for a flow — the same rule run(transient=) follows. 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
InvalidInputError

If system is measured data rather than a model, or is neither a discrete map nor a continuous flow (a delay system, a derived wrapper). A TypeError subclass, like every other wrong-subject refusal in the analysis layer — it used to be a NotImplementedError, so except TypeError caught this mistake everywhere but here.

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
        Dynamics discarded before tracking, in the unit the family advances in:
        **iterations** for a map, **time units** for a flow — the same rule
        ``run(transient=)`` follows.  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
    ------
    InvalidInputError
        If ``system`` is measured data rather than a model, or is neither a
        discrete map nor a continuous flow (a delay system, a derived wrapper).
        A ``TypeError`` subclass, like every other wrong-subject refusal in the
        analysis layer — it used to be a ``NotImplementedError``, so
        ``except TypeError`` caught this mistake everywhere but here.

        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.
    """
    # Measured data first, and through the shared guard: GALI evolves the
    # equations' *tangent* dynamics, so an array cannot be coerced into one — and
    # a bare ``NotImplementedError`` (a ``RuntimeError``) meant ``except TypeError``
    # caught this mistake at eighteen doors and missed it at this one.
    reject_data(system, analysis="gali")
    if isinstance(system, DiscreteMap):
        mode = "map"
    elif isinstance(system, ContinuousSystem):
        mode = "flow"
    else:
        raise InvalidInputError(
            f"gali evolves the tangent dynamics of a map or a continuous flow, and "
            f"{type(system).__name__} is neither (a delay system's tangent space is the "
            f"infinite-dimensional history, and a derived wrapper has no equations of "
            f"its own)."
            + remedy(
                "ts.analysis.gali(system, k=2)",
                lead="Measure the alignment index on the underlying system:",
            )
        )

    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=_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:
        # ``resolve_ic`` commits the resolved IC to ``system.ic`` before the run, so
        # a run that then fails would leave the bad IC latched and silently poison
        # every later, unrelated analysis.  Same guard the family layer uses.
        with system._ic_rollback():
            x = np.asarray(system._resolve_ic(ic), dtype=float).ravel()
            result = _run(x)
            if result is not None:
                return _wrap(result)
            # Raise INSIDE the guard: ``_run`` signals divergence by returning
            # None rather than raising, so leaving the raise outside would exit the
            # block cleanly and latch the bad IC anyway.
            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):
        with system._ic_rollback():
            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 / the readout repr / .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).

applicable property

applicable: bool

Whether enough of the GALI\ :sub:k curve was recorded to read a verdict off.

A verdict is a reading of where the curve went, so it needs a curve: a single sample reported GALI_2 = 1 at the end regular (GALI bounded) from one point, which is the initial condition, not a measurement.

RETURNS DESCRIPTION
bool

chaotic property

chaotic: bool | None

Whether the orbit is chaotic, at the shipped collapse threshold.

The one adjective-named spelling of the verdict (contract §4.2 rule 10); :meth:is_chaotic is the same test with a tunable threshold. None — never False — when the curve is not :attr:applicable or ends in the undecided band between the two thresholds.

RETURNS DESCRIPTION
bool or None

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 GALI\ :sub:k collapsed below your threshold by the end.

This is the one thing :attr:chaotic cannot do, and the reason a second spelling of one verdict is allowed to exist here. Everywhere else in the library a classification has exactly one name (contract §4.2 rule 10) and that name answers with the library's own judgement. But GALI\ :sub:k is a decay test, and how far the index must fall before you call an orbit chaotic depends on how long you integrated and how cleanly the system separates — so the threshold is genuinely the caller's to set, and a closed-vocabulary property has nowhere to put it.

Prefer :attr:chaotic (which knows about :attr:applicable and hedges with None in the undecided band) and reach for this only when you are sweeping the threshold or reproducing a published cut::

gali = ts.analysis.gali(system, k=2, final_time=500)
gali.chaotic                      # the library's verdict, or None
gali.is_chaotic(threshold=1e-8)   # yours, on the same curve

Unlike :attr:chaotic this always returns a plain bool: you asked a sharp question, so it gives a sharp answer and does not consult :attr:applicable. On a curve that never resolved, that answer is False because nothing collapsed — check :attr:applicable first if that distinction matters.

PARAMETER DESCRIPTION
threshold

Collapse level for :attr:final. Keyword-only, so the call always reads as is_chaotic(threshold=...).

TYPE: float DEFAULT: 1e-6

RETURNS DESCRIPTION
bool

True when final < threshold.

See Also

chaotic : the verdict at the shipped thresholds, hedging when undecided. decay_rate : the fitted exponential rate the collapse happened at.

Source code in src/tsdynamics/analysis/chaos/gali.py
def is_chaotic(self, *, threshold: float = 1e-6) -> bool:
    r"""Whether GALI\ :sub:`k` collapsed below **your** threshold by the end.

    **This is the one thing :attr:`chaotic` cannot do, and the reason a
    second spelling of one verdict is allowed to exist here.**  Everywhere
    else in the library a classification has exactly one name (contract §4.2
    rule 10) and that name answers with the library's own judgement.  But
    GALI\ :sub:`k` is a *decay* test, and how far the index must fall before
    you call an orbit chaotic depends on how long you integrated and how
    cleanly the system separates — so the threshold is genuinely the
    caller's to set, and a closed-vocabulary property has nowhere to put it.

    Prefer :attr:`chaotic` (which knows about :attr:`applicable` and hedges
    with ``None`` in the undecided band) and reach for this only when you
    are sweeping the threshold or reproducing a published cut::

        gali = ts.analysis.gali(system, k=2, final_time=500)
        gali.chaotic                      # the library's verdict, or None
        gali.is_chaotic(threshold=1e-8)   # yours, on the same curve

    Unlike :attr:`chaotic` this always returns a plain ``bool``: you asked a
    sharp question, so it gives a sharp answer and does not consult
    :attr:`applicable`.  On a curve that never resolved, that answer is
    ``False`` because nothing collapsed — check :attr:`applicable` first if
    that distinction matters.

    Parameters
    ----------
    threshold : float, default 1e-6
        Collapse level for :attr:`final`.  Keyword-only, so the call always
        reads as ``is_chaotic(threshold=...)``.

    Returns
    -------
    bool
        ``True`` when ``final < threshold``.

    See Also
    --------
    chaotic : the verdict at the shipped thresholds, hedging when undecided.
    decay_rate : the fitted exponential rate the collapse happened at.
    """
    return self.final < threshold

zero_one_test

zero_one_test(
    system: Any,
    *,
    components: int | str = 0,
    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,
    oversampling: str = "resample",
) -> 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

components

Which state component is the scalar observable, by index or by name. The test reads ONE observable; the default is the first component, so zero_one_test(lorenz) works on a system of any dimension.

TYPE: int or str DEFAULT: 0

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

Dynamics discarded before recording, in the unit the family advances in: time units for a flow, iterations for a map (system input) — the same rule run(transient=) follows.

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

oversampling

What to do when the observable is oversampled — the classic misuse of this test, and one it fails silently: an observable sampled many times per oscillation makes :math:(p_c, q_c) drift smoothly instead of diffusing, and :math:K collapses toward 0 for a chaotic orbit (Lorenz at dt = 0.02 gives :math:K = -0.02). "resample" decimates the observable to about five samples per oscillation — the remedy Gottwald & Melbourne prescribe — and records the stride in result.meta; if the record is too short to decimate that far it decimates as far as it can and warns. "warn" leaves the observable alone and raises an :class:OversamplingWarning. "ignore" disables the guard entirely (you are then on your own).

TYPE: ('resample', 'warn', 'ignore') DEFAULT: "resample"

RETURNS DESCRIPTION
ZeroOneResult

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, the translation plane :math:(p_c, q_c) (result.plot() renders it) and the per-frequency result.distribution the median was taken over. 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.

WARNS DESCRIPTION
OversamplingWarning

When the observable is oversampled and the guard could not (or was asked not to) fix it — see oversampling. :math:K is then biased toward 0 and a chaotic orbit may be reported as regular.

RAISES DESCRIPTION
InvalidParameterError

If the observable is shorter than 200 points (too short for the test to be meaningful); if n_c < 1; if oversampling is not one of "resample" / "warn" / "ignore"; 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}).run(steps=5000)["x"]
>>> zero_one_test(x) > 0.9          # the data overload
True
>>> lorenz = Lorenz(ic=[1.0, 1.0, 1.0])                  # a flow, sampled fine
>>> zero_one_test(lorenz, components=0, dt=0.02, final_time=600.0) > 0.9
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,
    *,
    components: int | str = 0,
    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,
    oversampling: str = "resample",
) -> 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.
    components : int or str, default 0
        Which state component is the scalar observable, by index or by name.
        The test reads ONE observable; the default is the first component, so
        ``zero_one_test(lorenz)`` works on a system of any dimension.
    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
        Dynamics discarded before recording, in the unit the family advances in:
        **time units** for a flow, **iterations** for a map (system input) — the
        same rule ``run(transient=)`` follows.
    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).
    oversampling : {"resample", "warn", "ignore"}, default "resample"
        What to do when the observable is **oversampled** — the classic misuse of
        this test, and one it fails silently: an observable sampled many times
        per oscillation makes :math:`(p_c, q_c)` drift smoothly instead of
        diffusing, and :math:`K` collapses toward ``0`` for a chaotic orbit
        (Lorenz at ``dt = 0.02`` gives :math:`K = -0.02`).  ``"resample"``
        decimates the observable to about five samples per oscillation — the
        remedy Gottwald & Melbourne prescribe — and records the stride in
        ``result.meta``; if the record is too short to decimate that far it
        decimates as far as it can and warns.  ``"warn"`` leaves the observable
        alone and raises an :class:`OversamplingWarning`.  ``"ignore"`` disables
        the guard entirely (you are then on your own).

    Returns
    -------
    ZeroOneResult
        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``, the translation plane
        :math:`(p_c, q_c)` (``result.plot()`` renders it) and the per-frequency
        ``result.distribution`` the median was taken over.  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.

    Warns
    -----
    OversamplingWarning
        When the observable is oversampled and the guard could not (or was asked
        not to) fix it — see ``oversampling``.  :math:`K` is then biased toward
        ``0`` and a chaotic orbit may be reported as regular.

    Raises
    ------
    InvalidParameterError
        If the observable is shorter than 200 points (too short for the test to
        be meaningful); if ``n_c < 1``; if ``oversampling`` is not one of
        ``"resample"`` / ``"warn"`` / ``"ignore"``; 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}).run(steps=5000)["x"]
    >>> zero_one_test(x) > 0.9          # the data overload
    True
    >>> lorenz = Lorenz(ic=[1.0, 1.0, 1.0])                  # a flow, sampled fine
    >>> zero_one_test(lorenz, components=0, dt=0.02, final_time=600.0) > 0.9
    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.
    """
    if oversampling not in {"resample", "warn", "ignore"}:
        raise InvalidParameterError(
            f"oversampling must be 'resample', 'warn' or 'ignore', got {oversampling!r}."
        )
    phi = _observable(
        system, components, final_time=final_time, n=n, dt=dt, transient=transient, ic=ic
    )
    phi, stride, spo = _apply_oversampling_guard(phi, oversampling)
    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]
    meta = _build_meta(system, analysis="zero_one_test")
    meta["samples_per_oscillation"] = float(spo)
    meta["stride"] = int(stride)
    meta["n_samples"] = int(n_pts)
    return ZeroOneResult(value=k, p=p_rep, q=q_rep, distribution=k_c, meta=meta)

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 — the same quantity attractors / fixed_points spell n_seeds and basin_fractions / continuation spell n.

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
InvalidInputError

If system is measured data rather than a model, or is neither a discrete map nor a continuous flow. A TypeError subclass, like every other wrong-subject refusal in the analysis layer — it used to be a NotImplementedError, so except TypeError missed it here alone.

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 — the
        same quantity ``attractors`` / ``fixed_points`` spell ``n_seeds`` and
        ``basin_fractions`` / ``continuation`` spell ``n``.
    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
    ------
    InvalidInputError
        If ``system`` is measured data rather than a model, or is neither a
        discrete map nor a continuous flow.  A ``TypeError`` subclass, like every
        other wrong-subject refusal in the analysis layer — it used to be a
        ``NotImplementedError``, so ``except TypeError`` missed it here alone.
    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.
    """
    # Measured data first, and through the shared guard (see ``gali``): expansion
    # entropy re-integrates the equations' tangent dynamics from a lattice of
    # starts, so a point set cannot stand in for the model.
    reject_data(system, analysis="expansion_entropy")
    if isinstance(system, DiscreteMap):
        mode = "map"
    elif isinstance(system, ContinuousSystem):
        mode = "flow"
    else:
        raise InvalidInputError(
            f"expansion_entropy evolves the tangent dynamics of a map or a continuous "
            f"flow, and {type(system).__name__} is neither (a delay system's tangent "
            f"space is the infinite-dimensional history, and a derived wrapper has no "
            f"equations of its own)."
            + remedy(
                "ts.analysis.expansion_entropy(system, region)",
                lead="Measure it on the underlying system:",
            )
        )

    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=_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 / the readout repr / .to_dict() / the .plot seam) and float(result) (the entropy :math:H). The domain names for the curve are :attr:entropy, :attr:times and ordinate.

ATTRIBUTE DESCRIPTION
entropy

The estimated expansion entropy :math:H. float(result) returns it; it is the domain name for the inherited estimate field.

TYPE: float

times

The :math:t grid (iterations for maps, time for flows) — the domain name for the inherited abscissa field.

TYPE: ndarray

ordinate

:math:\ln E(t) at each :math:t.

TYPE: ndarray

fit_region

Inclusive (lo, hi) indices of the fitted range.

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

.. versionchanged

fit_slice and log_growth are gone — both were measured exact duplicates (of :attr:fit_region and ordinate), and log_growth had no reference anywhere in the repository. abscissa is still readable but is off dir(): :attr:times is the name this estimator's curve is known by.

TYPE: : 6.0

entropy property

entropy: float

The estimated expansion entropy (the domain name for estimate).

times property

times: ndarray

The :math:t grid the growth curve was measured on.

applicable property

applicable: bool

Whether enough trajectories survived the region for the fit to mean anything.

False on a degenerate fit — fewer than two survivors, or a stderr of exactly zero, which is what an empty survivor set produces. Before v6 the "indistinguishable from zero" guard was written se > 0.0 and abs(h) < 2*se, so 0 ± 0 fell straight through to the most confident branch and a run where nothing survived printed H0 = 0 ± 0 non-chaotic (H0 ≤ 0).

RETURNS DESCRIPTION
bool

chaotic property

chaotic: bool | None

Whether :math:H_0 > 0 — Hunt & Ott's definition of chaos.

None — never False — when the fit is not :attr:applicable or the slope is within two standard errors of zero.

RETURNS DESCRIPTION
bool or None

Fractal dimensions

correlation_dimension

correlation_dimension(
    data: Any,
    *,
    theiler: int | str = "auto",
    metric: str | float = "euclidean",
    radii: ndarray | None = None,
    n_radii: int = 24,
    c_lo: float | None = None,
    c_hi: float = _DEFAULT_C_HI,
    min_window: int = 5,
    flatness: 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).

Not the same estimator as generalized_dimension(data, 2.0), despite both being called :math:D_2: this one counts pairwise distances and the Rényi one counts box occupancies, and they disagree by more than round-off (measured on a Lorenz orbit: 2.09 vs 1.86). Prefer this one — it is the standard :math:D_2 estimator, it is far less sensitive to the box alignment, and it works down to smaller scales on a modest point set. Reach for :func:generalized_dimension when you want the whole :math:D_q spectrum on one consistent box partition.

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). "auto" reads the decorrelation time off the data, so a densely sampled flow is corrected without the caller having to know to ask; pass 0 for the uncorrected sum.

TYPE: int or 'auto' DEFAULT: "auto"

metric

Distance metric.

TYPE: str or float DEFAULT: "euclidean"

radii

Explicit radii. The default grid spans the informative window of :math:C(r) — from c_lo to c_hi — rather than a fixed fraction of the attractor extent.

TYPE: ndarray DEFAULT: None

n_radii

Number of radii when radii is not given.

TYPE: int DEFAULT: 24

c_lo

Target :math:C(r) levels bracketing the automatic radius grid (defaults 1e-4 and 0.1); see :func:correlation_sum.

TYPE: float DEFAULT: None

c_hi

Target :math:C(r) levels bracketing the automatic radius grid (defaults 1e-4 and 0.1); see :func:correlation_sum.

TYPE: float DEFAULT: None

min_window

Minimum number of radii in the fitted scaling region.

TYPE: int DEFAULT: 5

flatness

How flat the fitted scaling region has to be: a window is admitted when its straight-line residual is within this factor of the flattest window found. Larger admits wider but less straight regions. (It is not a solver tolerance — that is what the v6 rename off tol= is for.)

TYPE: float DEFAULT: 1.5

RETURNS DESCRIPTION
DimensionResult

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

Notes

Two defaults changed in v6, both because the old ones needed overriding to be right. The radius grid used to span the 1st to the 50th percentile of the pair-distance distribution — i.e. up to :math:C = 0.5, deep inside the saturation bend — and the Theiler window used to default to 0, which counts temporally adjacent samples of a flow as genuine neighbours. On the Hénon map the two together returned :math:D_2 = 1.169 against the published :math:1.220 \pm 0.005; with the current defaults the same call returns 1.206, and — unlike before — stays there as the radius grid is refined.

References

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

J. Theiler, "Spurious dimension from correlation algorithms applied to limited time-series data", Phys. Rev. A 34, 2427 (1986).

Examples:

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

If fewer than :data:~tsdynamics.analysis.dimensions._common.MIN_DIMENSION_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 | str = "auto",
    metric: str | float = "euclidean",
    radii: np.ndarray | None = None,
    n_radii: int = 24,
    c_lo: float | None = None,
    c_hi: float = _DEFAULT_C_HI,
    min_window: int = 5,
    flatness: 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`).

    **Not the same estimator as** ``generalized_dimension(data, 2.0)``, despite
    both being called :math:`D_2`: this one counts *pairwise distances* and the
    Rényi one counts *box occupancies*, and they disagree by more than round-off
    (measured on a Lorenz orbit: 2.09 vs 1.86).  Prefer this one — it is the
    standard :math:`D_2` estimator, it is far less sensitive to the box
    alignment, and it works down to smaller scales on a modest point set.  Reach
    for :func:`generalized_dimension` when you want the whole :math:`D_q`
    *spectrum* on one consistent box partition.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    theiler : int or "auto", default "auto"
        Theiler window — exclude pairs with :math:`|i - j| \le w` (see
        :func:`correlation_sum`).  ``"auto"`` reads the decorrelation time off
        the data, so a densely sampled flow is corrected without the caller
        having to know to ask; pass ``0`` for the uncorrected sum.
    metric : str or float, default "euclidean"
        Distance metric.
    radii : ndarray, optional
        Explicit radii.  The default grid spans the informative window of
        :math:`C(r)` — from ``c_lo`` to ``c_hi`` — rather than a fixed fraction
        of the attractor extent.
    n_radii : int, default 24
        Number of radii when ``radii`` is not given.
    c_lo, c_hi : float, optional
        Target :math:`C(r)` levels bracketing the automatic radius grid
        (defaults ``1e-4`` and ``0.1``); see :func:`correlation_sum`.
    min_window : int, default 5
        Minimum number of radii in the fitted scaling region.
    flatness : float, default 1.5
        How flat the fitted scaling region has to be: a window is admitted when
        its straight-line residual is within this factor of the flattest window
        found.  Larger admits wider but less straight regions.  (It is **not** a
        solver tolerance — that is what the v6 rename off ``tol=`` is for.)

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

    Notes
    -----
    Two defaults changed in v6, both because the old ones needed overriding to
    be right.  The radius grid used to span the 1st to the 50th percentile of the
    pair-distance distribution — i.e. up to :math:`C = 0.5`, deep inside the
    saturation bend — and the Theiler window used to default to ``0``, which
    counts temporally adjacent samples of a flow as genuine neighbours.  On the
    Hénon map the two together returned :math:`D_2 = 1.169` against the published
    :math:`1.220 \pm 0.005`; with the current defaults the same call returns
    ``1.206``, and — unlike before — stays there as the radius grid is refined.

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

    J. Theiler, "Spurious dimension from correlation algorithms applied to
    limited time-series data", *Phys. Rev. A* **34**, 2427 (1986).

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

    Raises
    ------
    InvalidParameterError
        If fewer than
        :data:`~tsdynamics.analysis.dimensions._common.MIN_DIMENSION_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, analysis="correlation_dimension")
    require_min_points(
        points,
        analysis="correlation_dimension",
        reason=(
            "the Grassberger-Procaccia correlation sum cannot resolve a scaling "
            "region from so few points"
        ),
    )
    radii, c, w = _correlation_sum_from_points(
        points,
        radii=radii,
        theiler=theiler,
        metric=metric,
        n_radii=n_radii,
        c_lo=c_lo,
        c_hi=c_hi,
    )
    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=flatness)
    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,
            "theiler": w,
            # One coordinate means "points on a line" — the estimator answers
            # D ~ 1 whatever the attractor is, so the result says so.
            "n_components": int(points.shape[1]),
            # The escape stamp, when the point set is a runaway orbit: the
            # cleanest-looking fit in the library is the one taken through a
            # blow-up, so ``trusted`` reads this before it reads R^2.
            **runaway_meta(points, analysis="correlation_dimension"),
        },
    )

correlation_sum

correlation_sum(
    data: Any,
    radii: ndarray | None = None,
    *,
    theiler: int | str = "auto",
    metric: str | float = "euclidean",
    n_radii: int = 24,
    c_lo: float | None = None,
    c_hi: float = _DEFAULT_C_HI,
) -> CorrelationSum

Correlation sum :math:C(r) over a grid of radii — the raw curve.

This is the curve :func:correlation_dimension fits a slope to, exposed on its own for anyone who wants to choose the scaling region by eye rather than let the fitter choose it. correlation_dimension(data) carries the same data in log form (.abscissa / .ordinate), so reach for this one when you want :math:r and :math:C(r) themselves — a plot, a custom fit, a comparison against an analytic :math:C(r) — and for the dimension reach for :func:correlation_dimension.

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 grid spanning the informative window of :math:C(r) itself — see c_lo / c_hi and :func:~tsdynamics.analysis.dimensions._common._radii_for_c_window.

TYPE: ndarray DEFAULT: None

theiler

Exclude pairs with :math:|i - j| \le w. "auto" reads w off the space--time separation profile — the first lag at which points that far apart in time are typically half a typical pair-distance apart in state space (:func:~tsdynamics.analysis.dimensions._common._auto_theiler). That is 1 for an already-decorrelated point cloud or map orbit, and a few tens of samples for a densely-sampled flow. Pass 0 for the raw, uncorrected sum.

TYPE: int or 'auto' DEFAULT: "auto"

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

c_lo

Lower target value of :math:C(r) for the automatic grid. None uses 1e-4, raised if that would put fewer than 200 pairs below the smallest radius.

TYPE: float DEFAULT: None

c_hi

Upper target value of :math:C(r) for the automatic grid.

TYPE: float DEFAULT: 0.1

RETURNS DESCRIPTION
CorrelationSum

A named 2-tuple (radii, sums) — the radii and the corresponding correlation-sum values, normalised so sums lies in [0, 1]. It unpacks like the plain tuple it replaced (r, C = correlation_sum(x)) and also names its halves, so a reader never has to count positions to find out which array is which.

RAISES DESCRIPTION
ValueError

If the Theiler window leaves no valid pairs, or theiler is neither a non-negative int nor "auto".

Source code in src/tsdynamics/analysis/dimensions/correlation.py
def correlation_sum(
    data: Any,
    radii: np.ndarray | None = None,
    *,
    theiler: int | str = "auto",
    metric: str | float = "euclidean",
    n_radii: int = 24,
    c_lo: float | None = None,
    c_hi: float = _DEFAULT_C_HI,
) -> CorrelationSum:
    r"""Correlation sum :math:`C(r)` over a grid of radii — the raw curve.

    This is the *curve* :func:`correlation_dimension` fits a slope to, exposed
    on its own for anyone who wants to choose the scaling region by eye rather
    than let the fitter choose it.  ``correlation_dimension(data)`` carries the
    same data in log form (``.abscissa`` / ``.ordinate``), so reach for this one
    when you want :math:`r` and :math:`C(r)` themselves — a plot, a custom fit, a
    comparison against an analytic :math:`C(r)` — and for the *dimension* reach
    for :func:`correlation_dimension`.

    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 grid spanning the
        informative window of :math:`C(r)` itself — see ``c_lo`` / ``c_hi`` and
        :func:`~tsdynamics.analysis.dimensions._common._radii_for_c_window`.
    theiler : int or "auto", default "auto"
        Exclude pairs with :math:`|i - j| \le w`.  ``"auto"`` reads ``w`` off the
        **space--time separation profile** — the first lag at which points that
        far apart in time are typically half a typical pair-distance apart in
        state space (:func:`~tsdynamics.analysis.dimensions._common._auto_theiler`).
        That is ``1`` for an already-decorrelated point cloud or map orbit, and a
        few tens of samples for a densely-sampled flow.  Pass ``0`` for the raw,
        uncorrected sum.
    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.
    c_lo : float, optional
        Lower target value of :math:`C(r)` for the automatic grid.  ``None``
        uses ``1e-4``, raised if that would put fewer than 200 pairs below the
        smallest radius.
    c_hi : float, default 0.1
        Upper target value of :math:`C(r)` for the automatic grid.

    Returns
    -------
    CorrelationSum
        A named 2-tuple ``(radii, sums)`` — the radii and the corresponding
        correlation-sum values, normalised so ``sums`` lies in ``[0, 1]``.  It
        unpacks like the plain tuple it replaced (``r, C = correlation_sum(x)``)
        and also names its halves, so a reader never has to count positions to
        find out which array is which.

    Raises
    ------
    ValueError
        If the Theiler window leaves no valid pairs, or ``theiler`` is neither a
        non-negative int nor ``"auto"``.
    """
    out_radii, c, _w = _correlation_sum_from_points(
        _as_points(data, analysis="correlation_sum"),
        radii,
        theiler=theiler,
        metric=metric,
        n_radii=n_radii,
        c_lo=c_lo,
        c_hi=c_hi,
    )
    return CorrelationSum(out_radii, c)

generalized_dimension

generalized_dimension(
    data: Any,
    q: float = 2.0,
    *,
    scales: ndarray | None = None,
    n_scales: int = 20,
    min_occupancy: float = _DEFAULT_MIN_OCCUPANCY,
    min_resolution: float = _DEFAULT_MIN_RESOLUTION,
    min_window: int = 5,
    flatness: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
) -> DimensionResult

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

The one Rényi estimator: :func:box_counting_dimension is this at :math:q = 0 and :func:information_dimension is this at :math:q = 1, both by delegation, so those names cost nothing to learn and can never give a different answer. :math:D_2 from this function is the Rényi :math:q = 2, which is a different estimator from :func:correlation_dimension (box occupancies vs pairwise distances); prefer that one for :math:D_2 alone.

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, and no estimator in this library computes them (see :data:_NEGATIVE_Q_HINT).

TYPE: float DEFAULT: 2.0

scales

Box sizes :math:\epsilon. Default: n_scales log-spaced across the informative band — every one of them resolved and unsaturated by construction (:func:_informative_scales). A supplied grid is instead trimmed to that band by :func:_informative_mask.

TYPE: ndarray DEFAULT: None

n_scales

Number of box sizes when scales is not given.

TYPE: int DEFAULT: 20

min_occupancy

Minimum mean number of points per occupied box for a scale to be used. This is the saturation guard: at fewer than a few points per box the box count is capped by the sample size rather than set by the geometry, and the curve flattens. (It replaces the pre-v6 sat_frac, which cut at a fraction of N and so meant something different in every dimension.)

TYPE: float DEFAULT: 5.0

min_resolution

Minimum linear resolution diam / eps — how many boxes span the set's largest extent — for a scale to be used. This is the large-box guard: a cover a few boxes across measures the bounding box, not the geometry.

TYPE: float DEFAULT: 6.0

min_window

Minimum number of box sizes in the fitted scaling region.

TYPE: int DEFAULT: 5

flatness

How flat the fitted scaling region has to be: a window is admitted when its straight-line residual is within this factor of the flattest window found. (It is not a solver tolerance — hence the v6 rename.)

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.

Notes

This is the raw single-order estimator: it cannot check itself, because the only cheap internal consistency test on a box-counting dimension is that :math:D_q is non-increasing in :math:q, which needs more than one order. :func:box_counting_dimension and :func:dimension_spectrum do run that check. Small :math:q — and :math:q = 0 above all — converge slowly, because they weight the rarely-visited boxes a finite sample resolves worst.

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 = 20,
    min_occupancy: float = _DEFAULT_MIN_OCCUPANCY,
    min_resolution: float = _DEFAULT_MIN_RESOLUTION,
    min_window: int = 5,
    flatness: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
) -> DimensionResult:
    r"""Generalized (Rényi) dimension :math:`D_q` by box counting.

    The one Rényi estimator: :func:`box_counting_dimension` is this at
    :math:`q = 0` and :func:`information_dimension` is this at :math:`q = 1`,
    both by delegation, so those names cost nothing to learn and can never give a
    different answer.  :math:`D_2` from *this* function is the Rényi
    :math:`q = 2`, which is a **different estimator** from
    :func:`correlation_dimension` (box occupancies vs pairwise distances);
    prefer that one for :math:`D_2` alone.

    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, and no estimator in this library computes them (see
        :data:`_NEGATIVE_Q_HINT`).
    scales : ndarray, optional
        Box sizes :math:`\epsilon`.  Default: ``n_scales`` log-spaced across the
        *informative* band — every one of them resolved and unsaturated by
        construction (:func:`_informative_scales`).  A supplied grid is instead
        trimmed to that band by :func:`_informative_mask`.
    n_scales : int, default 20
        Number of box sizes when ``scales`` is not given.
    min_occupancy : float, default 5.0
        Minimum mean number of points per occupied box for a scale to be used.
        This is the saturation guard: at fewer than a few points per box the box
        count is capped by the sample size rather than set by the geometry, and
        the curve flattens.  (It replaces the pre-v6 ``sat_frac``, which cut at a
        fraction of ``N`` and so meant something different in every dimension.)
    min_resolution : float, default 6.0
        Minimum linear resolution ``diam / eps`` — how many boxes span the set's
        largest extent — for a scale to be used.  This is the large-box guard: a
        cover a few boxes across measures the bounding box, not the geometry.
    min_window : int, default 5
        Minimum number of box sizes in the fitted scaling region.
    flatness : float, default 1.5
        How flat the fitted scaling region has to be: a window is admitted when
        its straight-line residual is within this factor of the flattest window
        found.  (It is **not** a solver tolerance — hence the v6 rename.)
    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`.

    Notes
    -----
    This is the raw single-order estimator: it cannot check itself, because the
    only cheap internal consistency test on a box-counting dimension is that
    :math:`D_q` is non-increasing in :math:`q`, which needs more than one order.
    :func:`box_counting_dimension` and :func:`dimension_spectrum` do run that
    check.  Small :math:`q` — and :math:`q = 0` above all — converge slowly,
    because they weight the rarely-visited boxes a finite sample resolves worst.

    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).
    """
    q = _coerce_q(q, analysis="generalized_dimension")
    if q < 0.0:
        raise invalid_value("q", q, rule=_NEGATIVE_Q_RULE, hint=_NEGATIVE_Q_HINT)
    return _spectrum_core(
        _as_points(data, analysis="generalized_dimension"),
        [float(q)],
        scales=scales,
        n_scales=n_scales,
        min_occupancy=min_occupancy,
        min_resolution=min_resolution,
        min_window=min_window,
        flatness=flatness,
        offsets=offsets,
        analysis="generalized_dimension",
    )[float(q)]

box_counting_dimension

box_counting_dimension(
    data: Any, *, strict: bool = False, **kwargs: Any
) -> DimensionResult

Box-counting (capacity) dimension :math:D_0, with a self-consistency check.

Numerically identical to generalized_dimension(data, 0.0) — it calls it — so there is one estimator here, under the name the literature uses for :math:q = 0. Choose this one when :math:D_0 is the quantity you want; choose :func:generalized_dimension when :math:q is a variable you are sweeping, and :func:dimension_spectrum when you want the whole curve.

:math:D_0 is the count of occupied boxes, :math:N(\epsilon) \sim \epsilon^{-D_0} — the :math:q = 0 member of :func:generalized_dimension, and the hardest order to estimate: weighting every occupied box equally makes it dominated by the sparsely-visited parts of the support, which are exactly what a finite sample resolves last. An under-resolved box count is biased down, and silently so.

So this function does not just return :math:D_0. It also computes :math:D_1 and :math:D_2 from the same box partition (free — the occupancies are already there) and checks the exact inequality :math:D_0 \ge D_1 \ge D_2. A violation means the estimate has not resolved: the number is still returned, but marked trusted=False and accompanied by a :class:NonMonotoneSpectrumWarning naming the violation. On an 8000-point Lorenz trajectory, for instance, this fires: :math:D_0 = 1.75 against :math:D_2 = 2.00, and the :math:D_0 end is the one that is wrong -- an independent box count of the same points puts the :math:\log N(\epsilon) slope at ~1.75 and the :math:q = 2 slope at ~2.00, so it is box counting's notoriously slow convergence at :math:q = 0, not a property of the attractor.

PARAMETER DESCRIPTION
data

The point set.

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

strict

True raises :class:~tsdynamics.errors.ConvergenceError when the :math:D_0 \ge D_1 \ge D_2 check fails. The default warns (:class:NonMonotoneSpectrumWarning) and returns the number with trusted=False.

TYPE: bool DEFAULT: False

**kwargs

Forwarded to :func:generalized_dimension (scales, n_scales, min_occupancy, min_resolution, min_window, flatness, offsets).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
DimensionResult

float(result) is :math:D_0. Identical to generalized_dimension(data, 0.0, **kwargs) whenever the check passes.

RAISES DESCRIPTION
ConvergenceError

Only when strict=True and the shared-partition spectrum increases with q.

Source code in src/tsdynamics/analysis/dimensions/generalized.py
def box_counting_dimension(data: Any, *, strict: bool = False, **kwargs: Any) -> DimensionResult:
    r"""Box-counting (capacity) dimension :math:`D_0`, with a self-consistency check.

    Numerically **identical** to ``generalized_dimension(data, 0.0)`` — it calls
    it — so there is one estimator here, under the name the literature uses for
    :math:`q = 0`.  Choose this one when :math:`D_0` is the quantity you want;
    choose :func:`generalized_dimension` when :math:`q` is a variable you are
    sweeping, and :func:`dimension_spectrum` when you want the whole curve.

    :math:`D_0` is the count of occupied boxes,
    :math:`N(\epsilon) \sim \epsilon^{-D_0}` — the :math:`q = 0` member of
    :func:`generalized_dimension`, and the *hardest* order to estimate: weighting
    every occupied box equally makes it dominated by the sparsely-visited parts
    of the support, which are exactly what a finite sample resolves last.  An
    under-resolved box count is biased **down**, and silently so.

    So this function does not just return :math:`D_0`.  It also computes
    :math:`D_1` and :math:`D_2` from the *same* box partition (free — the
    occupancies are already there) and checks the exact inequality
    :math:`D_0 \ge D_1 \ge D_2`.  A violation means the estimate has not
    resolved: the number is still returned, but marked ``trusted=False`` and
    accompanied by a :class:`NonMonotoneSpectrumWarning` naming the violation.  On an 8000-point Lorenz trajectory, for
    instance, this fires: :math:`D_0 = 1.75` against :math:`D_2 = 2.00`, and the
    :math:`D_0` end is the one that is wrong -- an independent box count of the
    same points puts the :math:`\log N(\epsilon)` slope at ~1.75 and the
    :math:`q = 2` slope at ~2.00, so it is box counting's notoriously slow
    convergence at :math:`q = 0`, not a property of the attractor.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    strict : bool, default False
        ``True`` raises :class:`~tsdynamics.errors.ConvergenceError` when the
        :math:`D_0 \ge D_1 \ge D_2` check fails.  The default warns
        (:class:`NonMonotoneSpectrumWarning`) and returns the number with
        ``trusted=False``.
    **kwargs
        Forwarded to :func:`generalized_dimension` (``scales``, ``n_scales``,
        ``min_occupancy``, ``min_resolution``, ``min_window``, ``flatness``, ``offsets``).

    Returns
    -------
    DimensionResult
        ``float(result)`` is :math:`D_0`.  Identical to
        ``generalized_dimension(data, 0.0, **kwargs)`` whenever the check passes.

    Raises
    ------
    ConvergenceError
        Only when ``strict=True`` and the shared-partition spectrum increases
        with ``q``.
    """
    spectrum = _spectrum_for_wrapper(data, [0.0, 1.0, 2.0], "box_counting_dimension", kwargs)
    resolved = _report_monotonicity(spectrum, strict=strict, what="box_counting_dimension")
    return dataclasses.replace(spectrum[0.0], trusted=resolved)

information_dimension

information_dimension(
    data: Any, *, strict: bool = False, **kwargs: Any
) -> DimensionResult

Information dimension :math:D_1, with a self-consistency check.

Numerically identical to generalized_dimension(data, 1.0) — it calls it — under the name the literature uses for :math:q = 1. See :func:box_counting_dimension for when to reach for which spelling.

The :math:q \to 1 limit of the Rényi family: the slope of the Shannon information :math:\sum_i p_i \log p_i against :math:\log \epsilon. (The Rényi formula is a 0/0 at :math:q = 1; the entropy form is its exact limit and is what is evaluated — see :func:_partition_ordinate.)

Like :func:box_counting_dimension this also computes the neighbouring orders off the same partition and checks :math:D_0 \ge D_1 \ge D_2.

PARAMETER DESCRIPTION
data

The point set.

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

strict

Raise on a non-monotone spectrum; False warns instead.

TYPE: bool DEFAULT: True

**kwargs

Forwarded to :func:generalized_dimension (scales, n_scales, min_occupancy, min_resolution, min_window, flatness, offsets).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
DimensionResult

float(result) is :math:D_1.

RAISES DESCRIPTION
ConvergenceError

If the shared-partition spectrum increases with q (strict=True).

Source code in src/tsdynamics/analysis/dimensions/generalized.py
def information_dimension(data: Any, *, strict: bool = False, **kwargs: Any) -> DimensionResult:
    r"""Information dimension :math:`D_1`, with a self-consistency check.

    Numerically **identical** to ``generalized_dimension(data, 1.0)`` — it calls
    it — under the name the literature uses for :math:`q = 1`.  See
    :func:`box_counting_dimension` for when to reach for which spelling.

    The :math:`q \to 1` limit of the Rényi family: the slope of the Shannon
    information :math:`\sum_i p_i \log p_i` against :math:`\log \epsilon`.
    (The Rényi formula is a 0/0 at :math:`q = 1`; the entropy form is its exact
    limit and is what is evaluated — see :func:`_partition_ordinate`.)

    Like :func:`box_counting_dimension` this also computes the neighbouring
    orders off the same partition and checks :math:`D_0 \ge D_1 \ge D_2`.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The point set.
    strict : bool, default True
        Raise on a non-monotone spectrum; ``False`` warns instead.
    **kwargs
        Forwarded to :func:`generalized_dimension` (``scales``, ``n_scales``,
        ``min_occupancy``, ``min_resolution``, ``min_window``, ``flatness``, ``offsets``).

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

    Raises
    ------
    ConvergenceError
        If the shared-partition spectrum increases with ``q`` (``strict=True``).
    """
    spectrum = _spectrum_for_wrapper(data, [0.0, 1.0, 2.0], "information_dimension", kwargs)
    resolved = _report_monotonicity(spectrum, strict=strict, what="information_dimension")
    return dataclasses.replace(spectrum[1.0], trusted=resolved)

dimension_spectrum

dimension_spectrum(
    data: Any,
    qs: Any = None,
    *,
    scales: ndarray | None = None,
    n_scales: int = 20,
    min_occupancy: float = _DEFAULT_MIN_OCCUPANCY,
    min_resolution: float = _DEFAULT_MIN_RESOLUTION,
    min_window: int = 5,
    flatness: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
    strict: bool = False,
) -> 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.

An increasing :math:D_q signals nothing about the attractor at all — :math:D_q is non-increasing in :math:q for every measure — so it means the estimate has not resolved.

strict defaults to False here and to True on the single-value estimators (:func:box_counting_dimension, :func:information_dimension), and the asymmetry is deliberate. Asking for one number leaves the caller no way to see that it is unresolved, so those refuse rather than hand back a figure that looks authoritative. Asking for the whole spectrum returns the very evidence of the failure — the rising :math:D_q values are right there in the result — so it warns (:class:NonMonotoneSpectrumWarning) and returns them, which is what makes the diagnosis possible. Box counting is slow to converge at small :math:q, so this fires on ordinary inputs (a Lorenz trajectory, a Gaussian sample); :func:correlation_dimension is the right tool when it does.

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 and the informative-scale mask are chosen once and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale — which is what makes comparing orders legitimate.

TYPE: int DEFAULT: 20

min_occupancy

As in :func:generalized_dimension. The minimal-cover grid origin and the informative-scale mask are chosen once and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale — which is what makes comparing orders legitimate.

TYPE: int DEFAULT: 20

min_resolution

As in :func:generalized_dimension. The minimal-cover grid origin and the informative-scale mask are chosen once and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale — which is what makes comparing orders legitimate.

TYPE: int DEFAULT: 20

min_window

As in :func:generalized_dimension. The minimal-cover grid origin and the informative-scale mask are chosen once and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale — which is what makes comparing orders legitimate.

TYPE: int DEFAULT: 20

flatness

As in :func:generalized_dimension. The minimal-cover grid origin and the informative-scale mask are chosen once and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale — which is what makes comparing orders legitimate.

TYPE: int DEFAULT: 20

offsets

As in :func:generalized_dimension. The minimal-cover grid origin and the informative-scale mask are chosen once and shared across every q, so the spectrum is read from one consistent, alignment-debiased partition per scale — which is what makes comparing orders legitimate.

TYPE: int DEFAULT: 20

strict

True raises :class:~tsdynamics.errors.ConvergenceError if the computed spectrum increases with q. The default warns (:class:NonMonotoneSpectrumWarning) and returns the numbers, because the rising values are the diagnosis — see the note above on why this differs from the single-value estimators.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
dict[float, DimensionResult]

{q: DimensionResult} in the order of qs.

RAISES DESCRIPTION
ConvergenceError

If the computed spectrum increases with q (strict=True).

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 = 20,
    min_occupancy: float = _DEFAULT_MIN_OCCUPANCY,
    min_resolution: float = _DEFAULT_MIN_RESOLUTION,
    min_window: int = 5,
    flatness: float = 1.5,
    offsets: tuple[float, ...] = _DEFAULT_OFFSETS,
    strict: bool = False,
) -> 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.

    An *increasing* :math:`D_q` signals nothing about the attractor at all —
    :math:`D_q` is non-increasing in :math:`q` for every measure — so it means the
    estimate has not resolved.

    ``strict`` defaults to ``False`` *here* and to ``True`` on the single-value
    estimators (:func:`box_counting_dimension`, :func:`information_dimension`), and
    the asymmetry is deliberate.  Asking for one number leaves the caller no way to
    see that it is unresolved, so those refuse rather than hand back a figure that
    looks authoritative.  Asking for the whole spectrum returns the very evidence
    of the failure — the rising :math:`D_q` values are right there in the result —
    so it warns (:class:`NonMonotoneSpectrumWarning`) and returns them, which is
    what makes the diagnosis possible.  Box counting is slow to converge at small
    :math:`q`, so this fires on ordinary inputs (a Lorenz trajectory, a Gaussian
    sample); :func:`correlation_dimension` is the right tool when it does.

    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, min_occupancy, min_resolution, min_window, flatness, offsets
        As in :func:`generalized_dimension`.  The minimal-cover grid origin and
        the informative-scale mask are chosen once and shared across every ``q``,
        so the spectrum is read from one consistent, alignment-debiased partition
        per scale — which is what makes comparing orders legitimate.
    strict : bool, default False
        ``True`` raises :class:`~tsdynamics.errors.ConvergenceError` if the
        computed spectrum increases with ``q``.  The default warns
        (:class:`NonMonotoneSpectrumWarning`) and returns the numbers, because the
        rising values *are* the diagnosis — see the note above on why this differs
        from the single-value estimators.

    Returns
    -------
    dict[float, DimensionResult]
        ``{q: DimensionResult}`` in the order of ``qs``.

    Raises
    ------
    ConvergenceError
        If the computed spectrum increases with ``q`` (``strict=True``).
    """
    points = _as_points(data, analysis="dimension_spectrum")
    if qs is None:
        qs = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
    qs = [_coerce_q(q, analysis="dimension_spectrum", param="qs") 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)
    out = _spectrum_core(
        points,
        qs,
        scales=scales,
        n_scales=n_scales,
        min_occupancy=min_occupancy,
        min_resolution=min_resolution,
        min_window=min_window,
        flatness=flatness,
        offsets=offsets,
        analysis="dimension_spectrum",
    )
    _report_monotonicity(out, strict=strict, what="dimension_spectrum")
    return out

fixed_mass_dimension

fixed_mass_dimension(
    data: Any,
    *,
    ks: ndarray | None = None,
    theiler: int | str = "auto",
    metric: str | float = "euclidean",
    n_ref: int | None = 1500,
    n_ks: int = 16,
    min_window: int = 5,
    flatness: 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. "auto" reads the window off the space--time separation profile of the data, exactly as in :func:~tsdynamics.analysis.dimensions.correlation.correlation_sum; pass 0 for the uncorrected estimate. The resolved window is recorded in result.meta["theiler"].

TYPE: int or 'auto' DEFAULT: "auto"

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

flatness

How flat the fitted scaling region has to be: a window is admitted when its straight-line residual is within this factor of the flattest window found. (It is not a solver tolerance — hence the v6 rename.)

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 | str = "auto",
    metric: str | float = "euclidean",
    n_ref: int | None = 1500,
    n_ks: int = 16,
    min_window: int = 5,
    flatness: 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 or "auto", default "auto"
        Exclude neighbours with :math:`|i - j| \le w`.  ``"auto"`` reads the
        window off the space--time separation profile of the data, exactly as in
        :func:`~tsdynamics.analysis.dimensions.correlation.correlation_sum`; pass
        ``0`` for the uncorrected estimate.  The resolved window is recorded in
        ``result.meta["theiler"]``.
    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.
    flatness : float, default 1.5
        How flat the fitted scaling region has to be: a window is admitted when
        its straight-line residual is within this factor of the flattest window
        found.  (It is **not** a solver tolerance — hence the v6 rename.)
    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, analysis="fixed_mass_dimension")
    require_min_points(
        points,
        analysis="fixed_mass_dimension",
        reason=(
            "the k-th-neighbour distances of a handful of points measure the sample, not the set"
        ),
    )
    n = points.shape[0]
    w = _resolve_theiler(theiler, points)
    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=flatness)
    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,
            "theiler": w,
            # One coordinate means "points on a line" — the estimator answers
            # D ~ 1 whatever the attractor is, so the result says so.
            "n_components": int(points.shape[1]),
            **runaway_meta(points, analysis="fixed_mass_dimension"),
        },
    )

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,
    trusted: bool = True,
    *,
    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 / the readout repr / .to_dict() / the .plot seam) and behaves as the dimension number (float(result) and comparisons). The domain-named :attr:dimension is the spelling for the number.

ATTRIBUTE DESCRIPTION
dimension

The estimated dimension (the fitted slope). float(result) returns it; it is the domain name for the inherited estimate field.

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).

TYPE: ndarray

fit_region

Inclusive (lo, hi) indices of the selected scaling region.

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

.. versionchanged

fit_slice is gone — it was a measured exact duplicate of :attr:fit_region (both answered (6, 23) on the same result), and a reader who met both had to discover they were one thing. x / y remain readable but are off dir(): they alias :attr:abscissa / :attr:ordinate, and a single letter x on a phase-space library is one keystroke from a state component.

TYPE: : 6.0

unembedded property

unembedded: bool

Whether this dimension was measured on a single coordinate.

See :class:UnembeddedSeriesWarning: the estimate is then ~1 by construction, so it is reported untrusted and the repr says why.

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).

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.

Choosing the window yourself

Reading this and wanting to set it is the usual next step, and the keyword is named here rather than guessed at, because it differs from the one :func:~tsdynamics.analysis.lyapunov_from_data uses for the same job:

  • here — c_lo= / c_hi= bound the correlation-sum fraction the window is drawn from, and flatness= says how flat the fitted log–log window has to be;
  • there — fit=(lo, hi) takes index bounds straight into the stretching curve S(k).

They are spelled differently because they select on different things: a dimension window is chosen by where the correlation sum is well-sampled, a Lyapunov window by which stretching-curve samples are in the linear regime.

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] | None = None,
    delay: int | Sequence[int] | None = None,
    *,
    components: int | str | None = None,
    **renamed: Any,
) -> 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 components 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 components 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. Omit it and it is estimated with :func:~tsdynamics.analysis.embedding.embedding_dimension (Cao's averaged false neighbours) at the resolved delay.

TYPE: int or sequence of int DEFAULT: None

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. Omit it and it is estimated with :func:~tsdynamics.analysis.embedding.optimal_delay (the first minimum of the time-delayed mutual information).

TYPE: int or sequence of int DEFAULT: None

components

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

**renamed

Not a real parameter: it exists so that a near-miss spelling of one of the two above — dim=, m=, tau=, lag=, all of them banned by the naming glossary and all of them the first thing a reader tries — is answered with the canonical name instead of Python's bare unexpected keyword argument.

TYPE: Any DEFAULT: {}

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.

InvalidParameterError

If a keyword is a banned spelling of dimension / delay (or is not a parameter at all).

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.

Estimated parameters are recorded, never silent. meta["delay_auto"] / meta["dimension_auto"] say which of the two the caller supplied, so a reconstruction always carries how it was parameterised. Estimating both costs a mutual-information scan and a Cao sweep; pass either one to skip its estimate. Auto-selection is for the univariate case (one series has one :math:\tau and one :math:m) — a multivariate bundle needs its per-channel values named.

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] | None = None,
    delay: int | Sequence[int] | None = None,
    *,
    components: int | str | None = None,
    **renamed: Any,
) -> 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 ``components`` 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** ``components`` to embed every
        channel jointly (multivariate embedding).
    dimension : int or sequence of int, optional
        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``.  **Omit it** and it is estimated with
        :func:`~tsdynamics.analysis.embedding.embedding_dimension` (Cao's
        averaged false neighbours) at the resolved ``delay``.
    delay : int or sequence of int, optional
        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``.  **Omit it** and it is estimated with
        :func:`~tsdynamics.analysis.embedding.optimal_delay` (the first minimum of
        the time-delayed mutual information).
    components : 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.
    **renamed
        Not a real parameter: it exists so that a *near-miss* spelling of one of
        the two above — ``dim=``, ``m=``, ``tau=``, ``lag=``, all of them banned
        by the naming glossary and all of them the first thing a reader tries —
        is answered with the canonical name instead of Python's bare
        ``unexpected keyword argument``.

    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.
    tsdynamics.errors.InvalidParameterError
        If a keyword is a banned spelling of ``dimension`` / ``delay`` (or is not
        a parameter at all).

    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.

    **Estimated parameters are recorded, never silent.**  ``meta["delay_auto"]``
    / ``meta["dimension_auto"]`` say which of the two the caller supplied, so a
    reconstruction always carries how it was parameterised.  Estimating both
    costs a mutual-information scan and a Cao sweep; pass either one to skip its
    estimate.  Auto-selection is for the univariate case (one series has one
    :math:`\tau` and one :math:`m`) — a multivariate bundle needs its per-channel
    values named.

    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.])
    """
    if renamed:
        raise _renamed_keyword_error(renamed)
    auto = {"dimension_auto": dimension is None, "delay_auto": delay is None}

    # Univariate path: a 1-D series, or an explicitly selected single component.
    univariate = components is not None or _looks_univariate(data)
    if univariate:
        series = _as_series(data, component=components, analysis="embed")
        if delay is None or dimension is None:
            dimension, delay = _estimate_parameters(series, dimension, delay)
        # ``operator.index`` FIRST: chaining is the documented idiom
        # (``embed(x, dimension=embedding_dimension(x), delay=optimal_delay(x))``)
        # and a ``CountResult`` is an ``int`` subclass that ``isinstance(..., int)``
        # accepts — but a bare ``ScalingResult`` is not, so ``dimension=fnn_result``
        # was refused with a message about "a per-channel sequence" and
        # "multivariate input", two concepts the caller never mentioned, while
        # ``delay=`` (whose result IS an int subclass) sailed through.  The
        # asymmetry was invisible until it bit.
        dimension = _as_count(dimension, "dimension")
        delay = _as_count(delay, "delay")
        for label, value in (("dimension", dimension), ("delay", delay)):
            if not isinstance(value, (int, np.integer)):
                raise ValueError(f"a per-channel `{label}` sequence needs a multivariate input.")
        m_int, tau_int = int(cast(int, dimension)), int(cast(int, delay))
        embedded = _embed_single(series, m_int, tau_int)
        return Embedding(values=embedded, meta={**_embed_meta(dimension, delay), **auto})

    if dimension is None or delay is None:
        missing = [n for n, v in (("dimension", dimension), ("delay", delay)) if v is None]
        raise invalid_value(
            "/".join(missing),
            None,
            rule=(
                "must be given for a multivariate embedding (each channel has its own, so "
                "there is nothing to estimate from one series)"
            ),
            hint=(
                "pass a per-channel sequence, e.g. embed(data, dimension=[3, 3], "
                "delay=[7, 5]) — or select one channel with components= to get the "
                "estimated univariate reconstruction."
            ),
        )

    channels = _as_channels(data, analysis="embed")
    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), **auto})

Delay selection

optimal_delay

optimal_delay(
    data: Any,
    *,
    method: str = "mi",
    max_delay: int = 50,
    bins: int | None = None,
    components: int | str | None = None,
) -> CountResult

Recommend an embedding delay :math:\tau (in samples).

PARAMETER DESCRIPTION
data

The scalar series (or a selected components).

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

components

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"; if the series is no longer than max_delay (there is then no curve to read a lag off, and the answer would be arithmetic rather than a measurement); 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,
    components: 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 ``components``).
    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"``).
    components : 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"``; if the
        series is no longer than ``max_delay`` (there is then no curve to read a
        lag off, and the answer would be arithmetic rather than a measurement);
        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
    """
    reject_system(data, analysis="optimal_delay")
    # A lag can only be read off a curve the data can support.  ``np.arange(3.)``
    # with ``max_delay=50`` used to come back a confident ``1``: the curve is
    # three points long, the search finds "a first minimum" in it, and nothing
    # says the number is arithmetic rather than a measurement.  Its sibling
    # ``cao_dimension`` already refuses this by name ("series too short").
    series = _as_series(data, component=components, analysis="optimal_delay")
    if series.size <= int(max_delay):
        raise ValueError(
            f"series too short: {series.size} samples for max_delay={int(max_delay)}, so "
            f"the lag curve has no lag it can measure at. Lower max_delay (below "
            f"{series.size}) or supply more data."
        )
    method = method.lower()
    if method == "mi":
        curve = mutual_information(data, max_delay=max_delay, bins=bins, components=components)
        tau = curve.optimal_lag
    elif method in ("acf", "acf_zero"):
        acf = autocorrelation(data, max_delay=max_delay, components=components)
        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,
    components: 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 components).

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``

components

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,
    components: 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 ``components``).
    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.
    components : 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=components, analysis="mutual_information")
    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,
            # Recorded so ``optimal_lag`` can rebuild the estimator's independence
            # floor (``_mi_noise_floor``) without re-deriving the binning rule.
            "bins": nbins,
            "n_samples": n,
        },
    )

autocorrelation

autocorrelation(
    data: Any,
    *,
    max_delay: int = 50,
    components: int | str | None = None,
) -> ndarray

Normalised autocorrelation function up to max_delay.

PARAMETER DESCRIPTION
data

The scalar series (or a selected components).

TYPE: array - like or Trajectory

max_delay

Largest lag returned. Clamped to N - 1.

TYPE: int DEFAULT: 50

components

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, components: 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 ``components``).
    max_delay : int, default 50
        Largest lag returned.  Clamped to ``N - 1``.
    components : 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=components, analysis="autocorrelation")
    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 | None = None,
    max_dim: int = 10,
    components: int | str | None = None,
    **kwargs: Any,
) -> EmbeddingDimension

Estimate the minimum embedding dimension by the chosen method.

The one door for the question "how many coordinates does this attractor need?", with method= naming the estimator — the shape every other estimator-choosing analysis in the library has (fixed_points(method=), optimal_delay(method=), lyapunov_from_data(method=)). The two estimators keep their own names and their own citations, so embedding_dimension(x, method="cao") and cao_dimension(x) return the identical object; type whichever reads better at your call site. Reach for :func:cao_dimension first if you have no reason to prefer otherwise — Cao's :math:E_1 needs no threshold to be chosen for it, which is the usual difficulty with :func:false_nearest_neighbors.

PARAMETER DESCRIPTION
data

The scalar series (or a selected components).

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. None (the default) estimates it with :func:~tsdynamics.analysis.optimal_delay, so this door and that one agree about the same data; the resolved value is in the repr and in result.meta["delay"].

.. versionchanged:: 6.0 Was a hard 1. Two adjacent estimators disagreeing by default is a trap on an oversampled series, where a lag-1 reconstruction is a thin diagonal line.

TYPE: int DEFAULT: None

max_dim

Largest dimension evaluated.

TYPE: int DEFAULT: 10

components

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 | None = None,
    max_dim: int = 10,
    components: int | str | None = None,
    **kwargs: Any,
) -> EmbeddingDimension:
    """Estimate the minimum embedding dimension by the chosen method.

    The one door for the question "how many coordinates does this attractor
    need?", with ``method=`` naming the estimator — the shape every other
    estimator-choosing analysis in the library has (``fixed_points(method=)``,
    ``optimal_delay(method=)``, ``lyapunov_from_data(method=)``).  The two
    estimators keep their own names and their own citations, so
    ``embedding_dimension(x, method="cao")`` and ``cao_dimension(x)`` return the
    *identical* object; type whichever reads better at your call site.  Reach for
    :func:`cao_dimension` first if you have no reason to prefer otherwise — Cao's
    :math:`E_1` needs no threshold to be chosen for it, which is the usual
    difficulty with :func:`false_nearest_neighbors`.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``components``).
    method : {"cao", "fnn"}, default "cao"
        ``"cao"`` → :func:`cao_dimension`; ``"fnn"`` → :func:`false_nearest_neighbors`.
    delay : int, optional
        Embedding delay in samples.  ``None`` (the default) estimates it with
        :func:`~tsdynamics.analysis.optimal_delay`, so this door and that one
        agree about the same data; the resolved value is in the repr and in
        ``result.meta["delay"]``.

        .. versionchanged:: 6.0
            Was a hard ``1``.  Two adjacent estimators disagreeing by default is
            a trap on an oversampled series, where a lag-1 reconstruction is a
            thin diagonal line.
    max_dim : int, default 10
        Largest dimension evaluated.
    components : 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.
    """
    reject_system(data, analysis="embedding_dimension")
    method = method.lower()
    if method == "cao":
        return cao_dimension(data, delay=delay, max_dim=max_dim, components=components, **kwargs)
    if method == "fnn":
        return false_nearest_neighbors(
            data, delay=delay, max_dim=max_dim, components=components, **kwargs
        )
    raise ValueError(f"unknown method {method!r}; use 'cao' or 'fnn'.")

cao_dimension

cao_dimension(
    data: Any,
    *,
    delay: int | None = None,
    max_dim: int = 10,
    threshold: float = 0.9,
    theiler: int = 0,
    components: int | str | None = None,
) -> EmbeddingDimension

Cao's averaged-false-neighbour minimum embedding dimension.

The recommended estimator, and what embedding_dimension(x, method="cao") (the generic door) calls: Cao's :math:E_1 saturates on its own, with no rejection threshold to pick, where :func:false_nearest_neighbors needs rtol / atol chosen for the data.

PARAMETER DESCRIPTION
data

The scalar series (or a selected components).

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

components

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 | None = None,
    max_dim: int = 10,
    threshold: float = 0.9,
    theiler: int = 0,
    components: int | str | None = None,
) -> EmbeddingDimension:
    r"""Cao's averaged-false-neighbour minimum embedding dimension.

    The recommended estimator, and what ``embedding_dimension(x, method="cao")``
    (the generic door) calls: Cao's :math:`E_1` saturates on its own, with no
    rejection threshold to pick, where :func:`false_nearest_neighbors` needs
    ``rtol`` / ``atol`` chosen for the data.

    Parameters
    ----------
    data : array-like or Trajectory
        The scalar series (or a selected ``components``).
    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.
    components : 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=components, analysis="cao_dimension")
    tau, max_dim = _resolve_delay(x, delay, None), 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 | None = None,
    max_dim: int = 10,
    rtol: float = 15.0,
    atol: float = 2.0,
    threshold: float = 0.01,
    theiler: int = 0,
    components: int | str | None = None,
) -> EmbeddingDimension

Kennel's false-nearest-neighbour minimum embedding dimension.

The classic estimator, and what embedding_dimension(x, method="fnn") calls. Choose it over :func:cao_dimension when you want an explicit, interpretable rejection criterion (the fraction of neighbours that are false) rather than a saturation curve — at the cost of choosing rtol / atol for your data.

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 components).

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

components

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 | None = None,
    max_dim: int = 10,
    rtol: float = 15.0,
    atol: float = 2.0,
    threshold: float = 0.01,
    theiler: int = 0,
    components: int | str | None = None,
) -> EmbeddingDimension:
    r"""Kennel's false-nearest-neighbour minimum embedding dimension.

    The classic estimator, and what ``embedding_dimension(x, method="fnn")``
    calls.  Choose it over :func:`cao_dimension` when you want an explicit,
    interpretable rejection criterion (the fraction of neighbours that are false)
    rather than a saturation curve — at the cost of choosing ``rtol`` / ``atol``
    for your data.

    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 ``components``).
    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`.
    components : 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=components, analysis="false_nearest_neighbors")
    tau, max_dim = _resolve_delay(x, delay, None), 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 / the readout repr / .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

saturated property

saturated: bool

Whether the criterion was met inside the scanned range.

False when the selected dimension is the largest one tried, which means the search hit its ceiling rather than finding saturation — and the repr then says so instead of presenting the ceiling as the answer (measured: m = 10 reported with :math:E_1 = 0.6, nowhere near saturation, with no flag).

RETURNS DESCRIPTION
bool

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, in the units of the data. Give this or recurrence_rate, never both.

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). This is the one to reach for when the scale of the data is not already known; :data:DEFAULT_RECURRENCE_RATE is the value RQA practice recommends.

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
InvalidInputError

If data is a System: this is a data-first analysis, so run the system and pass its trajectory.

InvalidParameterError

If neither or both of threshold / recurrence_rate are given (they set the same quantity two different ways), or if either is out of range. A ValueError subclass, so except ValueError keeps catching it.

ValueError

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`, in the units of the data.
        Give this *or* ``recurrence_rate``, never both.
    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).  This is the one to reach for when the scale of the
        data is not already known; :data:`DEFAULT_RECURRENCE_RATE` is the value
        RQA practice recommends.
    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
    ------
    InvalidInputError
        If ``data`` is a ``System``: this is a *data-first* analysis, so run the
        system and pass its trajectory.
    InvalidParameterError
        If neither or both of ``threshold`` / ``recurrence_rate`` are given (they
        set the same quantity two different ways), or if either is out of range.
        A ``ValueError`` subclass, so ``except ValueError`` keeps catching it.
    ValueError
        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

    # Before any keyword validation: handing a System to a data-first analysis is
    # the mistake to name, and a message about threshold=/recurrence_rate= would
    # send the caller down the wrong path entirely.
    reject_system(data, analysis="recurrence_matrix")
    if threshold is not None and recurrence_rate is not None:
        raise InvalidParameterError(
            f"pass exactly one of threshold= or recurrence_rate=: they set the same "
            f"quantity two different ways (the threshold is what a target rate is "
            f"solved for), and you gave threshold={threshold!r} *and* "
            f"recurrence_rate={recurrence_rate!r}."
            + remedy(
                f"ts.analysis.recurrence_matrix(data, recurrence_rate={recurrence_rate!r})",
                f"ts.analysis.recurrence_matrix(data, threshold={threshold!r})",
                lead="Either fix the density, or fix the distance:",
            )
        )
    if threshold is None and recurrence_rate is None:
        raise InvalidParameterError(
            "a recurrence plot needs a scale, so pass exactly one of threshold= (a "
            "distance, in the units of the data) or recurrence_rate= (the fraction "
            "of point pairs to count as recurrent)."
            + remedy(
                f"ts.analysis.recurrence_matrix(data, recurrence_rate={_RATE_TEXT})",
                lead=(
                    "If you do not yet know the scale of your data, ask for the "
                    f"density RQA practice recommends ({_RATE_TEXT}):"
                ),
            )
        )
    points = _as_points(data, analysis="recurrence_matrix")
    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 InvalidParameterError(
                f"threshold is a distance in the units of the data, so it must be "
                f"positive; got {threshold!r}. If you do not know the scale of the "
                f"data, ask for a density instead."
                + remedy(f"ts.analysis.recurrence_matrix(data, recurrence_rate={_RATE_TEXT})")
            )
    else:
        assert recurrence_rate is not None  # guaranteed by the defaulting above
        rate = float(recurrence_rate)
        if not (0.0 < rate < 1.0):
            raise InvalidParameterError(
                f"recurrence_rate is the fraction of point pairs counted as "
                f"recurrent, so it must lie in (0, 1); got {recurrence_rate!r}"
                + (" — that looks like a percentage." if rate > 1.0 else ".")
                + remedy(
                    f"ts.analysis.recurrence_matrix(data, recurrence_rate="
                    f"{min(0.99, rate / 100) if rate > 1.0 else _RATE_TEXT})"
                )
            )
        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); 0 when unpopulated.

The field defaults to None (it carries no matrix until an estimator fills it in), so this reports 0 rather than raising — the repr reads it, and a repr must never be the thing that raises in a console.

recurrence_rate property

recurrence_rate: float

Matrix density :math:RR = \#\{R_{ij}=1\}/N^2 (0 when unpopulated).

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_frame

to_frame() -> Any

Return the recurrent pairs as a tidy (nnz, 2) i / j table.

The base :meth:~tsdynamics.analysis._result.AnalysisResult.to_frame would drop the matrix and tabulate ['epsilon', 'metric', 'theiler_window'] — the settings, with the answer missing (contract §4.2 rule 8). The matrix is never densified: the stored COO coordinates are exactly the recurrences, so the frame is nnz rows, not :math:N^2. The settings ride on frame.attrs.

RETURNS DESCRIPTION
DataFrame

Columns i / j, one row per recurrent pair.

RAISES DESCRIPTION
ImportError

If :mod:pandas is not installed.

Source code in src/tsdynamics/analysis/recurrence/matrix.py
def to_frame(self) -> Any:
    """Return the recurrent pairs as a tidy ``(nnz, 2)`` ``i`` / ``j`` table.

    The base :meth:`~tsdynamics.analysis._result.AnalysisResult.to_frame`
    would drop the matrix and tabulate ``['epsilon', 'metric',
    'theiler_window']`` — the settings, with **the answer** missing (contract
    §4.2 rule 8).  The matrix is never densified: the stored COO coordinates
    are exactly the recurrences, so the frame is ``nnz`` rows, not
    :math:`N^2`.  The settings ride on ``frame.attrs``.

    Returns
    -------
    pandas.DataFrame
        Columns ``i`` / ``j``, one row per recurrent pair.

    Raises
    ------
    ImportError
        If :mod:`pandas` is not installed.
    """
    pd = self._require_pandas()
    if self.matrix is None:
        frame = pd.DataFrame({"i": [], "j": []})
    else:
        coo = self.matrix.tocoo()
        frame = pd.DataFrame({"i": coo.row, "j": coo.col})
    frame.attrs["meta"] = dict(self.meta) if self.meta else {}
    frame.attrs["epsilon"] = float(self.epsilon)
    frame.attrs["metric"] = self.metric
    frame.attrs["theiler_window"] = int(self.theiler_window)
    return frame

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). 0 is the standard convention and the only value for which the vertical measures (LAM / TT / V_max) are the textbook ones: the Theiler window is a diagonal-line correction, and a nonzero band cuts a hole through every vertical line (see Notes). For the diagonal measures on a densely sampled flow a nonzero window is essential — theiler=0 there saturates L_max and rqa warns.

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.

WARNS DESCRIPTION
UserWarning

When L_max is set by tangential motion — the longest line in the matrix is the one on diagonal k = theiler + 1 (the one next to the excluded band) and it spans at least half the series, so L_max and DIV report the sampling, not the dynamics. Raise theiler. A genuinely periodic signal, whose longest line sits on its period diagonal, does not warn however long that line is.

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).

Line of identity. The diagonal measures are read off the matrix without :math:R_{ii} (one trivial infinite line). At theiler == 0 the vertical measures are read off the matrix with it, as Marwan et al. (2007) §3.5 define them — a laminar sojourn is one vertical line through the diagonal, and dropping the diagonal point bisects it (measured on 60 identical states followed by 140 separated ones: TT 30.5 and V_max 59 without the restoration, against the closed-form 60 and 60).

Theiler window and the vertical measures. theiler > 0 removes the band :math:|i - j| \le w from the matrix, which is what the diagonal statistics need but punches a hole through every vertical line: LAM / TT / V_max are then computed on a truncated matrix and are not comparable to published values. Quantify laminarity at theiler=0 (the default) and use a nonzero window only for the diagonal measures. The excluded band contains the line of identity, so :math:R_{ii} is not restored in that case — fabricating those N points inside the removed band would make LAM / TT / V_max describe a matrix that is neither the textbook one nor the one the caller asked for.

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.analysis.embed(np.sin(t), dimension=2, delay=5)
>>> res = ts.analysis.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).  ``0`` is the standard convention and the only value for which
        the **vertical** measures (``LAM`` / ``TT`` / ``V_max``) are the textbook
        ones: the Theiler window is a *diagonal*-line correction, and a nonzero
        band cuts a hole through every vertical line (see Notes).  For the
        diagonal measures on a densely sampled flow a nonzero window is
        essential — ``theiler=0`` there saturates ``L_max`` and ``rqa`` warns.
    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`.

    Warns
    -----
    UserWarning
        When ``L_max`` is set by **tangential motion** — the longest line in the
        matrix is the one on diagonal ``k = theiler + 1`` (the one next to the
        excluded band) and it spans at least half the series, so ``L_max`` and
        ``DIV`` report the sampling, not the dynamics.  Raise ``theiler``.  A
        genuinely periodic signal, whose longest line sits on its *period*
        diagonal, does not warn however long that line is.

    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).

    **Line of identity.**  The diagonal measures are read off the matrix without
    :math:`R_{ii}` (one trivial infinite line).  At ``theiler == 0`` the vertical
    measures are read off the matrix **with** it, as Marwan et al. (2007) §3.5
    define them — a laminar sojourn is one vertical line *through* the diagonal,
    and dropping the diagonal point bisects it (measured on 60 identical states
    followed by 140 separated ones: ``TT`` 30.5 and ``V_max`` 59 without the
    restoration, against the closed-form 60 and 60).

    **Theiler window and the vertical measures.**  ``theiler > 0`` removes the
    band :math:`|i - j| \le w` from the matrix, which is what the diagonal
    statistics need but punches a hole through every vertical line: ``LAM`` /
    ``TT`` / ``V_max`` are then computed on a truncated matrix and are *not*
    comparable to published values.  Quantify laminarity at ``theiler=0`` (the
    default) and use a nonzero window only for the diagonal measures.  The
    excluded band *contains* the line of identity, so :math:`R_{ii}` is **not**
    restored in that case — fabricating those ``N`` points inside the removed
    band would make ``LAM`` / ``TT`` / ``V_max`` describe a matrix that is
    neither the textbook one nor the one the caller asked for.

    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.analysis.embed(np.sin(t), dimension=2, delay=5)
    >>> res = ts.analysis.rqa(emb, recurrence_rate=0.05)
    >>> 0.0 <= res.determinism <= 1.0
    True
    """
    # Before any keyword validation: a System handed to this data-first analysis
    # must be named as such, not reported as a missing threshold.
    reject_system(data, analysis="rqa")
    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)
    # Vertical structures are defined on the matrix carrying R_ii = 1 (Marwan et
    # al. 2007 §3.5) -- but only when R_ii is not itself inside the excluded
    # Theiler band.  `_vertical_run_lengths` owns that decision, so LAM, TT and
    # V_max are always read off one and the same matrix.
    vert = _vertical_run_lengths(rm.matrix, theiler=rm.theiler_window)

    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")
    _warn_if_lmax_saturated(rm.matrix, l_max, rm.size, rm.theiler_window)

    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. At theiler == 0 the vertical statistics are read off the matrix with the line of identity (Marwan et al. 2007), so the denominator is nnz + N; at theiler > 0 the line of identity is inside the excluded band and the denominator is nnz.

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, on the same matrix LAM is read from (at theiler == 0 that matrix carries the line of identity, so a state trapped for v consecutive samples contributes one line of length v, not two of length ~v/2).

TYPE: float

max_vertical_length

V_max — longest vertical line, with no min_vertical filter (the vertical analogue of L_max). At theiler == 0 it is 1 when nothing recurs, since :math:R_{ii}=1 is itself a length-1 vertical line; at theiler > 0 (no line of identity) it is 0.

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

applicable property

applicable: bool

Whether there were any recurrence points to quantify at all.

False when the matrix holds no recurrence point (RR = 0) or no diagonal line was counted: DET/LAM/ENTR are then 0 by vacuity, not by measurement. Before v6 that vacuum printed the full verdict — rqa(np.random.normal(size=300), threshold=1e-12) returned DET = 0.000 … stochastic (few diagonal lines), a confident reading of a matrix with zero points — the same defect :attr:~tsdynamics.analysis.results.WadaResult.applicable was introduced for, one subpackage away.

RETURNS DESCRIPTION
bool

deterministic property

deterministic: bool | None

Whether the diagonal statistics say the signal is deterministic.

None — never False — in the unnamed middle of the DET range, and whenever the readout is not :attr:applicable.

RETURNS DESCRIPTION
bool or None

windowed_rqa

windowed_rqa(
    data: Any,
    *,
    window: int | None = None,
    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

RQA in a sliding window: nonstationarity in time.

Runs :func:~tsdynamics.analysis.rqa over overlapping windows.

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). Required: it is the time scale the analysis is about — long enough to hold several recurrences, short enough that the dynamics is stationary across it — so there is no honest default, and omitting it is answered with a concrete suggestion sized from this series.

TYPE: int DEFAULT: None

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. Same caveats as :func:~tsdynamics.analysis.rqa: 0 is required for textbook LAM/TT, a nonzero window is required for a meaningful L_max on a densely sampled flow (each window warns when its L_max saturates).

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
InvalidInputError

If data is a System: this is a data-first analysis, so run the system and pass its trajectory.

InvalidParameterError

If window is missing or out of range, or step < 1. A ValueError subclass, so except ValueError keeps catching it.

Source code in src/tsdynamics/analysis/recurrence/windowed.py
def windowed_rqa(
    data: Any,
    *,
    window: int | None = None,
    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"""RQA in a sliding window: nonstationarity in time.

    Runs :func:`~tsdynamics.analysis.rqa` over overlapping windows.

    Parameters
    ----------
    data : Trajectory or array-like, shape (N, dim)
        The state points (or a 1-D series).
    window : int
        Window length in samples (``>= 2``).  Required: it is the time scale the
        analysis is *about* — long enough to hold several recurrences, short
        enough that the dynamics is stationary across it — so there is no honest
        default, and omitting it is answered with a concrete suggestion sized
        from this series.
    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.  Same caveats as
        :func:`~tsdynamics.analysis.rqa`: ``0`` is required for textbook
        ``LAM``/``TT``, a nonzero window is required for a meaningful ``L_max``
        on a densely sampled flow (each window warns when its ``L_max``
        saturates).
    min_diagonal, min_vertical : int
        Minimum line lengths (see :func:`~tsdynamics.analysis.rqa`).

    Returns
    -------
    WindowedRQA

    Raises
    ------
    InvalidInputError
        If ``data`` is a ``System``: this is a *data-first* analysis, so run the
        system and pass its trajectory.
    InvalidParameterError
        If ``window`` is missing or out of range, or ``step < 1``.  A
        ``ValueError`` subclass, so ``except ValueError`` keeps catching it.
    """
    points = _as_points(data, analysis="windowed_rqa")
    n = points.shape[0]
    if window is None:
        raise InvalidParameterError(
            "windowed_rqa needs a window: the number of samples each RQA is "
            "measured over. It is the time scale the result is about — long enough "
            "to hold several recurrences, short enough that the dynamics does not "
            "change across it — so there is no default that could be right."
            + remedy(
                f"ts.analysis.windowed_rqa(data, window={max(2, n // 20)}, "
                f"recurrence_rate={DEFAULT_RECURRENCE_RATE})",
                lead=f"A twentieth of this {n}-sample series is a place to start:",
            )
        )
    window = int(window)
    if window < 2:
        raise InvalidParameterError(
            f"window must be >= 2 — it is a count of samples, and one sample has no "
            f"recurrence structure; got {window}."
            + remedy(f"ts.analysis.windowed_rqa(data, window={max(2, n // 20)})")
        )
    if window > n:
        raise InvalidParameterError(
            f"window={window} exceeds the series length N={n}, so not one window "
            f"fits." + remedy(f"ts.analysis.windowed_rqa(data, window={max(2, n // 20)})")
        )
    step = window if step is None else int(step)
    if step < 1:
        raise InvalidParameterError(
            f"step must be >= 1 — it is the stride between windows in samples; got {step}."
            + remedy(f"ts.analysis.windowed_rqa(data, window={window}, step={max(1, window // 2)})")
        )

    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

details property

details: tuple[RQAResult, ...]

The per-window readouts, in order — the objects [] no longer hands back.

w.details[3] is window 3's full :class:~tsdynamics.analysis.results.RQAResult, repr, verdict and all.

measures property

measures: tuple[str, ...]

Names of the columns of :meth:table / np.asarray(self) / self[i].

table

table() -> ndarray

Return every measure over every window as one (n_windows, 9) array.

Columns are :attr:measures, in that order.

RETURNS DESCRIPTION
ndarray
Source code in src/tsdynamics/analysis/recurrence/windowed.py
def table(self) -> np.ndarray:
    """Return every measure over every window as one ``(n_windows, 9)`` array.

    Columns are :attr:`measures`, in that order.

    Returns
    -------
    numpy.ndarray
    """
    return np.array(
        [[float(getattr(r, name)) for name in _MEASURES] for r in self.results], dtype=float
    ).reshape(len(self.results), len(_MEASURES))

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)

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.

attractors

attractors(
    system: Any,
    region: Grid
    | Box
    | Ball
    | Sequence[tuple[float, ...]]
    | None = None,
    *,
    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 DEFAULT: None

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).

WARNS DESCRIPTION
UserWarning

When a located set fails the invariance audit and is discarded — the recurrence predicate was fooled by slow motion (dt too small for the cell size). See :func:audit_attractors.

Notes

Every located attractor is verified before it is returned (:func:audit_attractors). The bare recurrence predicate — mx_fnd consecutive steps into already-visited cells — is also satisfied by an orbit that merely moves less than one cell per dt, so a bottleneck, a slow manifold or the crawl past an unstable spiral would otherwise be reported as an attractor (measured: 2–4 phantom attractors on :math:\dot x = \mu + x^2, which provably has none; 2–4 fragments of the one Lorenz attractor at dt <= 0.1). The audit re-marches from each located set, fuses mutually reachable fragments and drops sets the orbit leaves and never returns to.

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 attractors(
    system: Any,
    region: Grid | Box | Ball | Sequence[tuple[float, ...]] | None = None,
    *,
    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).

    Warns
    -----
    UserWarning
        When a located set fails the invariance audit and is discarded — the
        recurrence predicate was fooled by slow motion (``dt`` too small for the
        cell size).  See :func:`audit_attractors`.

    Notes
    -----
    **Every located attractor is verified before it is returned**
    (:func:`audit_attractors`).  The bare recurrence predicate — ``mx_fnd``
    consecutive steps into already-visited cells — is also satisfied by an orbit
    that merely moves less than one cell per ``dt``, so a bottleneck, a slow
    manifold or the crawl past an unstable spiral would otherwise be reported as
    an attractor (measured: 2–4 phantom attractors on :math:`\dot x = \mu + x^2`,
    which provably has none; 2–4 fragments of the *one* Lorenz attractor at
    ``dt <= 0.1``).  The audit re-marches from each located set, fuses mutually
    reachable fragments and drops sets the orbit leaves and never returns to.

    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, "attractors")
    reject_unknown_fsm(fsm, analysis="attractors")
    region = coerce_region(region, analysis="attractors", system=system, want_grid=False)

    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", "jit"))
    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)
    found, _ = canonical_relabel(found, merge)
    # Attach provenance without re-allocating the (potentially large) attractor
    # dict — ``replace`` reuses every field but ``meta``.
    return replace(found, meta=_build_meta(system, analysis="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, indexing to numbers.

aset[0] is the first attractor's (dim,) centre as a plain :class:numpy.ndarray, equal to np.asarray(aset)[0]; iteration yields those centres. The :class:Attractor records are :attr:details (by position) and :meth:by_id (by label), and :attr:centers / :attr:cells / :attr:ids read the same data column-wise, so nothing here needs a loop::

aset = ts.analysis.attractors(system, region)
aset[0]            # array([-1.,  0.])   the centre
aset.centers       # (n, dim)
aset.cells         # (n,) int
aset.details[0]    # Attractor  #1  centre [-1, 0] · 3 cells
aset.by_id(2)      # the attractor LABELLED 2, as a record
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

details property

details: tuple[Attractor, ...]

The :class:Attractor records, in the order [] indexes.

aset[0] is the centre; aset.details[0] is the attractor it came from, with its repr, its points cloud and its cells count. One name across every collection in the library, so it is learned once.

ids property

ids: list[int]

Sorted attractor ids.

centers property

centers: ndarray

Stack of attractor representatives, shape (n_attractors, dim).

The same array np.asarray(aset) returns: a SET arrays as one representative per member, while a MEMBER arrays as its own point cloud.

cells property

cells: ndarray

How many grid cells each attractor occupies, shape (n_attractors,).

by_id

by_id(key: int) -> Attractor

Return the record of the attractor labelled key.

[] indexes by position and hands back numbers; an id is something you read off a basin image and then want to know about, so this door hands back the :class:Attractor itself — the same object :attr:details holds.

RAISES DESCRIPTION
KeyError

If no attractor carries that id.

Source code in src/tsdynamics/analysis/basins/attractors.py
def by_id(self, key: int) -> Attractor:
    """Return the **record** of the attractor labelled ``key``.

    ``[]`` indexes by position and hands back numbers; an id is something you
    read off a basin image and then want to know *about*, so this door hands
    back the :class:`Attractor` itself — the same object :attr:`details`
    holds.

    Raises
    ------
    KeyError
        If no attractor carries that id.
    """
    return self.attractors[int(key)]

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__)

Attractor dataclass

Attractor(
    id: int = 0,
    points: ndarray = (lambda: empty((0, 0)))(),
    cells: int = 0,
    *,
    meta: Mapping[str, Any] = dict(),
)

Bases: _ArrayBacked, AnalysisResult

One located attractor: a point cloud of states sampled on it.

It IS its point cloud (v6): np.asarray(attractor) is the (m, dim) array of sampled states, attractor[0] is the first of them, and len(attractor) is how many were sampled — so attractors(sys, …)[0] hands back numbers and the class stays invisible, showing itself only in the repr. The label and the coarse size are one dot away: :attr:id, :attr:center, :attr:cells.

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. np.asarray(attractor) returns it.

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

basins(
    system: Any,
    region: Grid
    | Box
    | Ball
    | Sequence[tuple[float, ...]]
    | None = None,
    *,
    recurrence: Box
    | Grid
    | Sequence[tuple[float, ...]]
    | 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 DEFAULT: None

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: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).

WARNS DESCRIPTION
UserWarning

When a located set fails the invariance audit and is discarded — the recurrence predicate mistook slow motion for convergence (dt too small for the cell size). Every located attractor is verified before it is returned; see :func:~tsdynamics.analysis.basins.attractors.audit_attractors.

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(
    system: Any,
    region: Grid | Box | Ball | Sequence[tuple[float, ...]] | None = None,
    *,
    recurrence: Box | Grid | Sequence[tuple[float, ...]] | 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:`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).

    Warns
    -----
    UserWarning
        When a located set fails the invariance audit and is discarded — the
        recurrence predicate mistook slow motion for convergence (``dt`` too
        small for the cell size).  Every located attractor is verified before it
        is returned; see
        :func:`~tsdynamics.analysis.basins.attractors.audit_attractors`.

    References
    ----------
    G. Datseris and A. Wagemakers, "Effortless estimation of basins of
    attraction", *Chaos* **32**, 023104 (2022).
    """
    _reject_unsupported(system, "basins")
    reject_unknown_fsm(fsm, analysis="basins")
    region = coerce_region(
        region,
        analysis="basins",
        system=system,
        want_grid=True,
    )
    if not isinstance(region, Grid):
        raise InvalidInputError(
            f"basins paints one label per lattice point, so region must "
            f"be a Grid (a {type(region).__name__} carries no resolution)."
            + remedy(
                "ts.analysis.basins(system, "
                f"{_region_example(int(getattr(system, 'dim', 2) or 2), triples=True)})",
                lead="Say how many initial conditions per axis:",
            )
        )
    if recurrence is None:
        # region is a Grid → keeps its own counts (resolution arg is ignored).
        cellgrid = _recurrence_grid(region)
    else:
        cellgrid = _recurrence_grid(
            coerce_region(
                recurrence,
                analysis="basins",
                system=system,
                want_grid=False,
            ),
            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", "jit"))
    labels = classify_seeds(mapper, points, backend=backend, jit=backend == "jit")
    diverged = int(np.sum(labels == DIVERGED))

    _warn_if_nothing_settled(
        diverged, points.shape[0], analysis="basins", system=system, cellgrid=cellgrid
    )

    merge = mapper.merge_map(resolve_merge_tol(cellgrid, merge_tol))
    attractors = mapper.attractor_set(diverged=diverged, seeds=points.shape[0], merge=merge)
    # Order the colours by WHERE the attractors are, not by the order the seeds
    # happened to find them — otherwise the same well is #1 in one panel and #2
    # in the next.  One composed permutation, applied to the image and the set.
    attractors, merge = canonical_relabel(attractors, merge)
    labels = _apply_merge(labels.reshape(region.shape), merge)
    return BasinsResult(
        labels=labels,
        grid=region,
        attractors=attractors,
        meta=_build_meta(
            system,
            analysis="basins",
            seed=seed,
            variables=getattr(system, "variables", None),
        ),
    )

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.

A census of this image: every cell of the lattice, counted once, with the diverged/lost share reported separately by :attr:diverged_fraction.

This is not the same statistic as :func:basin_fractions, despite the shared word, and the two measurably disagree -- on Van der Pol, {1: 0.9922} here against {1: 1.0} there. That function is Monte Carlo basin stability (Menck et al. 2013): an estimate, with a standard error, of the share of a measure over the region, from randomly drawn initial conditions. This one is an exact count of a particular grid, so it depends on the grid and carries no error bar -- and it includes cells that never settled, which the sampler drops.

Use this to read off the picture you just drew; use :func:basin_fractions to estimate how likely a randomly perturbed state is to end up on each attractor.

diverged_fraction property

diverged_fraction: float

Fraction of cells that diverged / never settled.

basin_fractions

basin_fractions(
    system: Any,
    region: Grid
    | Box
    | Ball
    | Sequence[tuple[float, ...]]
    | None = None,
    *,
    n_seeds: 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.

Not the same statistic as basins(system, region).fractions, despite the shared word: that is an exact census of one grid image (every cell counted once, diverged cells included in the total), this is a Monte Carlo estimate over a measure with a standard error. They measurably disagree — on Van der Pol, {1: 1.0} here against {1: 0.9922} there. Ask this one "how likely is a random perturbation to land here?"; ask that one "what does this picture show?".

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 DEFAULT: None

n_seeds

Number of random initial conditions drawn from region — the word attractors / fixed_points / periodic_orbits have always used. (It was n before v6 round 8, the only door in the family that spelled it differently; n= now raises, naming this one.) The standard error of every reported fraction is :math:\sqrt{p(1-p)/n}, so this is the accuracy knob.

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).

WARNS DESCRIPTION
UserWarning

When a located set fails the invariance audit and is discarded — the recurrence predicate mistook slow motion for convergence (dt too small for the cell size). Every located attractor is verified before it is returned; see :func:~tsdynamics.analysis.basins.attractors.audit_attractors.

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: Grid | Box | Ball | Sequence[tuple[float, ...]] | None = None,
    *,
    n_seeds: 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``.

    **Not the same statistic as** ``basins(system, region).fractions``, despite
    the shared word: that is an exact *census* of one grid image (every cell
    counted once, diverged cells included in the total), this is a Monte Carlo
    estimate over a *measure* with a standard error.  They measurably disagree —
    on Van der Pol, ``{1: 1.0}`` here against ``{1: 0.9922}`` there.  Ask this
    one "how likely is a random perturbation to land here?"; ask that one "what
    does this picture show?".

    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_seeds : int, default 10000
        Number of random **initial conditions** drawn from ``region`` — the word
        ``attractors`` / ``fixed_points`` / ``periodic_orbits`` have always used.
        (It was ``n`` before v6 round 8, the only door in the family that spelled
        it differently; ``n=`` now raises, naming this one.)  The standard error
        of every reported fraction is :math:`\sqrt{p(1-p)/n}`, so this is the
        accuracy knob.
    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).

    Warns
    -----
    UserWarning
        When a located set fails the invariance audit and is discarded — the
        recurrence predicate mistook slow motion for convergence (``dt`` too
        small for the cell size).  Every located attractor is verified before it
        is returned; see
        :func:`~tsdynamics.analysis.basins.attractors.audit_attractors`.

    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")
    reject_unknown_fsm(fsm, analysis="basin_fractions")
    region = coerce_region(region, analysis="basin_fractions", system=system, want_grid=False)
    cellgrid = _recurrence_grid(region, resolution)
    mapper = _AttractorMapper(system, cellgrid, dt=dt, max_steps=max_steps, **fsm)
    draw = sampler(region, seed=seed)

    n = int(n_seeds)
    # 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", "jit"))
    labels = classify_seeds(mapper, samples, backend=backend, jit=backend == "jit")
    diverged = int(np.sum(labels == DIVERGED))
    _warn_if_nothing_settled(
        diverged, int(n), analysis="basin_fractions", system=system, cellgrid=cellgrid
    )
    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))
    attractors = mapper.attractor_set(diverged=diverged, seeds=n, merge=merge)
    attractors, merge = canonical_relabel(attractors, merge)
    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()}
    return BasinFractions(
        fractions=fractions,
        diverged=diverged / n,
        n=n,
        attractors=attractors,
        meta=_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).

ids property

ids: list[int]

Sorted attractor ids — the order [], iteration and np.asarray use.

to_frame

to_frame() -> Any

Return a tidy frame: one row per attractor, with its share and error.

The base :meth:~tsdynamics.analysis.results.AnalysisResult.to_frame builds ONE row of the declared fields, and the answer here lives in a dict[int, float], which a table cell cannot hold — so it was dropped: measured, the frame came back (1, 3) of diverged/n/ dominant with no fractions.

RETURNS DESCRIPTION
DataFrame

Columns attractor · fraction · stderr · n · diverged, plus one center_<var> column per state component.

Source code in src/tsdynamics/analysis/basins/basins.py
def to_frame(self) -> Any:
    """Return a tidy frame: one row per attractor, with its share and error.

    The base :meth:`~tsdynamics.analysis.results.AnalysisResult.to_frame`
    builds ONE row of the declared fields, and the answer here lives in a
    ``dict[int, float]``, which a table cell cannot hold — so it was dropped:
    measured, the frame came back ``(1, 3)`` of ``diverged``/``n``/
    ``dominant`` with **no fractions**.

    Returns
    -------
    pandas.DataFrame
        Columns ``attractor`` · ``fraction`` · ``stderr`` · ``n`` ·
        ``diverged``, plus one ``center_<var>`` column per state component.
    """
    pd = self._require_pandas()
    errors = self.standard_error
    names = self.meta.get("variables") if self.meta else None
    labels = tuple(str(v) for v in names) if names else None
    rows: list[dict[str, Any]] = []
    for gid in self.ids:
        row: dict[str, Any] = {
            "attractor": int(gid),
            "fraction": float(self.fractions[gid]),
            "stderr": float(errors[gid]),
            "n": int(self.n),
            "diverged": float(self.diverged),
        }
        try:
            center = np.asarray(self.attractors.by_id(int(gid)).center, dtype=float)
        except (KeyError, ValueError, AttributeError):
            center = np.empty(0)
        if center.size:
            row.update(_spread("center", center, labels))
        rows.append(row)
    frame = pd.DataFrame(rows)
    frame.attrs["meta"] = dict(self.meta) if self.meta else {}
    return frame

by_id

by_id(key: int) -> float

Return the basin fraction of the attractor labelled key.

[] is positional (the sequence convention every collection here follows); this is the explicit id lookup, the same pairing :class:~tsdynamics.analysis.results.AttractorSet uses. The whole {id: share} mapping is still :attr:fractions.

RAISES DESCRIPTION
KeyError

If no attractor carries that id.

Source code in src/tsdynamics/analysis/basins/basins.py
def by_id(self, key: int) -> float:
    """Return the basin fraction of the attractor **labelled** ``key``.

    ``[]`` is positional (the sequence convention every collection here
    follows); this is the explicit id lookup, the same pairing
    :class:`~tsdynamics.analysis.results.AttractorSet` uses.  The whole
    ``{id: share}`` mapping is still :attr:`fractions`.

    Raises
    ------
    KeyError
        If no attractor carries that id.
    """
    return float(self.fractions[int(key)])

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, analysis="basin_entropy")
    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

fractal_boundary

True when :math:S_{bb} > \log 2, the sufficient fractal-boundary criterion of Daza et al. (2016).

TYPE: bool

Notes

box_size (the box side length in cells) and log_base (the base of the logarithm, e by default) are the caller's own settings echoed back, so they are carried and exported but kept off dir() — R1, contract §11.3. Both still resolve and both appear in :meth:to_dict.

Sb property

Sb: float

Basin entropy, in Daza's casing — the same number as :attr:sb.

Sbb property

Sbb: float

Boundary basin entropy, in Daza's casing — the same number as :attr:sbb.

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, analysis="uncertainty_exponent")
    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:
        n_basins = len({int(i) for i in np.unique(labels) if int(i) >= 1})
        why = (
            f"this image holds only {n_basins} basin, so it has no boundary between "
            f"basins to be uncertain about"
            if n_basins < 2
            else "the grid is too coarse for any cell to straddle a boundary"
        )
        raise InvalidParameterError(
            f"uncertainty_exponent measures how the uncertain fraction f(eps) shrinks "
            f"with eps, and f(eps) is zero at all but {int(positive.sum())} of the "
            f"probed radii — {why}."
            + remedy(
                "res = ts.analysis.basins(system, [(-2.0, 2.0, 400), (-2.0, 2.0, 400)])",
                "ts.analysis.uncertainty_exponent(res)",
                lead="Image a region that contains more than one attractor, more finely:",
            )
        )

    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
    # Ask the OTHER boundary test about the SAME image, and carry its answer.
    # Daza et al. (2016) prove ``Sbb > ln 2`` is *sufficient* for a fractal
    # boundary; this estimator fits a power law.  When the proof-style test fires
    # and the fit says "smooth", one of them is wrong about this picture, and a
    # reader running both — which the library invites, they sit side by side in
    # ``find("basin")`` — deserves to be told by whichever they called rather
    # than left to notice.  It is a label-image computation (no integration), so
    # it costs nothing next to the march that produced the image.
    sbb: float | None = None
    try:
        other = basin_entropy(basins, include_diverged=include_diverged)
    except (ValueError, InvalidInputError):  # pragma: no cover - a boundary-free image
        established = False
    else:
        established = bool(other.fractal_boundary)
        sbb = float(other.sbb)
    return UncertaintyExponent(
        alpha=alpha,
        boundary_dimension=float(dim - alpha),
        state_dimension=dim,
        epsilons=epsilons,
        uncertain_fractions=fractions,
        r_squared=float(r2),
        meta={
            "analysis": "uncertainty_exponent",
            "state_dimension": int(dim),
            # What the verdict's resolution hedge names when it asks for a
            # finer image — the user should never have to work out "2n" itself.
            "grid_shape": tuple(int(n) for n in labels.shape),
            "basin_entropy_sbb": sbb,
            "basin_entropy_fractal": established,
        },
    )

UncertaintyExponent dataclass

UncertaintyExponent(
    alpha: float = 0.0,
    boundary_dimension: float = 0.0,
    state_dimension: int = 0,
    epsilons: ndarray = (lambda: empty(0))(),
    uncertain_fractions: 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

uncertain_fractions

Fraction of :math:\varepsilon-uncertain cells at each radius — the measured curve :math:f(\varepsilon) whose slope is :attr:alpha. Pairs with :attr:epsilons, same length, same order.

TYPE: ndarray

r_squared

Coefficient of determination of the log-log fit.

TYPE: float

.. versionchanged

The curve was called f. A one-letter field on a public result reads as a throwaway local, not as the measurement the exponent is read from, and it sat next to epsilons — its own abscissa — spelled in full. result.f now raises, naming :attr:uncertain_fractions. slope_drift is still readable but off dir(): it is the intermediate behind the public :attr:resolved verdict.

TYPE: : 6.0

D0 property

D0: float

Boundary dimension in Grebogi's casing — the same number as :attr:boundary_dimension.

applicable property

applicable: bool

Whether enough radii were measured for a power law to be read off.

False below :data:_MIN_UNCERTAINTY_RADII radii: :math:R^2 clears any acceptance level trivially on two or three points, so the verdict was gated on a diagnostic that could not fail — measured, a two-radius run printed final-state sensitive (fractal boundary).

RETURNS DESCRIPTION
bool

slope_drift property

slope_drift: float

Ratio of the last local slope to the first, across the window.

A power law has one slope everywhere, so this is 1 for a converged measurement and falls towards 0 as the uncertain fraction saturates against its ceiling of 1. It is the diagnostic :math:R^2 cannot be: :math:R^2 says how straight the fitted line is, not whether the relation is a power law at all (measured, R² = 0.9926 on a fit whose local slope had already halved).

RETURNS DESCRIPTION
float

nan when there are fewer than three usable radii, or when a local slope is non-positive (the fraction did not grow with the radius).

resolved property

resolved: bool

Whether the power law is converged enough for :math:\alpha to be read.

False when the local slope decays by more than :data:_SLOPE_STABLE_RATIO across the probed radii — the uncertain fraction has saturated, so the fitted :math:\alpha is a lower bound set by the grid rather than a measurement of the boundary.

final_state_sensitive property

final_state_sensitive: bool | None

Whether :math:\alpha \ll 1 — a fractal basin boundary.

The one adjective-named spelling of the verdict (contract §4.2 rule 10). None — never False — when the test does not apply or the power law itself did not fit.

RETURNS DESCRIPTION
bool or None

contradicted_by_basin_entropy property

contradicted_by_basin_entropy: bool

Whether :func:basin_entropy proves fractal where this fit reads smooth.

Only one direction is a contradiction. :math:S_{bb} > \ln 2 is sufficient for a fractal boundary and not necessary (Daza et al. 2016), so failing it says "not established", never "smooth" — but passing it while :math:\alpha \approx 1 is two answers to one question about one image.

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, analysis="wada_property")
    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
wada

The verdict: True when there are at least three basins and the fraction of boundary cells seeing all basins reaches the acceptance threshold at the largest radius (a sufficient grid criterion, not a proof). None — never False — when the test does not :attr:apply <applicable>.

TYPE: bool or None

W

The measured Wada fraction at the largest radius.

TYPE: float or None

applicable

Whether the test could be run at all (it needs ≥ 3 basins).

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

.. versionchanged

is_wada (the raw stored flag) and threshold (the caller's own acceptance fraction) are off dir(). One verdict had three spellings — wada, is_wada and W — and only wada knows about :attr:applicable, so is_wada read False on an image where nothing had been measured. Both still resolve; is_wada is in to_dict() and W in to_dict(full=True).

TYPE: : 6.0

applicable property

applicable: bool

Whether the Wada test could be run on this image at all.

The Wada property is defined as every boundary point being on the boundary of three or more basins, so the test needs at least three basins and at least one boundary cell. When either is missing, :func:wada_property early-returns zeros — and a W = 0 reads as a measured negative ("we looked, the boundaries are not Wada") when the truth is that nothing was measured. This flag separates the two, and :meth:_answer prints the reason instead of the number.

Derived from the fields the result already carries, so no estimator change: it is exactly the condition wada_property tests before it early-returns.

W property

W: float | None

The Wada fraction at the largest radius, or None when inapplicable.

wada property

wada: bool | None

Whether the boundary is Wada — the one adjective-named verdict spelling.

None — never False — when the test does not :attr:apply <applicable>. This is the spelling: it is the one every other classifying result uses (contract §4.2 rule 10), and the only one that distinguishes a measured no from nothing was measured. The raw stored flag is_wada is still readable (and still exported) but is off dir(), because it answers False in both cases.

RETURNS DESCRIPTION
bool or None

resilience

resilience(
    result: BasinsResult, attractor_id: int | None = None
) -> 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. A basin image with exactly one attractor needs no choice, so it may be omitted there; with two or more it is required, and the error lists the ids actually present.

TYPE: int DEFAULT: None

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 | None = None) -> 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, optional
        Which attractor (basin label) to measure.  A basin image with exactly
        **one** attractor needs no choice, so it may be omitted there; with two
        or more it is required, and the error lists the ids actually present.

    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

    # The shared builder (CONTRACT §5.6) answers the system door; the bespoke
    # branch below still answers the *data* door, because it names the keyword
    # (``attractor_id=``) that no generic message can know about.
    _reject_system(result, analysis="resilience")
    if not isinstance(result, BasinsResult):
        raise InvalidInputError(
            f"resilience measures a state-space distance, so it needs the full "
            f"BasinsResult (with its grid and attractors), not a "
            f"{type(result).__name__}."
            + remedy(
                "res = ts.analysis.basins(system, [(-2.0, 2.0, 200), (-2.0, 2.0, 200)])",
                "ts.analysis.resilience(res, attractor_id=1)",
            )
        )
    labels = result.labels
    attractor_id = _resolve_attractor_id(result, attractor_id)
    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.
    # ``by_id``, not ``[]``: since v6 an AttractorSet is a SEQUENCE and ``[]`` is a
    # positional lookup (CONTRACT §4.2 rule 6 / M25), while ``attractor_id`` here is
    # the basin LABEL painted into the image.
    att = result.attractors.by_id(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])
    # The answer is a distance read off a CELL GRID, so it is quantised: it is
    # always an exact number of cells, and (because the grid edge and the
    # attractor's own cell both round outward) it is biased slightly high —
    # measured against a direct 72-direction bisection on a two-well oscillator,
    # +7.8% at 40x40, +6.4% at 80x80, +1.4% at 160x160.  Erring high is the
    # dangerous direction for a safety margin, so the readout carries the
    # resolution instead of six significant figures.
    quantum = float(np.max(spacing))
    grid_words = " × ".join(str(int(n)) for n in np.asarray(grid.shape)[free])
    return ScalarResult(
        value=value,
        meta={
            "analysis": "resilience",
            "attractor_id": int(attractor_id),
            "quantization": quantum,
            "quantization_reason": (
                # "1 cells" is the commonest reading of all — a margin one cell
                # wide is exactly when the reader must not be distracted by the
                # grammar of the sentence telling them so.
                f"{value / quantum:.0f} cell{'' if round(value / quantum) == 1 else 's'} "
                f"on a {grid_words} grid — "
                "refine the grid to tighten, and read it as an upper bound"
            ),
        },
    )

Continuation & tipping

continuation

continuation(
    system: Any,
    param: str,
    values: Any,
    region: Box
    | Ball
    | Grid
    | Sequence[tuple[float, ...]]
    | None = None,
    *,
    n_seeds: 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 DEFAULT: None

n_seeds

Number of random initial conditions sampled at each parameter value — the word attractors / fixed_points / periodic_orbits / basin_fractions all use. (It was n before v6 round 8; n= now raises, naming this one.)

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 | Sequence[tuple[float, ...]] | None = None,
    *,
    n_seeds: 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_seeds : int, default 2000
        Number of random **initial conditions** sampled at each parameter value
        — the word ``attractors`` / ``fixed_points`` / ``periodic_orbits`` /
        ``basin_fractions`` all use.  (It was ``n`` before v6 round 8; ``n=``
        now raises, naming this one.)
    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")
    reject_unknown_fsm(fsm, analysis="continuation")
    region = coerce_region(
        region,
        analysis="continuation",
        system=system,
        want_grid=False,
        args="param, values, ",
    )
    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_seeds=n_seeds,
            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=_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

The swept parameter axis, not a measurement — the values of :attr:param, in sweep order. What was measured at each of them is :attr:fractions / :attr:attractors / :attr:diverged, all indexed by the same position. (values is the one name in the result layer that means different things on different classes: the answer on a spectrum or an embedding, the swept parameter here and on :class:~tsdynamics.analysis.results.OrbitDiagram, the observable series on :class:~tsdynamics.analysis.results.ReturnMap.)

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 — the records. For the numbers, use :attr:centers, which is the same information as one rectangular (n_values, n_ids, dim) array rather than three nested containers.

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.

centers property

centers: ndarray

Where each tracked attractor sits at each value: (n_values, n_ids, dim).

The numbers behind :attr:attractors, which is a list of dicts of records — three containers deep before a coordinate. Row k is the parameter value :attr:values [k], column j the attractor :attr:ids [j], and a value where that id is absent is nan, so the array is rectangular and aligns with np.asarray(self) index for index::

cont.centers[:, 0, :]     # attractor ids[0]'s path through state space

An empty sweep gives shape (0, 0, 0).

to_frame

to_frame() -> Any

Return a tidy frame: one row per (value, attractor), with the share.

The base :meth:~tsdynamics.analysis.results.AnalysisResult.to_frame builds ONE row of the declared fields, and a continuation's answer — the basin fractions — lives in a dict[int, ndarray], which a table cell cannot hold, so it was dropped: measured, the frame came back (1, 13) with the parameter values spread across columns and no fractions at all. A frame that silently omits the payload is worse than no frame.

RETURNS DESCRIPTION
DataFrame

Columns <param> · attractor · fraction · diverged, plus one center_<var> column per state component.

Source code in src/tsdynamics/analysis/basins/continuation.py
def to_frame(self) -> Any:
    """Return a tidy frame: one row per ``(value, attractor)``, with the share.

    The base :meth:`~tsdynamics.analysis.results.AnalysisResult.to_frame`
    builds ONE row of the declared fields, and a continuation's answer — the
    basin fractions — lives in a ``dict[int, ndarray]``, which a table cell
    cannot hold, so it was dropped: measured, the frame came back
    ``(1, 13)`` with the parameter values spread across columns and **no
    fractions at all**.  A frame that silently omits the payload is worse
    than no frame.

    Returns
    -------
    pandas.DataFrame
        Columns ``<param>`` · ``attractor`` · ``fraction`` · ``diverged``,
        plus one ``center_<var>`` column per state component.
    """
    pd = self._require_pandas()
    values = np.asarray(self.values, dtype=float)
    centers = self.centers
    names = self.meta.get("variables") if self.meta else None
    diverged = np.asarray(self.diverged, dtype=float)
    rows: list[dict[str, Any]] = []
    for j, gid in enumerate(self.ids):
        band = np.asarray(self.fractions[gid], dtype=float)
        for k, value in enumerate(values):
            row: dict[str, Any] = {
                self.param or "parameter": float(value),
                "attractor": int(gid),
                "fraction": float(band[k]) if k < band.size else float("nan"),
                "diverged": float(diverged[k]) if k < diverged.size else float("nan"),
            }
            if centers.size and centers.shape[2]:
                labels = tuple(str(v) for v in names) if names else None
                row.update(_spread("center", centers[k, j], labels))
            rows.append(row)
    frame = pd.DataFrame(rows)
    frame.attrs["meta"] = dict(self.meta) if self.meta else {}
    return frame

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.
    """
    # No ``hint=``: the shared builder (CONTRACT §5.6) already knows this is a
    # *result*-first analysis and which analysis produces its subject, so the
    # system door and the data door below answer with one body.
    reject_system(result, analysis="tipping_points")
    if not isinstance(result, ContinuationResult):
        if is_data(result):
            raise wrong_subject("tipping_points", type(result).__name__, "data")
        raise InvalidInputError(
            f"tipping_points reads the basin fractions along a parameter sweep, so it "
            f"needs the ContinuationResult that sweep produced, not a "
            f"{type(result).__name__} — a single trajectory holds no parameter axis."
            + remedy(
                "cont = ts.analysis.continuation(system, 'rho', np.linspace(0.0, 50.0, 40),"
                " [(-2.0, 2.0), (-2.0, 2.0), (0.0, 50.0)])",
                "ts.analysis.tipping_points(cont)",
                lead="Run the continuation first:",
            )
        )
    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
            # WHERE the attractor was, not only its integer label: ids are
            # assigned in sweep order, so the same physical well is ``#1``
            # sweeping from one end and ``#2`` from the other.  A location is
            # the same state whichever direction you walked.
            centers = result.centers
            j = result.ids.index(int(gid))
            where = np.asarray(centers[i - 1, j], dtype=float) if centers.size else np.empty(0)
            if not np.all(np.isfinite(where)):
                where = np.asarray(centers[i, j], dtype=float) if centers.size else np.empty(0)
            events.append(
                _TippingEvent(
                    {
                        "value": float(vals[i]),
                        # The sweep brackets the event; it does not locate it.
                        # ``value`` is the first sampled parameter at which the
                        # basin is already gone, so the true fold lies in the
                        # half-open interval between the two samples that
                        # straddle it — and a reader who is handed one number
                        # with five significant figures has no way to know the
                        # grid was that coarse.  Recorded so the repr can say it
                        # and a caller can refine into it.
                        "bracket": (float(vals[i - 1]), float(vals[i])),
                        "attractor": int(gid),
                        "kind": kind,
                        "before": before,
                        "after": after,
                        "where": where,
                    }
                )
            )
    events.sort(key=lambda e: (e["value"], e["attractor"]))
    return CollectionResult(
        items=tuple(events),
        meta={
            "analysis": "tipping_points",
            "threshold": float(threshold),
            # An empty collection is an ANSWER here, not a failure to find one:
            # "none found" alone reads like the sweep broke.  The repr prints
            # this clause so the reader learns what the emptiness means.
            "means_none": "no basin annihilates over the sweep",
        },
    )

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

Output dt that keeps the drawn bow under epsilon.

The 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"""Output dt that keeps the drawn bow under epsilon.

    The 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) --------
    reject_system(y, analysis="estimate_dt_from_sagitta")
    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``.
    """
    reject_system(samples, analysis="sagitta_profile")
    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