#! /usr/bin/env python3

import numpy as np
import nmrglue as ng
import matplotlib as mpl
import matplotlib.pyplot as plt
import scipy.io.wavfile as WF
import math
import warnings
import scipy.linalg as slinalg
from copy import deepcopy
from pathlib import Path

try:
    import jeol_parser
except Exception:
    pass

from .config import cprint
from . import misc, sim

""" Collection of all-purpose functions """

print = cprint


def get_datadir_filename(in_file, isexp=True):
    """
    Computes the attributes ``datadir`` and ``filename`` to feed in the `Spectra` classes.

    If ``in_file`` is a dictionary of acqusition parameters, ``datadir`` is the current working directory and ``filename='dict'``.
    In any other case, ``datadir`` is the absolute path to the directory that contains the raw FID, and ``filename`` is the name of the last directory.
    Example: if the path to the FID is `/path/to/dataset/expno/fid`, ``datadir = '/path/to/dataset/expno/fid'``, ``filename = expno``.

    Parameters
    ----------
    in_file : str or Path or dict
        Path to the dataset (also relative works) or dictionary of acquisition parameters
    isexp : bool
        For the correct computation of the filename, you have to specify if it is a real (``True``) or simulated (``False``) dataset

    Returns
    -------
    datadir : str
        Absolute path to the dataset
    filename : str
        Filename of the spectrum
    """
    if isinstance(in_file, dict):   # Simulated data with already given acqus dictionary
        datadir = Path.cwd()      # Current directory
        filename = 'dict'  # Filename is just "dict"
    else:                           # You need to actually read a file
        datadir = Path(in_file).absolute()  # Get the file position
        if not datadir.is_dir():
            datadir = datadir.parent
        if isexp:
            filename = datadir.stem
        else:
            filename = Path(in_file).stem
        # If filename is a directory, write things inside it
        extended_dir = datadir / filename
        if extended_dir.is_dir() and isexp:
            datadir = extended_dir
    return Path(datadir), filename


def read_fid_acqus_1D(in_file, spect='bruker'):
    """
    Uses :mod:`nmrglue` to read inside ``in_file`` in search for 1D NMR data.
    Returns the complex FID and the dictionaries of setup parameters.

    Parameters
    ----------
    in_file : str or Path
        Path where to find the data
    spect : str
        Data format. Valid options are: ``'bruker'``, ``'varian'``, ``'magritek'``, ``'oxford'``, ``'jeol'``

    Returns
    -------
    fid : 1darray
        FID of the dataset
    acqus : dict
        Dictionary of acquisition parameters, required by `klassez`
    ngdic : dict
        Complete set of acquisition, processing, and setup parameters.
    """
    def read_bruker(path):
        """ For bruker data """
        dic, data = ng.bruker.read(path, cplex=True)
        acqus = misc.makeacqus_1D(dic)
        # Set the data format keys in acqus
        acqus['BYTORDA'] = dic['acqus']['BYTORDA']
        acqus['DTYPA'] = dic['acqus']['DTYPA']
        return data, acqus, dic

    def read_varian(path):
        """ For Varian data """
        dic, data = ng.varian.read(path)
        acqus = misc.makeacqus_1D_varian(dic)
        return data, acqus, dic

    def read_magritek(path):
        """ Spinsolve data """
        dic, data = ng.spinsolve.read(path, specfile='data.1d')         # Actual FID
        if (path / 'spectrum.1d').is_file():
            dic2, _, = ng.spinsolve.read(path, specfile='spectrum.1d')       # for config
            dic.update(dic2)
        if (path / 'nmr_fid.dx').is_file():
            dic3, _, = ng.spinsolve.read(path, specfile='nmr_fid.dx')        # for config
            dic.update(dic3)
        acqus = misc.makeacqus_1D_spinsolve(dic)
        return data, acqus, dic

    def read_oxford(path):
        """ jdx files """
        if '.jdx' in path:
            jdx_file = path
        else:
            # Try to see if there is a .jdx file
            all_jdx = list(path.glob('*.jdx'))
            if len(all_jdx) == 0:
                raise NameError(f'No .jdx file were found in {path}.')
            elif len(all_jdx) > 1:
                raise ValueError(f'There are more than one .jdx file in {path}.')
            jdx_file = all_jdx[-1]
        dic, cplx = ng.jcampdx.read(jdx_file)
        acqus = misc.makeacqus_1D_oxford(dic)
        data = cplx[0] + 1j*cplx[1]
        return data, acqus, dic

    def read_jeol(path):
        """ Jeol files """
        dic = jeol_parser.parse(path)
        # Compute fid
        fid = dic.pop('data')  # remove the data from the dictionary (useless)
        # join real and imaginary part
        fid_re = np.array(fid['re']).astype('float64')
        fid_im = np.array(fid['im']).astype('float64')
        # Complex FID
        data = (fid_re + 1j*fid_im).reshape(-1)
        # Make acqus
        acqus = misc.makeacqus_1D_jeol(dic)
        return data, acqus, dic

    # MAIN READING
    in_file = Path(in_file)
    with warnings.catch_warnings():   # Suppress errors due to CONVDTA in TopSpin
        warnings.simplefilter("ignore")

        # Discriminate between different spectrometer formats
        match spect:
            case 'bruker':
                fid, acqus, ngdic = read_bruker(in_file)
            case 'varian':
                fid, acqus, ngdic = read_varian(in_file)
            case 'magritek':
                fid, acqus, ngdic = read_magritek(in_file)
            case 'oxford':
                fid, acqus, ngdic = read_oxford(in_file)
            case 'jeol':
                fid, acqus, ngdic = read_jeol(in_file)
            case _:
                raise NotImplementedError('Unknown dataset format.')

    # Add the file version to acqus
    acqus['spect'] = spect
    # Look for group delay points: if there are not, put it to 0
    try:
        acqus['GRPDLY'] = int(ngdic['acqus']['GRPDLY'])
    except Exception:
        acqus['GRPDLY'] = 0

    return fid, acqus, ngdic


def read_fid_acqus_2D(in_file, spect='bruker'):
    """
    Uses :mod:`nmrglue` to read inside ``in_file`` in search for 2D NMR data.
    Returns the complex FID and the dictionaries of setup parameters.

    Parameters
    ----------
    in_file : str or Path
        Path where to find the data
    spect : str
        Data format. Valid options are: ``'bruker'`` only.

    Returns
    -------
    fid : 1darray
        FID of the dataset
    acqus : dict
        Dictionary of acquisition parameters, required by `klassez`
    ngdic : dict
        Complete set of acquisition, processing, and setup parameters.
    """
    def read_bruker(in_file):
        """ Bruker files """
        dic, fid = ng.bruker.read(in_file, cplex=True)
        acqus = misc.makeacqus_2D(dic)
        fid = np.reshape(fid, (acqus['TD1'], -1))
        acqus['BYTORDA'] = dic['acqus']['BYTORDA']
        acqus['DTYPA'] = dic['acqus']['DTYPA']
        FnMODE_flag = dic['acqu2s']['FnMODE']       # Get f1 acquisition mode
        # List of possible modes
        FnMODEs = ['Undefined', 'QF', 'QSEC', 'TPPI', 'States', 'States-TPPI', 'Echo-Antiecho', 'QF-nofreq']
        acqus['FnMODE'] = FnMODEs[FnMODE_flag]  # Add to acqus
        return fid, acqus, dic

    # MAIN READING
    with warnings.catch_warnings():   # Suppress errors due to CONVDTA in TopSpin
        warnings.simplefilter("ignore")
        # Discriminate between different spectrometer formats
        match spect:
            case 'bruker':
                fid, acqus, ngdic = read_bruker(in_file)
            case _:
                raise NotImplementedError('Unknown dataset format.')

    # Get group delay points
    try:
        acqus['GRPDLY'] = int(ngdic['acqus']['GRPDLY'])
    except Exception:
        acqus['GRPDLY'] = 0

    return fid, acqus, ngdic


def makeacqus_1D(dic):
    """
    Given a NMRGLUE dictionary from a 1D spectrum (generated by
    :func:`nmrglue.bruker.read`), this function builds the acqus file with only
    the "important" parameters.

    Parameters
    ----------

    dic : dict
        NMRglue dictionary returned by ng.bruker.read

    Returns
    -------

    acqus : dict
        Dictionary with only few parameters
    """
    acqus = {}
    acqus['nuc'] = dic['acqus']['NUC1']
    acqus['SFO1'] = np.abs(dic['acqus']['SFO1'])
    acqus['SWp'] = dic['acqus']['SW']
    acqus['TD'] = int(dic['acqus']['TD'])//2    # Fuckin' Bruker
    acqus['o1'] = dic['acqus']['O1']

    acqus['B0'] = acqus['SFO1'] / sim.gamma[acqus['nuc']]
    acqus['o1p'] = acqus['o1'] / acqus['SFO1']
    acqus['SW'] = acqus['SWp'] * np.abs(acqus['SFO1'])
    acqus['dw'] = 1 / acqus['SW']
    acqus['t1'] = np.linspace(0, acqus['TD']*acqus['dw'], acqus['TD'])
    acqus['AQ'] = acqus['t1'][-1]
    return acqus


def makeacqus_1D_varian(dic):
    """
    Given a NMRGLUE dictionary from a 1D spectrum (generated by
    :func:`nmrglue.varian.read`), this function builds the acqus file
    with only the "important" parameters.

    Parameters
    ----------
    dic : dict
        NMRglue dictionary returned by ng.varian.read

    Returns
    -------
    acqus : dict
        Dictionary with only few parameters
    """
    acqus = {}

    nuc = dic['procpar']['tn']['values'][-1]
    import re
    fnuc = re.split(r'(\D+)', nuc)
    acqus['nuc'] = ''.join(fnuc[::-1])
    acqus['SFO1'] = eval(dic['procpar']['sreffrq']['values'][-1])
    acqus['SW'] = eval(dic['procpar']['sw']['values'][-1])
    acqus['TD'] = eval(dic['procpar']['np']['values'][-1]) // 2  # Fuckin' Varian

    # Varian does not have center frequency specified
    lowest_freq = eval(dic['procpar']['rfl']['values'][-1])
    acqus['o1'] = acqus['SW'] / 2 - lowest_freq
    acqus['SWp'] = acqus['SW'] / np.abs(acqus['SFO1'])
    acqus['B0'] = acqus['SFO1'] / sim.gamma[acqus['nuc']]
    acqus['o1p'] = acqus['o1'] / acqus['SFO1']
    acqus['dw'] = 1 / acqus['SW']
    acqus['t1'] = np.linspace(0, acqus['TD']*acqus['dw'], acqus['TD'])
    acqus['AQ'] = acqus['t1'][-1]
    return acqus


def makeacqus_1D_spinsolve(dic):
    """
    Given a NMRGLUE dictionary from a 1D spectrum (generated by :func:`nmrglue.spinsolve.read`), this function builds the acqus file with only the "important" parameters.
    Be sure to get the info from all the configuration files!

    Parameters
    ----------
    dic : dict
        NMRglue dictionary returned by ng.spinsolve.read

    Returns
    -------
    acqus : dict
        Dictionary with only few parameters
    """
    acqus = {}

    ppm = dic['spectrum']['xaxis']          # ppm scale

    acqus['nuc'] = dic['acqu']['rxChannel']
    acqus['SFO1'] = dic['acqu']['b1Freq']
    acqus['SWp'] = np.abs(ppm[0] - ppm[-1])
    acqus['TD'] = int(dic['acqu']['nrPnts'])
    acqus['o1p'] = np.mean(ppm)

    acqus['SW'] = acqus['SWp'] * np.abs(acqus['SFO1'])
    acqus['B0'] = acqus['SFO1'] / sim.gamma[acqus['nuc']]
    acqus['o1'] = acqus['o1p'] * acqus['SFO1']
    acqus['dw'] = 1 / acqus['SW']
    acqus['t1'] = np.linspace(0, acqus['TD']*acqus['dw'], acqus['TD'])
    acqus['AQ'] = acqus['t1'][-1]
    return acqus


def makeacqus_1D_oxford(dic):
    """
    Given a NMRGLUE dictionary from a 1D spectrum (generated by :func:`nmrglue.jcampdx.read`), this function builds the acqus file with only the "important" parameters.

    Parameters
    ----------
    dic : dict
        NMRglue dictionary returned by ng.jcampdx.read

    Returns
    -------
    acqus : dict
        Dictionary with only few parameters
    """
    acqus = {}

    acqus['nuc'] = dic['.OBSERVENUCLEUS'][-1].replace('^', '')
    acqus['SFO1'] = eval(dic['$SFO1'][-1])
    acqus['SW'] = eval(dic['$SWEEPWIDTH'][-1])
    acqus['TD'] = int(eval(dic['$TD'][-1]) // 2)  # Fuckin' Oxford
    for entry in dic['_comments']:
        if 'TxPPM=' in entry:
            acqus['o1p'] = eval(entry.split('=')[-1])
            break

    acqus['SWp'] = acqus['SW'] / np.abs(acqus['SFO1'])
    acqus['B0'] = acqus['SFO1'] / sim.gamma[acqus['nuc']]
    acqus['o1'] = acqus['o1p'] * acqus['SFO1']
    acqus['dw'] = 1 / acqus['SW']
    acqus['t1'] = np.linspace(0, acqus['TD']*acqus['dw'], acqus['TD'])
    acqus['AQ'] = acqus['t1'][-1]
    return acqus


def makeacqus_1D_jeol(dic):
    """
    Given a dictionary from a 1D spectrum (generated by ``jeol_parser.parse``), this function builds the acqus file with only the "important" parameters.

    Parameters
    ----------
    dic : dict
        Dictionary generated with jeol_parser.parse

    Returns
    -------
    acqus : dict
        Dictionary with only few parameters
    """
    # Initialize empty dictionary
    acqus = {}
    # Get bytorder and dataype
    endianness = dic['headers']['endian']
    if endianness == 'littleEndian':
        acqus['BYTORDA'] = 0
    else:
        acqus['BYTORDA'] = 1
    datatype = dic['headers']['dataType']
    if 'Float' in datatype:
        acqus['DTYPA'] = 2
    else:
        acqus['DTYPA'] = 0

    acqus['nuc'] = sim.jeol_nuclei[dic['info']['nucleus'][0]]
    acqus['SFO1'] = dic['headers']['baseFreq'][0]
    acqus['TD'] = int(dic['headers']['dataPoints'][0])
    acqus['AQ'] = np.abs(dic['headers']['dataAxisStop'][0] - dic['headers']['dataAxisStart'][0])
    acqus['o1p'] = dic['info']['frequencyOffset'][0]['magnitude']

    acqus['dw'] = acqus['AQ'] / acqus['TD']
    acqus['SW'] = 1 / acqus['dw']
    acqus['SWp'] = acqus['SW'] / np.abs(acqus['SFO1'])
    acqus['o1'] = acqus['o1p'] * acqus['SFO1']
    acqus['B0'] = acqus['SFO1'] / sim.gamma[acqus['nuc']]
    acqus['t1'] = np.linspace(0, acqus['TD']*acqus['dw'], acqus['TD'])

    acqus['GRPDLY'] = 20  # placeholder, we do not know where to find it yet

    return acqus


def makeacqus_2D(dic):
    """
    Given a NMRGLUE dictionary from a 2D spectrum (generated by :func:`nmrglue.bruker.read` ), this function builds the acqus file with only the "important" parameters.

    Parameters
    ----------
    dic : dict
        NMRglue dictionary returned by ng.bruker.read

    Returns
    -------
    acqus : dict
        Dictionary with only few parameters
    """
    acqus = {}
    acqus['nuc1'] = dic['acqu2s']['NUC1']
    acqus['nuc2'] = dic['acqus']['NUC1']
    acqus['SFO1'] = dic['acqu2s']['SFO1']
    acqus['SFO2'] = dic['acqus']['SFO1']
    acqus['SW1p'] = dic['acqu2s']['SW']
    acqus['SW2p'] = dic['acqus']['SW']
    acqus['TD1'] = int(dic['acqu2s']['TD'])         # Indirect evolution is not /2
    acqus['TD2'] = int(dic['acqus']['TD'])//2       # Fuckin' Bruker
    acqus['o1'] = dic['acqu2s']['O1']
    acqus['o2'] = dic['acqus']['O1']

    acqus['B0'] = acqus['SFO2'] / sim.gamma[acqus['nuc2']]
    acqus['o1p'] = acqus['o1'] / acqus['SFO1']
    acqus['o2p'] = acqus['o2'] / acqus['SFO2']
    acqus['SW1'] = acqus['SW1p'] * np.abs(acqus['SFO1'])
    acqus['SW2'] = acqus['SW2p'] * np.abs(acqus['SFO2'])
    acqus['dw1'] = 1 / acqus['SW1']
    acqus['dw2'] = 1 / acqus['SW2']
    if dic['acqu2s']['FnMODE'] in (3, 4, 5):
        acqus['t1'] = np.linspace(0, acqus['TD1']//2*acqus['dw1'], acqus['TD1'])
    else:
        acqus['t1'] = np.linspace(0, acqus['TD1']*acqus['dw1'], acqus['TD1'])
    acqus['t2'] = np.linspace(0, acqus['TD2']*acqus['dw2'], acqus['TD2'])
    acqus['AQ1'] = acqus['t1'][-1]
    acqus['AQ2'] = acqus['t2'][-1]
    return acqus


def makeacqus_pp3D(dic):
    """
    Given a NMRGLUE dictionary from a 3D spectrum with two pseudo dimensions, (generated by ``nmrglue.bruker.read`` ), this function builds the acqus file with only the "important" parameters.

    Parameters
    ----------
    dic : dict
        NMRglue dictionary returned by :func:`ng.bruker.read`

    Returns
    -------
    acqus : dict
        Dictionary with only few parameters
    """
    # Same structure of Pseudo_2D
    acqus = {}
    acqus['nuc'] = dic['acqus']['NUC1']
    acqus['SFO1'] = dic['acqus']['SFO1']
    acqus['SWp'] = dic['acqus']['SW']
    acqus['TD1'] = int(dic['acqu3s']['TD'])         # Indirect evolution is not /2
    acqus['TD2'] = int(dic['acqu2s']['TD'])         # Indirect evolution is not /2
    acqus['TD'] = int(dic['acqus']['TD'])//2       # Fuckin' Bruker
    acqus['o1'] = dic['acqus']['O1']

    acqus['B0'] = acqus['SFO1'] / sim.gamma[acqus['nuc']]
    acqus['o1p'] = acqus['o1'] / acqus['SFO1']
    acqus['SW'] = acqus['SWp'] * np.abs(acqus['SFO1'])
    acqus['dw'] = 1 / acqus['SW']
    acqus['t1'] = np.linspace(0, acqus['TD']*acqus['dw'], acqus['TD'])
    acqus['AQ'] = acqus['t1'][-1]
    return acqus


def write_acqus_1D(acqus, path='sim_in_1D'):
    """
    Writes the input file for a simulated spectrum, basing on a dictionary of parameters.

    Parameters
    ----------
    acqus : dict
        The dictionary containing the parameters for the simulation
    path : str, optional
        Directory where the file will be saved.
    """
    f = open(path, 'w')
    keylist = acqus.keys()
    for key in keylist:
        if key[:1] == 't':
            pass
        else:
            if isinstance(acqus[key], (list, tuple)):
                f.write('{}\t'.format(key))
                for w in acqus[key]:
                    f.write('{}, '.format(w))
                f.write('\n')
            else:
                f.write('{}\t{}\n'.format(key, acqus[key]))

    f.close()


def write_acqus_2D(acqus, path='sim_in_2D'):
    """
    Writes the input file for a simulated spectrum, basing on a dictionary of parameters.

    Parameters
    ----------
    acqus : dict
        The dictionary containing the parameters for the simulation
    path : str, optional
        Directory where the file will be saved.
    """
    f = open(path, 'w')
    keylist = acqus.keys()
    for key in keylist:
        if key[:1] == 't':
            pass
        else:
            if isinstance(acqus[key], (list, tuple)):
                f.write('{}\t'.format(key))
                for w in acqus[key]:
                    f.write('{}, '.format(w))
                f.write('\n')
            else:
                f.write('{}\t{}\n'.format(key, acqus[key]))
    f.close()


def calcres(fqscale):
    """
    Calculates the frequency resolution of an axis scale, i.e. how many Hz is a "tick".

    Parameters
    ----------
    fqscale : 1darray
        Scale to be processed

    Returns
    -------
    res : float
        The resolution of the scale
    """
    return np.abs(fqscale[1]-fqscale[0])


def hz2pt(fqscale, hz):
    """
    Converts ``hz`` from frequency units to points, on the basis of its scale.

    Parameters
    ----------
    fqscale : 1darray
        Scale to be processed
    hz : float
        Value to be converted

    Returns
    -------
    pt : float
        The frequency value converted in points
    """
    hzpt = misc.calcres(fqscale)
    pt = int(round(hz / hzpt))
    return pt


def in2px(*in_args):
    """
    Converts a sequence of numbers from inches to pixels by multiplying times 96.

    Parameters
    ----------
    in_args : sequence of floats
        Values in inches to convert

    Returns
    -------
    px_args : tuple of ints
        Values in pixels
    """
    px_args = tuple([int(X * 96) for X in in_args])
    return px_args


def px2in(*px_args):
    """
    Converts a sequence of numbers from inches to pixels by multiplying times 96.

    Parameters
    ----------
    px_args : sequence of ints
        Values in pixels to convert

    Returns
    -------
    in_args : tuple of floats
        Values in inches
    """
    in_args = tuple([X / 96 for X in px_args])
    return in_args


def find_nearest(array, value):
    """
    Finds the value in array which is the nearest to value .

    Parameters
    ----------
    array : 1darray
        Self-explanatory
    value : float
        Value to be found

    Returns
    -------
    val : float
        The closest value in array tovalue
    """
    # Finds the value in 'array' which is the nearest to 'value'
    array = np.asarray(array)
    idx = (np.abs(array - value)).argmin()
    return array[idx]


def trim_data(ppm_scale, y, lims=None):
    """
    Trims the frequency scale and correspondant 1D dataset ``y`` from sx (ppm) to dx (ppm).

    Parameters
    ----------
    ppm_scale : 1darray
        ppm scale of the spectrum
    y : 1darray
        spectrum
    lims : tuple
        ppm values where to start and stop trimming

    Returns
    -------
    xtrim : 1darray
        Trimmed ppm scale
    ytrim : 1darray
        Trimmed spectrum
    """
    if lims is None:
        lims = min(ppm_scale), max(ppm_scale)

    lims_p = sorted([misc.ppmfind(ppm_scale, x)[0] for x in lims])
    # Avoid truncation on the last point
    lims_p[1] += 1
    slice_x = slice(*lims_p)
    xtrim = ppm_scale[slice_x]
    ytrim = y[..., slice_x]
    return xtrim, ytrim


def trim_data_2D(x_scale, y_scale, data, xlim=None, ylim=None):
    """
    Trims data and the scales according to ``xlim`` and ``ylim``.
    Returns the trimmed data and the correspondant trimmed scales.

    Parameters
    ----------
    x_scale: 1darray
        Scale for the rows of data
    y_scale: 1darray
        Scale for the columns of data
    data: 2darray
        Data to be trimmed
    xlim: tuple
        Limits for ``x_scale`` (L, R)
    ylim: tuple
        Limits for ``y_scale`` (L, R)

    Returns
    -------
    trimmed_x: 1darray
        Trimmed x_scale
    trimmed_y: 1darray
        Trimmed y_scale
    trimmed_data: 2darray
        Trimmed data
    """

    # Shallow copy
    trimmed_x = np.copy(x_scale)
    trimmed_y = np.copy(y_scale)
    datap = np.copy(data)

    if xlim is None:    # Set whole scale
        xlim = x_scale[0], x_scale[-1]
    if ylim is None:    # Set whole scale
        ylim = y_scale[0], y_scale[-1]
    # Get the indexes of the limits on both scale and sort them
    xlim_p = sorted([misc.ppmfind(x_scale, lim)[0] for lim in xlim])
    ylim_p = sorted([misc.ppmfind(y_scale, lim)[0] for lim in ylim])
    # Avoid truncation of last point
    xlim_p[1] += 1
    ylim_p[1] += 1
    # Trim
    slice_x = slice(*xlim_p)            # slice on X
    slice_y = slice(*ylim_p)            # slice on Y
    #   Scales
    trimmed_x = trimmed_x[slice_x]
    trimmed_y = trimmed_y[slice_y]
    #   Data
    trimmed_data = datap[..., slice_x]
    trimmed_data = trimmed_data[slice_y, ...]

    return trimmed_x, trimmed_y, trimmed_data


def ppmfind(ppm_scale, value):
    """
    Finds the exact ``value`` in ``ppm_scale``.

    Parameters
    ----------
    ppm_scale : 1darray
        Self-explanatory
    value : float
        The value to be found

    Returns
    -------
    index : int
        The index correspondant to ``V`` in ``ppm_scale``
    value_exact : float
        The closest value to ``value`` in ``ppm_scale``
    """
    # Finds the exact 'value' in ppm scale 'ppm_1h'
    # Returns the found value 'V' and its index 'I'
    avgstep = np.abs((ppm_scale[0]-ppm_scale[1])/2)
    index, value_exact = None, None
    for i, delta in enumerate(ppm_scale):
        if value-avgstep <= delta and delta < value+avgstep:
            index = i
            value_exact = ppm_scale[i]
            break
        else:
            continue
    if index is None or value_exact is None:
        raise ValueError('Value {} not found.'.format(value))
    else:
        return index, value_exact


def ppm2freq(x, B0=701.125, o1p=0):
    """
    Converts ``x`` from ppm to Hz.

    Parameters
    ----------
    x : float
        Value to be converted
    B0 : float
        Field frequency, in MHz. Default: 700 MHz
    o1p : float
        Carrier frequency, in ppm. Default: 0.

    Returns
    -------
    y : float
        The converted value
    """
    # Converts 'x' from ppm to Hz.
    # B0 is the frequency of the field in MHz.
    y = (x-o1p)*np.abs(B0)
    return y


def freq2ppm(x, B0=701.125, o1p=0):
    """
    Converts ``x`` from Hz to ppm.

    Parameters
    ----------
    x : float
        Value to be converted
    B0 : float
        Field frequency, in MHz. Default: 700 MHz
    o1p : float
        Carrier frequency, in ppm. Default: 0.

    Returns
    -------
    y : float
        The converted value
    """
    y = x/np.abs(B0) + o1p
    return y


def readlistfile(datafile):
    """
    Takes as input the path of a file containing one entry for each row. Returns a list of the aforementioned entries.

    Parameters
    ----------
    datafile : str
        Path to a file that contains one entry for each row

    Returns
    -------
    files : list
        List of the entries contained in the file
    """
    with open(datafile) as F:
        names = F.readlines()

    files = []
    for i in range(len(names)):
        files.append(names[i].strip())

    return files


def polyn(x, c):
    """
    Computes p(x), polynomion of degree `n-1`, where `n` is the number of provided coefficients.

    Parameters
    ----------
    x : 1darray
        Scale upon which to build the polynomion
    c : list or 1darray
        Sequence of the polynomion coeffiecient, starting from the 0-th order coefficient

    Returns
    -------
    px : 1darray
        Polynomion of degree `n-1`.
    """
    c = np.array(c)
    # Make the Vandermonde matrix of the x-scale
    T = np.array(
            [x**k for k in range(len(c))]
            ).T
    # Compute the polynomion via matrix multiplication
    px = T @ c
    return px


def write_ser(fid, path=None, BYTORDA=0, DTYPA=0, overwrite=True, filename=None, cplx=True):
    """
    Writes the FID file in directory ``path``, in a TopSpin-readable way (i.e. little endian, int32).
    The binary file is named 'fid' if 1D, 'ser' if multiD.
    The parameters BYTORDA and DTYPA can be found in the acqus file.

    +---------------+------------------+--------+
    | BYTORDA = 1   |  big endian      |  '>'   |
    +---------------+------------------+--------+
    | BYTORDA = 0   |  little endian   |  '<'   |
    +---------------+------------------+--------+
    | DTYPA = 0     |  int32           |  'i4'  |
    +---------------+------------------+--------+
    | DTYPA = 2     |  float64         |  'f8'  |
    +---------------+------------------+--------+

    Parameters
    ----------
    fid : ndarray
        FID array to be written
    path : str or Path
        Directory where to save the file. If ``None``, the current working directory is used
    BYTORDA : int
        little/big endian
    DTYPA : int
        int/float
    overwrite : bool
        Overwrite existing directory
    filename : str
        Name for the file
    """
    def uncomplexify_data(data_in, ddtype):
        """ Uncomplexify data (pack real,imag) into a int32 array """
        size = list(data_in.shape)
        size[-1] = size[-1] * 2
        data_out = np.empty(size, dtype=ddtype)
        data_out[..., ::2] = data_in.real
        data_out[..., 1::2] = data_in.imag
        return data_out

    def open_towrite(filename):
        """ Open filename for writing and return file object """
        path = Path(filename)
        path.parent.mkdir(parents=True, exist_ok=True)
        return path.open('wb')

    if path is None:
        path = Path.cwd()

    if BYTORDA == 0:
        endian = '<'
    elif BYTORDA == 1:
        endian = '>'
    else:
        raise ValueError('Endianness not defined')

    if DTYPA == 0:
        dtype = 'i4'
        ddtype = 'int32'
    elif DTYPA == 2:
        dtype = 'f8'
        ddtype = 'float64'
    else:
        raise ValueError('Data type not defined')

    fid = np.squeeze(fid)
    if filename is None:
        if len(fid.shape) == 1:
            filename = 'fid'
        else:
            filename = 'ser'

    bin_path = Path(path) / filename
    if bin_path.exists():
        if overwrite is True:
            bin_path.unlink()
        else:
            what_to_do = input(f'{bin_path} already exists. Overwrite it? [YES/no]') or 'y'
            if what_to_do.lower().startswith('n'):
                return 0
            else:
                bin_path.unlink()
    f = open_towrite(bin_path)
    if np.iscomplexobj(fid):
        fid = uncomplexify_data(fid, ddtype)
    print('Writing \'{}\' file in {}...'.format(filename, path), c='tab:blue')
    f.write(fid.astype(endian + dtype).tobytes())
    f.close()
    print('Done.', c='tab:blue')


def load_ser(path, TD1=1, BYTORDA=0, DTYPA=0, cplx=True):
    """
    Reads a binary file and transforms it in an array.
    The parameters BYTORDA and DTYPA can be found in the acqus file.

    +---------------+------------------+--------+
    | BYTORDA = 1   |  big endian      |  '>'   |
    +---------------+------------------+--------+
    | BYTORDA = 0   |  little endian   |  '<'   |
    +---------------+------------------+--------+
    | DTYPA = 0     |  int32           |  'i4'  |
    +---------------+------------------+--------+
    | DTYPA = 2     |  float64         |  'f8'  |
    +---------------+------------------+--------+

    Parameters
    ----------
    path : str
        Path to the file to read
    TD1 : int
        Number of experiments in the indirect dimension
    BYTORDA : int
        Endianness of data
    DTYPA : int
        Data type format
    cplx : bool
        If True, the input data are interpreted as complex, which means that in the direct dimension there will be real and imaginary parts alternated.

    Returns
    -------
    data : 2darray
        Array of data.
    """
    def complexify_data(data):
        """
        Complexify data packed real, imag.
        """
        return data[..., ::2] + data[..., 1::2] * 1.j

    # Evaluate endianness
    if BYTORDA == 0:
        endian = '<'
    elif BYTORDA == 1:
        endian = '>'
    else:
        raise ValueError('Endianness not defined')

    # Evaluate data format
    if DTYPA == 0:
        dtype = 'i4'
    elif DTYPA == 2:
        dtype = 'f8'
    else:
        raise ValueError('Data type not defined')

    # Read the binary file
    with open(path, 'rb') as f:
        data = np.frombuffer(f.read(), dtype=endian+dtype)

    # Binary data do not have array-like dimensions:
    # Reshape them to your will
    if TD1 < 2:  # i.e. it is 0 or 1
        shape = -1,     # tuple, so that it is 1darray at the end
    else:       # all other dimensions
        shape = TD1, -1   # tuple also here
    # Reshape data according to TD1
    data = np.reshape(data, shape)

    if cplx:    # Uncomplexify
        data = complexify_data(data)

    print(f'Binary file {path} has been successfully read.', c='tab:blue')
    return data


def pretty_scale(ax, limits, axis='x', n_major_ticks=10, *, minor_each=5, fmt=None):
    """
    This function computes a pretty scale for your plot.
    Calculates and sets a scale made of ``n_major_ticks`` numbered ticks, spaced by ``minor_each`` unnumbered ticks.
    After that, the plot borders are trimmed according to the given limits.

    Parameters
    ----------
    ax : matplotlib.AxesSubplot object
        Panel of the figure of which to calculate the scale
    limits : tuple
        limits to apply of the given axis. (left, right)
    axis : str
        'x' for x-axis, 'y' for y-axis, 'z' for z-axis
    n_major_ticks : int
        Number of numbered ticks in the final scale. An oculated choice gives very pleasant results.
    minor_each : int
        Number of divisions for each interval between two major ticks
    fmt : str
        String-formatting for the numbers on the axis. Should be given as e.g. '.3f'
    """
    # Convert the input format to something matplotlib can understand
    # Define optimal division steps
    steps = [1, 2, 4, 5, 10]
    # Compute location of major ticks
    majorlocs = mpl.ticker.MaxNLocator(nbins=n_major_ticks, steps=steps)
    # Compute location of minor ticks
    minorlocs = mpl.ticker.AutoMinorLocator(minor_each)

    # Find on which axis to apply modifications
    if axis == 'x':
        target_axis = ax.xaxis
        lim_function = ax.set_xlim
    elif axis == 'y':
        target_axis = ax.yaxis
        lim_function = ax.set_ylim
    elif axis == 'z':
        target_axis = ax.yaxis
        lim_function = ax.set_zlim
    else:
        raise ValueError(f'Unrecognized Axis "{axis}"')

    # Apply the given settings
    target_axis.set_major_locator(majorlocs)
    target_axis.set_minor_locator(minorlocs)
    if fmt:  # Change the format only if explicitely given
        fmt = r'{x:' + fmt + r'}'
        # Here it is passed as mpl.ticker.ScalarFormatter
        target_axis.set_major_formatter(fmt)

    # Set the limits on the given axis
    lim_function(limits)


def molfrac(n):
    """
    Computes the "molar fraction" ``x`` of the array ``n``.
    Also computes the total amount.

    Parameters
    ----------
    n : list or 1darray
        list of values

    Returns
    -------
    x : list or 1darray
        molar fraction array
    N : float
        sum of all the elements in ``n``
    """
    if isinstance(n, list):
        n = np.array(n)
    N = np.sum(n)
    x = [n[i]/N for i in range(len(n))]
    if isinstance(n, list):
        x = np.array(x)
    return x, N


def split_acqus_2D(acqus):
    """
    Split the acqus dictionary of a 2D spectrum into two separate 1D-like acqus dictionaries.

    Parameters
    ----------
    acqus : dict
        acqus dictionary of a 2D spectrum

    Returns
    -------
    acqu1s : dict
        acqus dictionary of the indirect dimension
    acqu2s : dict
        acqus dictionary of the direct dimension
    """
    keys = ['B0', 'nuc', 'o1p', 'SWp', 'TD', 'SFO1', 'SW', 'dw', 't1', 'o1', 'AQ1']
    acqu1v = [
            acqus['B0'],
            acqus['nuc1'],
            acqus['o1p'],
            acqus['SW1p'],
            acqus['TD1'],
            acqus['SFO1'],
            acqus['SW1'],
            acqus['dw1'],
            acqus['t1'],
            acqus['AQ1'],
            acqus['o1']]
    acqu2v = [
            acqus['B0'],
            acqus['nuc2'],
            acqus['o2p'],
            acqus['SW2p'],
            acqus['TD2'],
            acqus['SFO2'],
            acqus['SW2'],
            acqus['dw2'],
            acqus['t2'],
            acqus['AQ2'],
            acqus['o2']]
    acqu1s = {}
    acqu2s = {}
    for k, key in enumerate(keys):
        acqu1s[key] = acqu1v[k]
        acqu2s[key] = acqu2v[k]
    return acqu1s, acqu2s


def split_procs_2D(procs):
    """
    Split the procs dictionary of a 2D spectrum into two separate 1D-like procs dictionaries.

    Parameters
    ----------
    procs : dict
        procs dictionary of a 2D spectrum

    Returns
    -------
    proc1s : dict
        procs dictionary of the indirect dimension
    proc2s : dict
        procs dictionary of the direct dimension
    """
    keys = ['wf', 'zf', 'fcor', 'tdeff', 'p0', 'p1', 'pv']
    proc1v = [
            procs['wf'][0],
            procs['zf'][0],
            procs['fcor'][0],
            procs['tdeff'][0],
            procs['p0_1'],
            procs['p1_1'],
            procs['pv_1']]
    proc2v = [
            procs['wf'][1],
            procs['zf'][1],
            procs['fcor'][1],
            procs['tdeff'][1],
            procs['p0_2'],
            procs['p1_2'],
            procs['pv_2']]
    proc1s = {}
    proc2s = {}
    for k, key in enumerate(keys):
        proc1s[key] = proc1v[k]
        proc2s[key] = proc2v[k]
    return proc1s, proc2s


def nuc_format(nuc):
    """
    Converts the ``'nuc'`` key you may find in acqus in the formatted label, e.g. ``'13C'`` -> ``'$^{13}$C'``

    Parameters
    ----------
    nuc : str
        Unformatted string

    Returns
    -------
    fnuc : str
        Formatted string.
    """
    import re
    fnuc = re.split(r'(\D+)', nuc)
    f_nuc = '$^{' + str(fnuc[0]) + '}$'+str(fnuc[1])
    return f_nuc


def set_ylim(ax, data_inp):
    """
    Sets the y-limits of ``ax`` by calling :func:`klassez.misc.get_ylim` to compute the values.

    Parameters
    ----------
    ax : matplotlib.Subplot Object
        Panel of the figure where to apply this scale
    data_inp : ndarray or list
        Input data. If it is a list, ``data_inp`` is converted to array.
    """
    try:    # T and B can raise errors in certain situations
        ax.set_ylim(*misc.get_ylim(data_inp))
    except Exception:
        pass


def get_ylim(data_inp):
    """
    Calculates the y-limits of ax as follows:

        * Bottom:     ``min(data) - 0.05 * ``height``
        * Top:        ``max(data) + 0.05 * ``height``

    where ``height = max(data) - min(data)``.

    Parameters
    ----------
    data_inp : ndarray or list
        Input data. If it is a list, ``data_inp`` is converted to array.

    Returns
    -------
    lims : tuple
        Bottom, Top
    """
    if isinstance(data_inp, list):
        datain = np.concatenate(data_inp)
    else:
        datain = np.copy(data_inp)

    B = np.min(datain.real)
    T = np.max(datain.real)
    H = np.abs(T - B)

    if H:
        lims = B - 0.05 * H, T + 0.05 * H,
    else:
        lims = -0.01, 0.01
    return sorted(lims)


def mathformat(ax, axis='y', limits=(-2, 2)):
    """
    Apply exponential formatting to the given axis of the given figure panel. The offset text size is uniformed to the tick labels' size.

    Parameters
    ----------
    ax: matplotlib.Subplot Object
        Panel of the figure to edit
    axis: str
        'x', 'y' or 'both'.
    limits: tuple
        tuple of ints that indicate the order of magnitude range outside which the exponential format is applied.
    """
    ax.ticklabel_format(axis=axis, style='scientific', scilimits=limits, useMathText=True)
    if axis == 'y' or axis == 'both':
        tmp = (ax.get_yticklabels())
        fontsize = tmp[0].get_fontsize()
        ax.yaxis.get_offset_text().set_size(fontsize)

    if axis == 'x' or axis == 'both':
        tmp = (ax.get_xticklabels())
        fontsize = tmp[0].get_fontsize()
        ax.xaxis.get_offset_text().set_size(fontsize)


def set_fontsizes(ax, fontsize=10):
    """
    Automatically adjusts the fontsizes of all the figure elements.
    In particular:

        * title = ``fontsize``
        * axis labels = ``fontsize`` - 2
        * ticks labels = ``fontsize`` - 3
        * legend entries = ``fontsize`` - 4


    Parameters
    ----------
    ax : matplotlib.Subplot Object
        Subplot of interest
    fontsize : float
        Starting fontsize
    """

    # ---------------------------------------------------------------------
    def _modify_legend(ax, **kwargs):
        """
        Copied from StackOverflow:
            https://stackoverflow.com/questions/23689728/how-to-modify-matplotlib-legend-after-it-has-been-created
        """

        leg = ax.legend_
        defaults = dict(
            loc=leg._loc,
            numpoints=leg.numpoints,
            markerscale=leg.markerscale,
            scatterpoints=leg.scatterpoints,
            scatteryoffsets=leg._scatteryoffsets,
            prop=leg.prop,
            borderpad=leg.borderpad,
            labelspacing=leg.labelspacing,
            handlelength=leg.handlelength,
            handleheight=leg.handleheight,
            handletextpad=leg.handletextpad,
            borderaxespad=leg.borderaxespad,
            columnspacing=leg.columnspacing,
            ncol=leg._ncols,
            mode=leg._mode,
            fancybox=type(leg.legendPatch.get_boxstyle()) == mpl.patches.BoxStyle.Round,
            shadow=leg.shadow,
            title=leg.get_title().get_text() if leg._legend_title_box.get_visible() else None,
            framealpha=leg.get_frame().get_alpha(),
            bbox_to_anchor=leg.get_bbox_to_anchor()._bbox,
            bbox_transform=leg.get_bbox_to_anchor()._transform,
            frameon=leg.draw_frame,
            handler_map=leg._custom_handler_map,
        )

        if "fontsize" in kwargs and "prop" not in kwargs:
            defaults["prop"].set_size(kwargs["fontsize"])

        ax.legend(**dict(list(defaults.items()) + list(kwargs.items())))
    # ---------------------------------------------------------------------

    # Set the dimensions
    title_font = fontsize
    label_font = fontsize - 2
    ticks_font = fontsize - 3
    legen_font = fontsize - 4

    ax.title.set_fontsize(title_font)                   # title
    ax.xaxis.label.set_fontsize(label_font)             # xlabel
    ax.yaxis.label.set_fontsize(label_font)             # xlabel
    # Ticks
    for label in (ax.get_xticklabels() + ax.get_yticklabels()):
        label.set_fontsize(ticks_font)
    # Offset text
    ax.xaxis.get_offset_text().set_size(ticks_font)
    ax.yaxis.get_offset_text().set_size(ticks_font)

    # Legend
    if ax.legend_ is not None:
        _modify_legend(ax, fontsize=legen_font)


def hankel(data, n=None):
    """
    Computes a Hankel matrix from data.
    If ``data`` is a `1darray` of length `N`, computes the correspondant Hankel matrix of dimensions `(N-n+1, n)`.
    If ``data`` is a `2darray`, computes the closest Hankel matrix in the Frobenius norm sense by averaging the values on the antidiagonals.

    Parameters
    ----------
    data : 1darray or 2darray
        Vector to be Hankel-ized, of length N
    n : int
        Number of columns that the Hankel matrix will have

    Returns
    -------
    H : 2darray
        Hankel matrix of dimensions `(N-n+1, n)`
    """
    if isinstance(data, np.ndarray):
        if len(data.shape) == 1:
            if n is None:
                raise ValueError('You must specify the number of columns of the Hankel matrix.')
            H = slinalg.hankel(data[:n], data[n-1:]).T
        elif len(data.shape) == 2:
            H = misc.avg_antidiag(data)
        else:
            raise ValueError('{}D arrays are not supported.'.format(len(data.shape)))
    else:
        raise ValueError('Input data is not an array.')
    return H


def unhankel(H):
    """
    Concatenates the first row and the last column of the matrix ``H``, which should have Hankel-like structure, so to build the array of independent parameters.

    Parameters
    ----------
    H : 2darray
        Hankel-like matrix

    Returns
    -------
    h : 1darray
        First row and last column, concatenated
    """
    h = np.concatenate((H[0, :], H[1:, -1]), axis=-1)
    return h


def avg_antidiag(X):
    """
    Given a matrix ``X`` without any specific structure, finds the closest Hankel matrix in the Frobenius norm sense by averaging the antidiagonals.

    Parameters
    ----------
    X : 2darray
        Input matrix

    Returns
    -------
    Xp : 2darray
        Hankel matrix obtained from ``X``
    """
    m, n = X.shape  # Get dimensions of X
    N = m + n - 1   # Degrees of freedom that Xp will have
    data = np.array([np.mean(np.diag(X[:, ::-1], w)) for w in range(-N+n, n)])[::-1]      # Mean on the antidiagonals
    Xp = misc.hankel(data, n)    # Transform the "data" array into a matrix

    return Xp


def show_cmap(cmap, N=10, start=0, end=1, filename=None):
    """
    Plot the colors extracted from a colormap.

    Parameters
    ----------
    cmap : matplotlib.Colormap Object
        The colormap from which you want to extract the list of colors
    N : int
        Number of samples to extract
    start : float
        Start point of the sampling. 0 = beginning of the cmap; 1 = end of the cmap.
    end : float
        End point of the sampling. 0 = beginning of the cmap; 1 = end of the cmap.
    filename : str or None
        Filename of the figure to be saved. The ".png" extension is added automatically. If None, the figure is shown instead
    """

    x = np.linspace(start, end, N)
    colors = cmap2list(cmap, N, start, end)

    # To fill the space
    width = (end - start) / (N - 1)

    # Make the figure
    fig = plt.figure('Colormap Sample')
    fig.set_size_inches(12, 3)
    fig.subplots_adjust(left=0.01, right=0.99)
    ax = fig.add_subplot()

    ax.set_title(cmap.name)

    # Draw the colors
    ax.bar(x, 1, width=width, bottom=None, align='center', data=None, color=colors)

    # Remove the white spaces
    ax.set_xlim(start-width/2, end+width/2)
    ax.set_ylim(0, 1)
    ax.tick_params(axis='y', left=False, labelleft=False)

    if filename:
        plt.savefig(f'{filename}.png', dpi=400)
    else:
        plt.show()
    plt.close()


def cmap2list(cmap, N=10, start=0, end=1):
    """
    Extract the colors from a colormap and returns it as a list.

    Parameters
    ----------
    cmap : matplotlib.Colormap Object
        The colormap from which you want to extract the list of colors
    N : int
        Number of samples to extract
    start : float
        Start point of the sampling. 0 = beginning of the cmap; 1 = end of the cmap.
    end : float
        End point of the sampling. 0 = beginning of the cmap; 1 = end of the cmap.

    Returns
    -------
    colors : list
        List of the extracted colors.
    """
    x = np.linspace(start, end, N)
    colors = cmap(x)
    return colors


def edit_checkboxes(checkbox, xadj=0, yadj=0, dim=100, color=None):
    """
    Edit the size of the box to be checked, and adjust the lines accordingly.

    Parameters
    ----------
    checkbox : matplotlib.widgets.CheckBox Object
        The checkbox to edit
    xadj : float
        modifier value for bottom left corner x-coordinate of the rectangle, in ``checkbox.ax`` coordinates
    yadj : float
        modifier value for bottom left corner y-coordinate of the rectangle, in ``checkbox.ax`` coordinates
    dim : float
        Area of the square, in pixels. Default value is 25
    color : str or list or None
        If it is not None, change color to the lines
    """

    # Get number of labels
    n_labels = len(checkbox.labels)

    # Get the original positions of the frames
    xy_frames = checkbox._frames.get_offsets()
    # Adjust the positions of the frames according to xadj and yadj
    xy_frames = [[x+xadj, y+yadj] for x, y in xy_frames]
    # All frames must have same dimension
    dim_frames = [dim for w in range(n_labels)]

    # Make a dictionary with the new parameters
    props = {
            'offsets': xy_frames,
            'sizes': dim_frames,
            }
    # Set the new properties of the frames
    checkbox.set_frame_props(props)

    if color:   # Add the option for coloring the lines
        if isinstance(color, str):  # One color fits all
            props['facecolors'] = [color for w in range(n_labels)]
        else:
            if len(color) < n_labels:
                raise ValueError('Not enough colors for all the checkboxes!')
            props['facecolors'] = [color[w] for w in range(n_labels)]
    else:
        props['facecolors'] = [checkbox.labels[w].get_color() for w in range(n_labels)]
    # Set the new properties of the checks
    # They are the same of the frame to keep the fitting
    checkbox.set_check_props(props)


def binomial_triangle(n):
    """
    Calculates the n-th row of the binomial triangle. The first row is ``n=1``, not ``n=0``.

    Parameters
    ----------
    n : int
        Row index

    Returns
    -------
    row : 1darray
        The n-th row of binomial triangle.

    Examples
    --------
    >>> binomial_triangle(4)
    1 3 3 1

    """

    row = []
    n -= 1
    for k in range(n+1):
        row.append(math.comb(n, k))
    return np.array(row)


def data2wav(data, filename='audiofile', cutoff=None, rate=44100):
    """
    Converts an array of data in a .wav file.
    The data are converted in `float32` format, then normalized to fit the (-1, 1) interval

    Parameters
    ----------
    data: ndarray
        Data to listen to
    filename: str
        Filename for the .wav file, without extension
    cutoff: float or None
        Clipping borders for the audio. If None, no clipping is performed
    rate: int
        Sample rate in samples/sec
    """
    def uncomplexify_data(data):
        """ Transform array z: z_k = x_k + 1j y_k in array Z: Z_{2k} = x_k, Z_{2k+1} = y_k """
        if not np.iscomplexobj(data):   # do nothing!
            return data
        else:
            # Create new array with last dimension twice as long as the original one
            newdata = np.zeros((*data.shape[:-1], data.shape[-1] * 2))
            # Write real part in even positions, imaginary part in odd positions
            newdata[..., ::2] = data.real
            newdata[..., 1::2] = data.imag
            return newdata

    # Convert complex data to real
    data = uncomplexify_data(np.copy(data)).flatten().astype(np.float64)
    if cutoff:
        # Clip the data according to cutoff
        data = np.clip(data, -cutoff, cutoff)
    # Normalize data so they fall in [-1, 1]
    data /= max(np.abs(data))
    # Write the .wav file
    WF.write(f'{filename}.wav', rate, data)
    print(f'Audio file saved as {filename}.wav', c='tab:blue')


def zero_crossing(array, after=False):
    """
    Find the indices where the elements in the array change sign.
    The identified positions are the ones before the sign changes.
    This behavior can be modified by setting ``after=True``.

    Parameters
    ----------
    array : 1darray
        Data to analyze
    after : bool
        If True, returns the indices of the element after the sign change; if False, the indices before.

    Returns
    -------
    zerocross : 1darray
        Position of the zero-crossing, according to 'after'
    """
    zerocross = np.where(np.diff(np.sign(array)))[0]
    if after:
        zerocross += 1
    return zerocross


def merge_dict(*dics):
    """
    Merge a sequence of dictionaries in a single dictionary.

    Parameters
    ----------
    dics : sequence of dict
        Dictionaries to merge

    Returns
    -------
    merged_dict : dict
        Merged dictionary
    """
    merged_dict = {k: v for d in dics for k, v in d.items()}
    return merged_dict


def sum_overlay(y1, y2, x0, x=None):
    """
    Compute the sum of two arrays ``y1`` and ``y2`` of different dimensions.
    The sum starts at ``x0`` (on the scale ``x``) as left anchor point.

    Parameters
    ----------
    y1 : 1darray
        Original array
    y2 : 1darray
        Array to sum
    x0 : float
        Value on the ``x`` scale as left anchor point
    x : 1darray
        Reference scale. If None, the index points is used.

    Returns
    -------
    ym : 1darray
        Summed arrays
    """
    if x is None:
        # Compute the index points
        x = np.arange(len(y1))
    # Find left anchor point
    x0p = misc.ppmfind(x, x0)[0]
    # Find right anchor point
    x1p = x0p + len(y2)
    # Make the sum
    ym = np.copy(y1)
    ym[x0p:x1p] += y2
    return ym


def lenslice(a):
    """
    Calculates the length of a slice, i.e. the length an array would have when sliced with this slice.

    Parameters
    ----------
    a : slice
        Slice of which to calculate the length

    Returns
    -------
    length : int
        Length of the slice
    """
    if a.step is None:
        step = 1
    else:
        step = a.step
    length = int(a.stop - a.start) // step
    return length


def listsqueeze(lst):
    """
    Removes empty annidated lists.

    Parameters
    ----------
    lsti : list
        List to process

    Returns
    -------
    a : list
        Squeezed list
    """
    a = deepcopy(lst)
    while isinstance(a, list) and len(a) == 1:
        if isinstance(a[0], list):
            a = a[0]
        else:
            break
    return a


def detect_jumps(a):
    """
    Detects where array ``a`` changes value.

    Parameters
    ----------
    a : 1darray
        Mask

    Returns
    -------
    starts : list
        Left border of the windows /pt
    ends : list
        Right border of the windows /pt
    """
    # First derivative
    diff = np.diff(np.concatenate(([0], a, [0])))
    # Raises
    starts = np.where(diff == 1)[0]
    # Lowers
    ends = np.where(diff == -1)[0]
    # If it is all constant, raises at the beginning and ends at the end
    if not (len(starts)) and not (len(ends)):
        starts, ends = [0], [len(a)]
    return starts, ends


def key_to_limits(keys):
    """
    Converts the key of a dictionary that identifies for a ppm range to the actual limits to be used.

    Parameters
    ----------
    keys : str or list of str
        Format of the string to process: ``'ppm1:ppm2'``

    Returns
    -------
    limits : ndarray
        Limits of the form ``[[ppm1, ppm2] for _ in len(keys)]``

    """
    if isinstance(keys, str):
        keys = [keys]
    lims = np.array([
        [eval(value) for value in key.split(':', 1)]
        for key in keys
        ])
    return np.squeeze(lims)


def limits_to_key(limits):
    """
    Converts the key of a dictionary that identifies for a ppm range to the actual limits to be used.

    Parameters
    ----------
    limits : ndarray
        Limits of the form ``[[ppm1, ppm2] for _ in len(keys)]``

    Returns
    -------
    keys : str or list of str
        Format of the string to process: ``'ppm1:ppm2'``

    """
    if len(np.asarray(limits).shape) == 1:
        limits = [limits]
    keys = [
        ':'.join([f'{w:.3f}' for w in lims])
        for lims in limits
        ]
    if len(keys) == 1:
        keys = keys[0]
    return keys


def expformat(num, df=3):
    r"""
    Converts numbers like ``2e-5`` to ``$2 \times 10^{-5}$``, i.e.
    that will be displayed as :math:`2 \times 10^{-5}` in elements that can render
    latex text.

    Parameters
    ----------
    num : float
        Number to convert
    df : int
        Number of decimal figures to consider

    Returns
    -------
    str_num : str
        Processed string
    """
    str_num = f'{num:.{df}e}'
    str_num = r'$' + str_num.replace('e', r'\times 10^{') + r'}$'
    return str_num

def get_extent(data):
    """
    Compute the extent of an array as ``np.max(data) - np.min(data)``.

    Parameters
    ----------
    data : ndarray
        Input data

    Returns
    -------
    extent : float
        ``max(data) - min(data)``
    """
    return np.max(data) - np.min(data)
        
