import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ..signal import signal_detrend, signal_psd
[docs]
def fractal_psdslope(signal, method="voss1988", show=False, **kwargs):
    """**Fractal dimension via Power Spectral Density (PSD) slope**
    Fractal exponent can be computed from Power Spectral Density slope (PSDslope) analysis in
    signals characterized by a frequency power-law dependence.
    It first transforms the time series into the frequency domain, and breaks down the signal into
    sine and cosine waves of a particular amplitude that together "add-up" to represent the
    original signal.
    If there is a systematic relationship between the frequencies in the signal and the power of
    those frequencies, this will reveal itself in log-log coordinates as a linear relationship. The
    slope of the best fitting line is taken as an estimate of the fractal scaling exponent and can
    be converted to an estimate of the fractal dimension.
    A slope of 0 is consistent with white noise, and a slope of less than 0 but greater than -1,
    is consistent with pink noise i.e., 1/f noise. Spectral slopes as steep as -2 indicate
    fractional Brownian motion, the epitome of random walk processes.
    Parameters
    ----------
    signal : Union[list, np.array, pd.Series]
        The signal (i.e., a time series) in the form of a vector of values.
    method : str
        Method to estimate the fractal dimension from the slope,
        can be ``"voss1988"`` (default) or ``"hasselman2013"``.
    show : bool
        If True, returns the log-log plot of PSD versus frequency.
    **kwargs
        Other arguments to be passed to ``signal_psd()`` (such as ``method``).
    Returns
    ----------
    slope : float
        Estimate of the fractal dimension obtained from PSD slope analysis.
    info : dict
        A dictionary containing additional information regarding the parameters used
        to perform PSD slope analysis.
    Examples
    ----------
    .. ipython:: python
      import neurokit2 as nk
      # Simulate a Signal with Laplace Noise
      signal = nk.signal_simulate(duration=2, sampling_rate=200, frequency=[5, 6], noise=0.5)
      # Compute the Fractal Dimension from PSD slope
      @savefig p_fractal_psdslope1.png scale=100%
      psdslope, info = nk.fractal_psdslope(signal, show=True)
      @suppress
      plt.close()
    .. ipython:: python
      psdslope
    References
    ----------
    * https://complexity-methods.github.io/book/power-spectral-density-psd-slope.html
    * Hasselman, F. (2013). When the blind curve is finite: dimension estimation and model
      inference based on empirical waveforms. Frontiers in Physiology, 4, 75. https://doi.org/10.3389/fphys.2013.00075
    * Voss, R. F. (1988). Fractals in nature: From characterization to simulation. The Science of
      Fractal Images, 21-70.
    * Eke, A., Hermán, P., Kocsis, L., and Kozak, L. R. (2002). Fractal characterization of
      complexity in temporal physiological signals. Physiol. Meas. 23, 1-38.
    """
    # Sanity checks
    if isinstance(signal, (np.ndarray, pd.DataFrame)) and signal.ndim > 1:
        raise ValueError(
            "Multidimensional inputs (e.g., matrices or multichannel data) are not supported yet."
        )
    # Translated from https://github.com/FredHasselman/casnet/blob/master/R/fd.R
    # Detrend
    signal = signal_detrend(signal)
    # Standardise using N instead of N-1
    signal = (signal - np.nanmean(signal)) / np.nanstd(signal)
    # Get psd with fourier transform
    psd = signal_psd(signal, sampling_rate=1000, method="fft", show=False, **kwargs)
    psd = psd[psd["Frequency"] < psd.quantile(0.25).iloc[0]]
    psd = psd[psd["Frequency"] > 0]
    # Get slope
    slope, intercept = np.polyfit(np.log10(psd["Frequency"]), np.log10(psd["Power"]), 1)
    # "Check the global slope for anti-persistent noise (GT +0.20) and fit the line starting from
    # the highest frequency" in FredHasselman/casnet.
    # Not sure about that, commenting it out for now.
    # if slope > 0.2:
    #     slope, intercept = np.polyfit(np.log10(np.flip(psd["Frequency"])), np.log10(np.flip(psd["Power"])), 1)
    # Sanitize method name
    method = method.lower()
    if method in ["voss", "voss1988"]:
        fd = (5 - slope) / 2
    elif method in ["hasselman", "hasselman2013"]:
        # Convert from periodogram based self-affinity parameter estimate (`sa`) to an informed
        # estimate of fd
        fd = 3 / 2 + ((14 / 33) * np.tanh(slope * np.log(1 + np.sqrt(2))))
    if show:
        _fractal_psdslope_plot(psd["Frequency"], psd["Power"], slope, intercept, fd, ax=None)
    return fd, {"Slope": slope, "Method": method} 
# =============================================================================
# Plotting
# =============================================================================
def _fractal_psdslope_plot(frequency, psd, slope, intercept, fd, ax=None):
    if ax is None:
        fig, ax = plt.subplots()
        fig.suptitle(
            "Power Spectral Density (PSD) slope analysis" + ", slope = " + str(np.round(slope, 2))
        )
    else:
        fig = None
        ax.set_title(
            "Power Spectral Density (PSD) slope analysis" + ", slope = " + str(np.round(slope, 2))
        )
    ax.set_ylabel(r"$\log_{10}$(Power)")
    ax.set_xlabel(r"$\log_{10}$(Frequency)")
    # ax.scatter(np.log10(frequency), np.log10(psd), marker="o", zorder=1)
    ax.plot(np.log10(frequency), np.log10(psd), zorder=1)
    # fit_values = [slope * i + intercept for i in np.log10(frequency)]
    fit = np.polyval((slope, intercept), np.log10(frequency))
    ax.plot(
        np.log10(frequency),
        fit,
        color="#FF9800",
        zorder=2,
        label="Fractal Dimension = " + str(np.round(fd, 2)),
    )
    ax.legend(loc="lower right")
    return fig