# (nolds) Multidimensional array (solved)

import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from scipy.spatial.distance import pdist, squareform
import warnings


# # 함수 사용 예시

# # Rosenstein
# lyap_r(data, emb_dim=3)
# # lyap_r(data, emb_dim=6)
# # lyap_r(data, emb_dim=3, lag=1, min_tsep=20)

# # Eckmann
# lyap_e(data, emb_dim=6, matrix_dim=2)


################################################################################
# ====================== Part 0: Others ========================
################################################################################



def rowwise_euclidean(x, y):
    return np.sqrt(np.sum((x - y) ** 2, axis=1))

def rowwise_chebyshev(x, y):
  return np.max(np.abs(x - y), axis=1)

def delay_embedding(data, emb_dim, lag=1):
    data = np.asarray(data)
    min_len = (emb_dim - 1) * lag + 1
    if len(data) < min_len:
        msg = "cannot embed data of length {} with embedding dimension {} " \
              + "and lag {}, minimum required length is {}"
        raise ValueError(msg.format(len(data), emb_dim, lag, min_len))
    m = len(data) - min_len + 1
    indices = np.repeat([np.arange(emb_dim) * lag], m, axis=0)
    indices += np.arange(m).reshape((m, 1))
    return data[indices]



################################################################################
# ====================== Part 1: Rosenstein's method ========================
################################################################################



def lyap_r_len(**kwargs):
    min_len = (kwargs['emb_dim'] - 1) * kwargs['lag'] + 1
    min_len += kwargs['trajectory_len'] - 1
    min_len += kwargs['min_tsep'] * 2 + 1
    return min_len

def lyap_r(data, emb_dim=10, lag=None, min_tsep=None, tau=1, min_neighbors=20,
           trajectory_len=20, fit="RANSAC", debug_plot=False, debug_data=False,
           plot_file=None, fit_offset=0):
    # Convert to numpy array if not already and handle multidimensional input
    data = np.asarray(data, dtype=np.float64)

    # If data is multidimensional, apply lyap_r on each sub-array along the last axis
    if data.ndim > 1:
        lyap_results = np.apply_along_axis(
            lambda x: lyap_r(x, emb_dim, lag, min_tsep, tau, min_neighbors,
                             trajectory_len, fit, debug_plot, debug_data, plot_file, fit_offset),
            axis=-1, arr=data)
        return lyap_results

    # Handle single time series case (1D data)
    n = len(data)
    max_tsep_factor = 0.25

    if lag is None or min_tsep is None:
        f = np.fft.rfft(data, n * 2 - 1)

    if min_tsep is None:
        power_spectrum = np.abs(f) ** 2
        freqs = np.fft.rfftfreq(n * 2 - 1)
        mf = np.sum(freqs[1:] * power_spectrum[1:]) / np.sum(power_spectrum[1:])
        min_tsep = int(np.ceil(1.0 / mf))
        if min_tsep > max_tsep_factor * n:
            min_tsep = int(max_tsep_factor * n)
            warnings.warn(f"Signal has very low mean frequency, setting min_tsep = {min_tsep}",
                          RuntimeWarning)

    if lag is None:
        acorr = np.fft.irfft(f * np.conj(f))
        acorr = np.roll(acorr, n - 1)
        eps = acorr[n - 1] * (1 - 1.0 / np.e)
        lag = 1
        # small helper function to calculate resulting number of vectors for a
        # given lag value
        def nb_neighbors(lag_value):
            min_len = lyap_r_len(
                emb_dim=emb_dim, lag=i, trajectory_len=trajectory_len,
                min_tsep=min_tsep
            )
            return max(0, n - min_len)
        # find lag
        for i in range(1, n):
            lag = i
            if acorr[n - 1 + i] < eps or acorr[n - 1 - i] < eps:
                break
            if nb_neighbors(i) < min_neighbors:
                msg = "autocorrelation declined too slowly to find suitable lag" \
                      + ", setting lag to {}"
                warnings.warn(msg.format(lag), RuntimeWarning)
                break
        for i in range(1, n):
            lag = i
            if acorr[n - 1 + i] < eps or acorr[n - 1 - i] < eps:
                break

    min_len = lyap_r_len(emb_dim=emb_dim, lag=lag, trajectory_len=trajectory_len, min_tsep=min_tsep)
    if len(data) < min_len:
        warnings.warn(f"For emb_dim = {emb_dim}, lag = {lag}, min_tsep = {min_tsep}, "
                      f"and trajectory_len = {trajectory_len}, you need at least {min_len} datapoints.",
                      RuntimeWarning)

    orbit = delay_embedding(data, emb_dim, lag)
    m = len(orbit)
    dists = np.array([rowwise_euclidean(orbit, orbit[i]) for i in range(m)])

    for i in range(m):
        dists[i, max(0, i - min_tsep):i + min_tsep + 1] = float("inf")

    ntraj = m - trajectory_len + 1
    min_traj = min_tsep * 2 + 2  # in each row min_tsep + 1 disances are inf
    if ntraj <= 0:
        msg = "Not enough data points. Need {} additional data points to follow " \
              + "a complete trajectory."
        raise ValueError(msg.format(-ntraj + 1))
    if ntraj < min_traj:
        # not enough data points => there are rows where all values are inf
        assert np.any(np.all(np.isinf(dists[:ntraj, :ntraj]), axis=1))
        msg = "Not enough data points. At least {} trajectories are required " \
              + "to find a valid neighbor for each orbit vector with min_tsep={} " \
              + "but only {} could be created."
        raise ValueError(msg.format(min_traj, min_tsep, ntraj))
    assert np.all(np.any(np.isfinite(dists[:ntraj, :ntraj]), axis=1))
    # find nearest neighbors (exclude last columns, because these vectors cannot
    # be followed in time for trajectory_len steps)
    nb_idx = np.argmin(dists[:ntraj, :ntraj], axis=1)

    div_traj = np.zeros(trajectory_len, dtype=float)
    for k in range(trajectory_len):
        indices = (np.arange(ntraj) + k, nb_idx + k)
        div_traj_k = dists[indices]
        nonzero = np.where(div_traj_k != 0)
        if len(nonzero[0]) == 0:
            div_traj[k] = -np.inf
        else:
            div_traj[k] = np.mean(np.log(div_traj_k[nonzero]))

    ks = np.arange(trajectory_len)
    finite = np.where(np.isfinite(div_traj))
    ks = ks[finite]
    div_traj = div_traj[finite]

    if len(ks) < 1:
        poly = [-np.inf, 0]
    else:
        poly = np.polyfit(ks[fit_offset:], div_traj[fit_offset:], 1)

    le = poly[0] / tau
    return le



################################################################################
# ====================== Part 2: Eckmann's method ========================
################################################################################



def lyap_e_len(**kwargs):
    m = (kwargs['emb_dim'] - 1) // (kwargs['matrix_dim'] - 1)
    # minimum length required to find single orbit vector
    min_len = kwargs['emb_dim']
    # we need to follow each starting point of an orbit vector for m more steps
    min_len += m
    # we need min_tsep * 2 + 1 orbit vectors to find neighbors for each
    min_len += kwargs['min_tsep'] * 2
    # we need at least min_nb neighbors for each orbit vector
    min_len += kwargs['min_nb']
    return min_len

def lyap_e(data, emb_dim=10, matrix_dim=4, min_nb=None, min_tsep=0, tau=1,
           debug_plot=False, debug_data=False, plot_file=None):

    # Convert to numpy array if not already and handle multidimensional input
    data = np.asarray(data, dtype=np.float64)

    # If data is multidimensional, apply lyap_r on each sub-array along the last axis
    if data.ndim > 1:
        lyap_results = np.apply_along_axis(
            lambda x: lyap_e(x, emb_dim, matrix_dim, min_nb, min_tsep, tau,
                            debug_plot, debug_data, plot_file),
            axis=-1, arr=data)
        return lyap_results

    # convert to float to avoid errors when using 'inf' as distance
    data = np.asarray(data, dtype=np.float64)
    n = len(data)
    if (emb_dim - 1) % (matrix_dim - 1) != 0:
      raise ValueError("emb_dim - 1 must be divisible by matrix_dim - 1!")
    m = (emb_dim - 1) // (matrix_dim - 1)
    if min_nb is None:
      # minimal number of neighbors as suggested by Eckmann et al.
      min_nb = min(2 * matrix_dim, matrix_dim + 4)

    min_len = lyap_e_len(
      emb_dim=emb_dim, matrix_dim=matrix_dim, min_nb=min_nb, min_tsep=min_tsep
    )
    if n < min_len:
      msg = "{} data points are not enough! For emb_dim = {}, matrix_dim = {}" \
        + ", min_tsep = {} and min_nb = {} you need at least {} data points " \
        + "in your time series"
      warnings.warn(
        msg.format(n, emb_dim, matrix_dim, min_tsep, min_nb, min_len),
        RuntimeWarning
      )

    # construct orbit as matrix (e = emb_dim)
    # x0 x1 x2 ... xe-1
    # x1 x2 x3 ... xe
    # x2 x3 x4 ... xe+1
    # ...

    # note: we need to be able to step m points further for the beta vector
    #       => maximum start index is n - emb_dim - m
    orbit = delay_embedding(data[:-m], emb_dim, lag=1)
    if len(orbit) < min_nb:
      assert len(data) < min_len
      msg = "Not enough data points. Need at least {} additional data points " \
          + "to have min_nb = {} neighbor candidates"
      raise ValueError(msg.format(min_nb-len(orbit), min_nb))
    old_Q = np.identity(matrix_dim)
    lexp = np.zeros(matrix_dim, dtype=np.float64)
    lexp_counts = np.zeros(lexp.shape)
    debug_values = []
    # TODO reduce number of points to visit?
    # TODO performance test!
    for i in range(len(orbit)):
      # find neighbors for each vector in the orbit using the chebyshev distance
      diffs = rowwise_chebyshev(orbit, orbit[i])
      # ensure that we do not count the difference of the vector to itself
      diffs[i] = float('inf')
      # mask all neighbors that are too close in time to the vector itself
      mask_from = max(0, i - min_tsep)
      mask_to = min(len(diffs), i + min_tsep + 1)
      diffs[mask_from:mask_to] = np.inf
      indices = np.argsort(diffs)
      idx = indices[min_nb - 1]  # index of the min_nb-nearest neighbor
      r = diffs[idx]  # corresponding distance
      if np.isinf(r):
        assert len(data) < min_len
        msg = "Not enough data points. Orbit vector {} has less than min_nb = " \
            + "{} valid neighbors that are at least min_tsep = {} time steps " \
            + "away. Input must have at least length {}."
        raise ValueError(msg.format(i, min_nb, min_tsep, min_len))
      # there may be more than min_nb vectors at distance r (if multiple vectors
      # have a distance of exactly r)
      # => update index accordingly
      indices = np.where(diffs <= r)[0]

      # find the matrix T_i that satisifies
      # T_i (orbit'[j] - orbit'[i]) = (orbit'[j+m] - orbit'[i+m])
      # for all neighbors j where orbit'[i] = [x[i], x[i+m],
      # ... x[i + (matrix_dim-1)*m]]

      # note that T_i has the following form:
      # 0  1  0  ... 0
      # 0  0  1  ... 0
      # ...
      # a0 a1 a2 ... a(matrix_dim-1)

      # This is because for all rows except the last one the aforementioned
      # equation has a clear solution since orbit'[j+m] - orbit'[i+m] =
      # [x[j+m]-x[i+m], x[j+2*m]-x[i+2*m], ... x[j+d_M*m]-x[i+d_M*m]]
      # and
      # orbit'[j] - orbit'[i] =
      # [x[j]-x[i], x[j+m]-x[i+m], ... x[j+(d_M-1)*m]-x[i+(d_M-1)*m]]
      # therefore x[j+k*m] - x[i+k*m] is already contained in
      # orbit'[j] - orbit'[x] for all k from 1 to matrix_dim-1. Only for
      # k = matrix_dim there is an actual problem to solve.

      # We can therefore find a = [a0, a1, a2, ... a(matrix_dim-1)] by
      # formulating a linear least squares problem (mat_X * a = vec_beta)
      # as follows.

      # build matrix X for linear least squares (d_M = matrix_dim)
      # x_j1 - x_i   x_j1+m - x_i+m   ...   x_j1+(d_M-1)m - x_i+(d_M-1)m
      # x_j2 - x_i   x_j2+m - x_i+m   ...   x_j2+(d_M-1)m - x_i+(d_M-1)m
      # ...

      # note: emb_dim = (d_M - 1) * m + 1
      mat_X = np.array([data[j:j + emb_dim:m] for j in indices])
      mat_X -= data[i:i + emb_dim:m]

      # build vector beta for linear least squares
      # x_j1+(d_M)m - x_i+(d_M)m
      # x_j2+(d_M)m - x_i+(d_M)m
      # ...
      if max(np.max(indices), i) + matrix_dim * m >= len(data):
        assert len(data) < min_len
        msg = "Not enough data points. Cannot follow orbit vector {} for " \
            + "{} (matrix_dim * m) time steps. Input must have at least " \
            + "length {}."
        raise ValueError(msg.format(i, matrix_dim * m, min_len))
      vec_beta = data[indices + matrix_dim * m] - data[i + matrix_dim * m]

      # perform linear least squares
      a, _, _, _ = np.linalg.lstsq(mat_X, vec_beta, rcond=-1)
      # build matrix T
      # 0  1  0  ... 0
      # 0  0  1  ... 0
      # ...
      # 0  0  0  ... 1
      # a1 a2 a3 ... a_(d_M)
      mat_T = np.zeros((matrix_dim, matrix_dim))
      mat_T[:-1, 1:] = np.identity(matrix_dim - 1)
      mat_T[-1] = a

      # QR-decomposition of T * old_Q
      mat_Q, mat_R = np.linalg.qr(np.dot(mat_T, old_Q))
      # force diagonal of R to be positive
      # (if QR = A then also QLL'R = A with L' = L^-1)
      sign_diag = np.sign(np.diag(mat_R))
      sign_diag[np.where(sign_diag == 0)] = 1
      sign_diag = np.diag(sign_diag)
      mat_Q = np.dot(mat_Q, sign_diag)
      mat_R = np.dot(sign_diag, mat_R)

      old_Q = mat_Q
      # successively build sum for Lyapunov exponents
      diag_R = np.diag(mat_R)
      # filter zeros in mat_R (would lead to -infs)
      idx = np.where(diag_R > 0)
      lexp_i = np.zeros(diag_R.shape, dtype=np.float64)
      lexp_i[idx] = np.log(diag_R[idx])
      lexp_i[np.where(diag_R == 0)] = np.inf
      if debug_plot or debug_data:
        debug_values.append(lexp_i / tau / m)
      lexp[idx] += lexp_i[idx]
      lexp_counts[idx] += 1
    # end of loop over orbit vectors
    # it may happen that all R-matrices contained zeros => exponent really has
    # to be -inf
#     if debug_plot:
#       plot_histogram_matrix(np.array(debug_values), "layp_e", fname=plot_file)
    # normalize exponents over number of individual mat_Rs
    idx = np.where(lexp_counts > 0)
    lexp[idx] /= lexp_counts[idx]
    lexp[np.where(lexp_counts == 0)] = np.inf
    # normalize with respect to tau
    lexp /= tau
    # take m into account
    lexp /= m
    if debug_data:
      return (lexp, np.array(debug_values))
    return max(lexp)




################################################################################
################################################################################
# ====================== Test 1: multidimensional input ========================
################################################################################
################################################################################




# # Testing the modified code with a more structured multidimensional time series
# np.random.seed(42)

# # Generate less random data with clear patterns
# time_series_3d = np.zeros((3, 3, 2000))

# # Fill with sine and cosine waves, adding small noise
# for i in range(3):
    # for j in range(3):
        # t = np.linspace(0, 50, 2000)
        # # time_series_3d[i, j] = np.sin(t + i) + np.cos(t * (j + 1)) + 0.1 * np.random.randn(2000)
        # time_series_3d[i, j] = np.exp(t / (100 * (i+1))) + np.cos(t) * (j + 1) + 0.1 * np.random.randn(2000)

# # Apply the Lyapunov exponent function to the 3D input
# lyap_results_per_series_r = lyap_r(time_series_3d, emb_dim=3)
# kwargs_e = {"emb_dim": 6, "matrix_dim": 2}
# # lyap_results_per_series_e = np.max(lyap_e(time_series_3d, **kwargs_e), axis=2)
# lyap_results_per_series_e = lyap_e(time_series_3d, **kwargs_e)

# # Flatten and calculate for the combined data
# flattened_time_series = time_series_3d.reshape(-1, time_series_3d.shape[-1])
# combined_lyap_r = lyap_r(flattened_time_series, emb_dim=3)
# kwargs_e = {"emb_dim": 6, "matrix_dim": 2}
# # combined_lyap_e = np.max(lyap_e(flattened_time_series, **kwargs_e), axis=1)
# combined_lyap_e = lyap_e(flattened_time_series, **kwargs_e)

# # Convert results to dataframes for better visualization
# lyap_df_per_series_r = pd.DataFrame(lyap_results_per_series_r, columns=["Param2_1", "Param2_2", "Param2_3"], index=["Param1_1", "Param1_2", "Param1_3"])
# combined_lyap_df_r = pd.DataFrame(combined_lyap_r, columns=["Combined Lyapunov Exponent"])
# lyap_df_per_series_e = pd.DataFrame(lyap_results_per_series_e, columns=["Param2_1", "Param2_2", "Param2_3"], index=["Param1_1", "Param1_2", "Param1_3"])
# combined_lyap_df_e = pd.DataFrame(combined_lyap_e, columns=["Combined Lyapunov Exponent"])

# # Plot results for each time series
# fig, ax = plt.subplots(figsize=(10, 6))
# ax.set_title("Lyapunov Exponents per Series (Rosenstein)")
# pd.plotting.table(ax, lyap_df_per_series_r, loc='center', cellLoc='center')
# ax.axis('off')
# fige1, ax1 = plt.subplots(figsize=(10, 6))
# ax1.set_title("Lyapunov Exponents per Series (Eckmann)")
# pd.plotting.table(ax1, lyap_df_per_series_e, loc='center', cellLoc='center')
# ax1.axis('off')

# # Show the combined Lyapunov exponent results
# fig2, ax2 = plt.subplots(figsize=(5, 2))
# ax2.set_title("Combined Lyapunov Exponent (Rosenstein)")
# pd.plotting.table(ax2, combined_lyap_df_r, loc='center', cellLoc='center')
# ax2.axis('off')
# fig3, ax3 = plt.subplots(figsize=(5, 2))
# ax3.set_title("Combined Lyapunov Exponent (Eckmann)")
# pd.plotting.table(ax3, combined_lyap_df_e, loc='center', cellLoc='center')
# ax3.axis('off')

# plt.show()




################################################################################
################################################################################
# ====================== Test 2: logistic map, tent map ========================
################################################################################
################################################################################



def plot_lyap(maptype="logistic"):
    """
    Plots a bifurcation plot of the given map and superimposes the true
    lyapunov exponent as well as the estimates of the largest lyapunov exponent
    obtained by ``lyap_r`` and ``lyap_e``. The idea for this plot is taken
    from [ll]_.

    This function requires the package ``matplotlib``.

    References:

    .. [ll] Manfred Füllsack, "Lyapunov exponent",
      url: http://systems-sciences.uni-graz.at/etextbook/sw2/lyapunov.html

    Kwargs:
      maptype (str):
        can be either ``"logistic"`` for the logistic map or ``"tent"`` for the
        tent map.
    """
    # local import to avoid dependency for non-debug use
    # import matplotlib.pyplot as plt

    x_start = 0.1
    n = 140
    nbifur = 40
    if maptype == "logistic":
      param_name = "r"
      param_range = np.arange(2, 4, 0.01)
      full_data = np.array([
        np.fromiter(logistic_map(x_start, n, r), dtype="float32")
        for r in param_range
      ])
      # It can be proven that the lyapunov exponent of the logistic map
      # (or any map that is an iterative application of a function) can be
      # calculated as the mean of the logarithm of the absolute of the
      # derivative at the individual data points.
      # For a proof see for example:
      # https://blog.abhranil.net/2015/05/15/lyapunov-exponent-of-the-logistic-map-mathematica-code/
      # Derivative of logistic map: f(x) = r * x * (1 - x) = r * x - r * x²
      # => f'(x) = r - 2 * r * x
      lambdas = [
        np.mean(np.log(abs(r - 2 * r * x[np.where(x != 0.5)])))
        for x, r in zip(full_data, param_range)
      ]
    elif maptype == "tent":
      param_name = "$\\mu$"
      param_range = np.arange(0, 2, 0.01)
      full_data = np.array([
        np.fromiter(tent_map(x_start, n, mu), dtype="float32")
        for mu in param_range
      ])
      # for the tent map the lyapunov exponent is much easier to calculate
      # since the values are multiplied by mu in each step, two trajectories
      # starting in x and x + delta will have a distance of delta * mu^n after n
      # steps. Therefore the lyapunov exponent should be log(mu).
      lambdas = np.log(param_range, where=param_range > 0)
      lambdas[np.where(param_range <= 0)] = np.nan
    else:
      raise Error("maptype %s not recognized" % maptype)

    kwargs_e = {"emb_dim": 6, "matrix_dim": 2}
    # kwargs_r = {"emb_dim": 6, "lag": 2, "min_tsep": 20, "trajectory_len": 20}
    kwargs_r = {"emb_dim": 3, "lag": 1, "min_tsep": 20}
    # kwargs_r = {"emb_dim": 3, "min_tsep": 20, "trajectory_len": 20}
    # kwargs_r = {"emb_dim": 6, "lag": 2}
    # lambdas_e = [max(lyap_e(d, **kwargs_e)) for d in full_data]
    lambdas_e = [lyap_e(d, **kwargs_e) for d in full_data]
    lambdas_r = [lyap_r(d, **kwargs_r) for d in full_data]
    bifur_x = np.repeat(param_range, nbifur)
    bifur = np.reshape(full_data[:, -nbifur:], nbifur * param_range.shape[0])

    # Plot the bifurcation and lyapunov exponent plot
    plt.figure(figsize=(10, 6))
    plt.title("Lyapunov exponent of the %s map" % maptype)
    plt.plot(param_range, lambdas, "b-", label="true lyap. exponent")
    elab = "estimation using lyap_e"
    rlab = "estimation using lyap_r"
    plt.plot(param_range, lambdas_e, color="#00AAAA", label=elab)
    plt.plot(param_range, lambdas_r, color="#AA00AA", label=rlab)
    plt.plot(param_range, np.zeros(len(param_range)), "g--")
    plt.plot(bifur_x, bifur, "ro", alpha=0.1, label="bifurcation plot")
    plt.ylim((-2, 2))
    plt.xlabel(param_name)
    plt.ylabel("lyap. exp / %s(x, %s)" % (maptype, param_name))
    plt.legend(loc="best")

    # Visualization of 'full_data' for selected r values (example: r=2.5, 3.5)
    # r_indices = [50, 150]  # Indices corresponding to r=2.5 and r=3.5
    r_indices = [149, 199]  # Indices corresponding to r=2.5 and r=3.5
    selected_r_values = [param_range[i] for i in r_indices]

    plt.figure(figsize=(10, 6))
    for i, r_idx in enumerate(r_indices):
        plt.plot(full_data[r_idx], label=f"Time Series for r={selected_r_values[i]:.1f}")
    plt.title("Time Series for Selected r Values in the Logistic Map")
    plt.xlabel("Time")
    plt.ylabel("Value")
    plt.legend()

    plt.show()

def logistic_map(x, steps, r=4):
    """
    Generates a time series of the logistic map.

    Characteristics and Background:
      The logistic map is among the simplest examples for a time series that can
      exhibit chaotic behavior depending on the parameter r. For r between 2 and
      3, the series quickly becomes static. At r=3 the first bifurcation point is
      reached after which the series starts to oscillate. Beginning with r = 3.6
      it shows chaotic behavior with a few islands of stability until perfect
      chaos is achieved at r = 4.

    Calculating the Lyapunov exponent:
      To calculate the "true" Lyapunov exponent of the logistic map, we first
      have to make a few observations for maps in general that are repeated
      applications of a function to a starting value.

      If we have two starting values that differ by some infinitesimal
      :math:`delta_0` then according to the definition of the lyapunov exponent
      we will have an exponential divergence:

      .. math::
        |\delta_n| = |\delta_0| e^{\lambda n}

      We can now write that:

      .. math::
        e^{\lambda n} = \lim_{\delta_0 -> 0} |\frac{\delta_n}{\delta_0}|

      This is the definition of the derivative :math:`\frac{dx_n}{dx_0}` of a
      point :math:`x_n` in the time series with respect to the starting point
      :math:`x_0` (or rather the absolute value of that derivative). Now we can
      use the fact that due to the definition of our map as repetitive
      application of some f we have:

      .. math::
        f^{n\prime}(x) = f(f(f(...f(x_0)...))) = f'(x_n-1) \cdot f'(x_n-2)
        \cdot ... \cdot f'(x_0)

      with

      .. math::
        e^{\lambda n} = |f^{n\prime}(x)|

      we now have

      .. math::

        e^{\lambda n} &= |f'(x_n-1) \cdot f'(x_n-2) \cdot ... \cdot f'(x_0)| \\
        \Leftrightarrow \\
        \lambda n &= \ln |f'(x_n-1) \cdot f'(x_n-2) \cdot ... \cdot f'(x_0)| \\
        \Leftrightarrow \\
        \lambda &= \frac{1}{n} \ln |f'(x_n-1) \cdot f'(x_n-2) \cdot ... \cdot f'(x_0)| \\
              &= \frac{1}{n} \sum_{k=0}^{n-1} \ln |f'(x_k)|

      With this sum we can now calculate the lyapunov exponent for any map.
      For the logistic map we simply have to calculate :math:`f'(x)` and as we
      have

      .. math::
        f(x) = r x (1-x) = rx - rx²

      we now get

      .. math::
        f'(x) = r - 2 rx



    References:
      .. [lm_1] https://en.wikipedia.org/wiki/Tent_map
      .. [lm_2] https://blog.abhranil.net/2015/05/15/lyapunov-exponent-of-the-logistic-map-mathematica-code/

    Args:
      x (float):
        starting point
      steps (int):
        number of steps for which the generator should run

    Kwargs:
      r (int):
        parameter r that controls the behavior of the map

    Returns:
      generator object:
        the generator that creates the time series
    """
    for _ in range(steps):
      x = r * x * (1 - x)
      yield x

def tent_map(x, steps, mu=2):
    """
    Generates a time series of the tent map.

    Characteristics and Background:
      The name of the tent map is derived from the fact that the plot of x_i vs
      x_i+1 looks like a tent. For mu > 1 one application of the mapping function
      can be viewed as stretching the surface on which the value is located and
      then folding the area that is greater than one back towards the zero. This
      corresponds nicely to the definition of chaos as expansion in one dimension
      which is counteracted by a compression in another dimension.

    Calculating the Lyapunov exponent:
      The lyapunov exponent of the tent map can be easily calculated as due to
      this stretching behavior a small difference delta between two neighboring
      points will indeed grow exponentially by a factor of mu in each iteration.
      We thus can assume that:

      delta_n = delta_0 * mu^n

      We now only have to change the basis to e to obtain the exact formula that
      is used for the definition of the lyapunov exponent:

      delta_n = delta_0 * e^(ln(mu) * n)

      Therefore the lyapunov exponent of the tent map is:

      lambda = ln(mu)

    References:
      .. [tm_1] https://en.wikipedia.org/wiki/Tent_map

    Args:
      x (float):
        starting point
      steps (int):
        number of steps for which the generator should run

    Kwargs:
      mu (int):
        parameter mu that controls the behavior of the map

    Returns:
      generator object:
        the generator that creates the time series
    """
    for _ in range(steps):
      x = mu * x if x < 0.5 else mu * (1 - x)
      yield x



# #===============================================================================



# plot_lyap(maptype="logistic")
# plot_lyap(maptype="tent")




