Skip to content

Reference

Derived systems

Wrappers that re-present an existing system through a new lens while keeping the System protocol intact, so every analysis composes with them transparently — and so you run them exactly as you ran the original. Prose introduction: the mental model.

Two of them are reached by a verb on the system, because they speak the same vocabulary the class does:

# skip-doctest — the two verbs, for reference (`sys` is any flow)
sys.poincare("y", 0.0)          # a PoincareMap   — a section plane
sys.poincare(period=4.488)      # a StroboscopicMap — the phase circle
sys.ensemble(states)            # an Ensemble     — many copies, one object

The other two are Lyapunov and projection machinery rather than everyday verbs, so they are constructed at their own address (ts.derived.TangentSystem(system, k=2), ts.derived.ProjectedSystem(system, (0, 2))).

PoincareMap

PoincareMap(
    system: Any,
    plane: tuple[Any, ...] | None = None,
    *,
    direction: int | str = +1,
    dt: float = 0.01,
    max_time: float = 10000.0,
)

Bases: DerivedSystem

Present a flow as the discrete map of its crossings through a hyperplane.

One step() advances the underlying system until the trajectory crosses the section plane in the chosen direction, refines the crossing point by cubic Hermite interpolation of the bracketing samples (using the system's numeric RHS for endpoint derivatives — O(dt⁴) accuracy), and returns the full-dimensional crossing state.

Because a PoincareMap is a discrete system, everything written for maps applies to flows through it — e.g. an orbit diagram over a PoincareMap is a bifurcation diagram of the flow.

PARAMETER DESCRIPTION
system

A continuous-time system (ODE or DDE).

TYPE: System

plane

The section, in any of three spellings: (axis, c) where axis is a component name (resolved against the system's variables) 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; or (normal, offset) with an arbitrary normal vector, for the section normal · y = offset. Examples: plane=("y", 0.0), plane=("y", 0.0, "up"), plane=(1, 0.0), plane=([1, 0, 0], 0.0).

Omit it and one is chosen — see :func:auto_plane — from a short probe orbit: the widest-spread component the orbit keeps returning to, sectioned at its median (an offset the orbit provably straddles, and recrosses). The choice is never silent: it lands in :attr:plane, in section.meta["plane"] with meta["plane_auto"] = True, and in the section repr. Name a plane whenever you have one — auto is for exploring, not for a result you are going to publish.

TYPE: tuple DEFAULT: None

direction

Count only crossings with d(normal·y)/dt > 0 (+1 / "up", default), < 0 (-1 / "down"), or both (0 / "both"). A direction given inside plane (its third element) overrides this.

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

dt

March step used for crossing detection. The refinement makes the crossing itself far more accurate than dt; this only needs to be small enough not to skip crossings.

TYPE: float DEFAULT: 0.01

max_time

Raise if no crossing is found within this much time (e.g. the plane misses the attractor).

TYPE: float DEFAULT: 10000.0

Examples:

>>> pmap = PoincareMap(Rossler(), plane=("x", 0.0, "up"))
>>> section = pmap.run(500)                # 500 crossings → PoincareSection
>>> section.y.shape
(500, 3)
>>> pmap = PoincareMap(Lorenz())           # no plane named → one is chosen
>>> pmap.plane                             # ...and the choice is readable
(2, ...)
Source code in src/tsdynamics/derived/poincare.py
def __init__(
    self,
    system: Any,
    plane: tuple[Any, ...] | None = None,
    *,
    direction: int | str = +1,
    dt: float = 0.01,
    max_time: float = 1e4,
) -> None:
    super().__init__(system)
    # Whether the plane was chosen by ``auto_plane`` rather than named.
    # Off the tab surface (``CONTRACT.md`` §11, T2): the fact is already
    # reported where a reader meets it — ``repr(section)`` says "plane chosen
    # automatically", and ``section.meta["plane_auto"]`` carries it into any
    # record — so a third spelling on the object is one more name to scroll
    # past.  Still bound, still readable.
    self.plane_auto = plane is None
    # The plane **as the user typed it**, kept beside the resolved
    # ``(index, offset)`` for the repr and for ``_rebuild``.  Private: two
    # public attributes for one concept, one of them the internal encoding,
    # is exactly the double spelling v6 exists to remove — ``plane`` is the
    # canonical (resolved) reading and ``repr(pmap)`` renders it in words.
    self._plane_given = plane
    plane, direction = _resolve_section_plane(system, plane, direction)
    normal, offset = self._parse_plane(system.dim, plane)
    self._plane = plane
    self._normal = normal
    self._offset = offset
    self.direction = direction
    self.dt = float(dt)
    self.max_time = float(max_time)

    # Numeric RHS for Hermite endpoint derivatives; falls back to linear
    # interpolation (O(dt²)) for systems without one (e.g. DDEs).
    self._rhs = system._rhs_numeric() if hasattr(system, "_rhs_numeric") else None

    self._u_cross: np.ndarray | None = None
    self._t_cross: float | None = None
    self._n_cross = 0

plane property writable

plane: tuple[Any, ...]

The section this map actually marches — read-only.

The resolved reading: (component_index, offset) for an axis-aligned section, or (normal_vector, offset) for an arbitrary normal. A direction word given inside the constructor's plane argument has been peeled off into :attr:direction, and repr(section) renders the whole thing back in the words it was asked for (y = 0 up).

Read-only because writing it was a provenance lie (CONTRACT.md §11.6 defect 1): the unit normal and offset the marcher actually uses are derived from this tuple at construction, so pmap.plane = ("z", 27.0) relabelled a section it never crossed — every later run() kept returning crossings of the old plane, under the new plane's name. To section somewhere else, build the map you mean::

pmap = system.poincare("z", 27.0)
RETURNS DESCRIPTION
tuple

(index, offset) or (normal, offset).

family property

family: str

"map" — a Poincaré map is a DISCRETE view of the flow.

v6 replaced is_discrete with family, and the wrapper's override did not come across: :class:~tsdynamics.derived._base.DerivedSystem answers with the inner system's word, so a section of a flow called itself "ode". False/"ode" are both legal values, so every reader that routes on it — the 0–1 test's observable, the sweep's horizon word — went quietly wrong rather than raising. Its sibling :class:~tsdynamics.derived.stroboscopic.StroboscopicMap already says "map"; the two are the same concept and must agree.

crossing_count property

crossing_count: int

Return the number of crossings recorded so far.

step

step(n_or_dt: int | None = None) -> ndarray

Advance to the n-th next crossing and return it (full-dim coords).

Source code in src/tsdynamics/derived/poincare.py
def step(self, n_or_dt: int | None = None) -> np.ndarray:
    """Advance to the ``n``-th next crossing and return it (full-dim coords)."""
    n = int(n_or_dt) if n_or_dt is not None else 1
    for _ in range(n):
        self._advance_to_crossing()
    assert self._u_cross is not None  # set by _advance_to_crossing (n >= 1)
    return self._u_cross.copy()

state

state() -> ndarray

Return the last crossing point (or the inner state before any crossing).

Source code in src/tsdynamics/derived/poincare.py
def state(self) -> np.ndarray:
    """Return the last crossing point (or the inner state before any crossing)."""
    if self._u_cross is not None:
        return self._u_cross.copy()
    return cast(np.ndarray, self.system.state())

set_state

set_state(u: Any) -> None

Overwrite the inner flow state and reset crossing bookkeeping.

Source code in src/tsdynamics/derived/poincare.py
def set_state(self, u: Any) -> None:
    """Overwrite the inner flow state and reset crossing bookkeeping."""
    self.system.set_state(u)
    self._u_cross = None
    self._t_cross = None

time

time() -> float

Return the continuous time of the last crossing (inner time before any).

Source code in src/tsdynamics/derived/poincare.py
def time(self) -> float:
    """Return the continuous time of the last crossing (inner time before any)."""
    return self._t_cross if self._t_cross is not None else self.system.time()

as_events

as_events() -> list[Event]

Return the section as a one-element [Event] for system.run(events=...).

A Poincaré section is an event: the crossing of g(u) = normal·u − offset in the map's :attr:direction. This exposes it as an :class:~tsdynamics._engine.run.Event so the general events= API reproduces the section — PoincareMap is one consumer of the same wired engine seam (stream WS-EVENTSAPI / WS-CROSSKERNEL). Driven at the same fixed-step march (method="rk4" at this map's dt) from the same initial condition, the crossings of inner.run(events=pmap.as_events(), ...) match :meth:run to the engine's refinement accuracy.

Examples:

>>> pmap = PoincareMap(Rossler(), plane=("y", 0.0, "up"), dt=0.01)
>>> sol = Rossler().run(final_time=400, dt=0.01, solver="rk4",
...                     events=pmap.as_events())
>>> sol.meta["y_events"][0][:5].shape      # the same crossing states
(5, 3)
Notes

You never have to build an Event yourself: every events= door also accepts a plain plane tuple (("y", 0.0, "up")) or a bare g(y, t) callable, which is why the type is not exported. This is the one place the library hands one back.

Source code in src/tsdynamics/derived/poincare.py
def as_events(self) -> list[Event]:
    """Return the section as a one-element ``[Event]`` for ``system.run(events=...)``.

    A Poincaré section *is* an event: the crossing of ``g(u) = normal·u −
    offset`` in the map's :attr:`direction`.  This exposes it as an
    :class:`~tsdynamics._engine.run.Event` so the general ``events=`` API
    reproduces the section — ``PoincareMap`` is one consumer of the same
    wired engine seam (stream WS-EVENTSAPI / WS-CROSSKERNEL).  Driven at the
    same fixed-step march (``method="rk4"`` at this map's ``dt``) from the
    same initial condition, the crossings of
    ``inner.run(events=pmap.as_events(), ...)`` match
    :meth:`run` to the engine's refinement accuracy.

    Examples
    --------
    >>> pmap = PoincareMap(Rossler(), plane=("y", 0.0, "up"), dt=0.01)
    >>> sol = Rossler().run(final_time=400, dt=0.01, solver="rk4",
    ...                     events=pmap.as_events())
    >>> sol.meta["y_events"][0][:5].shape      # the same crossing states
    (5, 3)

    Notes
    -----
    You never have to build an ``Event`` yourself: every ``events=`` door
    also accepts a plain plane tuple (``("y", 0.0, "up")``) or a bare
    ``g(y, t)`` callable, which is why the type is not exported.  This is the
    one place the library hands one back.
    """
    from tsdynamics._engine.run import Event

    return [Event((self._normal.copy(), self._offset), direction=self.direction)]

reinit

reinit(u: Any | None = None, **kwargs: Any) -> None

Restart the inner flow and clear crossing bookkeeping.

Source code in src/tsdynamics/derived/poincare.py
def reinit(self, u: Any | None = None, **kwargs: Any) -> None:
    """Restart the inner flow and clear crossing bookkeeping."""
    self.system.reinit(u, **kwargs)
    # Parameter values are baked into the numeric RHS — rebuild it so the
    # Hermite refinement matches the (possibly re-parametrized) dynamics.
    if hasattr(self.system, "_rhs_numeric"):
        self._rhs = self.system._rhs_numeric()
    self._u_cross = None
    self._t_cross = None
    self._n_cross = 0

run

run(
    steps: int = 100,
    *,
    ic: Any | None = None,
    transient: int = 0,
    backend: str | None = None,
    **unknown: Any,
) -> PoincareSection

Collect crossings as a :class:PoincareSection — from a fresh start.

t holds the continuous crossing times; y the full-dimensional crossing states. transient crossings are discarded first. The returned :class:PoincareSection is a :class:~tsdynamics.data.Trajectory carrying section intent (so a renderer draws the in-plane scatter) plus a repr / .to_dict() readout.

For an ordinary (non-stiff) ODE on the compiled engine this marches the whole attractor and refines every crossing in one engine call (the wired Rust integrate_events, stream WS-CROSSKERNEL) — ~100× faster than the per-dt Python loop it replaces. DDEs, systems without a numeric RHS, stiff defaults, and backend="reference" keep the Python loop. The engine path is answer-identical to that loop's fixed-step (rk4) refinement; see :mod:tsdynamics.derived._crossings.

PARAMETER DESCRIPTION
steps

Number of crossings to collect — the horizon word, counted in crossings (a Poincaré map's iterate is a crossing).

TYPE: int DEFAULT: 100

ic

Start state for the inner flow — system.dim numbers.

TYPE: array - like DEFAULT: None

transient

Leading stretch to discard, in this view's own horizon unit: crossings. (The free function :func:~tsdynamics.analysis.poincare_section spells the same idea skip_crossings=.)

TYPE: int DEFAULT: 0

backend

Engine evaluator for the fast path; defaults to the inner system's _default_backend ("jit" for every concrete family since v6). "reference" forces the pure-Python loop.

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

RETURNS DESCRIPTION
PoincareSection

The collected crossings (continuous times in t, full-dimensional states in y), carrying section plot intent and a repr / .to_dict() readout.

RAISES DESCRIPTION
ConvergenceError

If the inner flow diverges (non-finite state) or no crossing is found within max_time of marching — the plane misses the attractor, or the crossing :attr:direction is wrong.

Notes

Live-cursor semantics. run advances the inner system as a side effect, so a subsequent :meth:step continues forward rather than re-yielding the crossings just collected. The two collection paths leave the inner cursor at slightly different places: the Python loop stops just past the last collected crossing, while the engine path stops at the span end it marched to (which can be a little beyond the last crossing). Both invariants — that step() resumes after the collected crossings — hold; do not rely on the exact cursor offset. Call :meth:reinit first if you need a deterministic restart point.

Source code in src/tsdynamics/derived/poincare.py
def run(
    self,
    steps: int = 100,
    *,
    ic: Any | None = None,
    transient: int = 0,
    backend: str | None = None,
    **unknown: Any,
) -> PoincareSection:
    """
    Collect crossings as a :class:`PoincareSection` — **from a fresh start**.

    ``t`` holds the continuous crossing times; ``y`` the full-dimensional
    crossing states.  ``transient`` crossings are discarded first.  The
    returned :class:`PoincareSection` is a :class:`~tsdynamics.data.Trajectory`
    carrying section intent (so a renderer draws the in-plane scatter) plus a
    repr / ``.to_dict()`` readout.

    For an ordinary (non-stiff) ODE on the compiled engine this marches the
    whole attractor and refines every crossing in **one engine call** (the
    wired Rust ``integrate_events``, stream WS-CROSSKERNEL) — ~100× faster than
    the per-``dt`` Python loop it replaces.  DDEs, systems without a numeric
    RHS, stiff defaults, and ``backend="reference"`` keep the Python loop.  The
    engine path is answer-identical to that loop's fixed-step (``rk4``)
    refinement; see :mod:`tsdynamics.derived._crossings`.

    Parameters
    ----------
    steps : int
        Number of crossings to collect — the horizon word, counted in
        **crossings** (a Poincaré map's iterate *is* a crossing).
    ic : array-like, optional
        Start state for the inner flow — ``system.dim`` numbers.
    transient : int
        Leading stretch to discard, in this view's own horizon unit:
        **crossings**.  (The free function
        :func:`~tsdynamics.analysis.poincare_section` spells the same idea
        ``skip_crossings=``.)
    backend : {"jit", "interp", "reference"}, optional
        Engine evaluator for the fast path; defaults to the inner system's
        ``_default_backend`` (``"jit"`` for every concrete family since v6).
        ``"reference"`` forces the pure-Python loop.

    Returns
    -------
    PoincareSection
        The collected crossings (continuous times in ``t``, full-dimensional
        states in ``y``), carrying section plot intent and a
        repr / ``.to_dict()`` readout.

    Raises
    ------
    ConvergenceError
        If the inner flow diverges (non-finite state) or no crossing is found
        within ``max_time`` of marching — the plane misses the attractor, or
        the crossing :attr:`direction` is wrong.

    Notes
    -----
    **Live-cursor semantics.**  ``run`` advances the *inner* system as
    a side effect, so a subsequent :meth:`step` continues forward rather than
    re-yielding the crossings just collected.  The two collection paths leave
    the inner cursor at slightly different places: the Python loop stops just
    past the **last collected** crossing, while the engine path stops at the
    **span end** it marched to (which can be a little beyond the last
    crossing).  Both invariants — that ``step()`` resumes *after* the
    collected crossings — hold; do not rely on the exact cursor offset.  Call
    :meth:`reinit` first if you need a deterministic restart point.
    """
    # ``run`` is a fresh run on every family and every wrapper.  Before v6
    # this started from the inner flow's LIVE cursor, so ``pmap.run(5)``
    # twice returned different data; ``step()`` is the verb that continues.
    _reject_wrapper_keywords(self, unknown, accepted=("steps", "ic", "transient", "backend"))
    self.reinit(ic)

    if _crossings.engine_eligible(self.system, backend):
        from tsdynamics._engine.run import EngineNotAvailableError

        try:
            times, points = self._engine_trajectory(steps, transient, backend)
        except EngineNotAvailableError:
            times, points = self._python_trajectory(steps, transient)
    else:
        times, points = self._python_trajectory(steps, transient)

    meta = {
        "derived": "PoincareMap",
        # Section intent, so a renderer draws the 2-D in-plane scatter rather
        # than mistaking the full-dimensional crossing states for a flow line
        # (the string value of viz.PlotKind.POINCARE_SECTION).
        "plot_kind": "poincare_section",
        "plane": self.plane,
        # Recorded so an auto-chosen section is never a silent choice (the
        # same contract orbit_diagram keeps for its auto discrete view).
        # This ``meta`` key is the *public* reading of the fact; the
        # attribute behind it is off the tab surface (§11, T2).
        "plane_auto": self.plane_auto,
        "direction": self.direction,
        "dt": self.dt,
        "system": type(self.system).__name__,
        "params": self.params.as_dict(),
    }
    return PoincareSection(t=times, y=points, system=self.system, meta=meta)

StroboscopicMap

StroboscopicMap(system: Any, period: float)

Bases: DerivedSystem

Present a forced flow as the discrete map of once-per-period samples.

One step() advances the underlying continuous system by exactly one forcing period and returns the new state. Orbit diagrams over a StroboscopicMap are the standard way to study forced oscillators (Duffing, forced van der Pol, ...).

PARAMETER DESCRIPTION
system

A continuous-time system.

TYPE: System

period

Sampling period (the forcing period).

TYPE: float

Examples:

>>> smap = StroboscopicMap(ForcedVanDerPol(), period=2 * np.pi / 0.63)
>>> samples = smap.run(300, transient=100)
Source code in src/tsdynamics/derived/stroboscopic.py
def __init__(self, system: Any, period: float) -> None:
    super().__init__(system)
    if period <= 0:
        raise ValueError(f"period must be positive, got {period}")
    self.period = float(period)

family property

family: str

A stroboscopic map is a discrete view of the flow.

step

step(n_or_dt: int | None = None) -> ndarray

Advance n forcing periods (default 1) and return the new state.

n_or_dt is a period count, not a time increment — the wrapper presents a discrete map, so the argument is coerced to an integer with int() (a float is truncated toward zero, matching the discrete-view convention; pass a whole number to be explicit).

PARAMETER DESCRIPTION
n_or_dt

Number of forcing periods to advance. None advances one period.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
ndarray

The full-dimensional state after n periods.

Source code in src/tsdynamics/derived/stroboscopic.py
def step(self, n_or_dt: int | None = None) -> np.ndarray:
    """Advance ``n`` forcing periods (default 1) and return the new state.

    ``n_or_dt`` is a **period count**, not a time increment — the wrapper
    presents a discrete map, so the argument is coerced to an integer with
    ``int()`` (a float is *truncated toward zero*, matching the discrete-view
    convention; pass a whole number to be explicit).

    Parameters
    ----------
    n_or_dt : int, optional
        Number of forcing periods to advance.  ``None`` advances one period.

    Returns
    -------
    numpy.ndarray
        The full-dimensional state after ``n`` periods.
    """
    n = int(n_or_dt) if n_or_dt is not None else 1
    return cast(np.ndarray, self.system.step(n * self.period))

time

time() -> float

Return the inner flow time.

Source code in src/tsdynamics/derived/stroboscopic.py
def time(self) -> float:
    """Return the inner flow time."""
    return cast(float, self.system.time())

run

run(
    steps: int = 100,
    *,
    ic: Any | None = None,
    transient: int = 0,
    **unknown: Any,
) -> Trajectory

Collect steps once-per-period samples — from a fresh start.

Sampling restarts the inner flow and advances by exactly one forcing :attr:period per sample; transient leading periods are stepped through and discarded first. run() twice returns the same data; step() is the verb that continues.

PARAMETER DESCRIPTION
steps

Number of once-per-period samples to collect — the horizon word, counted in forcing periods.

TYPE: int DEFAULT: 100

ic

Start state for the inner flow — system.dim numbers.

TYPE: array - like DEFAULT: None

transient

Leading stretch to discard, in this view's own horizon unit: forcing periods.

TYPE: int DEFAULT: 0

RETURNS DESCRIPTION
Trajectory

The strobed samples — continuous sample times in t (one period apart), full-dimensional states in y.

Source code in src/tsdynamics/derived/stroboscopic.py
def run(
    self, steps: int = 100, *, ic: Any | None = None, transient: int = 0, **unknown: Any
) -> Trajectory:
    """Collect ``steps`` once-per-period samples — **from a fresh start**.

    Sampling restarts the inner flow and advances by exactly one forcing
    :attr:`period` per sample; ``transient`` leading periods are stepped
    through and discarded first.  ``run()`` twice returns the same data;
    ``step()`` is the verb that continues.

    Parameters
    ----------
    steps : int
        Number of once-per-period samples to collect — the horizon word,
        counted in **forcing periods**.
    ic : array-like, optional
        Start state for the inner flow — ``system.dim`` numbers.
    transient : int
        Leading stretch to discard, in this view's own horizon unit:
        **forcing periods**.

    Returns
    -------
    Trajectory
        The strobed samples — continuous sample times in ``t`` (one period
        apart), full-dimensional states in ``y``.
    """
    _reject_wrapper_keywords(self, unknown, accepted=("steps", "ic", "transient"))
    self.reinit(ic)
    if transient:
        self.system.step(transient * self.period)
    times = np.empty(steps)
    points = np.empty((steps, self.system.dim))
    for k in range(steps):
        points[k] = self.step()
        times[k] = self.system.time()
    meta = {
        "derived": "StroboscopicMap",
        "period": self.period,
        "system": type(self.system).__name__,
        "params": self.params.as_dict(),
    }
    return Trajectory(t=times, y=points, system=self.system, meta=meta)

TangentSystem

TangentSystem(
    system: Any,
    k: int | None = None,
    *,
    backend: str | None = None,
)

Bases: DerivedSystem

Evolve a system together with k deviation (tangent) vectors.

Each step() advances the state and the deviation vectors, then QR-reorthonormalises; :meth:exponents is their running time-average — the Lyapunov spectrum estimate — and :meth:deviations is the live orthonormal frame. ts.analysis.lyapunov_spectrum wraps that into the standard burn-in + time-averaged estimate, and is the single implementation every family's Lyapunov answer delegates to.

Two members are withheld from dir() (CONTRACT.md §11, T2) and remain fully callable:

convergence(steps, ...) The Lyapunov estimates settling over time. Measured, it returns exactly run(steps, ...).unpack() — the same two arrays, element-for-element — so it is a second spelling of the one trajectory verb (corollary C3). The picture it exists for has a front door of its own::

    ts.plot(system, "lyapunov_convergence", steps=4000)

growths() The most recent step's log|diag R|. A per-step internal of the Benettin accumulation, meaningful only while you are driving step() yourself; the quantity users want is :meth:exponents, its running average.

Implementation per family
  • Maps: pure NumPy — W ← J(x)·W with _jacobian evaluated at the pre-image (the correct tangent-map convention), then QR.
  • ODEs: the extended ODE (state ⊕ k tangent vectors, see :mod:tsdynamics.derived._variational) is lowered to an engine tape and integrated per step on the Rust engine (or the pure-Python reference oracle), then QR-reorthonormalised here. Select the variational backend with backend=: "jit" (default), "interp", or "reference".
  • DDEs: not supported — tangent dynamics of a DDE lives in an infinite-dimensional history space; use DelaySystem.lyapunov_spectrum (the engine DDE Lyapunov estimator).
PARAMETER DESCRIPTION
system

The base system whose tangent dynamics to evolve.

TYPE: DiscreteMap or ContinuousSystem

k

Number of deviation vectors (1 ≤ k ≤ system.dim). Defaults to the full state dimension.

TYPE: int DEFAULT: None

backend

ODE variational backend (ignored for maps): "jit" (default, the Cranelift JIT served from the compiled-evaluator cache), "interp" (the bit-for-bit identical SSA-tape interpreter), or "reference" (the pure-Python oracle — not for production use).

.. versionchanged:: 6.0 Default moved from "interp" to "jit", once the v6 compiled-evaluator cache removed the JIT's per-call recompile.

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

Examples:

>>> tang = TangentSystem(Henon(), k=2)
>>> tang.reinit([0.1, 0.1])
>>> for _ in range(5000):
...     tang.step()
>>> tang.exponents()        # ≈ [0.42, -1.62]
Source code in src/tsdynamics/derived/tangent.py
def __init__(self, system: Any, k: int | None = None, *, backend: str | None = None) -> None:
    if isinstance(system, DelaySystem):
        raise NotImplementedError(
            "TangentSystem does not support delay systems — their tangent space is the "
            "infinite-dimensional history space. Use DelaySystem.lyapunov_spectrum."
        )
    if isinstance(system, DiscreteMap):
        self._mode = "map"
    elif isinstance(system, ContinuousSystem):
        self._mode = "ode"
    else:
        raise TypeError(
            f"TangentSystem needs a DiscreteMap or ContinuousSystem, "
            f"got {type(system).__name__}."
        )
    super().__init__(system)
    self.k = system.dim if k is None else k

    # Both maps and ODEs select among the same three backends: the compiled
    # engine (``jit``, the default, or ``interp``) or the pure-Python
    # ``reference`` oracle.  For maps the two engine evaluators run the Rust
    # QR tangent-map kernel (stream
    # perf/map-lyapunov-kernel) and ``reference`` the pure-NumPy QR loop (also
    # the transparent fallback when the engine declines — a non-lowering
    # ``_step`` or an absent wheel); for ODEs they select the variational
    # integration backend as before.
    self._backend = (backend or "jit").lower()
    if self._backend not in _ENGINE_BACKENDS:
        raise ValueError(
            f"unknown {'map' if self._mode == 'map' else 'ODE'} tangent backend "
            f"{backend!r}; choose from {sorted(_ENGINE_BACKENDS)}."
        )

    self._W: np.ndarray | None = None  # (dim, k) deviation vectors (map mode)
    self._ext_tape: Any = None  # extended variational tape (ode engine mode)
    self._ext_tape_key: Any = None  # structural-param key the tape was built for
    self._ext_tape_arrays: Any = None  # the extended tape's engine wire arrays (cached)
    self._z: np.ndarray | None = None  # extended state (ode engine mode)
    self._t = 0.0
    # ODE integration options (engine mode), captured at reinit.
    self._method: str | None = None
    self._rtol = DEFAULT_RTOL
    self._atol = DEFAULT_ATOL
    self._integrator_kwargs: dict[str, Any] = {}
    # Resumable engine stepper amortisation (stream perf/lyapunov-stepper): when
    # the resolved kernel is *explicit* and the backend is the compiled engine
    # (``interp``/``jit``), the per-dt-chunk loop reuses one durable
    # ``OdeStepper`` handle (built once over the extended variational tape,
    # re-seeded after each QR via ``set_state``) instead of constructing a fresh
    # ``ODEProblem`` + calling ``run.integrate`` every chunk.  ``advance(dt)`` is
    # bit-for-bit identical to the per-dt ``integrate_dense`` it supersedes, so
    # the spectrum is unchanged.  ``make_ode_stepper`` rejects an implicit kernel
    # at construction, so a stiff base flow (an implicit ``_default_method``) and
    # the ``reference`` oracle keep the per-chunk ``run.integrate`` path.
    self._ode_stepper: Any = None  # the durable OdeStepper handle (explicit only)
    self._step_kernel: str | None = None  # canonical kernel name (e.g. "rk45")
    self._step_explicit_engine = False  # eligible for the stepper fast path?
    self._last_growths = np.zeros(self.k)
    self._sum_growths = np.zeros(self.k)
    self._elapsed = 0.0
    # The batch engine map kernel (:meth:`_map_spectrum_engine`) returns only
    # the time-averaged spectrum and the interval count, never the final
    # orthonormal frame nor the last interval's per-step stretch.  When that
    # path runs, the streaming accessors ``deviations()`` / ``growths()`` cannot
    # be reproduced and are marked stale (set True there, cleared by ``reinit``)
    # so they raise an honest error instead of returning the long-run average.
    self._map_engine_stale = False

k property writable

k: int

How many deviation vectors ride along — the number of exponents.

Writable, and validated on the way in against the same rule the constructor applies (1 <= k <= system.dim). It used to be a plain attribute, so tang.k = 7 on a 3-D flow was accepted and the next step() died inside NumPy with cannot reshape array of size 6 into shape (7,3) — an error about an array the caller never saw, from a rule the constructor had already written down (CONTRACT.md §11.3 T2, §11.6 defect 1).

Changing it re-arms the estimator: the orthonormal frame and the accumulated growth records are shaped by k, so they are dropped and the next :meth:reinit starts a fresh average. Assigning the value it already has is a no-op and keeps a running average intact.

run

run(
    steps: int = 2000,
    n_or_dt: float | None = None,
    *,
    ic: Any | None = None,
) -> Trajectory

Record the running Lyapunov estimates as a :class:Trajectory.

A tangent system's trajectory is not a state orbit — it is the growth of the deviation vectors, i.e. each exponent's running estimate settling as the time-average accumulates. That is what this records, one column per exponent, named lambda1 … lambdak, so it plots, tabulates and indexes like any other trajectory::

tang = ts.derived.TangentSystem(lor, k=3)
conv = tang.run(steps=2000)
conv["lambda1"][-1]        # the leading exponent at the horizon
ts.plot(conv)              # the convergence read-out

.. versionchanged:: 6.0 run was inherited from :class:~tsdynamics.derived._base.DerivedSystem and raised a NotImplementedError with an empty message, while hasattr and isinstance(tang, System) both answered True — a member that lies. The numbers it returns are exactly :meth:convergence's, which it calls.

PARAMETER DESCRIPTION
steps

Number of tangent steps to record. Default 2000.

TYPE: int DEFAULT: 2000

n_or_dt

Per-step increment — iterations for a map, a dt in time units for a flow. None uses the family default.

TYPE: float DEFAULT: None

ic

Start state for the base system; None resolves its default.

TYPE: array - like DEFAULT: None

RETURNS DESCRIPTION
Trajectory

(steps, k): the running estimate of each exponent after every step, against the base system's clock.

Source code in src/tsdynamics/derived/tangent.py
def run(
    self,
    steps: int = 2000,
    n_or_dt: float | None = None,
    *,
    ic: Any | None = None,
) -> Trajectory:
    """Record the running Lyapunov estimates as a :class:`Trajectory`.

    A tangent system's trajectory is not a state orbit — it is the **growth**
    of the deviation vectors, i.e. each exponent's running estimate settling
    as the time-average accumulates.  That is what this records, one column
    per exponent, named ``lambda1 … lambdak``, so it plots, tabulates and
    indexes like any other trajectory::

        tang = ts.derived.TangentSystem(lor, k=3)
        conv = tang.run(steps=2000)
        conv["lambda1"][-1]        # the leading exponent at the horizon
        ts.plot(conv)              # the convergence read-out

    .. versionchanged:: 6.0
        ``run`` was inherited from
        :class:`~tsdynamics.derived._base.DerivedSystem` and raised a
        ``NotImplementedError`` **with an empty message**, while ``hasattr``
        and ``isinstance(tang, System)`` both answered ``True`` — a member
        that lies.  The numbers it returns are exactly :meth:`convergence`'s,
        which it calls.

    Parameters
    ----------
    steps : int, optional
        Number of tangent steps to record.  Default ``2000``.
    n_or_dt : float, optional
        Per-step increment — **iterations** for a map, a ``dt`` in **time
        units** for a flow.  ``None`` uses the family default.
    ic : array-like, optional
        Start state for the base system; ``None`` resolves its default.

    Returns
    -------
    Trajectory
        ``(steps, k)``: the running estimate of each exponent after every
        step, against the base system's clock.
    """
    times, estimates = self.convergence(steps, n_or_dt, ic=ic)
    names = tuple(f"lambda{i + 1}" for i in range(self.k))
    return Trajectory(
        times,
        estimates,
        self,
        meta={
            "system": type(self.system).__name__,
            "derived": "TangentSystem",
            "k": self.k,
            "variables": names,
            "quantity": "running Lyapunov estimate",
        },
    )

reinit

reinit(
    u: Any | None = None,
    *,
    t: float | None = None,
    params: dict[str, Any] | None = None,
    **kwargs: Any,
) -> None

Restart state, deviation vectors, and accumulated growth sums.

Source code in src/tsdynamics/derived/tangent.py
def reinit(
    self,
    u: Any | None = None,
    *,
    t: float | None = None,
    params: dict[str, Any] | None = None,
    **kwargs: Any,
) -> None:
    """Restart state, deviation vectors, and accumulated growth sums."""
    self._reset_accumulators()

    if self._mode == "map":
        self.system.reinit(u, t=t, params=params)
        self._W = np.eye(self.system.dim)[:, : self.k]
        return

    # ode mode — apply any parameter overrides to the inner system first.
    if params:
        for key, value in params.items():
            self.params[key] = value
    ic_arr = self.system._resolve_ic(u)
    t0 = float(t) if t is not None else 0.0
    self._t = t0
    self._method = kwargs.pop("method", None)
    self._rtol = kwargs.pop("rtol", DEFAULT_RTOL)
    self._atol = kwargs.pop("atol", DEFAULT_ATOL)
    self._integrator_kwargs = dict(kwargs)

    self._reinit_ode_engine(ic_arr)

step

step(n_or_dt: float | None = None) -> ndarray

Advance state + deviation vectors and reorthonormalise.

For maps n_or_dt is the number of iterations (QR after each); for ODEs it is the time increment (default 0.1). Returns the new state.

Source code in src/tsdynamics/derived/tangent.py
def step(self, n_or_dt: float | None = None) -> np.ndarray:
    """
    Advance state + deviation vectors and reorthonormalise.

    For maps ``n_or_dt`` is the number of iterations (QR after each);
    for ODEs it is the time increment (default 0.1).
    Returns the new state.
    """
    if self._mode == "map":
        return self._step_map(int(n_or_dt) if n_or_dt is not None else 1)
    dt = float(n_or_dt) if n_or_dt is not None else self._ODE_DEFAULT_DT
    return self._step_ode_engine(dt)

state

state() -> ndarray

Return a copy of the current base-system state.

Source code in src/tsdynamics/derived/tangent.py
def state(self) -> np.ndarray:
    """Return a copy of the current base-system state."""
    if self._mode == "map":
        return cast(np.ndarray, self.system.state())
    # ODE mode: the constructor guarantees an engine backend, so the extended
    # state ``self._z`` always carries the base state in its leading slots.
    if self._z is None:
        self.reinit()
    assert self._z is not None  # reinit() seeds the extended state in ODE mode
    return self._z[: self.system.dim].copy()

set_state

set_state(u: Any) -> None

Overwrite the base state (map mode only — ODE tangent vectors would desync).

Source code in src/tsdynamics/derived/tangent.py
def set_state(self, u: Any) -> None:
    """Overwrite the base state (map mode only — ODE tangent vectors would desync)."""
    if self._mode == "map":
        self.system.set_state(u)
    else:
        raise NotImplementedError(
            "TangentSystem(ode).set_state is not supported — the tangent vectors "
            "would desynchronise. Use reinit(u)."
        )

time

time() -> float

Return the current time / iteration count.

Source code in src/tsdynamics/derived/tangent.py
def time(self) -> float:
    """Return the current time / iteration count."""
    return self.system.time() if self._mode == "map" else self._t

deviations

deviations() -> ndarray

Return the current orthonormal deviation vectors, shape (dim, k).

The live tangent frame: column j is the direction the j-th Lyapunov exponent is currently being measured along, reorthonormalised at every step(). Columns are ordered by decreasing growth, so deviations()[:, 0] is the most unstable direction at this point on the orbit — the covariant-direction estimate you want in order to perturb a trajectory where it will actually separate, to seed a control scheme, or to see how the stable/unstable splitting turns as the orbit moves.

This is the only route to that frame, which is why it stays on the tab surface while :meth:growths (a per-step scalar of the same accumulation) does not: the spectrum is available from ts.analysis.lyapunov_spectrum, the directions are available here and nowhere else.

Available on every supported backend — maps and the ODE engine backends both carry the deviation matrix explicitly.

RETURNS DESCRIPTION
ndarray

Shape (dim, k), orthonormal columns. A copy: mutating it cannot corrupt the running estimate.

RAISES DESCRIPTION
RuntimeError

Before the first :meth:reinit, and — in map mode — after a batch engine lyapunov_spectrum, whose Rust kernel returns the averaged spectrum without the final frame. Drive reinit() + step() yourself (or pass backend='reference') for a streaming frame.

Examples:

>>> tang = TangentSystem(Henon(), k=2)
>>> tang.reinit([0.1, 0.1])
>>> for _ in range(100):
...     tang.step()
>>> tang.deviations()[:, 0]                 # the unstable direction here
...
Source code in src/tsdynamics/derived/tangent.py
def deviations(self) -> np.ndarray:
    """Return the current orthonormal deviation vectors, shape ``(dim, k)``.

    The live tangent frame: column ``j`` is the direction the ``j``-th
    Lyapunov exponent is currently being measured along, reorthonormalised at
    every ``step()``.  Columns are ordered by decreasing growth, so
    ``deviations()[:, 0]`` is the **most unstable direction at this point on
    the orbit** — the covariant-direction estimate you want in order to
    perturb a trajectory where it will actually separate, to seed a control
    scheme, or to see how the stable/unstable splitting turns as the orbit
    moves.

    This is the only route to that frame, which is why it stays on the tab
    surface while :meth:`growths` (a per-step scalar of the same accumulation)
    does not: the spectrum is available from
    ``ts.analysis.lyapunov_spectrum``, the *directions* are available here and
    nowhere else.

    Available on every supported backend — maps and the ODE engine
    backends both carry the deviation matrix explicitly.

    Returns
    -------
    numpy.ndarray
        Shape ``(dim, k)``, orthonormal columns.  A copy: mutating it cannot
        corrupt the running estimate.

    Raises
    ------
    RuntimeError
        Before the first :meth:`reinit`, and — in map mode — after a *batch*
        engine ``lyapunov_spectrum``, whose Rust kernel returns the averaged
        spectrum without the final frame.  Drive ``reinit()`` + ``step()``
        yourself (or pass ``backend='reference'``) for a streaming frame.

    Examples
    --------
    >>> tang = TangentSystem(Henon(), k=2)      # doctest: +SKIP
    >>> tang.reinit([0.1, 0.1])                 # doctest: +SKIP
    >>> for _ in range(100):                    # doctest: +SKIP
    ...     tang.step()
    >>> tang.deviations()[:, 0]                 # the unstable direction here
    ...                                         # doctest: +SKIP
    """
    if self._mode == "map":
        if self._map_engine_stale:
            raise RuntimeError(
                "deviations() is unavailable after a batch engine map "
                "lyapunov_spectrum: the Rust kernel returns only the averaged "
                "spectrum, not the final orthonormal frame. Call reinit() and "
                "step() to drive the streaming tangent frame explicitly, or use "
                "backend='reference' for the streaming pure-Python QR loop."
            )
        if self._W is None:
            raise RuntimeError("deviations() is available after reinit()")
        return self._W.copy()
    # ODE engine backend (interp/jit/reference): the constructor rejects any
    # other backend, so the deviation vectors are always carried explicitly.
    if self._z is None:
        raise RuntimeError("deviations() is available after reinit()")
    return split_extended(self._z, self.system.dim, self.k)[1]

growths

growths() -> ndarray

Return the log stretch factors log|diag R| from the most recent step.

RAISES DESCRIPTION
RuntimeError

In map mode, after a batch engine lyapunov_spectrum call: the Rust kernel returns only the time-averaged spectrum, not the last interval's per-step stretch, so the most-recent-step value cannot be reproduced. Drive the frame with reinit() + step() (or use backend='reference') for streaming per-step growths.

Source code in src/tsdynamics/derived/tangent.py
def growths(self) -> np.ndarray:
    """Return the log stretch factors ``log|diag R|`` from the most recent step.

    Raises
    ------
    RuntimeError
        In map mode, after a *batch* engine ``lyapunov_spectrum`` call: the
        Rust kernel returns only the time-averaged spectrum, not the last
        interval's per-step stretch, so the most-recent-step value cannot be
        reproduced. Drive the frame with ``reinit()`` + ``step()`` (or use
        ``backend='reference'``) for streaming per-step growths.
    """
    if self._mode == "map" and self._map_engine_stale:
        raise RuntimeError(
            "growths() is unavailable after a batch engine map "
            "lyapunov_spectrum: the Rust kernel returns only the averaged "
            "spectrum, not the most recent step's log|diag R|. Call reinit() and "
            "step() to drive the streaming tangent frame explicitly, or use "
            "backend='reference' for the streaming pure-Python QR loop."
        )
    return self._last_growths.copy()

exponents

exponents() -> ndarray

Return the running Lyapunov-spectrum estimate (accumulated growths / elapsed).

Source code in src/tsdynamics/derived/tangent.py
def exponents(self) -> np.ndarray:
    """Return the running Lyapunov-spectrum estimate (accumulated growths / elapsed)."""
    if self._elapsed == 0.0:
        return np.zeros(self.k)
    return self._sum_growths / self._elapsed

convergence

convergence(
    steps: int = 2000,
    n_or_dt: float | None = None,
    *,
    ic: Any | None = None,
) -> tuple[ndarray, ndarray]

Record the running Lyapunov estimates as they converge.

Reinitialises the tangent frame and steps it steps times, capturing the running :meth:exponents estimate after every step. The estimates settle as the time-average accumulates — the curve a user inspects to judge whether a Lyapunov run has converged.

PARAMETER DESCRIPTION
steps

Number of tangent steps to record. Default 2000.

TYPE: int DEFAULT: 2000

n_or_dt

Per-step increment (iterations for a map, dt for an ODE). None uses the family default.

TYPE: float DEFAULT: None

ic

Initial condition; None resolves the system's default.

TYPE: array - like DEFAULT: None

RETURNS DESCRIPTION
(times, estimates)

times shape (steps,); estimates shape (steps, k) — the running estimate of each of the k exponents after each step.

Source code in src/tsdynamics/derived/tangent.py
def convergence(
    self,
    steps: int = 2000,
    n_or_dt: float | None = None,
    *,
    ic: Any | None = None,
) -> tuple[np.ndarray, np.ndarray]:
    """Record the running Lyapunov estimates as they converge.

    Reinitialises the tangent frame and steps it ``steps`` times, capturing
    the running :meth:`exponents` estimate after every step.  The estimates
    settle as the time-average accumulates — the curve a user inspects to
    judge whether a Lyapunov run has converged.

    Parameters
    ----------
    steps : int, optional
        Number of tangent steps to record.  Default ``2000``.
    n_or_dt : float, optional
        Per-step increment (iterations for a map, ``dt`` for an ODE).
        ``None`` uses the family default.
    ic : array-like, optional
        Initial condition; ``None`` resolves the system's default.

    Returns
    -------
    (times, estimates)
        ``times`` shape ``(steps,)``; ``estimates`` shape ``(steps, k)`` —
        the running estimate of each of the ``k`` exponents after each step.
    """
    if steps <= 0:
        raise ValueError(f"steps must be positive, got {steps}")
    # Random-IC retry on divergence, mirroring the map Lyapunov path
    # (:meth:`_map_spectrum`): when called without an explicit ``ic`` the
    # system resolves a random one, which for a small-basin map (Hénon) can
    # land off-attractor and diverge.  Re-draw a fresh IC and retry; an
    # explicitly supplied ``ic`` that diverges is the caller's, so re-raise.
    max_retries = 10
    for attempt in range(max_retries):
        use_ic = ic if attempt == 0 else None
        times = np.empty(steps)
        estimates = np.empty((steps, self.k))
        try:
            self.reinit(use_ic)
            for s in range(steps):
                self.step(n_or_dt)
                times[s] = self.time()
                estimates[s] = self.exponents()
        except RuntimeError:
            if ic is not None or attempt == max_retries - 1:
                raise
            # Force a fresh random IC on the next attempt.
            object.__setattr__(self.system, "ic", None)
            continue
        return times, estimates
    # Unreachable: the loop returns on success or re-raises on the last
    # attempt; this satisfies the type checker that all paths are covered.
    raise RuntimeError(  # pragma: no cover
        f"{type(self.system).__name__}: tangent convergence diverged from every IC."
    )

Ensemble

Ensemble(system: Any, states: Any)

Many copies of one system, advanced synchronously from different states.

Used for two-trajectory Lyapunov estimates, basin sampling, and ensemble statistics. Members are independent copies — parameters are shared at construction, states are per-member.

Two members are withheld from dir() (CONTRACT.md §11, T2) and stay callable. Both are second spellings of the System protocol this class already answers in the protocol's own words:

states() Identical to :meth:state — the very body of state() is return self.states(). state() is the word every other view in the library uses, so it is the one that stays listed (corollary C3). set_states(states) The plural of set_state, which is a capability rather than a protocol member since v6. Reach it with hasattr like the others, or build the ensemble you want: system.ensemble(new_states) is one call and cannot desynchronise the member clocks.

PARAMETER DESCRIPTION
system

The system to copy per member (the original is untouched). Readable afterwards as :attr:system.

TYPE: System

states

One initial state per member.

TYPE: (array - like, shape(m, dim))

ATTRIBUTE DESCRIPTION
system

The system every member is a copy of.

.. versionchanged:: 6.0 Was template. Every other wrapper in :mod:tsdynamics.derived calls the thing it wraps system, so the one exception meant that code walking a mixed list of views — the thing wrappers exist to make possible — had to special-case this class by name (corollary C3: one concept, one spelling). band.template names the replacement.

TYPE: System

members

The per-member copies, in construction order.

TYPE: list of System

Examples:

>>> band = Lorenz().ensemble([[1, 1, 1], [1.001, 1, 1]])
>>> band.step(0.01)
array([[...], [...]])
>>> band.run(final_time=10.0).final
array([[...], [...]])
Source code in src/tsdynamics/derived/ensemble.py
def __init__(self, system: Any, states: Any) -> None:
    states_arr = np.atleast_2d(np.asarray(states, dtype=float))
    if states_arr.shape[1] != system.dim:
        raise ValueError(f"states must have shape (m, {system.dim}), got {states_arr.shape}")
    self.system = system
    self.members = []
    for s in states_arr:
        member = system.copy()
        member.reinit(s)
        self.members.append(member)

size property

size: int

Number of ensemble members.

dim property

dim: int

State-space dimension of each member.

family property

family: str

The template system's family word.

params property

params: Any

The template system's parameters (shared by every member).

variables property

variables: tuple[str, ...]

The template system's component names.

state

state() -> ndarray

Return the stacked member states — the System-protocol reading.

Source code in src/tsdynamics/derived/ensemble.py
def state(self) -> np.ndarray:
    """Return the stacked member states — the ``System``-protocol reading."""
    return self.states()

reinit

reinit(u: Any | None = None, **kwargs: Any) -> None

Restart every member (from u, or from its own construction state).

Source code in src/tsdynamics/derived/ensemble.py
def reinit(self, u: Any | None = None, **kwargs: Any) -> None:
    """Restart every member (from ``u``, or from its own construction state)."""
    for member in self.members:
        member.reinit(u, **kwargs)

run

run(
    final_time: float | None = None,
    dt: float | None = None,
    *,
    steps: int | None = None,
    transient: float | None = None,
    seed: int | None = None,
    **run_kw: Any,
) -> TrajectoryBatch

Run every member and return a :class:TrajectoryBatch.

Takes the template family's own run vocabulary — final_time / dt for a flow, steps for a map — and forwards it verbatim, with each member's own initial state supplied as ic=::

band = lor.ensemble(np.random.rand(100, 3))
batch = band.run(final_time=10.0)
batch.final          # the (100, 3) end states
PARAMETER DESCRIPTION
final_time

Flow horizon, in time units.

TYPE: float DEFAULT: None

dt

Output sampling interval, in time units.

TYPE: float DEFAULT: None

steps

Map horizon, in iterations. A flow refuses it by name.

TYPE: int DEFAULT: None

transient

Leading stretch discarded, in the template family's own horizon unit.

TYPE: float DEFAULT: None

seed

For an SDE batch, member i draws its noise from seed_for(seed, i) — depending only on the index, which is the engine's parallel-equals-serial contract. On every other family it is forwarded as the shared initial-condition seed.

TYPE: int DEFAULT: None

**run_kw

Everything else the template family's run accepts (solver, rtol, atol, backend, max_step, …). An unknown word is refused by the family that owns it, with its reason.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
TrajectoryBatch
RAISES DESCRIPTION
InvalidParameterError

If ic= is passed: an ensemble's initial states are the ones it was built with.

Source code in src/tsdynamics/derived/ensemble.py
def run(
    self,
    final_time: float | None = None,
    dt: float | None = None,
    *,
    steps: int | None = None,
    transient: float | None = None,
    seed: int | None = None,
    **run_kw: Any,
) -> TrajectoryBatch:
    """Run every member and return a :class:`TrajectoryBatch`.

    Takes the **template family's own** ``run`` vocabulary — ``final_time`` /
    ``dt`` for a flow, ``steps`` for a map — and forwards it verbatim, with
    each member's own initial state supplied as ``ic=``::

        band = lor.ensemble(np.random.rand(100, 3))
        batch = band.run(final_time=10.0)
        batch.final          # the (100, 3) end states

    Parameters
    ----------
    final_time : float, optional
        Flow horizon, in time units.
    dt : float, optional
        Output sampling interval, in time units.
    steps : int, optional
        Map horizon, in iterations.  A flow refuses it by name.
    transient : float, optional
        Leading stretch discarded, in the template family's own horizon unit.
    seed : int, optional
        For an **SDE** batch, member ``i`` draws its noise from
        ``seed_for(seed, i)`` — depending only on the index, which is the
        engine's parallel-equals-serial contract.  On every other family it
        is forwarded as the shared initial-condition seed.
    **run_kw
        Everything else the template family's ``run`` accepts (``solver``,
        ``rtol``, ``atol``, ``backend``, ``max_step``, …).  An unknown word
        is refused **by the family that owns it**, with its reason.

    Returns
    -------
    TrajectoryBatch

    Raises
    ------
    InvalidParameterError
        If ``ic=`` is passed: an ensemble's initial states are the ones it
        was built with.
    """
    if "ic" in run_kw:
        from tsdynamics.errors import InvalidParameterError, remedy

        raise InvalidParameterError(
            "an ensemble's initial states are the ones you built it with, so "
            "run() has no ic=. One state per member, given once."
            + remedy(
                "band = system.ensemble(states)   # the (n, dim) starts",
                "batch = band.run(final_time=10.0)",
            )
        )
    # Forward by NAME, never positionally: a bare ``(dt,)`` tuple would be
    # read by the template as ``final_time``.  A word the template family
    # does not have (``steps`` on a flow) is refused by that family, by name.
    kwargs: dict[str, Any] = dict(run_kw)
    for word, value in (
        ("final_time", final_time),
        ("dt", dt),
        ("steps", steps),
        ("transient", transient),
    ):
        if value is not None:
            kwargs[word] = value
    per_member_seed: list[int | None]
    if self.family == "sde" and seed is not None:
        # Member ``i`` draws its noise from ``seed_for(seed, i)`` — depending
        # only on the index — which is the engine's parallel-equals-serial
        # contract.  One shared seed would give every member the SAME path.
        from tsdynamics.families.stochastic import _seed_for as seed_for

        per_member_seed = [seed_for(seed, i) for i in range(len(self.members))]
    else:
        if seed is not None:
            kwargs["seed"] = seed
        per_member_seed = [None] * len(self.members)

    return TrajectoryBatch(self._run_members((), kwargs, per_member_seed))

step

step(n_or_dt: float | int | None = None) -> ndarray

Advance every member synchronously and return the stacked states.

PARAMETER DESCRIPTION
n_or_dt

The per-member increment (a dt for a flow, an iteration count for a map). None uses each member's default.

TYPE: float or int DEFAULT: None

RETURNS DESCRIPTION
ndarray

The new states, shape (size, dim) — one row per member.

Source code in src/tsdynamics/derived/ensemble.py
def step(self, n_or_dt: float | int | None = None) -> np.ndarray:
    """Advance every member synchronously and return the stacked states.

    Parameters
    ----------
    n_or_dt : float or int, optional
        The per-member increment (a ``dt`` for a flow, an iteration count for
        a map).  ``None`` uses each member's default.

    Returns
    -------
    numpy.ndarray
        The new states, shape ``(size, dim)`` — one row per member.
    """
    return np.array([m.step(n_or_dt) for m in self.members])

states

states() -> ndarray

Return the current member states, shape (size, dim).

Source code in src/tsdynamics/derived/ensemble.py
def states(self) -> np.ndarray:
    """Return the current member states, shape ``(size, dim)``."""
    return np.array([m.state() for m in self.members])

set_states

set_states(states: Any) -> None

Overwrite every member's state.

PARAMETER DESCRIPTION
states

One new state per member, in member order.

TYPE: (array - like, shape(size, dim))

RAISES DESCRIPTION
ValueError

If states is not exactly (size, dim).

Source code in src/tsdynamics/derived/ensemble.py
def set_states(self, states: Any) -> None:
    """Overwrite every member's state.

    Parameters
    ----------
    states : array-like, shape (size, dim)
        One new state per member, in member order.

    Raises
    ------
    ValueError
        If ``states`` is not exactly ``(size, dim)``.
    """
    states_arr = np.atleast_2d(np.asarray(states, dtype=float))
    if states_arr.shape != (self.size, self.dim):
        raise ValueError(f"expected shape {(self.size, self.dim)}, got {states_arr.shape}")
    for member, s in zip(self.members, states_arr, strict=True):
        member.set_state(s)

time

time() -> float

Return the common member time.

Source code in src/tsdynamics/derived/ensemble.py
def time(self) -> float:
    """Return the common member time."""
    return self.members[0].time() if self.members else 0.0

collect

collect(
    steps: int, n_or_dt: float | int | None = None
) -> EnsembleSamples

Step every member steps times and stack the sampled states.

Advances the whole ensemble synchronously, recording each member's state after every step. This is the trajectory collector the static fan chart (:meth:__plot_spec__) summarises into a median line + percentile band.

This is a different numerical path from :meth:run, which is why it stays on the tab surface rather than being folded into it. run launches each member as its own independent integration, on the inner family's own adaptive solver and output grid; collect drives the live steppers in lockstep, so at every returned sample the members share one clock. That is the reading an ensemble measurement needs — a cross-member percentile at "time t" is only meaningful if every member really is at t — and it is also what makes the returned block rectangular in (sample, member, component). Use run when you want per-member trajectories; use collect when you want the ensemble's spread through time.

PARAMETER DESCRIPTION
steps

Number of samples to collect (one per step).

TYPE: int

n_or_dt

The per-step increment forwarded to each member's step (a dt for a flow, an iteration count for a map). None uses the member default.

TYPE: float or int DEFAULT: None

RETURNS DESCRIPTION
EnsembleSamples

A named pair — .times shape (steps,), .states shape (steps, size, dim) (sample, member, component). It unpacks as times, states = band.collect(n) like the bare tuple it replaced.

Source code in src/tsdynamics/derived/ensemble.py
def collect(self, steps: int, n_or_dt: float | int | None = None) -> EnsembleSamples:
    """Step every member ``steps`` times and stack the sampled states.

    Advances the whole ensemble synchronously, recording each member's state
    after every step.  This is the trajectory collector the static fan chart
    (:meth:`__plot_spec__`) summarises into a median line + percentile band.

    **This is a different numerical path from** :meth:`run`, which is why it
    stays on the tab surface rather than being folded into it.  ``run``
    launches each member as its own independent integration, on the inner
    family's own adaptive solver and output grid; ``collect`` drives the
    *live steppers* in lockstep, so at every returned sample the members
    share one clock.  That is the reading an ensemble measurement needs — a
    cross-member percentile at "time ``t``" is only meaningful if every
    member really is at ``t`` — and it is also what makes the returned block
    rectangular in ``(sample, member, component)``.  Use ``run`` when you want
    per-member trajectories; use ``collect`` when you want the ensemble's
    spread through time.

    Parameters
    ----------
    steps : int
        Number of samples to collect (one per step).
    n_or_dt : float or int, optional
        The per-step increment forwarded to each member's ``step`` (a ``dt``
        for a flow, an iteration count for a map).  ``None`` uses the member
        default.

    Returns
    -------
    EnsembleSamples
        A named pair — ``.times`` shape ``(steps,)``, ``.states`` shape
        ``(steps, size, dim)`` (sample, member, component).  It unpacks as
        ``times, states = band.collect(n)`` like the bare tuple it replaced.
    """
    if steps <= 0:
        raise ValueError(f"steps must be positive, got {steps}")
    times = np.empty(steps)
    states = np.empty((steps, self.size, self.dim))
    for k in range(steps):
        states[k] = self.step(n_or_dt)
        times[k] = self.time()
    return EnsembleSamples(times, states)

plot

plot(*transforms: Any, **kwargs: Any) -> PlotSpec

Draw the ensemble — the fan chart by default.

Source code in src/tsdynamics/derived/ensemble.py
def plot(self, *transforms: Any, **kwargs: Any) -> PlotSpec:
    """Draw the ensemble — the fan chart by default."""
    from tsdynamics.viz import plot as _plot

    return _plot(self, *transforms, **kwargs)

ProjectedSystem

ProjectedSystem(
    system: Any,
    components: Any,
    *,
    complete: Callable[[ndarray], Any] | None = None,
)

Bases: DerivedSystem

View a system through a subset of its components.

The full system is stepped underneath; only state()/step() outputs are projected. set_state needs the inverse direction and therefore requires a complete callable mapping a projected state back to a full state.

complete is withheld from dir() (CONTRACT.md §11, T2): it is a constructor argument echoed back, not a measurement — you already hold the callable you passed, and reading it off the view answers nothing the code that built the view does not already know. It remains bound, and :attr:components (which the constructor resolves, from names to indices) stays listed for exactly the opposite reason.

PARAMETER DESCRIPTION
system

The full system.

TYPE: System

components

Component indices (or names, when the system declares variables).

TYPE: sequence of int or str

complete

complete(u_projected) -> u_full used by set_state/reinit when given projected-dimensional inputs.

TYPE: callable DEFAULT: None

Examples:

>>> proj = ProjectedSystem(Lorenz(), ["x", "z"])
>>> proj.step(0.01).shape
(2,)
Source code in src/tsdynamics/derived/projected.py
def __init__(
    self,
    system: Any,
    components: Any,
    *,
    complete: Callable[[np.ndarray], Any] | None = None,
) -> None:
    super().__init__(system)
    self.components = components
    self.complete = complete

components property writable

components: tuple[int, ...]

Which components of the inner system this view hands you, in order.

This is the projection, so it is writable — re-aiming a view is a legitimate customization, and it accepts the same names-or-indices grammar the constructor does (proj.components = ["x", "z"]). The write is validated for the same reason the constructor's is: an out-of-range index used to be stored verbatim and then surface on the next step() as NumPy's index 99 is out of bounds, an error about the state array rather than about the selection that was written.

Note that :attr:dim and :attr:variables both follow it.

dim property

dim: int

Dimension of the projected view.

variables property

variables: tuple[str, ...]

Component names of the projected view (the inner names, subset).

Overrides :class:DerivedSystem's pass-through, which would return the inner system's full names and mislabel the projected columns.

step

step(n_or_dt: float | int | None = None) -> ndarray

Advance the full system; return the projected new state.

Source code in src/tsdynamics/derived/projected.py
def step(self, n_or_dt: float | int | None = None) -> np.ndarray:
    """Advance the full system; return the projected new state."""
    return cast(np.ndarray, self.system.step(n_or_dt)[list(self.components)])

state

state() -> ndarray

Return the projected current state.

Source code in src/tsdynamics/derived/projected.py
def state(self) -> np.ndarray:
    """Return the projected current state."""
    return cast(np.ndarray, self.system.state()[list(self.components)])

set_state

set_state(u: Any) -> None

Overwrite the state (projected inputs need a complete callable).

Source code in src/tsdynamics/derived/projected.py
def set_state(self, u: Any) -> None:
    """Overwrite the state (projected inputs need a ``complete`` callable)."""
    u_arr = np.asarray(u, dtype=float)
    self.system.set_state(self._to_full_state(u_arr, verb="set_state"))

reinit

reinit(u: Any | None = None, **kwargs: Any) -> None

Restart the full system (projected inputs need a complete callable).

Source code in src/tsdynamics/derived/projected.py
def reinit(self, u: Any | None = None, **kwargs: Any) -> None:
    """Restart the full system (projected inputs need a ``complete`` callable)."""
    if u is not None:
        u = self._to_full_state(np.asarray(u, dtype=float), verb="reinit")
    self.system.reinit(u, **kwargs)

run

run(*args: Any, **run_kw: Any) -> Trajectory

Run the full system and keep only the projected columns.

Takes the inner family's own run vocabulary verbatim — final_time / dt for a flow, steps for a map — so an unknown keyword is refused by the family that owns the word, with its reason.

RETURNS DESCRIPTION
Trajectory

(T, len(components)), back-referencing this wrapper so traj["x"] names the surviving columns.

Source code in src/tsdynamics/derived/projected.py
def run(self, *args: Any, **run_kw: Any) -> Trajectory:
    """Run the **full** system and keep only the projected columns.

    Takes the inner family's own ``run`` vocabulary verbatim — ``final_time``
    / ``dt`` for a flow, ``steps`` for a map — so an unknown keyword is
    refused by the family that owns the word, with its reason.

    Returns
    -------
    Trajectory
        ``(T, len(components))``, back-referencing *this* wrapper so
        ``traj["x"]`` names the surviving columns.
    """
    traj = self.system.run(*args, **run_kw)
    # Overwrite the INNER system's recorded names: a run records the component
    # names it was produced with, and ``Trajectory.variables`` reads
    # ``meta["variables"]`` before the system — so carrying the full tuple
    # through would leave a 1-column projection with a 2-name record, which
    # resolves to the generated ``y0`` and mislabels every column.
    meta = {**traj.meta, "projected": self.components, "variables": self.variables}
    # Back-reference ``self`` (not the inner system): the returned ``y`` holds
    # only the projected columns, and ``self.variables`` names exactly those —
    # so ``traj["x"]`` resolves to the right column and an unknown name raises
    # KeyError rather than silently mislabelling or IndexError-ing.
    return Trajectory(traj.t, traj.y[:, list(self.components)], self, meta=meta)

WrappedSystem

WrappedSystem(
    step_fn: Callable[[ndarray, float], Any],
    *,
    dim: int,
    family: str = "map",
    ic: Any | None = None,
    default_dt: float = 1.0,
    variables: tuple[str, ...] | None = None,
    **renamed: Any,
)

Bases: DeriveMixin, SystemPlottable

Wrap any external stepping rule as a first-class :class:System.

Give it a step_fn(state, n_or_dt) -> new_state and a dimension, and the whole analysis toolkit (orbit diagrams, Lyapunov-from-rescaling, Poincaré sections, ensembles, basins) applies to your own simulation code — a foreign ODE solver, an agent-based model, a hardware-in-the-loop rig, anything that advances a state vector.

PARAMETER DESCRIPTION
step_fn

step_fn(state, n_or_dt) -> new_state. new_state is array-like of length dim. n_or_dt is the iteration count (discrete) or the time increment (continuous); pass it through to your stepper.

TYPE: callable

dim

State-space dimension.

TYPE: int

family

"map" — n_or_dt counts iterations and the run horizon is steps; "ode" — n_or_dt is a time increment and the horizon is final_time / dt. It also fixes the unit of transient.

TYPE: ('map', 'ode') DEFAULT: "map"

ic

Default initial state, used when run / reinit gets none. Named ic like every other family (it was initial, the one family that spelled it differently).

TYPE: array - like DEFAULT: None

default_dt

Amount a bare step() advances by — iterations for a map, time units for a flow.

TYPE: float DEFAULT: 1.0

variables

Component names, enabling traj["x"] on produced trajectories. Defaults to y0 ... y{dim-1} — never None.

TYPE: tuple of str DEFAULT: None

Examples:

>>> # a plain logistic map written by hand
>>> import numpy as np
>>> def step(u, n):
...     x = u[0]
...     for _ in range(int(n)):
...         x = 3.9 * x * (1 - x)
...     return [x]
>>> sysm = WrappedSystem(step, dim=1, family="map", ic=[0.5])
>>> traj = sysm.run(500)
>>> import tsdynamics as ts
>>> float(ts.analysis.lyapunov_spectrum(sysm, k=1, ic=[0.3])[0]) > 0   # chaotic
True
Source code in src/tsdynamics/families/wrapped.py
def __init__(
    self,
    step_fn: Callable[[np.ndarray, float], Any],
    *,
    dim: int,
    family: str = "map",
    ic: Any | None = None,
    default_dt: float = 1.0,
    variables: tuple[str, ...] | None = None,
    **renamed: Any,
) -> None:
    if "initial" in renamed:
        raise InvalidParameterError(
            "WrappedSystem's initial state is spelled ic=, like every other "
            "family's run() and constructor. `initial=` was the only place in "
            "the library that named it differently."
            + remedy(f"ts.WrappedSystem(step_fn, dim={dim}, ic=[...])")
        )
    for bad in renamed:
        raise InvalidParameterError(
            f"WrappedSystem() got an unexpected keyword {bad!r}. It takes: "
            "step_fn, dim, family, ic, default_dt, variables."
        )
    self._step_fn = step_fn
    self._dim = int(dim)
    if family not in ("ode", "map"):
        from tsdynamics.errors import invalid_value

        raise invalid_value(
            "family",
            family,
            rule='must be "ode" (a flow you step by dt) or "map" (a rule you iterate)',
            hint="WrappedSystem adapts an opaque stepper; a delay or noise "
            "kernel needs DelaySystem / StochasticSystem, which own the history "
            "and the Wiener stream.",
        )
    self._family = family
    self._is_discrete = family == "map"
    self._ic = None if ic is None else np.asarray(ic, dtype=float).reshape(dim)
    self._default_dt = float(default_dt)
    # v6: every system names its own components.  ``None`` used to mean
    # "unnamed", which made ``traj["x"]`` and a section plane by name a guess.
    self.variables: tuple[str, ...] = (
        tuple(str(v) for v in variables)
        if variables is not None
        else tuple(f"y{i}" for i in range(self.dim))
    )

    self._state: np.ndarray | None = None
    self._t: float = 0.0
    #: The state ``run`` actually started from, recorded onto ``traj.meta``.
    self._first_ic: np.ndarray = np.zeros(self._dim)

dim property

dim: int

State-space dimension — read-only, as on every other family.

It used to be a plain attribute, so w.dim = 7 was accepted and left the wrapper describing a width its stepper does not produce.

family property

family: str

"ode" or "map" — the kind of dynamics this stepper implements.

reinit

reinit(
    u: Any | None = None,
    *,
    t: float | None = None,
    params: dict[str, Any] | None = None,
) -> None

Restart from state u (falls back to ic, then zeros).

Source code in src/tsdynamics/families/wrapped.py
def reinit(
    self,
    u: Any | None = None,
    *,
    t: float | None = None,
    params: dict[str, Any] | None = None,
) -> None:
    """Restart from state ``u`` (falls back to ``ic``, then zeros)."""
    if u is not None:
        self._state = np.asarray(u, dtype=float).reshape(self.dim)
    elif self._ic is not None:
        self._state = self._ic.copy()
    else:
        self._state = np.zeros(self.dim)
    self._t = float(t) if t is not None else 0.0

step

step(n_or_dt: float | None = None) -> ndarray

Advance by n_or_dt (default default_dt) and return the new state.

Source code in src/tsdynamics/families/wrapped.py
def step(self, n_or_dt: float | None = None) -> np.ndarray:
    """Advance by ``n_or_dt`` (default ``default_dt``) and return the new state."""
    if self._state is None:
        self.reinit()
    assert self._state is not None
    amount = self._default_dt if n_or_dt is None else n_or_dt
    new = np.asarray(self._step_fn(self._state, amount), dtype=np.float64).reshape(self.dim)
    if not np.all(np.isfinite(new)):
        raise ConvergenceError("WrappedSystem.step produced non-finite state")
    self._state = new
    self._t += amount
    return self._state.copy()

state

state() -> ndarray

Return a copy of the current state.

Source code in src/tsdynamics/families/wrapped.py
def state(self) -> np.ndarray:
    """Return a copy of the current state."""
    if self._state is None:
        self.reinit()
    assert self._state is not None
    return self._state.copy()

set_state

set_state(u: Any) -> None

Overwrite the current state.

Source code in src/tsdynamics/families/wrapped.py
def set_state(self, u: Any) -> None:
    """Overwrite the current state."""
    self._state = np.asarray(u, dtype=float).reshape(self.dim)

time

time() -> float

Return the current time (continuous) or iteration count (discrete).

Source code in src/tsdynamics/families/wrapped.py
def time(self) -> float:
    """Return the current time (continuous) or iteration count (discrete)."""
    return self._t

copy

copy() -> WrappedSystem

Return a fresh wrapper sharing the same step rule (independent state).

Source code in src/tsdynamics/families/wrapped.py
def copy(self) -> WrappedSystem:
    """Return a fresh wrapper sharing the same step rule (independent state)."""
    return WrappedSystem(
        self._step_fn,
        dim=self.dim,
        family=self._family,
        ic=self._ic,
        default_dt=self._default_dt,
        variables=self.variables,
    )

run

run(
    steps: int | None = None,
    *,
    transient: float = 0.0,
    ic: Any | None = None,
    final_time: float | None = None,
    dt: float | None = None,
) -> Trajectory

Step the wrapper repeatedly (after transient) and collect a Trajectory.

A wrapper stands in for either family, so it accepts both horizon words and you give exactly one: steps (a sample count, the map spelling) or final_time (the flow spelling, with the sample count round(final_time / dt)). The per-step increment is dt, which defaults to default_dt for both families.

PARAMETER DESCRIPTION
steps

Number of samples to collect — the map horizon word, counted in iterations. Mutually exclusive with final_time.

TYPE: int DEFAULT: None

transient

Leading stretch to discard (stepped but not recorded), in this wrapper's own horizon unit: family="map" counts iterations, family="ode" counts time units, exactly as on the four built-in families. It used to count samples on a continuous wrapper and refuse a float outright.

TYPE: float DEFAULT: 0

ic

Initial state for the run (falls back to the constructor's ic, then zeros).

TYPE: array - like DEFAULT: None

final_time

Integration horizon in time units — the flow horizon word. The sample count is round(final_time / dt).

TYPE: float DEFAULT: None

dt

Per-step increment, in this wrapper's own unit (time for "ode", iterations for "map"). Defaults to default_dt.

TYPE: float DEFAULT: None

RETURNS DESCRIPTION
Trajectory

steps recorded samples with their times, carrying the same provenance meta every other family records.

RAISES DESCRIPTION
InvalidInputError

If neither steps nor final_time is given, if both are, or if the resolved sample count is not positive.

Examples:

>>> import numpy as np
>>> flow = lambda u, dt: [u[0] * np.exp(0.5 * dt)]
>>> w = WrappedSystem(flow, dim=1, family="ode", default_dt=0.1)
>>> traj = w.run(final_time=1.0, dt=0.1)   # the one trajectory verb
>>> traj.y.shape
(10, 1)
Source code in src/tsdynamics/families/wrapped.py
def run(
    self,
    steps: int | None = None,
    *,
    transient: float = 0.0,
    ic: Any | None = None,
    final_time: float | None = None,
    dt: float | None = None,
) -> Trajectory:
    """Step the wrapper repeatedly (after ``transient``) and collect a Trajectory.

    A wrapper stands in for *either* family, so it accepts **both** horizon
    words and you give exactly one: ``steps`` (a sample count, the map
    spelling) or ``final_time`` (the flow spelling, with the sample count
    ``round(final_time / dt)``).  The per-step increment is ``dt``, which
    defaults to ``default_dt`` for both families.

    Parameters
    ----------
    steps : int, optional
        Number of samples to collect — the **map** horizon word, counted in
        iterations.  Mutually exclusive with ``final_time``.
    transient : float, default 0
        Leading stretch to discard (stepped but not recorded), in this
        wrapper's **own horizon unit**: ``family="map"`` counts iterations,
        ``family="ode"`` counts **time units**, exactly as on the four
        built-in families.  It used to count *samples* on a continuous
        wrapper and refuse a float outright.
    ic : array-like, optional
        Initial state for the run (falls back to the constructor's ``ic``,
        then zeros).
    final_time : float, optional
        Integration horizon in **time units** — the **flow** horizon word.
        The sample count is ``round(final_time / dt)``.
    dt : float, optional
        Per-step increment, in this wrapper's own unit (time for ``"ode"``,
        iterations for ``"map"``).  Defaults to ``default_dt``.

    Returns
    -------
    Trajectory
        ``steps`` recorded samples with their times, carrying the same
        provenance ``meta`` every other family records.

    Raises
    ------
    InvalidInputError
        If neither ``steps`` nor ``final_time`` is given, if both are, or if
        the resolved sample count is not positive.

    Examples
    --------
    >>> import numpy as np
    >>> flow = lambda u, dt: [u[0] * np.exp(0.5 * dt)]
    >>> w = WrappedSystem(flow, dim=1, family="ode", default_dt=0.1)
    >>> traj = w.run(final_time=1.0, dt=0.1)   # the one trajectory verb
    >>> traj.y.shape
    (10, 1)
    """
    step_dt = self._default_dt if dt is None else float(dt)
    if final_time is not None:
        if steps is not None:
            raise InvalidInputError(
                "WrappedSystem.run: pass either steps or final_time, not both."
            )
        steps = int(round(float(final_time) / step_dt))
    elif steps is None:
        raise InvalidInputError("WrappedSystem.run requires steps (a count) or final_time.")
    n = int(steps)
    if n <= 0:
        raise InvalidInputError(f"WrappedSystem.run needs a positive count, got {n}.")

    self.reinit(ic)
    self._first_ic = self.state()
    # ``transient`` is in the wrapper's own horizon unit — iterations for a
    # map, TIME for a flow — the rule every built-in family follows.  It used
    # to be read as a sample count on both, so a continuous wrapper discarded
    # ``transient * dt`` time units while asking for ``transient`` of them,
    # and a float raised a bare ``TypeError`` from ``range()``.
    skip = resolve_transient(transient, discrete=self._is_discrete)
    n_skip = int(skip) if self._is_discrete else int(round(skip / step_dt))
    for _ in range(n_skip):
        self.step(step_dt)
    ts = np.empty(n)
    ys = np.empty((n, self.dim))
    for i in range(n):
        ys[i] = self.step(step_dt)
        ts[i] = self._t
    from tsdynamics import __version__

    meta = {
        "system": "WrappedSystem",
        "family": self._family,
        "params": {},
        "dt": step_dt,
        "ic": np.asarray(self._first_ic, dtype=float).tolist(),
        "transient": float(skip),
        "variables": tuple(self.variables),
        "tsdynamics": __version__,
    }
    return Trajectory(t=ts, y=ys, system=self, meta=meta)

DerivedSystem

DerivedSystem(system: Any)

Base for wrappers that present an existing system through a new lens.

A derived system implements the :class:~tsdynamics.families.System protocol by delegating to a wrapped system, transforming what "one step" or "the state" means (Poincaré crossings, stroboscopic samples, projections...).

Parameters and metadata are forwarded to the wrapped system, and with_params re-parametrizes the inner system and rebuilds the wrapper, so parameter sweeps compose: an orbit diagram over a PoincareMap is a bifurcation diagram of the underlying flow.

Source code in src/tsdynamics/derived/_base.py
def __init__(self, system: Any) -> None:
    self.system = system

dim property

dim: int

Dimension of the state this view hands you.

The inner system's, by default — a Poincaré crossing and a stroboscopic sample are both full states of the flow. The wrappers that genuinely change the state's width override it (:class:~tsdynamics.derived.ProjectedSystem returns the number of surviving components, :class:~tsdynamics.derived.TangentSystem the extended state's).

Documented because it was not: help(DerivedSystem.dim) printed three blank lines, and on an instance help(pmap.dim) resolved the value and printed int([x]) -> integer — the builtin's reference, about a different subject entirely (CONTRACT.md §11.6 defect 2).

params property

params: ParamSet

The inner system's parameters — an empty set when it declares none.

Blindly forwarding self.system.params made PoincareMap(wrapped) march correctly and then die with a raw AttributeError from this line, because a :class:~tsdynamics.families.WrappedSystem has no parameter set at all.

family property

family: str

The inner system's family word.

variables property

variables: tuple[str, ...]

The inner system's component names.

with_params

with_params(**overrides: Any) -> DerivedSystem

Return a new wrapper of the same kind around a re-parametrized copy.

Source code in src/tsdynamics/derived/_base.py
def with_params(self, **overrides: Any) -> DerivedSystem:
    """Return a new wrapper of the same kind around a re-parametrized copy."""
    return self._rebuild(self.system.with_params(**overrides))

copy

copy() -> DerivedSystem

Return a new wrapper of the same kind around a copy of the inner system.

Source code in src/tsdynamics/derived/_base.py
def copy(self) -> DerivedSystem:
    """Return a new wrapper of the same kind around a copy of the inner system."""
    return self._rebuild(self.system.copy())

state

state() -> ndarray

Return the current state, as this view reports it.

The four members below are the :class:~tsdynamics.families.System protocol, delegated to the inner system. Each wrapper overrides whichever of them its lens actually changes — a :class:~tsdynamics.derived.ProjectedSystem returns the projected columns, a :class:~tsdynamics.derived.TangentSystem the state ⊕ deviation vectors — so reading the protocol off the wrapper always answers in the wrapper's own coordinates.

RETURNS DESCRIPTION
ndarray

The current state vector. On a cold view this triggers an implicit :meth:reinit, exactly as it does on a bare system.

Source code in src/tsdynamics/derived/_base.py
def state(self) -> np.ndarray:
    """Return the current state, as **this view** reports it.

    The four members below are the :class:`~tsdynamics.families.System`
    protocol, delegated to the inner system.  Each wrapper overrides
    whichever of them its lens actually changes — a
    :class:`~tsdynamics.derived.ProjectedSystem` returns the projected
    columns, a :class:`~tsdynamics.derived.TangentSystem` the state ⊕
    deviation vectors — so reading the protocol off the wrapper always
    answers in the wrapper's own coordinates.

    Returns
    -------
    numpy.ndarray
        The current state vector.  On a cold view this triggers an implicit
        :meth:`reinit`, exactly as it does on a bare system.
    """
    return cast(np.ndarray, self.system.state())

set_state

set_state(u: Any) -> None

Overwrite the live state without restarting or resetting time.

A capability, not a protocol member: it left the System protocol in v6 because a :class:~tsdynamics.families.DelaySystem's state is a history function, not a point. Ask for it with hasattr before calling it on an arbitrary view.

PARAMETER DESCRIPTION
u

The new state, in the inner system's coordinates.

TYPE: array - like

See Also

reinit : restart from a state — the one that also resets time.

Source code in src/tsdynamics/derived/_base.py
def set_state(self, u: Any) -> None:
    """Overwrite the live state **without** restarting or resetting time.

    A *capability*, not a protocol member: it left the ``System`` protocol in
    v6 because a :class:`~tsdynamics.families.DelaySystem`'s state is a
    history function, not a point.  Ask for it with ``hasattr`` before
    calling it on an arbitrary view.

    Parameters
    ----------
    u : array-like
        The new state, in the inner system's coordinates.

    See Also
    --------
    reinit : restart from a state — the one that also resets time.
    """
    self.system.set_state(u)

time

time() -> float

Return the current time — iteration count for a discrete view.

RETURNS DESCRIPTION
float

Elapsed time of the inner system. Note that for a :class:~tsdynamics.derived.PoincareMap the inner clock runs in continuous time while step() counts crossings, so this is flow time, not a crossing index.

Source code in src/tsdynamics/derived/_base.py
def time(self) -> float:
    """Return the current time — iteration count for a discrete view.

    Returns
    -------
    float
        Elapsed time of the inner system.  Note that for a
        :class:`~tsdynamics.derived.PoincareMap` the *inner* clock runs in
        continuous time while ``step()`` counts crossings, so this is flow
        time, not a crossing index.
    """
    return cast(float, self.system.time())

reinit

reinit(u: Any | None = None, **kwargs: Any) -> None

Restart the view from state u (or from the system's own default).

The way to pick up a parameter change: a live stepper holds a lowered tape, so edits to params do not reach it until you reinitialise.

PARAMETER DESCRIPTION
u

Initial state. None re-resolves the inner system's own initial condition (its _default_ic, or a fresh random draw).

TYPE: array - like DEFAULT: None

**kwargs

Forwarded verbatim to the inner system's reinit — solver, rtol/atol, backend, dt, as that family accepts them.

TYPE: Any DEFAULT: {}

Source code in src/tsdynamics/derived/_base.py
def reinit(self, u: Any | None = None, **kwargs: Any) -> None:
    """Restart the view from state ``u`` (or from the system's own default).

    The way to pick up a parameter change: a live stepper holds a lowered
    tape, so edits to ``params`` do not reach it until you reinitialise.

    Parameters
    ----------
    u : array-like, optional
        Initial state.  ``None`` re-resolves the inner system's own initial
        condition (its ``_default_ic``, or a fresh random draw).
    **kwargs
        Forwarded verbatim to the inner system's ``reinit`` — ``solver``,
        ``rtol``/``atol``, ``backend``, ``dt``, as that family accepts them.
    """
    self.system.reinit(u, **kwargs)

run

run(*args: Any, **kwargs: Any) -> Trajectory

Produce this derived view's trajectory — always from a fresh start.

The one trajectory verb, on a wrapper too::

section = Rossler().poincare("y", 0.0).run(steps=500)

Before v6 a wrapper's run continued the live stepper, so pmap.run(steps=5) twice returned different data. One rule now, on every family and every wrapper: run() is a fresh run from ic, and step() is the one that continues.

Source code in src/tsdynamics/derived/_base.py
def run(self, *args: Any, **kwargs: Any) -> Trajectory:
    """Produce this derived view's trajectory — **always from a fresh start**.

    The one trajectory verb, on a wrapper too::

        section = Rossler().poincare("y", 0.0).run(steps=500)

    Before v6 a wrapper's ``run`` continued the live stepper, so
    ``pmap.run(steps=5)`` twice returned *different* data.  One rule now, on
    every family and every wrapper: ``run()`` is a fresh run from ``ic``, and
    ``step()`` is the one that continues.
    """
    raise NotImplementedError(
        f"{type(self).__name__} inherits run() but does not implement it. "
        "A wrapper that cannot produce a trajectory must bind "
        "tsdynamics.families.base.Absent instead, so hasattr() and "
        "isinstance(view, System) tell the truth."
    )

plot

plot(*transforms: Any, **kwargs: Any) -> PlotSpec

Draw this derived view.

All five wrappers used to answer hasattr(pm, "plot") -> False while the plot seam was True: the seam was there and the verb was not.

Source code in src/tsdynamics/derived/_base.py
def plot(self, *transforms: Any, **kwargs: Any) -> PlotSpec:
    """Draw this derived view.

    All five wrappers used to answer ``hasattr(pm, "plot") -> False`` while
    the plot seam was ``True``: the seam was there and the verb was not.
    """
    from tsdynamics.viz import plot as _plot

    return _plot(self, *transforms, **kwargs)