Skip to content

Reference

Transforms

The signal layer that turns a raw Trajectory (or a bare array) into analysable features: spectral estimators, preprocessing, and a feature bank. These feed the from-a-signal quantifiers documented in the Analysis section.

Every entry point is shape-preserving where it makes sense — pass a Trajectory in and the sampling interval is read from its metadata — and is re-exported from tsdynamics.transforms.

Spectral

power_spectral_density

power_spectral_density(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    method: str = "welch",
    window: str = "hann",
    nperseg: int | None = None,
    detrend: str | bool = "constant",
    scaling: str = "density",
) -> tuple[ndarray, ndarray]

One-sided power spectral density of a signal.

PARAMETER DESCRIPTION
data

Signal with time along axis 0; a multi-channel (T, channels) signal is transformed channel-by-channel.

TYPE: Trajectory or array - like

fs

Sampling frequency or spacing. At most one; if neither is given and data is a :class:~tsdynamics.data.Trajectory, the rate is read off its time vector, otherwise fs = 1.0.

TYPE: float DEFAULT: None

dt

Sampling frequency or spacing. At most one; if neither is given and data is a :class:~tsdynamics.data.Trajectory, the rate is read off its time vector, otherwise fs = 1.0.

TYPE: float DEFAULT: None

method

Welch's averaged-periodogram estimator (lower variance) or a single raw periodogram (full frequency resolution, higher variance).

TYPE: ('welch', 'periodogram') DEFAULT: "welch"

window

Window passed to the estimator (any :func:scipy.signal.get_window name).

TYPE: str DEFAULT: "hann"

nperseg

Welch segment length. Defaults to min(256, n_samples) so short signals do not trigger a segment-length warning. Ignored by method="periodogram".

TYPE: int DEFAULT: None

detrend

Per-segment detrending ("constant" removes the mean, "linear" a least-squares line, False disables it).

TYPE: str or False DEFAULT: "constant"

scaling

"density" gives a PSD (units²/Hz), "spectrum" a power spectrum (units²).

TYPE: ('density', 'spectrum') DEFAULT: "density"

RETURNS DESCRIPTION
freqs

Frequency bins, in the same units as fs, from 0 (DC) to the Nyquist frequency fs / 2.

TYPE: (ndarray, shape(n_freqs))

psd

Power at each frequency, one column per channel.

TYPE: (ndarray, shape(n_freqs) or (n_freqs, channels))

Notes

The estimate is one-sided: for a real-valued signal the power of each pair of conjugate (positive/negative) frequencies is folded onto the single positive bin, so every interior bin's power is doubled relative to the two-sided spectrum. The DC (0) and — when present — Nyquist (fs / 2) bins have no conjugate twin and are not doubled. This is SciPy's default return_onesided=True convention, so a sinusoid of amplitude A carries total power A**2 / 2 (its time-average) at its line. With scaling="density" the value is a power spectral density (units²/Hz, so integrating over frequency recovers the signal variance); with scaling="spectrum" it is a power spectrum (units², so summing over bins recovers the variance).

Examples:

>>> import numpy as np
>>> t = np.linspace(0, 10, 2000, endpoint=False)
>>> f, p = power_spectral_density(np.sin(2 * np.pi * 5 * t), fs=200.0)
>>> round(float(f[np.argmax(p)]))    # dominant line at 5 Hz
5
Source code in src/tsdynamics/transforms/spectral.py
def power_spectral_density(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    method: str = "welch",
    window: str = "hann",
    nperseg: int | None = None,
    detrend: str | bool = "constant",
    scaling: str = "density",
) -> tuple[np.ndarray, np.ndarray]:
    """
    One-sided power spectral density of a signal.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0; a multi-channel ``(T, channels)`` signal
        is transformed channel-by-channel.
    fs, dt : float, optional
        Sampling frequency or spacing.  At most one; if neither is given and
        ``data`` is a :class:`~tsdynamics.data.Trajectory`, the rate is read off
        its time vector, otherwise ``fs = 1.0``.
    method : {"welch", "periodogram"}, default "welch"
        Welch's averaged-periodogram estimator (lower variance) or a single raw
        periodogram (full frequency resolution, higher variance).
    window : str, default "hann"
        Window passed to the estimator (any :func:`scipy.signal.get_window` name).
    nperseg : int, optional
        Welch segment length.  Defaults to ``min(256, n_samples)`` so short
        signals do not trigger a segment-length warning.  Ignored by
        ``method="periodogram"``.
    detrend : str or False, default "constant"
        Per-segment detrending (``"constant"`` removes the mean, ``"linear"`` a
        least-squares line, ``False`` disables it).
    scaling : {"density", "spectrum"}, default "density"
        ``"density"`` gives a PSD (units²/Hz), ``"spectrum"`` a power spectrum
        (units²).

    Returns
    -------
    freqs : ndarray, shape (n_freqs,)
        Frequency bins, in the same units as ``fs``, from ``0`` (DC) to the
        Nyquist frequency ``fs / 2``.
    psd : ndarray, shape (n_freqs,) or (n_freqs, channels)
        Power at each frequency, one column per channel.

    Notes
    -----
    The estimate is **one-sided**: for a real-valued signal the power of each
    pair of conjugate (positive/negative) frequencies is folded onto the single
    positive bin, so every interior bin's power is doubled relative to the
    two-sided spectrum.  The DC (``0``) and — when present — Nyquist (``fs / 2``)
    bins have no conjugate twin and are *not* doubled.  This is SciPy's default
    ``return_onesided=True`` convention, so a sinusoid of amplitude ``A`` carries
    total power ``A**2 / 2`` (its time-average) at its line.  With
    ``scaling="density"`` the value is a power *spectral density* (units²/Hz, so
    integrating over frequency recovers the signal variance); with
    ``scaling="spectrum"`` it is a power *spectrum* (units², so summing over bins
    recovers the variance).

    Examples
    --------
    >>> import numpy as np
    >>> t = np.linspace(0, 10, 2000, endpoint=False)
    >>> f, p = power_spectral_density(np.sin(2 * np.pi * 5 * t), fs=200.0)
    >>> round(float(f[np.argmax(p)]))    # dominant line at 5 Hz
    5
    """
    sig = to_signal(data)
    rate = resolve_fs(data, fs=fs, dt=dt)
    n = sig.shape[0]

    if method == "welch":
        seg = min(256, n) if nperseg is None else int(nperseg)
        freqs, psd = _sig.welch(
            sig,
            fs=rate,
            window=window,
            nperseg=seg,
            detrend=detrend,
            scaling=scaling,
            axis=0,
        )
    elif method == "periodogram":
        freqs, psd = _sig.periodogram(
            sig,
            fs=rate,
            window=window,
            detrend=detrend,
            scaling=scaling,
            axis=0,
        )
    else:
        raise ValueError(f"unknown PSD method {method!r}; use 'welch' or 'periodogram'.")

    return freqs, psd

spectral_entropy

spectral_entropy(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    normalize: bool = True,
    base: float = 2.0,
    **psd_kwargs: Any,
) -> Any

Shannon entropy of the normalised power spectrum (Powell & Percival 1979).

The PSD is normalised to a probability distribution over frequency, p_i = P_i / Σ P_i, and its Shannon entropy H = -Σ p_i log_base p_i is returned. With normalize=True the result is divided by log_base(n_freqs) so it lands in [0, 1]: 0 for a pure tone (all power in one bin), 1 for a flat (white) spectrum. A useful regular-vs-chaotic discriminator.

PARAMETER DESCRIPTION
data

Signal with time along axis 0.

TYPE: Trajectory or array - like

fs

Sampling frequency / spacing (see :func:power_spectral_density).

TYPE: float DEFAULT: None

dt

Sampling frequency / spacing (see :func:power_spectral_density).

TYPE: float DEFAULT: None

normalize

Divide by log_base(n_freqs) to rescale onto [0, 1].

TYPE: bool DEFAULT: True

base

Logarithm base (2 → bits). Only affects the result when normalize=False: the normalised spectral entropy divides by log_base(n_freqs), cancelling the base, so it is base-independent.

TYPE: float DEFAULT: 2.0

**psd_kwargs

Forwarded to :func:power_spectral_density (method, window, ...).

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
float or ndarray

Spectral entropy; scalar for a single channel, (channels,) otherwise.

Source code in src/tsdynamics/transforms/spectral.py
def spectral_entropy(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    normalize: bool = True,
    base: float = 2.0,
    **psd_kwargs: Any,
) -> Any:
    """
    Shannon entropy of the normalised power spectrum (Powell & Percival 1979).

    The PSD is normalised to a probability distribution over frequency,
    ``p_i = P_i / Σ P_i``, and its Shannon entropy ``H = -Σ p_i log_base p_i`` is
    returned.  With ``normalize=True`` the result is divided by ``log_base(n_freqs)``
    so it lands in ``[0, 1]``: ``0`` for a pure tone (all power in one bin), ``1``
    for a flat (white) spectrum.  A useful regular-vs-chaotic discriminator.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0.
    fs, dt : float, optional
        Sampling frequency / spacing (see :func:`power_spectral_density`).
    normalize : bool, default True
        Divide by ``log_base(n_freqs)`` to rescale onto ``[0, 1]``.
    base : float, default 2.0
        Logarithm base (2 → bits).  Only affects the result when
        ``normalize=False``: the normalised spectral entropy divides by
        ``log_base(n_freqs)``, cancelling the base, so it is base-independent.
    **psd_kwargs
        Forwarded to :func:`power_spectral_density` (``method``, ``window``, ...).

    Returns
    -------
    float or ndarray
        Spectral entropy; scalar for a single channel, ``(channels,)`` otherwise.
    """
    _, psd = power_spectral_density(data, fs=fs, dt=dt, **psd_kwargs)
    return _spectral_entropy_of(psd, normalize=normalize, base=base)

spectral_centroid

spectral_centroid(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    **psd_kwargs: Any,
) -> Any

Power-weighted mean frequency (the spectral "centre of mass").

centroid = Σ f_i P_i / Σ P_i — the frequency about which the spectrum is balanced.

PARAMETER DESCRIPTION
data

Signal with time along axis 0.

TYPE: Trajectory or array - like

fs

Sampling frequency / spacing (see :func:power_spectral_density).

TYPE: float DEFAULT: None

dt

Sampling frequency / spacing (see :func:power_spectral_density).

TYPE: float DEFAULT: None

**psd_kwargs

Forwarded to :func:power_spectral_density.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
float or ndarray

Centroid frequency; scalar for a single channel, (channels,) otherwise.

Source code in src/tsdynamics/transforms/spectral.py
def spectral_centroid(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    **psd_kwargs: Any,
) -> Any:
    """
    Power-weighted mean frequency (the spectral "centre of mass").

    ``centroid = Σ f_i P_i / Σ P_i`` — the frequency about which the spectrum is
    balanced.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0.
    fs, dt : float, optional
        Sampling frequency / spacing (see :func:`power_spectral_density`).
    **psd_kwargs
        Forwarded to :func:`power_spectral_density`.

    Returns
    -------
    float or ndarray
        Centroid frequency; scalar for a single channel, ``(channels,)`` otherwise.
    """
    freqs, psd = power_spectral_density(data, fs=fs, dt=dt, **psd_kwargs)
    return _spectral_centroid_of(freqs, psd)

dominant_frequency

dominant_frequency(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    exclude_dc: bool = True,
    **psd_kwargs: Any,
) -> Any

Frequency carrying the most power.

PARAMETER DESCRIPTION
data

Signal with time along axis 0.

TYPE: Trajectory or array - like

fs

Sampling frequency / spacing (see :func:power_spectral_density).

TYPE: float DEFAULT: None

dt

Sampling frequency / spacing (see :func:power_spectral_density).

TYPE: float DEFAULT: None

exclude_dc

Ignore the zero-frequency bin so a non-zero mean does not masquerade as the dominant component.

TYPE: bool DEFAULT: True

**psd_kwargs

Forwarded to :func:power_spectral_density.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
float or ndarray

Peak frequency; scalar for a single channel, (channels,) otherwise.

Source code in src/tsdynamics/transforms/spectral.py
def dominant_frequency(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    exclude_dc: bool = True,
    **psd_kwargs: Any,
) -> Any:
    """
    Frequency carrying the most power.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0.
    fs, dt : float, optional
        Sampling frequency / spacing (see :func:`power_spectral_density`).
    exclude_dc : bool, default True
        Ignore the zero-frequency bin so a non-zero mean does not masquerade as
        the dominant component.
    **psd_kwargs
        Forwarded to :func:`power_spectral_density`.

    Returns
    -------
    float or ndarray
        Peak frequency; scalar for a single channel, ``(channels,)`` otherwise.
    """
    freqs, psd = power_spectral_density(data, fs=fs, dt=dt, **psd_kwargs)
    return _dominant_frequency_of(freqs, psd, exclude_dc=exclude_dc)

Preprocessing

detrend

detrend(data: Any, *, method: str = 'linear') -> Any

Remove a constant or linear trend from each channel.

PARAMETER DESCRIPTION
data

Signal with time along axis 0.

TYPE: Trajectory or array - like

method

"linear" subtracts a least-squares straight line; "constant" subtracts the mean.

TYPE: ('linear', 'constant') DEFAULT: "linear"

RETURNS DESCRIPTION
Trajectory or ndarray

Same type and shape as data, detrended.

Source code in src/tsdynamics/transforms/preprocessing.py
def detrend(data: Any, *, method: str = "linear") -> Any:
    """
    Remove a constant or linear trend from each channel.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0.
    method : {"linear", "constant"}, default "linear"
        ``"linear"`` subtracts a least-squares straight line; ``"constant"``
        subtracts the mean.

    Returns
    -------
    Trajectory or ndarray
        Same type and shape as ``data``, detrended.
    """
    if method not in ("linear", "constant"):
        raise ValueError(f"detrend method must be 'linear' or 'constant', got {method!r}.")
    sig = to_signal(data)
    out = _sig.detrend(sig, axis=0, type=method)
    return wrap_like(data, out, detrended=method)

normalize

normalize(data: Any, *, method: str = 'zscore') -> Any

Rescale each channel by a per-channel statistic.

PARAMETER DESCRIPTION
data

Signal with time along axis 0.

TYPE: Trajectory or array - like

method
  • "zscore" — subtract the mean, divide by the standard deviation.
  • "minmax" — affinely map each channel onto [0, 1].
  • "l2" — divide by the Euclidean norm.
  • "demean" — subtract the mean only.

Channels with zero spread (constant signals) are passed through their location shift unscaled rather than producing inf/nan.

TYPE: ('zscore', 'minmax', 'l2', 'demean') DEFAULT: "zscore"

RETURNS DESCRIPTION
Trajectory or ndarray

Same type and shape as data, normalised.

Source code in src/tsdynamics/transforms/preprocessing.py
def normalize(data: Any, *, method: str = "zscore") -> Any:
    """
    Rescale each channel by a per-channel statistic.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0.
    method : {"zscore", "minmax", "l2", "demean"}, default "zscore"
        - ``"zscore"`` — subtract the mean, divide by the standard deviation.
        - ``"minmax"`` — affinely map each channel onto ``[0, 1]``.
        - ``"l2"`` — divide by the Euclidean norm.
        - ``"demean"`` — subtract the mean only.

        Channels with zero spread (constant signals) are passed through their
        location shift unscaled rather than producing ``inf``/``nan``.

    Returns
    -------
    Trajectory or ndarray
        Same type and shape as ``data``, normalised.
    """
    sig = to_signal(data)
    if method == "zscore":
        loc = sig.mean(axis=0)
        scale = sig.std(axis=0)
    elif method == "minmax":
        loc = sig.min(axis=0)
        scale = sig.max(axis=0) - loc
    elif method == "l2":
        loc = 0.0
        scale = np.sqrt((sig**2).sum(axis=0))
    elif method == "demean":
        loc = sig.mean(axis=0)
        scale = 1.0
    else:
        raise ValueError(
            f"unknown normalize method {method!r}; use 'zscore', 'minmax', 'l2', or 'demean'."
        )
    scale = np.where(np.abs(scale) < _TINY, 1.0, scale)
    out = (sig - loc) / scale
    return wrap_like(data, out, normalized=method)

butter_filter

butter_filter(
    x: Any,
    cutoff: float | tuple[float, float] | Any,
    *,
    btype: str,
    fs: float | None = None,
    dt: float | None = None,
    order: int = 4,
) -> Any

Zero-phase Butterworth filter.

Designs a Butterworth filter of the given order and applies it forward and backward (zero phase) as second-order sections.

PARAMETER DESCRIPTION
x

Signal with time along axis 0.

TYPE: Trajectory or array - like

cutoff

Cutoff frequency in the same units as fs; a scalar for "lowpass" / "highpass", a (low, high) pair for "bandpass" / "bandstop".

TYPE: float or (float, float)

btype

Filter band type.

TYPE: ('lowpass', 'highpass', 'bandpass', 'bandstop') DEFAULT: "lowpass"

fs

Sampling frequency / spacing (see :func:tsdynamics.transforms.power_spectral_density).

TYPE: float DEFAULT: None

dt

Sampling frequency / spacing (see :func:tsdynamics.transforms.power_spectral_density).

TYPE: float DEFAULT: None

order

Butterworth order (per band edge). Must be a positive integer.

TYPE: int DEFAULT: 4

RETURNS DESCRIPTION
Trajectory or ndarray

Same type and shape as x, filtered.

RAISES DESCRIPTION
InvalidParameterError

If order is not at least 1 (a :class:ValueError).

ValueError

If cutoff does not lie strictly between 0 and the Nyquist frequency, or the number of cutoff edges does not match btype.

Notes

Zero-phase filtering doubles the effective order and needs the signal to be longer than the filter's padding (a few times order); very short signals raise from :func:scipy.signal.sosfiltfilt.

References

Butterworth, S. (1930). On the theory of filter amplifiers. Experimental Wireless and the Wireless Engineer, 7, 536-541.

Source code in src/tsdynamics/transforms/preprocessing.py
def butter_filter(
    x: Any,
    cutoff: float | tuple[float, float] | Any,
    *,
    btype: str,
    fs: float | None = None,
    dt: float | None = None,
    order: int = 4,
) -> Any:
    """
    Zero-phase Butterworth filter.

    Designs a Butterworth filter of the given order and applies it forward and
    backward (zero phase) as second-order sections.

    Parameters
    ----------
    x : Trajectory or array-like
        Signal with time along axis 0.
    cutoff : float or (float, float)
        Cutoff frequency in the same units as ``fs``; a scalar for ``"lowpass"``
        / ``"highpass"``, a ``(low, high)`` pair for ``"bandpass"`` / ``"bandstop"``.
    btype : {"lowpass", "highpass", "bandpass", "bandstop"}
        Filter band type.
    fs, dt : float, optional
        Sampling frequency / spacing (see :func:`tsdynamics.transforms.power_spectral_density`).
    order : int, default 4
        Butterworth order (per band edge).  Must be a positive integer.

    Returns
    -------
    Trajectory or ndarray
        Same type and shape as ``x``, filtered.

    Raises
    ------
    InvalidParameterError
        If ``order`` is not at least 1 (a :class:`ValueError`).
    ValueError
        If ``cutoff`` does not lie strictly between 0 and the Nyquist frequency,
        or the number of cutoff edges does not match ``btype``.

    Notes
    -----
    Zero-phase filtering doubles the effective order and needs the signal to be
    longer than the filter's padding (a few times ``order``); very short signals
    raise from :func:`scipy.signal.sosfiltfilt`.

    References
    ----------
    Butterworth, S. (1930). On the theory of filter amplifiers.
    *Experimental Wireless and the Wireless Engineer*, 7, 536-541.
    """
    if order < 1:
        raise invalid_value("order", order, rule="must be a positive integer (>= 1)")
    rate = resolve_fs(x, fs=fs, dt=dt)
    nyquist = 0.5 * rate
    edges = np.atleast_1d(np.asarray(cutoff, dtype=float))
    if np.any(edges <= 0.0) or np.any(edges >= nyquist):
        raise ValueError(
            f"cutoff {cutoff!r} must lie strictly between 0 and the Nyquist "
            f"frequency ({nyquist:g}); reduce the cutoff or raise fs."
        )
    if btype in ("bandpass", "bandstop") and edges.size != 2:
        raise ValueError(f"{btype} needs a (low, high) cutoff pair, got {cutoff!r}.")
    if btype in ("lowpass", "highpass") and edges.size != 1:
        raise ValueError(f"{btype} needs a single scalar cutoff, got {cutoff!r}.")

    sig = to_signal(x)
    # Cache the design on the four scalars it depends on.  ``edges`` is the
    # float view of ``cutoff`` already built for validation; for a scalar cutoff
    # SciPy sees a Python float (a 0-d ndarray would broadcast the same Wn), and
    # for a pair it sees ``list((low, high))`` — both byte-identical designs to
    # passing the raw ``cutoff``.
    cutoff_key: float | tuple[float, ...] = (
        float(edges[0]) if edges.size == 1 else tuple(edges.tolist())
    )
    # sosfiltfilt needs a writeable SOS buffer; the cached design is read-only,
    # so hand it a fresh copy (a tiny (n_sections, 6) array — negligible vs the
    # pole/zero placement we just skipped).
    sos = _design_butter_sos(order, cutoff_key, btype, float(rate)).copy()
    out = _sig.sosfiltfilt(sos, sig, axis=0)
    return wrap_like(x, out, filtered={"btype": btype, "cutoff": cutoff, "order": order})

lowpass

lowpass(data: Any, cutoff: float, **kwargs: Any) -> Any

Zero-phase Butterworth low-pass filter (see :func:butter_filter).

Source code in src/tsdynamics/transforms/preprocessing.py
def lowpass(data: Any, cutoff: float, **kwargs: Any) -> Any:
    """Zero-phase Butterworth low-pass filter (see :func:`butter_filter`)."""
    return butter_filter(data, cutoff, btype="lowpass", **kwargs)

highpass

highpass(data: Any, cutoff: float, **kwargs: Any) -> Any

Zero-phase Butterworth high-pass filter (see :func:butter_filter).

Source code in src/tsdynamics/transforms/preprocessing.py
def highpass(data: Any, cutoff: float, **kwargs: Any) -> Any:
    """Zero-phase Butterworth high-pass filter (see :func:`butter_filter`)."""
    return butter_filter(data, cutoff, btype="highpass", **kwargs)

bandpass

bandpass(
    data: Any, low: float, high: float, **kwargs: Any
) -> Any

Zero-phase Butterworth band-pass filter (see :func:butter_filter).

Source code in src/tsdynamics/transforms/preprocessing.py
def bandpass(data: Any, low: float, high: float, **kwargs: Any) -> Any:
    """Zero-phase Butterworth band-pass filter (see :func:`butter_filter`)."""
    return butter_filter(data, (low, high), btype="bandpass", **kwargs)

bandstop

bandstop(
    data: Any, low: float, high: float, **kwargs: Any
) -> Any

Zero-phase Butterworth band-stop filter (see :func:butter_filter).

Source code in src/tsdynamics/transforms/preprocessing.py
def bandstop(data: Any, low: float, high: float, **kwargs: Any) -> Any:
    """Zero-phase Butterworth band-stop filter (see :func:`butter_filter`)."""
    return butter_filter(data, (low, high), btype="bandstop", **kwargs)

Feature extraction

The FEATURE_FUNCTIONS registry maps a feature name to its estimator; extract_features evaluates a selection of them into a flat dict, and feature_names lists what is available.

extract_features

extract_features(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    features: Sequence[str] | None = None,
) -> dict[str, Any]

Compute a named bag of scalar features for each channel of a signal.

PARAMETER DESCRIPTION
data

Signal with time along axis 0; a multi-channel (T, channels) signal is summarised channel-by-channel.

TYPE: Trajectory or array - like

fs

Sampling frequency / spacing, needed by the frequency-domain features (see :func:tsdynamics.transforms.power_spectral_density).

TYPE: float DEFAULT: None

dt

Sampling frequency / spacing, needed by the frequency-domain features (see :func:tsdynamics.transforms.power_spectral_density).

TYPE: float DEFAULT: None

features

Which features to compute (names from :data:FEATURE_FUNCTIONS). Defaults to all of them, in catalogue order.

TYPE: sequence of str DEFAULT: None

RETURNS DESCRIPTION
dict

Maps each requested feature name to its value: a float for a single channel, a (channels,) ndarray for a multi-channel signal.

Examples:

>>> import numpy as np
>>> feats = extract_features(np.random.default_rng(0).standard_normal(2000))
>>> round(feats["mean"], 1)
0.0
Source code in src/tsdynamics/transforms/features.py
def extract_features(
    data: Any,
    *,
    fs: float | None = None,
    dt: float | None = None,
    features: Sequence[str] | None = None,
) -> dict[str, Any]:
    """
    Compute a named bag of scalar features for each channel of a signal.

    Parameters
    ----------
    data : Trajectory or array-like
        Signal with time along axis 0; a multi-channel ``(T, channels)`` signal
        is summarised channel-by-channel.
    fs, dt : float, optional
        Sampling frequency / spacing, needed by the frequency-domain features
        (see :func:`tsdynamics.transforms.power_spectral_density`).
    features : sequence of str, optional
        Which features to compute (names from :data:`FEATURE_FUNCTIONS`).
        Defaults to all of them, in catalogue order.

    Returns
    -------
    dict
        Maps each requested feature name to its value: a ``float`` for a single
        channel, a ``(channels,)`` ``ndarray`` for a multi-channel signal.

    Examples
    --------
    >>> import numpy as np
    >>> feats = extract_features(np.random.default_rng(0).standard_normal(2000))
    >>> round(feats["mean"], 1)
    0.0
    """
    sig = to_signal(data)
    rate = resolve_fs(data, fs=fs, dt=dt)
    names = feature_names() if features is None else list(features)

    # Validate every requested name up front (preserving the original error and
    # available-list) so the shared-PSD fast path below never half-fills ``out``.
    for name in names:
        if name not in FEATURE_FUNCTIONS:
            raise KeyError(f"unknown feature {name!r}; available: {feature_names()}.")

    # Compute the per-channel PSD ONCE and feed it to every requested spectral
    # reduction, instead of each of dominant_frequency / spectral_centroid /
    # spectral_entropy recomputing the same Welch spectrum (up to a 3× saving on
    # the dominant cost of this routine).  The PSD here matches each spectral
    # core's bare ``f(col, fs)`` call exactly (default method/window/etc.).
    psd_names = [name for name in names if name in _PSD_FEATURES]
    psd_values: dict[str, Any] = {}
    if psd_names:
        per_channel: dict[str, list[float]] = {name: [] for name in psd_names}
        for _, col in channel_iter(sig):
            freqs, psd = power_spectral_density(col, fs=rate)
            for name in psd_names:
                per_channel[name].append(_psd_feature_of(name, freqs, psd))
        for name in psd_names:
            vals = per_channel[name]
            psd_values[name] = vals[0] if sig.ndim == 1 else np.asarray(vals, dtype=float)

    out: dict[str, Any] = {}
    for name in names:
        if name in psd_values:
            out[name] = psd_values[name]
        else:
            out[name] = _per_channel(sig, FEATURE_FUNCTIONS[name], rate)
    return out

feature_names

feature_names() -> list[str]

Return the names of every catalogued feature, in default column order.

Source code in src/tsdynamics/transforms/features.py
def feature_names() -> list[str]:
    """Return the names of every catalogued feature, in default column order."""
    return list(FEATURE_FUNCTIONS)

hjorth_parameters

hjorth_parameters(x: Any) -> dict[str, Any]

Hjorth activity, mobility and complexity per channel (Hjorth 1970).

The three descriptors are built from the variances of the signal x and its first / second discrete differences (x', x''):

  • activityvar(x), the signal's power.
  • mobilitysqrt(var(x') / var(x)), the standard deviation of the slope relative to that of the amplitude; a proxy for the mean frequency.
  • complexitymobility(x') / mobility(x), i.e. sqrt(var(x'') / var(x')) / sqrt(var(x') / var(x)); how much the signal's frequency content departs from a pure tone. It is 1 for an ideal sinusoid and grows as the spectrum broadens.

The differences are taken with :func:numpy.diff (unit sample spacing), so mobility is in cycles per sample. A constant channel (zero variance) has no shape and returns 0 for all three rather than dividing by zero.

PARAMETER DESCRIPTION
x

Signal with time along axis 0.

TYPE: Trajectory or array - like

RETURNS DESCRIPTION
dict

Keys "activity", "mobility", "complexity"; each value is a float for a single channel or a (channels,) array otherwise.

References

Hjorth, B. (1970). EEG analysis based on time domain properties. Electroencephalography and Clinical Neurophysiology, 29(3), 306-310.

Examples:

>>> import numpy as np
>>> t = np.linspace(0, 10, 4000, endpoint=False)
>>> round(hjorth_parameters(np.sin(2 * np.pi * 3 * t))["complexity"], 2)
1.0
Source code in src/tsdynamics/transforms/features.py
def hjorth_parameters(x: Any) -> dict[str, Any]:
    """
    Hjorth activity, mobility and complexity per channel (Hjorth 1970).

    The three descriptors are built from the variances of the signal ``x`` and
    its first / second discrete differences (``x'``, ``x''``):

    - **activity** — ``var(x)``, the signal's power.
    - **mobility** — ``sqrt(var(x') / var(x))``, the standard deviation of the
      slope relative to that of the amplitude; a proxy for the mean frequency.
    - **complexity** — ``mobility(x') / mobility(x)``, i.e.
      ``sqrt(var(x'') / var(x')) / sqrt(var(x') / var(x))``; how much the signal's
      frequency content departs from a pure tone.  It is ``1`` for an ideal
      sinusoid and grows as the spectrum broadens.

    The differences are taken with :func:`numpy.diff` (unit sample spacing), so
    mobility is in cycles per sample.  A constant channel (zero variance) has no
    shape and returns ``0`` for all three rather than dividing by zero.

    Parameters
    ----------
    x : Trajectory or array-like
        Signal with time along axis 0.

    Returns
    -------
    dict
        Keys ``"activity"``, ``"mobility"``, ``"complexity"``; each value is a
        ``float`` for a single channel or a ``(channels,)`` array otherwise.

    References
    ----------
    Hjorth, B. (1970). EEG analysis based on time domain properties.
    *Electroencephalography and Clinical Neurophysiology*, 29(3), 306-310.

    Examples
    --------
    >>> import numpy as np
    >>> t = np.linspace(0, 10, 4000, endpoint=False)
    >>> round(hjorth_parameters(np.sin(2 * np.pi * 3 * t))["complexity"], 2)
    1.0
    """
    sig = to_signal(x)
    triples = [_hjorth_triple(col) for _, col in channel_iter(sig)]
    keys = ("activity", "mobility", "complexity")
    if sig.ndim == 1:
        return dict(zip(keys, triples[0], strict=True))
    cols = np.asarray(triples, dtype=float)  # (channels, 3)
    return {k: cols[:, i] for i, k in enumerate(keys)}

zero_crossing_rate

zero_crossing_rate(x: Any) -> Any

Fraction of adjacent samples that change sign, per channel.

A crossing is a flip of the < 0 / >= 0 partition between consecutive samples; the rate is the crossing count divided by n_samples - 1, so it lies in [0, 1]. For a clean tone of frequency f sampled at fs it is approximately 2 f / fs. Signed zeros are normalised (-0.0 is treated as +0.0) so a sample grazing zero cannot fabricate a crossing.

PARAMETER DESCRIPTION
x

Signal with time along axis 0.

TYPE: Trajectory or array - like

RETURNS DESCRIPTION
float or ndarray

Scalar for a single channel, (channels,) otherwise.

Examples:

>>> import numpy as np
>>> t = np.linspace(0, 1, 200, endpoint=False)
>>> round(float(zero_crossing_rate(np.sin(2 * np.pi * 5 * t))), 2)
0.05
Source code in src/tsdynamics/transforms/features.py
def zero_crossing_rate(x: Any) -> Any:
    """
    Fraction of adjacent samples that change sign, per channel.

    A crossing is a flip of the ``< 0`` / ``>= 0`` partition between consecutive
    samples; the rate is the crossing count divided by ``n_samples - 1``, so it
    lies in ``[0, 1]``.  For a clean tone of frequency ``f`` sampled at ``fs`` it
    is approximately ``2 f / fs``.  Signed zeros are normalised (``-0.0`` is
    treated as ``+0.0``) so a sample grazing zero cannot fabricate a crossing.

    Parameters
    ----------
    x : Trajectory or array-like
        Signal with time along axis 0.

    Returns
    -------
    float or ndarray
        Scalar for a single channel, ``(channels,)`` otherwise.

    Examples
    --------
    >>> import numpy as np
    >>> t = np.linspace(0, 1, 200, endpoint=False)
    >>> round(float(zero_crossing_rate(np.sin(2 * np.pi * 5 * t))), 2)
    0.05
    """
    sig = to_signal(x)
    return _per_channel(sig, _zcr, 1.0)