#! /usr/bin/env python3

import os
import io
import sys
import numpy as np
from pathlib import Path
from scipy.signal import find_peaks, peak_widths
from csaps import csaps
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button, RadioButtons, TextBox, CheckButtons, Cursor, SpanSelector
import lmfit
from datetime import datetime
import warnings
from copy import deepcopy
import getpass

from . import fit, misc, sim, figures, processing, anal
from .config import CM, COLORS, cron, safe_kws, cprint

print = cprint

"""
Functions for performing fits.
"""

s_colors = ['tab:cyan', 'tab:red', 'tab:green', 'tab:purple', 'tab:pink', 'tab:gray', 'tab:brown', 'tab:olive', 'salmon', 'indigo']


def histogram(data, nbins=100, density=True, f_lims=None, xlabel=None, x_symm=False, fitG=True, barcolor='tab:blue', fontsize=10, filename=None, ext='png', dpi=600):
    """
    Computes an histogram of ``data`` and tries to fit it with a gaussian lineshape.
    The parameters of the gaussian function are calculated analytically directly from ``data`` using ``scipy.stats.norm``

    Parameters
    ----------
    data : ndarray
        the data to be binned
    nbins : int
        number of bins to be calculated
    density : bool
        True for normalize data
    f_lims : tuple or None
        limits for the x axis of the figure
    xlabel : str or None
        Text to be displayed under the x axis
    x_symm : bool
        set it to True to make symmetric x-axis with respect to 0
    fitG : bool
        Shows the gaussian approximation
    barcolor : str
        Color of the bins
    fontsize : float
        Biggest fontsize in the figure
    filename : str or Path
        name for the figure to be saved
    ext : str
        Format of the image
    dpi : int
        Resolution of the image in dots per inches

    Returns
    -------
    m : float
        Mean of data
    s : float
        Standard deviation of data.

    .. seealso::

        :func:`klassez.fit.ax_histogram`
    """
    filename = Path(filename)

    fig = plt.figure('Histogram')
    ax = fig.add_subplot()
    plt.subplots_adjust(left=0.10, bottom=0.10, top=0.90, right=0.95)
    fig.set_size_inches(figures.figsize_large)

    m, s = fit.ax_histogram(ax, data, nbins=nbins, density=density, f_lims=f_lims, xlabel=xlabel, x_symm=x_symm, fitG=fitG, barcolor=barcolor, fontsize=fontsize)

    if filename:
        print(f'Saving {filename}.{ext}...', c='tab:cyan')
        plt.savefig(filename.with_suffix(f'.{ext}'), dpi=dpi)
    else:
        plt.show()
    plt.close()
    print('Done.', c='tab:cyan')

    return m, s


def ax_histogram(ax, data0, nbins=100, density=True, f_lims=None, xlabel=None, x_symm=False, fitG=True, barcolor='tab:blue', fontsize=10):
    """
    Computes an histogram of ``data`` and tries to fit it with a gaussian lineshape.
    The parameters of the gaussian function are calculated analytically directly from ``data`` using ``scipy.stats.norm``

    Parameters
    ----------
    ax : matplotlib.subplot Object
        panel of the figure where to put the histogram
    data0 : ndarray
        the data to be binned
    nbins : int
        number of bins to be calculated
    density : bool
        True for normalize data
    f_lims : tuple or None
        limits for the x axis of the figure
    xlabel : str or None
        Text to be displayed under the x axis
    x_symm : bool
        set it to True to make symmetric x-axis with respect to 0
    fitG : bool
        Shows the gaussian approximation
    barcolor : str
        Color of the bins
    fontsize : float
        Biggest fontsize in the figure

    Returns
    -------
    m : float
        Mean of data
    s : float
        Standard deviation of data.
    """

    if len(data0.shape) > 1:
        data = data0.real.flatten()
    else:
        data = np.copy(data0.real)

    if x_symm:
        lims = (-max(np.abs(data)), max(np.abs(data)))
    else:
        lims = (min(data), max(data))

    hist, bin_edges = np.histogram(data, bins=nbins, range=lims, density=density)   # Computes the bins for the histogram

    x = np.linspace(lims[0], lims[1], len(data))        # Scale for a smooth gaussian
    m, s = np.mean(data), np.std(data)                  # Get mean and standard deviation of 'data'

    if density:
        A = 1
    else:
        A = np.trapz(hist, dx=bin_edges[1]-bin_edges[0])    # Integral

    if fitG:
        fit_g = sim.f_gaussian(x, m, s, A)  # Gaussian lineshape

    ax.hist(data, color=barcolor, density=density, bins=bin_edges)
    if fitG:
        ax.plot(x, fit_g, c='r', lw=0.6, label='Gaussian approximation')
        ax.legend(loc='upper right')

    if density:
        ax.set_ylabel('Normalized count')
    else:
        ax.set_ylabel('Count')
    if xlabel:
        ax.set_xlabel(xlabel)

    if f_lims:
        misc.pretty_scale(ax, f_lims, 'x')
    else:
        misc.pretty_scale(ax, ax.get_xlim(), 'x')
    misc.pretty_scale(ax, ax.get_ylim(), 'y')

    misc.mathformat(ax, axis='both', limits=(-3, 3))

    misc.set_fontsizes(ax, fontsize)

    return m, s


def bin_data(data0, nbins=100, density=True, x_symm=False):
    """
    Computes the histogram of data, sampling it into nbins bins.

    Parameters
    ----------
    data : ndarray
        the data to be binned
    nbins : int
        number of bins to be calculated
    density : bool
        True for normalize data
    x_symm : bool
        set it to True to make symmetric x-axis with respect to 0

    Returns
    -------
    hist : 1darray
        The bin intensity
    bin_scale : 1darray
        Scale built with the mean value of the bin widths.
    """
    if len(data0.shape) > 1:
        data = data0.flatten()
    else:
        data = np.copy(data0)

    if x_symm:
        lims = (- max(np.abs(data)), max(np.abs(data)))
    else:
        lims = (min(data), max(data))

    hist, bin_edges = np.histogram(data, bins=nbins, range=lims, density=density)   # Computes the bins for the histogram
    bin_scale = np.array([np.mean((bin_edges[k], bin_edges[k+1])) for k in range(len(bin_edges) - 1)])
    return hist, bin_scale


def lr(y, x=None, force_intercept=False, w=None):
    r"""
    Performs a linear regression of ``y`` with a model :math:`y_c = mx + q`.

    If ``w=None`` then ``w = np.ones_like(x)``.

    If ``force_intercept=False``:

    .. math::

        m = \frac{\sum w x y}{\sum w x^2}, \quad q = 0

    else, two more parameters are defined:

    .. math::

        x_w = \frac{ \sum w x }{ \sum w }, \quad y_w = \frac{ \sum w y }{ \sum w }

    .. math::

        m = \frac{ \sum w (x-x_w) (y-y_w)}{\sum w (x - x_w)^2}, \quad
        q = y_w - m\,x_w



    Parameters
    ----------
    y : 1darray
        Data to be fitted
    x : 1darray
        Independent variable. If None, the point indexes are used.
    force_intercept : bool
        If True, forces the intercept to be zero.
    w : 1darray or None
        Weights to be used for the linear regression.

    Returns
    -------
    y_c : 1darray
        Fitted trend
    values : tuple
        ``(m, q)``
    """
    # Make the scale of points, if not given
    if x is None:
        x = np.arange(y.shape[-1])
    if w is None:
        w = np.ones(y.shape[-1])

    if force_intercept:     # It is x
        m = np.sum(w * x * y) / np.sum(w * x**2)
        q = 0
    else:                   # it is [1, x]
        xw = np.sum(w * x) / np.sum(w)
        yw = np.sum(w * y) / np.sum(w)
        m = np.sum(w * (x - xw) * (y - yw)) / np.sum(w * (x - xw)**2)
        q = yw - m * xw

    # Compute the model
    y_c = m * x + q
    return y_c, (m, q)


def calc_R2(y, y_c):
    r"""
    Computes the R-squared coefficient of a linear regression as:

    .. math::

        R^2 = 1 - \frac{ \sum (y - <y>)^2  }{ \sum (y - y_c)^2 }


    Parameters
    ----------
    y : 1darray
        Experimental data
    y_c : 1darray
        Calculated data

    Returns
    -------
    R2 : float
        R-squared coefficient
    """
    sst = np.sum((y - np.mean(y))**2)
    sse = np.sum((y - y_c)**2)
    R2 = 1 - sse/sst
    return R2


def fit_int(y, y_c, q=True):
    r"""
    Computes the optimal intensity and intercept of a linear model in the least squares sense.
    Let :math:`y` be the experimental data and :math:`y_c` the model, and let :math:`<w>` the mean of variable :math:`w`.
    Then, if ``q=False``:

    .. math::

        A = \frac{ <y_c y> }{ <y_c^2> }, \qquad
        q = 0

    else:

    .. math::

        A = \frac{ <y_c y> - <y_c><y> }{ <y_c^2> - <y_c>^2 }, \qquad
        q = \frac{ <y_c>^2<y> - <y_c><y_c y> }{ <y_c^2> - <y_c>^2 }


    Parameters
    ----------
    y : 1darray
        Experimental data
    y_c : 1darray
        Model data
    q : bool
        If True, includes the offset in the calculation. If False, only the intensity factor is computed.

    Returns
    -------
    A : float
        Optimized intensity
    q : float
        Optimized intercept
    """

    if q:
        # Apply the formulaes, numerator only
        A = np.mean(y_c * y) - np.mean(y_c) * np.mean(y)
        q = np.mean(y_c**2) * np.mean(y) - np.mean(y_c) * np.mean(y * y_c)

        # Compute denominator
        Q = np.mean(y_c**2) - np.mean(y_c)**2
        # Divide
        A /= Q
        q /= Q
    else:
        # Apply other formula
        A = np.mean(y_c * y) / np.mean(y_c**2)
        q = 0

    return A, q


def get_region(ppmscale, S, fig_title='Region Selector'):
    """
    Interactively select the spectral region to be fitted.
    Returns the border ppm values.

    Parameters
    ----------
    ppmscale : 1darray
        The ppm scale of the spectrum
    S : 1darray
         The spectrum to be trimmed
    fig_title : str
        Title for the interactive figure panel

    Returns
    -------
    reg_lims : list of tuple
        Limits on ``ppmscale`` selected by the GUI.
    """

    # Creation of interactive figure panel
    fig = plt.figure(fig_title)
    fig.set_size_inches(15, 8)
    plt.subplots_adjust(left=0.065, bottom=0.115, right=0.84, top=0.90)    # Make room for the sliders
    ax = fig.add_subplot()

    # Limits of the x axis
    xlims = [max(ppmscale), min(ppmscale)]

    # Placeholders
    reg_lims = []       # container for the regions
    reg_spans = []      # container for the green spans
    sel_idx = None      # Selected region

    # Placeholder for the red text
    sel_text = fig.text(0.92, 0.80, '', ha='center', va='center', transform=fig.transFigure, fontsize=14, color='tab:red')

    # Make boxes for widgets
    add_box = plt.axes([0.855, 0.84, 0.06, 0.06])
    remove_box = plt.axes([0.925, 0.84, 0.06, 0.06])
    save_box = plt.axes([0.860, 0.05, 0.12, 0.06])

    # Box to list the selected regions
    list_box = plt.axes([0.855, 0.15, 0.13, 0.55])
    list_box.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
    list_box.set_title('Selected regions')

    all_text = list_box.text(0.5, 0.95, '', ha='center', va='top', transform=list_box.transAxes, fontsize=14, color='tab:green')

    # Make buttons
    add_button = Button(add_box, 'ADD', hovercolor='0.875')
    save_button = Button(save_box, 'SAVE and EXIT', hovercolor='0.875')
    remove_button = Button(remove_box, 'REMOVE', hovercolor='0.875')

    #   SLOTS
    def onselect(xmin, xmax):
        """ Connected to the slider, changes the red text """
        sel_text.set_text(f'{xmax:8.3f}:{xmin:8.3f}')
        fig.canvas.draw()

    def write_rlims():
        """ Writes the limits in the box """
        w_str = [f'{max(w):8.3f}:{min(w):8.3f}' for w in reg_lims]
        all_text.set_text('\n'.join(w_str))
        fig.canvas.draw()

    def add(event):
        """ ADD button """
        # Get limits from the span selector
        xmin, xmax = span.extents
        # Do not allow less than 5 points selection
        if xmax - xmin < 5 * misc.calcres(ppmscale):
            return
        # Update the limits
        reg_lims.append((xmax, xmin))
        # Draw the green span and add it to the list
        tmp_span = ax.axvspan(xmin, xmax, color='tab:green', alpha=0.2, zorder=10)
        reg_spans.append(tmp_span)
        # Set the spanselector invisible otherwise it looks ugly as shit
        span.set_visible(False)
        # Write the green text
        write_rlims()

    def remove(event):
        """ REMOVE button """
        # You have selected a peak
        if sel_text.get_color() == 'violet' and sel_idx is not None:
            #  Remove the selected entry from the list of limits
            _ = reg_lims.pop(sel_idx)
            # Extract the green span and do not draw it anymore
            kill_span = reg_spans.pop(sel_idx)
            kill_span.remove()
            # Update the green text
            write_rlims()
            fig.canvas.draw()

    def get_index_selected(x):
        """ Looks in the limits list if ``x`` is inside one region """
        for idx, lims in enumerate(reg_lims):
            if min(lims) < x and x < max(lims):
                return idx

    def on_click(event):
        """ Mouse click """
        # Do stuff only if you click inside the main panel
        if event.inaxes == ax:
            nonlocal sel_idx
            if event.dblclick:  # only if you double click
                # idx might be either a number or None
                idx = get_index_selected(event.xdata)
                sel_idx = idx
                if idx is not None:
                    # Set the selected span and the selection text to violet
                    reg_spans[idx].set_color('violet')
                    onselect(min(reg_lims[idx]), max(reg_lims[idx]))
                    sel_text.set_color('violet')
            else:
                # single click: reset everything to normal
                sel_idx = None
                for reg_span in reg_spans:
                    reg_span.set_color('tab:green')
                    sel_text.set_color('tab:red')
            fig.canvas.draw()

    def save(event):
        """ SAVE button """
        nonlocal reg_lims
        # Sort the values to be returned from left to right in the ppmscale
        reg_lims = sorted(reg_lims, key=lambda w: w[0])[::-1]
        plt.close()

    # Draw the spectrum
    ax.plot(ppmscale, S.real, c='tab:blue', lw=0.8)        # Plot the data

    # Cosmetic stuff
    ax.set_xlabel(r'$\delta\,$ /ppm')
    ax.set_ylabel('Intensity /a.u.')
    misc.pretty_scale(ax, xlims, 'x')
    misc.pretty_scale(ax, ax.get_ylim(), 'y')
    misc.mathformat(ax, 'y')
    misc.set_fontsizes(ax, 20)

    # Connect the widgets to the slots
    span = SpanSelector(ax, onselect, onmove_callback=onselect, minspan=5*misc.calcres(ppmscale),
                        direction='horizontal', interactive=True, drag_from_anywhere=True,
                        props={'facecolor': 'tab:red', 'alpha': 0.3})
    add_button.on_clicked(add)
    remove_button.on_clicked(remove)
    save_button.on_clicked(save)
    fig.canvas.mpl_connect('button_press_event', on_click)

    # Start event loop
    plt.show()
    plt.close()

    return reg_lims


def make_signal(t, u, s, k, b, phi, A, SFO1=701.125, o1p=0, N=None):
    """
    Generates a voigt signal on the basis of the passed parameters in the time domain. Then, makes the Fourier transform and returns it.

    Parameters
    ----------
    t : ndarray
        acquisition timescale
    u : float
        chemical shift /ppm
    s : float
        full-width at half-maximum /Hz
    k : float
        relative intensity
    b : float
        fraction of gaussianity (0 = Lorentzian, 1 = Gaussian)
    phi : float
        phase of the signal, in degrees
    A : float
        total intensity
    SFO1 : float
        Larmor frequency /MHz
    o1p : float
        pulse carrier frequency /ppm
    N : int or None
        length of the final signal. If None, the signal is not zero-filled before to be transformed.

    Returns
    -------
    sgn : 1darray
        generated signal in the frequency domain
    """
    U = misc.ppm2freq(u, SFO1, o1p)         # conversion to frequency units
    S = s * 2 * np.pi                       # conversion to radians
    phi = phi * np.pi / 180                 # conversion to radians
    sgn = sim.t_voigt(t, U, S, A=A*k, phi=phi, b=b)  # make the signal
    if isinstance(N, int):
        sgn = processing.zf(sgn, N)         # zero-fill it
    sgn = processing.ft(sgn)                # transform it
    return sgn


def plot_fit(S, ppm_scale, regions, t_AQ, SFO1, o1p, show_total=False,
             show_res=False, res_offset=0, show_basl=False, X_label=r'$\delta$ /ppm',
             labels=None, filename='fit', ext='png', dpi=600, dim=None):
    """
    Plots either the initial guess or the result of the fit, and saves all the figures.
    The figure `<filename>_full` will show the whole model and the whole spectrum.
    The figures labelled with `_R<k>` will depict a detail of the fit in the k-th fitting region.
    Optional labels for the components can be given: in this case, the structure of ``labels`` should match the structure of ``regions``.
    This means that the length of the outer list must be equal to the number of fitting region, and the length of the inner lists must be equal to the number of peaks in that region.

    Parameters
    ----------
    S : 1darray
        Spectrum to be fitted
    ppm_scale : 1darray
        ppm scale of the spectrum
    regions : dict
        Generated by :func:`klassez.fit.read_vf`
    t_AQ : 1darray
        Acquisition timescale
    SFO1 : float
        Larmor frequency of the observed nucleus, in MHz
    o1p : float
        Carrier position, in ppm
    show_total : bool
        Show the total trace (i.e. sum of all the components) or not
    show_res : bool
        Show the plot of the residuals
    res_offset : float
        Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental spectrum. ``res_offset`` > 0 will move the residuals BELOW the zero-line!
    show_basl : bool
        If True, displays the baseline on the spectrum and uses it to compute the total trace.
    X_label : str
        Text to show as label for the chemical shift axis
    labels : list of list
        Optional labels for the components. The structure of this parameter must match the structure of self.result
    filename : str or Path
        Root of the name of the figures that will be saved.
    ext : str
        Format of the saved figures
    dpi : int
        Resolution of the figures, in dots per inches
    dim : tuple
        Size of the figure in inches (length, height)
    """
    def calc_total(peaks, A=1):
        """
        Calculates the sum trace from a collection of peaks stored in a dictionary.
        If the dictionary is empty, returns an array of zeros.

        Parameters
        ----------
        peaks: dict
            Components
        A: float
            Absolute intensity

        Returns
        -------
        total: 1darray
            Sum spectrum
        """
        # Get the arrays from the dictionary
        T = [p(A) for _, p in peaks.items()]
        if len(T) > 0:  # Check for any peaks
            total = np.sum(T, axis=0)
            return total.real
        else:
            return np.zeros_like(ppm_scale)

    print('Saving figures...', c='tab:cyan')
    # Shallow copy of the real part of the experimental spectrum
    S_r = np.copy(S.real)
    N = S_r.shape[-1]       # For (eventual) zero-filling
    # Make the acqus dictionary to be fed into the fit.Peak objects
    acqus = {'t1': t_AQ, 'SFO1': SFO1, 'o1p': o1p, }

    # Single regions
    whole_basl = np.zeros_like(ppm_scale)
    for k, region in enumerate(regions):        # Loop on the regions
        # Shallow copy of the region dict
        in_region = dict(region)
        # Calculate the slice that delimits the fit region
        #   Remove from in_region
        limits = in_region.pop('limits')
        #   Convert to points on the ppm scale
        limits_pt = misc.ppmfind(ppm_scale, limits[0])[0], misc.ppmfind(ppm_scale, limits[1])[0]
        #   Make the slice
        lims = slice(min(limits_pt), max(limits_pt))
        # Get the absolute intensity
        Int = in_region.pop('I')
        if 'bas_c' in region.keys():
            bas_c = Int * region['bas_c']
            in_region.pop('bas_c')
        else:
            bas_c = np.zeros(5)

        # Create a dictionary of fit.Peak objects with the same structure of in_region
        peaks = {key: fit.Peak(acqus, N=N, **peakval) for key, peakval in in_region.items()}
        # Get the total trace
        total = calc_total(peaks, Int)

        # Trim the ppm scale according to the fitting region
        t_ppm = ppm_scale[lims]

        # Baseline computation
        x = np.linspace(0, 1, len(t_ppm))
        basl = misc.polyn(x, bas_c)
        whole_basl = misc.sum_overlay(whole_basl, basl, max(limits), ppm_scale)

        # Make the figure
        fig = plt.figure('Fit')
        if dim is None:
            fig.set_size_inches(figures.figsize_large)
        else:
            fig.set_size_inches(dim)
        ax = fig.add_subplot()
        plt.subplots_adjust(left=0.10, bottom=0.10, top=0.90, right=0.95)

        # Plot the experimental dataset
        ax.plot(t_ppm, S_r[lims], c='k', lw=1, label='Experimental')

        if show_total is True:  # Plot the total trace in blue
            ax.plot(t_ppm, total[lims]+basl, c='b', lw=0.5, label='Fit')
        if show_basl is True:
            ax.plot(t_ppm, basl, c='mediumorchid', lw=0.5, label='Baseline', zorder=3)

        for key, peak in peaks.items():  # Plot the components
            p_sgn, = ax.plot(t_ppm, peak(Int)[lims], lw=0.6, label=f'{key}')
            if labels is not None:  # Set the custom label
                p_sgn.set_label(labels[k][key-1])

        if show_res is True:    # Plot the residuals
            # Compute the absolute value of the offset
            r_off = res_offset * (max(S_r[lims])-min(S_r[lims]))
            ax.plot(t_ppm, (S_r - total)[lims] - basl - r_off, c='g', ls=':', lw=0.6, label='Residuals')

        # Visual adjustments
        ax.set_xlabel(X_label)
        ax.set_ylabel('Intensity /a.u.')
        misc.pretty_scale(ax, (max(t_ppm), min(t_ppm)), axis='x')
        misc.pretty_scale(ax, ax.get_ylim(), axis='y')
        misc.mathformat(ax)
        ax.legend()
        misc.set_fontsizes(ax, 20)
        # Save the figure
        plt.savefig(f'{filename}_R{k+1}.{ext}', dpi=dpi)
        plt.close()

    # Total
    # Make the figure
    fig = plt.figure('Fit')
    fig.set_size_inches(figures.figsize_large)
    plt.subplots_adjust(left=0.10, bottom=0.10, top=0.90, right=0.95)
    ax = fig.add_subplot()

    # Placeholder for total trace and for the limits
    total = np.zeros_like(ppm_scale)
    lims_pt = []

    # Plot the experimental spectrum
    ax.plot(ppm_scale, S_r, c='k', lw=1, label='Experimental', zorder=1)
    # Plot the components, region by region
    for k, region in enumerate(regions):
        # Shallow copy of in_region
        in_region = dict(region)
        # Make the slice
        limits = in_region.pop('limits')
        limits_pt = misc.ppmfind(ppm_scale, limits[0])[0], misc.ppmfind(ppm_scale, limits[1])[0]
        lims = slice(min(limits_pt), max(limits_pt))
        lims_pt.append(lims)    # Save it in the list
        # Get the absolute intensity
        Int = in_region.pop('I')
        if 'bas_c' in region.keys():
            in_region.pop('bas_c')
        else:
            pass
        # Make the dictionary of fit.Peak objects
        peaks = {key: fit.Peak(acqus, N=N, **peakval) for key, peakval in in_region.items()}
        # Plot the components
        for idx, peak in peaks.items():
            p_sgn, = ax.plot(ppm_scale, peak(Int), lw=0.6, label=f'Win. {k+1}, Comp. {idx}', zorder=10)
            if labels is not None:  # Set custom label
                p_sgn.set_label(labels[k][idx-1])

        # Add these contributions to the total trace
        total += calc_total(peaks, Int)

    # Residuals
    if show_total is True:  # Plot the total trace
        ax.plot(ppm_scale, total+whole_basl, c='b', lw=0.5, label='Fit', zorder=2)
    if show_basl is True:
        ax.plot(ppm_scale, whole_basl, c='mediumorchid', lw=0.5, label='Baseline', zorder=3)

    # Visual adjustments
    ax.set_xlabel(X_label)
    ax.set_ylabel('Intensity /a.u.')
    misc.pretty_scale(ax, (max(ppm_scale), min(ppm_scale)), axis='x')
    misc.pretty_scale(ax, ax.get_ylim(), axis='y')
    misc.mathformat(ax)
    ax.legend()
    misc.set_fontsizes(ax, 20)
    # Save the figure
    plt.savefig(f'{filename}_full.{ext}', dpi=dpi)
    plt.close()
    print('Done.', c='tab:cyan')


def voigt_fit_indep(S, ppm_scale, regions, t_AQ, SFO1, o1p,
                    u_lim=1, f_lim=10, k_lim=(0, 3), vary_phase=False, vary_b=True,
                    itermax=10000, fit_tol=1e-8, filename='fit', method='leastsq', basl_fit='no'):
    """
    Performs a lineshape deconvolution fit using a Voigt model.
    The initial guess must be read from a `.ivf file`. All components are treated as independent, regardless from the value of the ``group`` attribute.
    The fitting procedure operates iteratively one window at the time.

    Parameters
    ----------
    S : 1darray
        Experimental spectrum
    ppm_scale : 1darray
        PPM scale of the spectrum
    regions : dict
        Generated by :func:`klassez.fit.read_vf`
    t_AQ : 1darray
        Acquisition timescale
    SFO1 : float
        Nucleus Larmor frequency /MHz
    o1p : float
        Carrier frequency /ppm
    u_lim : float
        Maximum allowed displacement of the chemical shift from the initial value /ppm
    f_lim : float
        Maximum allowed displacement of the linewidth from the initial value /ppm
    k_lim : float or tuple
        If tuple, minimum and maximum allowed values for ``k`` during the fit. If float, maximum displacement from the initial guess
    vary_phase : bool
        Allow the peaks to change phase
    vary_b : bool
        Allow the peaks to change Lorentzian/Gaussian ratio
    itermax : int
        Maximum number of allowed iterations
    fit_tol : float
        Target value to be set for ``x_tol`` and ``f_tol``
    filename : str or Path
        Name of the file where the fitted values will be saved. The `.fvf` extension is added automatically
    method : str or list of str
        Method to be used for the optimization. See ``lmfit`` for details. There is the option to run multiple optimizations in series.
    basl_fit : str
        How to address the baseline fit. The options are:

        * ``"no"``: Do not use baseline (default)
        * ``"fixed"``: The baseline is computed once and kept fixed during the optimization
        * ``"fit"``: The baseline coefficients enter as fit parameters during the nonlinear optimization
        * ``"calc"``: The baseline coefficients are calculated during the optimization via linear least-squares optimization

    Returns
    -------
    lmfit_results: list of lmfit.Minimizer.MinimizerResult
        Sequence of the fit results, ordered as the regions dictionary


    .. seealso::

        :class:`lmfit.Minimizer`

        :func:`lmfit.Minimizer.minimize`

        :class:`lmfit.Minimizer.MinimizerResult`
    """

    # USED FUNCTIONS

    def calc_total(peaks, A=1):
        """
        Calculates the sum trace from a collection of peaks stored in a dictionary.
        If the dictionary is empty, returns an array of zeros.

        Parameters
        ----------
        peaks : dict
            Components
        A : float
            Absolute intensity

        Returns
        -------
        total : 1darray
            Sum spectrum
        """
        # Get the arrays from the dictionary
        T = [p(A) for _, p in peaks.items()]
        if len(T) > 0:  # Check for any peaks
            total = np.sum(T, axis=0)
            return total
        else:
            return np.zeros_like(ppm_scale)

    def peaks_frompar(peaks, par):
        """
        Replaces the values of a "peaks" dictionary, which contains a ``fit.Peak`` object for each key ``idx``, with the values contained in the ``par`` dictionary.
        The par dictionary keys must have keys of the form ``<parameter>_<idx>``, where ``<parameter>`` is in ``[u, fwhm, k, 'b', 'phi']``, and ``<idx>`` are the keys of the peaks dictionary.

        Parameters
        ----------
        peaks : dict
            Collection of fit.Peak objects
        par : dict
            New values for the peaks

        Returns
        -------
        peaks : dict
            Updated peaks dictionary with the new values
        """
        for idx, peak in peaks.items():
            peak.u = par[f'u_{idx}']
            peak.fwhm = par[f'fwhm_{idx}']
            peak.k = par[f'k_{idx}']
            peak.b = par[f'b_{idx}']
            peak.phi = par[f'phi_{idx}']
        return peaks

    def f2min(param, S, fit_peaks, Int, lims, x, first_residual=1, basl_fit='no'):
        """
        Function that calculates the residual to be minimized in the least squares sense.
        This function requires a set of pre-built ``fit.Peak`` objects, stored in a dictionary.
        The parameters of the peaks are replaced on this dictionary according to the values in the ``lmfit.Parameter object``.
        At this point, the total trace is computed and the residual is returned as the difference between the experimental spectrum and the total trace,
        only in the region delimited by the "lims" tuple.

        Parameters
        ----------
        param : lmfit.Parameters object
            Usual lmfit stuff
        S : 1darray
            Experimental spectrum
        fit_peaks : dict
            Collection of ``fit.Peak`` objects
        I : float
            Absolute intensity.
        lims : slice
            Trimming region corresponding to the fitting window, in points
        x : 1darray
            Baseline scale
        basl_fit : str
            Method for computation of the baseline
        first_residual : float
            Target value at the first call of this function. Used to compute the relative target function.

        Returns
        -------
        residual : 1darray
            ``experimental - calculated``, in the fitting window
        """
        param['count'].value += 1
        # Unpack the lmfit.Parameters object
        par = param.valuesdict()
        # create a shallow copy of the fit_peaks dictionary to prevent overwriting
        peaks = deepcopy(fit_peaks)
        # Update the peaks dictionary according to how lmfit is moving the fit parameters
        peaks = peaks_frompar(peaks, par)
        # Compute the total trace and the residuals
        total = calc_total(peaks, 1)
        exp = S[lims]/I
        calc = total[lims]
        # compute the baseline
        if basl_fit != 'calc':
            bas_c = np.array([par[f'c{w}'] for w in range(5)])
            basl = misc.polyn(x, bas_c)
            calc += basl
            correction_factor, _ = fit.fit_int(exp, calc, q=False)
        else:
            correction_factor = 1
        residual = exp - calc * correction_factor
        if basl_fit == 'calc':
            bas_c = fit.lsp(residual, x, 5)
            basl = misc.polyn(x, bas_c)
            for k, c in enumerate(bas_c):
                param[f'c{k}'].set(value=c)
            residual -= basl
        param['correction_factor'].set(value=correction_factor)
        print(f'Step: {par["count"]:6.0f} | Target: {np.sum(residual**2)/first_residual:10.5e}', end='\r')
        return residual

    def gen_reg(regions):
        """
        Generator function that loops on the regions and extracts the limits of the fitting window, the limits, and the dictionary of peaks.
        """
        for k, region in enumerate(regions):
            # Get limits and total intensity from the dictionary
            limits = region['limits']
            Int = region['I']
            if 'bas_c' in region.keys():
                bas_c = region['bas_c']
            else:
                bas_c = np.zeros(5)
            if 1:   # Switch: turn this print on and off
                print(f'Fitting of region {k+1}/{Nr}. [{limits[0]:.3f}:{limits[1]:.3f}] ppm', c='tab:orange')
            # Make a copy of the region dictionary and remove what is not a peak
            peaks = deepcopy(region)
            peaks.pop('limits')
            peaks.pop('I')
            if 'bas_c' in region.keys():
                peaks.pop('bas_c')
            yield limits, Int, peaks, bas_c

    # -----------------------------------------------------------------------------
    # Make the acqus dictionary to be fed into the fit.Peak objects
    acqus = {'t1': t_AQ, 'SFO1': SFO1, 'o1p': o1p, }

    N = S.shape[-1]     # Number of points of the spectrum
    Nr = len(regions)   # Number of regions to be fitted

    # Write info on the fit in the output file
    filename = Path(filename)
    with filename.with_suffix('.fvf').open('a', buffering=1) as f:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! Fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

    # Generate the values from the regions dictionary with the gen_reg generator
    Q = gen_reg(regions)
    with open('cnvg', 'w') as f:
        pass

    # Start fitting loop
    prev = 0
    lmfit_results = []      # placeholder
    for q in Q:
        limits, I, peaks, bas_c = q    # Unpack
        Np = len(peaks.keys())  # Number of Peaks

        # Create a dictionary which contains Peak objects
        fit_peaks = {}
        for key, peakval in peaks.items():
            # Same keys of the input dictionary
            fit_peaks[key] = fit.Peak(acqus, N=N, **peakval)

        # Add the peaks' parameters to a lmfit Parameters object
        peak_keys = ['u', 'fwhm', 'k', 'b', 'phi']
        param = lmfit.Parameters()
        # Add baseline coefficients to the Parameters object
        for n in range(5):
            if basl_fit == 'no':
                v = 0
                vary = False
            elif basl_fit == 'fixed' or basl_fit == 'calc':
                v = bas_c[n]
                vary = False
            elif basl_fit == 'fit':
                v = bas_c[n]
                vary = True
            param.add(f'c{n}', value=v, vary=vary)

        for idx, peak in fit_peaks.items():
            # Name of the object: <parameter>_<index>
            p_key = f'_{idx}'

            # Fill the Parameters object
            for key in peak_keys:
                par_key = f'{key}{p_key}'   # Add the parameter to the label
                val = peak.par()[key]       # Get the value from the input dictionary
                param.add(par_key, value=val)   # Make the Parameter object
                # Set the limits for each parameter, and fix the ones that have not to be varied during the fit
                if 'u' in key:  # u: [u-u_tol, u+u_tol]
                    param[par_key].set(min=max(val-u_lim, min(limits)), max=min(val+u_lim, max(limits)))
                elif 'fwhm' in key:  # fwhm: [max(0, fwhm-f_tol), fwhm+f_tol] (avoid negative fwhm)
                    param[par_key].set(min=max(0, val-f_lim), max=val+f_lim)
                elif 'k' in key:     # k: [0, 3]
                    if isinstance(k_lim, float):
                        param[par_key].set(min=param[par_key].value-k_lim, max=param[par_key].value+k_lim)
                    else:
                        param[par_key].set(min=min(k_lim), max=max(k_lim))
                elif 'phi' in key:  # phi: [-180°, +180°]
                    param[par_key].set(min=-180, max=180, vary=vary_phase)
                elif 'b' in key:  # b: [0, 1]
                    param[par_key].set(min=0, max=1, vary=vary_b)

        # Convert the limits from ppm to points and make the slice
        limits_pt = misc.ppmfind(ppm_scale, limits[0])[0], misc.ppmfind(ppm_scale, limits[1])[0]
        lims = slice(min(limits_pt), max(limits_pt))
        # Baseline x-scale
        x = np.linspace(0, 1, int(np.abs(limits_pt[1] - limits_pt[0])))

        # Wrap the fitting routine in a function in order to use @cron for measuring the runtime of the fit
        @cron
        def start_fit(method):
            # Initialize the nonused fit parameters
            param.add('count', value=0, vary=False)
            param.add('correction_factor', value=1, vary=False)
            # Compute the first residual as reference, suppress the output
            with open(os.devnull, 'w') as sys.stdout:
                first_residual = np.sum(f2min(param, S, fit_peaks, I, lims, x, 1, basl_fit)**2)
                # Reset the iteration counter
                param['count'].set(value=0)
            # Redirect output to stdout
            sys.stdout = sys.__stdout__

            # Initialize the fit
            minner = lmfit.Minimizer(f2min, param, fcn_args=(S, fit_peaks, I, lims, x, first_residual, basl_fit))
            if isinstance(method, str):
                method = [method]
            elif isinstance(method, (list, tuple)):
                pass
            else:
                raise ValueError('The "method" flag must be a string or a list of strings.')
            result = None
            for k, mthd in enumerate(method):
                if k == 0:
                    params = None
                else:
                    params = result.params

                print(f'Optimization {k+1:4.0f}/{len(method)}, method = {mthd}', c='tab:orange')
                if mthd == 'leastsq' or mthd == 'least_squares':
                    result = minner.minimize(method='leastsq', max_nfev=int(itermax), ftol=fit_tol, params=params)
                else:
                    result = minner.minimize(method=mthd, max_nfev=int(itermax), tol=fit_tol, params=params)
                print(f'{result.message} Number of function evaluations: {result.nfev}.\n')
            return result
        # Do the fit
        result = start_fit(method)
        # Unpack the fitted values
        popt = result.params.valuesdict()

        # Replace the initial values with the fitted ones
        fit_peaks = peaks_frompar(fit_peaks, popt)
        # The baseline coefficients must be normalized to the I at the end
        # Hence, first multiply them, and then divide them afterwards for the corrected I
        bas_c_opt = np.array([popt[f'c{k}'] for k in range(5)]) * I
        # Correct the intensities
        #   Get the correct ones
        r_i, I_corr = misc.molfrac([peak.k for _, peak in fit_peaks.items()])
        I *= I_corr * popt['correction_factor']
        # Here correct the baseline coefficients
        bas_c_opt /= I
        #   Replace them
        for k, idx in enumerate(fit_peaks.keys()):
            fit_peaks[idx].k = r_i[k]

        # Write a section of the output file
        fit.write_vf(filename.with_suffix('.fvf'), fit_peaks, limits, I, prev, bas_c=bas_c_opt)
        prev += Np
        lmfit_results.append(result)
    return lmfit_results


@cron
def voigt_fit_2D(x_scale, y_scale, data, parameters, lim_f1, lim_f2, acqus,
                 N=None, procs=None, utol=(1, 1), s1tol=(0, 500), s2tol=(0, 500), vary_b=False, logfile=None):
    """
    Function that performs the fit of a 2D peak using multiple components.
    The program reads a parameter matrix, that contains:
    .. code-block:: bash

        u1 /ppm, u2 /ppm, fwhm1 /Hz, fwhm2 /Hz, I /a.u., b

    in each row. The number of rows corresponds to the number of components used for the computation of the final signal.
    The function returns the analogue version of the parameters matrix, but with the optimized values.

    .. warning::

        Work in progress! Does not work right now.

    Parameters
    ----------
    x_scale : 1darray
        ppm_f2 of the spectrum, full
    y_scale : 1darray
        ppm_f1 of the spectrum, full
    data : 2darray
        spectrum, full
    parameters : 1darray or 2darray
        Matrix (# signals, 6). Read main caption.
    lim_f2 : tuple
        Trimming limits for x_scale
    lim_f1 : tuple
        Trimming limits for y_scale
    acqus : dict
        Dictionary of acquisition parameters.
    N : tuple of ints
        len(y_scale), len(x_scale). Used only if procs is None
    procs : dict
        Dictionary of processing parameters.
    utol : tuple of floats
        Tolerance for the chemical shifts (utol_f1, utol_f2). Values will be set to u1 +/- utol_f1, u2 +/- utol_f2
    s1tol : tuple of floats
        Range of variations for the fwhm in f1, in Hz
    s2tol : tuple of floats
        Range of variations for the fwhm in f2, in Hz
    vary_b : bool
        Choose if to fix the b value or not
    logfile : str or None
        Path to a file where to write the fit information. If it is None, they will be printed into standard output.

    Returns
    -------
    out_parameters : 2darray
        parameters, but with the optimized values.
    """

    def f2min(param, n_sgn, x_scale, y_scale, data_exp, lim_f2, lim_f1):
        """
        Cost function.

        Parameters
        ----------
        param : lmfit.Parameters object
            Fit parameters. See fit_2D caption.
        n_sgn : int
            Number of signals
        x_scale : 1darray
            ppm_f2 of the spectrum, full
        y_scale : 1darray
            ppm_f1 of the spectrum, full
        data_exp : 2darray
            spectrum trimmed around the peak of interest
        lim_f2 : tuple
            Trimming limits for x_scale
        lim_f1 : tuple
            Trimming limits for y_scale

        Returns
        -------
        res : 2darray
            Experimental -  calculated
        """
        # Extract dictionary of values from param
        P = param.valuesdict()

        # Organize the parameters into a matrix
        in_parameters = np.array([[
                P[f'u1_{i+1}'],
                P[f'u2_{i+1}'],
                P[f's1_{i+1}'],
                P[f's2_{i+1}'],
                P[f'k_{i+1}'] * P['A'],
                P[f'b_{i+1}'],
                ] for i in range(n_sgn)])

        # Feed the peaks to build_2D_sgn to make the calculated spectrum
        calc_peak = fit.build_2D_sgn(in_parameters, acqus, N=N, procs=procs)
        # Trim the calculated spectrum to the experimental one's sizes
        xtrim, ytrim, data_calc = misc.trim_data_2D(x_scale, y_scale, calc_peak, lim_f2, lim_f1)
        # Compute the residuals
        res = data_exp - data_calc
        return res

    # ---------------------------------------------------------------------------------------------

    # Redirect the output to logfile, if logfile is given
    if isinstance(logfile, str):    # Open the file in "append" mode
        sys.stdout = open(logfile, 'a', buffering=1)
    elif isinstance(logfile, io.TextIOWrapper):  # Just redirect
        sys.stdout = logfile

    # Trim the spectrum according to the given limits
    data_exp = misc.trim_data_2D(x_scale, y_scale, data, lim_f2, lim_f1)[-1]

    # Organize parameters
    parameters = np.array(parameters)
    if len(parameters.shape) == 1:  # it means it is only one signal
        parameters = parameters.reshape(1, -1)   # therefore transform in 1 x n matrix
    n_sgn = parameters.shape[0]     # Number of signals: number of rows of parameters

    # Express relative intensities in "molar fractions" and adjust the absolute intensity accordingly
    k_values, A = misc.molfrac(parameters[..., 4])

    # Initialize the Parameters object
    param = lmfit.Parameters()

    param.add('A', value=A, vary=False)     # Absolute intensity
    for i in range(n_sgn):
        param.add(f'u1_{i+1}', value=parameters[i, 0])   # chemical shift f1 /ppm
        param.add(f'u2_{i+1}', value=parameters[i, 1])   # chemical shift f2 /ppm
        param.add(f's1_{i+1}', value=parameters[i, 2])   # fwhm f1 /Hz
        param.add(f's2_{i+1}', value=parameters[i, 3])   # fwhm f2 /Hz
        param.add(f'k_{i+1}', value=k_values[i])        # relative intensity
        param.add(f'b_{i+1}', value=parameters[i, 5], min=0-1e-5, max=1+1e-5)   # Fraction of gaussianity

    # Set limits to u and s
    u1tol, u2tol = utol  # Unpack tolerances for chemical shifts
    [param[f'u1_{i+1}'].set(min=param[f'u1_{i+1}'].value - u1tol) for i in range(n_sgn)]    # min u1
    [param[f'u1_{i+1}'].set(max=param[f'u1_{i+1}'].value + u1tol) for i in range(n_sgn)]    # max u1
    [param[f'u2_{i+1}'].set(min=param[f'u2_{i+1}'].value - u2tol) for i in range(n_sgn)]    # min u2
    [param[f'u2_{i+1}'].set(max=param[f'u2_{i+1}'].value + u2tol) for i in range(n_sgn)]    # max u2
    [param[f's1_{i+1}'].set(min=min(s1tol)) for i in range(n_sgn)]  # min fwhm1
    [param[f's1_{i+1}'].set(max=max(s1tol)) for i in range(n_sgn)]  # max fwhm1
    [param[f's2_{i+1}'].set(min=min(s2tol)) for i in range(n_sgn)]  # min fwhm2
    [param[f's2_{i+1}'].set(max=max(s2tol)) for i in range(n_sgn)]  # max fwhm2
    [param[f'k_{i+1}'].set(min=0) for i in range(n_sgn)]    # min rel int to 0
    [param[f'k_{i+1}'].set(max=5) for i in range(n_sgn)]    # max rel int to 5
    if vary_b is False:    # fix it
        [param[f'b_{i+1}'].set(vary=False) for i in range(n_sgn)]

    # Initialize the fit
    minner = lmfit.Minimizer(f2min, param, fcn_args=(n_sgn, x_scale, y_scale, data_exp, lim_f2, lim_f1))
    result = minner.minimize(method='leastsq', max_nfev=10000, xtol=1e-10, ftol=1e-10)

    # Adjust relative and absolute intensities, then update the optimized Parameters object
    k_opt, A_opt = misc.molfrac([result.params.valuesdict()[f'k_{i+1}'] * param['A'] for i in range(n_sgn)])
    [result.params[f'k_{i+1}'].set(value=k_opt[i]) for i in range(n_sgn)]
    result.params['A'].set(value=A_opt)

    def calc_uncert(param, n_sgn, keys_list=None):
        """ Get the stderr from Parameters and organize them in a matrix. """
        if keys_list is None:   # Take all of them
            keys_list = list(set([f'{key.rsplit("_", 1)[0]}' for key in param.keys()]))

        uncert = []
        for i in range(n_sgn):
            tmp_stderr = []
            for key in keys_list:
                tmp_key = f'{key}_{i+1}'
                try:    # There might not be stderr (NaN)
                    tmp_stderr.append(param[tmp_key].stderr)
                except Exception as E:   # Therefore append None instead of NaN
                    print(E, tmp_key)
                    tmp_stderr.append(None)
            uncert.append(tmp_stderr)
        uncert = np.array(uncert)
        return uncert

    # Parameters
    keys_list = 'u1', 'u2', 's1', 's2', 'k', 'b'
    stderr = calc_uncert(result.params, n_sgn, keys_list)   # Uncertainties

    # Get dictionary of parameters
    popt = result.params.valuesdict()
    # Organize them in a matrix
    out_parameters = np.array([[
        popt[f'u1_{i+1}'],
        popt[f'u2_{i+1}'],
        popt[f's1_{i+1}'],
        popt[f's2_{i+1}'],
        popt[f'k_{i+1}'] * popt['A'],   # Put absolute intensity of each component
        popt[f'b_{i+1}'],
        ] for i in range(n_sgn)])

    # Print the outcome of the fit
    print(f'{result.message} Number of function evaluations: {result.nfev:5.0f}.\nEstimated uncertainties:')
    print(f'{"#":<4s},', *[f'{key:>8s},' for key in keys_list])
    for k in range(stderr.shape[0]):
        print(f'{k+1:<4},', *[f'{value:8.3g},' if value is not None else f'{"None":>8s}' for value in stderr[k]])
    print('')

    return out_parameters

# ----------------------------------------------------------------------------------------------------------


def smooth_spl(x, y, s_f=1, size=0, weights=None):
    """
    Fit the input data with a 3rd-order spline, given the smoothing factor to be applied.
    Uses the package ``csaps``.

    Parameters
    ----------
    x : 1darray
        Location of the experimental points
    y : 1darray
        Input data to be fitted
    s_f : float
        Smoothing factor of the spline. 0=best straight line, 1=native spline.
    size : int
        Size of the spline. If ``size=0``, the same dimension as ``y`` is chosen.

    Returns
    -------
    x_s : 1darray
        Location of the spline data points.
    y_s : 1darray
        Spline that fits the data.
    """
    # Reverse x and y if x is descending
    if x[0] > x[-1]:
        x_o = np.copy(x[::-1])
        y_o = np.copy(y[::-1])
        if weights is not None:
            weights = weights[::-1]
    else:
        x_o = np.copy(x)
        y_o = np.copy(y)

    # If size is not given, make the spline with the same size as the observed data
    if size:
        x_s = np.linspace(x_o[0], x_o[-1], size)
    else:
        x_s = np.linspace(x_o[0], x_o[-1], x.shape[-1])

    # Compute the spline
    if np.iscomplexobj(y_o):    # Treat real and imaginary part separately, then join them together
        y_sr = csaps(x_o, y_o.real, x_s, weights=weights, smooth=s_f)
        y_si = csaps(x_o, y_o.imag, x_s, weights=weights, smooth=s_f)
        y_s = y_sr + 1j*y_si
    else:   # Normal spline smoothing
        y_s = csaps(x_o, y_o, x_s, weights=weights, smooth=s_f)

    # Reverse the spline if you reversed the observed data
    if x[0] > x[-1]:
        x_s = x_s[::-1]
        y_s = y_s[::-1]
    return x_s, y_s


def interactive_smoothing(x, y, cmap='RdBu'):
    """
    Interpolate the given data with a 3rd-degree spline. Type the desired smoothing factor in the box and see the outcome directly on the figure.
    When the panel is closed, the smoothed function is returned.

    .. warning::

        Extremely slow rendering!!! There is a problem that must be fixed somewhen.


    Parameters
    ----------
    x : 1darray
        Scale of the data
    y : 1darray
        Data to be smoothed
    cmap : str
        Name of the colormap to be used to represent the weights. Must be present in ``CM``

    Returns
    -------
    sx : 1darray
        Location of the spline points
    sy : 1darray
        Smoothed ``y``
    s_f : float
        Employed smoothing factor for the spline
    weights : 1darray
        Weights vector
    """
    # Make the figure
    fig = plt.figure('Interactive Smoothing with spline')
    fig.set_size_inches(figures.figsize_large)
    plt.subplots_adjust(left=0.1, right=0.85, top=0.95, bottom=0.15)
    ax = fig.add_subplot()

    cmap = CM[f'{cmap}']        # Read the colormap

    # Get the limits for the figure
    lims = x[0], x[-1]

    # Initialize data
    s_f = 0.95                       # Smoothing factor
    size = x.shape[-1]               # Spline size
    weights = np.ones_like(x) * 0.5  # Weights vector
    sx, sy = fit.smooth_spl(x, y, size=size, s_f=s_f, weights=weights)  # Calculate starting spline

    # Make the widgets
    #   Smoothing factor textbox
    sf_box = plt.axes([0.25, 0.04, 0.1, 0.06])
    sf_tb = TextBox(sf_box, 'Insert\nSmoothing factor', textalignment='center')

    #   Size textbox
    size_box = plt.axes([0.60, 0.04, 0.1, 0.06])
    size_tb = TextBox(size_box, 'Insert\nSize', textalignment='center')

    #   Weights slider
    slider_box = plt.axes([0.90, 0.15, 0.01, 0.8])
    weight_slider = Slider(
        ax=slider_box,
        label='Weight',
        valmin=1e-5,
        valmax=1,
        valinit=0.5,
        valstep=0.05,
        orientation='vertical'
        )

    #   Colorbar for the weights
    cbar_box = plt.axes([0.94, 0.15, 0.02, 0.8])
    norm = matplotlib.colors.Normalize(vmin=0, vmax=1)  # Dummy values to plot the colorbar
    plt.colorbar(matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap), cax=cbar_box, orientation='vertical')

    # --------------------------------------------------------------------------------------------------------
    # Functions connected to the widgets

    def update_plot():
        """ Redraw the spline """
        sx, sy = fit.smooth_spl(x, y, size=size, s_f=s_f, weights=weights)
        s_plot.set_data(sx, sy.real)
        plt.draw()

    def update_size(text):
        """ Update size, write it, call update_plot"""
        nonlocal size
        try:
            size = int(eval(text))
        except Exception:
            pass
        size_text.set_text('Size:\n{:.0f}'.format(size))
        update_plot()

    def update_sf(text):
        """ Update s_f, write it, call update_plot"""
        nonlocal s_f
        try:
            s_f = eval(text)
        except Exception:
            pass
        s_text.set_text('Smoothing factor:\n{:.4f}'.format(s_f))
        update_plot()

    def onselect(*event):
        """ Stupid function connected to both mouse click and spanselector """
        if len(event) > 1:  # = Selector, hence OK
            span.set_visible(True)
        else:   # = Mousebutton?
            event, = event  # It is a tuple! Unpack
            if event.inaxes == ax and event.button == 1:
                # Only if you click inside the figure with left button
                span.set_visible(True)
        fig.canvas.draw()

    def update_bg_color(weights):
        """ Draw the figure background according to the weight vector """
        [fill.set_fc(cmap(q)) for fill, q in zip(tmp_fill, weights)]
        fig.canvas.draw()

    def press_space(key):
        """ When you press 'space' """
        if key.key == ' ':
            span.set_visible(False)                         # Hide the spanselector
            xmin, xmax = span.extents                       # Get the shaded area
            # Get indexes on x of the shaded area, and sort them
            imin, _ = misc.ppmfind(x, xmin)
            imax, _ = misc.ppmfind(x, xmax)
            imin, imax = min(imin, imax), max(imin, imax)
            # Set the weights according to the value set on the slider
            weights[imin:imax] = weight_slider.val
            # Draw the background and the spline
            update_bg_color(weights)
            update_plot()

    def mouse_scroll(event):
        """ Control slider with the mouse scroll """
        valstep = 0.05
        sl_lims = 1e-5, 1
        if event.button == 'up':
            if weight_slider.val < sl_lims[1]:
                weight_slider.set_val(weight_slider.val + valstep)
            else:
                weight_slider.set_val(sl_lims[1])
        elif event.button == 'down':
            if weight_slider.val > sl_lims[0]:
                weight_slider.set_val(weight_slider.val - valstep)
            else:
                weight_slider.set_val(sl_lims[0])

    # --------------------------------------------------------------------------------------------------------

    ax.set_title('Press SPACE to set the weights')

    # Background
    tmp_fill = [ax.axvspan(x[k-1], x[k], ec=None, fc=cmap(q), alpha=0.25) for k, q in enumerate(weights) if k != 0]

    # Plot things
    ax.plot(x, y.real, c='tab:blue', lw=0.9, label='Original')
    s_text = plt.text(0.45, 0.07, 'Smoothing factor:\n{:.5f}'.format(s_f), fontsize=16, ha='center', va='center', transform=fig.transFigure)
    size_text = plt.text(0.75, 0.07, 'Size:\n{:.0f}'.format(size), fontsize=16, ha='center', va='center', transform=fig.transFigure)
    s_plot, = ax.plot(sx, sy.real, c='tab:red', lw=0.8, label='Smoothed')

    # Adjust figure display
    misc.pretty_scale(ax, lims, 'x')
    misc.pretty_scale(ax, ax.get_ylim(), 'y')
    misc.mathformat(ax)
    misc.set_fontsizes(ax, 16)
    ax.legend(fontsize=12, loc='upper right')

    # Connect widget to function
    sf_tb.on_submit(update_sf)
    size_tb.on_submit(update_size)

    # Declare span selector
    span = SpanSelector(ax, onselect, "horizontal", useblit=True,
                        props=dict(alpha=0.25, facecolor="tab:blue"),
                        interactive=True, drag_from_anywhere=True)

    # Press space and mouse left button
    fig.canvas.mpl_connect('key_press_event', press_space)
    fig.canvas.mpl_connect('button_press_event', onselect)
    fig.canvas.mpl_connect('scroll_event', mouse_scroll)

    plt.show()

    # Compute final output
    sx, sy = fit.smooth_spl(x, y, size=size, s_f=s_f, weights=weights)

    return sx, sy, s_f, weights


class Peak:
    """
    Class to represent the characteristic parameters of an NMR peak, and to compute it.

    Attributes
    ----------
    t: 1darray
        Timescale for the FID
    SFO1: float
        Nucleus Larmor frequency
    o1p: float
        Carrier position
    N: int
        Number of points of the spectrum, i.e. after eventual zero-filling
    u: float
        Chemical shift /ppm
    fwhm: float
        Linewidth /Hz
    k: float
        Intensity, relative
    b: float
        Fraction of gaussianity (``b=0`` equals pure lorentzian)
    phi: float
        Phase /degrees
    group: int
        Identifier for the component of a multiplet
    """
    def __init__(self, acqus, u=None, fwhm=5, k=1, b=0, phi=0, N=None, group=0):
        """
        Initialize the class with the configuration parameters, and with defauls values, if not given.

        Parameters
        ----------
        acqus: dict
            It should contain "t", "SFO1", "o1p", and "N"
        u: float
            Chemical shift /ppm
        fwhm: float
            Linewidth /Hz
        k: float
            Intensity, relative
        b: float
            Fraction of gaussianity (``b=0`` equals pure lorentzian)
        phi: float
            Phase /degrees
        N: int
            Number of points of the spectrum, i.e. after eventual zero-filling. None means to not zero-fill
        group: int
            Identifier for the component of a multiplet
        """
        # Unpack the acqus dictionary
        self.t = processing.extend_taq(acqus['t1'], N)
        self.SFO1 = acqus['SFO1']
        self.o1p = acqus['o1p']
        self.N = N

        # Set the values as attributes
        if u is None:
            u = self.o1p
        self.u = u
        self.fwhm = fwhm
        self.k = k
        self.b = b
        self.phi = phi
        self.group = int(group)

    def __call__(self, A=1, cplx=False, get_fid=False):
        """
        Generates a voigt signal on the basis of the stored attributes, in the time domain. Then, makes the Fourier transform and returns it after the eventual zero-filling.

        Parameters
        ----------
        A : float
            Absolute intensity value
        cplx : bool
            Returns the complex (True) or only the real part (False) of the signal
        get_fid : bool
            If True, returns the FID instead of the transformed signal

        Returns
        -------
        sgn : 1darray
            generated signal according to ``get_fid`` and ``cplx``
        """
        sgn = self.get_fid(A=A)
        if not get_fid:
            sgn = processing.ft(sgn)                # transform it
        if cplx or get_fid:
            return sgn
        else:
            return sgn.real

    def get_fid(self, A=1):
        """
        Compute and returns the FID encoding for that signal.

        Parameters
        ----------
        A : float
            Absolute intensity value

        Returns
        -------
        sgn : 1darray
            generated signal in the time domain
        """
        v = misc.ppm2freq(self.u, self.SFO1, self.o1p)         # conversion to frequency units
        fwhm = self.fwhm * 2 * np.pi                    # conversion to radians
        phi = self.phi * np.pi / 180                    # conversion to radians
        sgn = sim.t_voigt(self.t, v, fwhm, A=A*self.k, phi=phi, b=self.b)    # make the signal
        return sgn

    def par(self):
        """
        Creates a dictionary with the currently stored attributes and returns it.

        Returns
        -------
        dic: dict
            Dictionary of parameters
        """
        dic = {
                'u': self.u,
                'fwhm': self.fwhm,
                'k': self.k,
                'b': self.b,
                'phi': self.phi,
                'group': self.group
                }
        return dict(dic)


def make_iguess(S_in, ppm_scale, t_AQ, SFO1=701.125, o1p=0, filename='i_guess'):
    """
    Creates the initial guess for a lineshape deconvolution fitting procedure, using a dedicated GUI.
    The GUI displays the experimental spectrum in black and the total function in blue.
    First, select the region of the spectrum you want to fit by focusing the zoom on it using the lens button.
    Then, use the "+" button to add components to the spectrum. The black column of text under the textbox will be colored with the same color of the active peak.
    Use the mouse scroll to adjust the parameters of the active peak. Write a number in the "Group" textbox to mark the components of the same multiplet.
    Group 0 identifies independent peaks, not part of a multiplet (default).
    The sensitivity of the mouse scroll can be regulated using the "up arrow" and "down arrow" buttons.
    The active peak can be changed in any moment using the slider.

    The baseline can be computed first by initializing the x-scale on the selected window through the "SET BASL" button. The informer light next to the button becomes green if it is properly set.
    The baseline coefficients can be set with the mouse scroll analogously to any other parameter.

    When you are satisfied with your fit, press "SAVE" to write the information in the output file.
    Then, the GUI is brought back to the initial situation, and the region you were working on will be marked with a green rectangle.
    You can repeat the procedure as many times as you wish, to prepare the guess on multiple spectral windows.

    Keyboard shortcuts:

    * "increase sensitivity" : '>'
    * "decrease sensitivity" : '<'
    * mouse scroll up: 'up arrow key'
    * mouse scroll down: 'down arrow key'
    * "add a component": '+'
    * "remove the active component": '-'
    * "change component, forward": 'page up'
    * "change component, backward": 'page down'


    Parameters
    ----------
    S_in : 1darray
        Experimental spectrum
    ppm_scale : 1darray
        PPM scale of the spectrum
    t_AQ : 1darray
        Acquisition timescale
    SFO1 : float
        Nucleus Larmor frequency /MHz
    o1p : float
        Carrier frequency /ppm
    filename : str or Path
        Path to the filename where to save the information. The '.ivf' extension is added automatically.


    Returns
    -------
    None

    .. seealso::

        :func:`klassez.fit.make_iguess_auto`
    """

    # -----------------------------------------------------------------------
    # USEFUL STRUCTURES
    def rename_dic(dic, Np):
        """
        Change the keys of a dictionary with a sequence of increasing numbers, starting from 1.

        Parameters
        ----------
        dic : dict
            Dictionary to edit
        Np : int
            Number of peaks, i.e. the sequence goes from 1 to Np

        Returns
        -------
        new_dic : dict
            Dictionary with the changed keys
        """
        old_keys = list(dic.keys())         # Get the old keys
        new_keys = [int(i+1) for i in np.arange(Np)]    # Make the new keys
        new_dic = {}        # Create an empty dictionary
        # Copy the old element in the new dictionary at the correspondant point
        for old_key, new_key in zip(old_keys, new_keys):
            new_dic[new_key] = dic[old_key]
        del dic
        return new_dic

    def calc_total(peaks):
        """
        Calculate the sum trace from a collection of peaks stored in a dictionary.
        If the dictionary is empty, returns an array of zeros.

        Parameters
        ----------
        peaks : dict
            Components

        Returns
        -------
        total : 1darray
            Sum spectrum
        """
        # Get the arrays from the dictionary
        T = [p(A) for _, p in peaks.items()]
        if len(T) > 0:  # Check for any peaks
            total = np.sum(T, axis=0)
            return total
        else:
            return np.zeros_like(ppm_scale)

    # -------------------------------------------------------------------------------
    # Write the info on the file
    filename = Path(filename)
    filename_x = filename.with_suffix('.ivf')
    with filename_x.open('a', buffering=1) as f:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! Initial guess computed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

    # Remove the imaginary part from the experimental data and make a shallow copy
    if np.iscomplexobj(S_in):
        S = np.copy(S_in).real
    else:
        S = np.copy(S_in)

    N = S.shape[-1]     # Number of points
    Np = 0              # Number of peaks
    lastgroup = 0       # Placeholder for last group added
    prev = 0            # Number of previous peaks

    # Make an acqus dictionary based on the input parameters.
    acqus = {'t1': t_AQ, 'SFO1': SFO1, 'o1p': o1p}

    # Baseline scale
    x_bsl = np.linspace(0, 1, N)
    # Baseline - from where to where?
    x_bsl_lims = [max(ppm_scale), min(ppm_scale)]
    x_bsl_lims_pts = [misc.ppmfind(ppm_scale, w)[0] for w in x_bsl_lims]
    if x_bsl_lims_pts[0] > x_bsl_lims_pts[1]:
        x_bsl_lims = x_bsl_lims[::-1]
    # for the plot
    x_bsl_2plot = np.linspace(*sorted(x_bsl_lims), len(x_bsl))
    whole_basl = np.zeros_like(ppm_scale)

    # Set limits
    limits = [max(ppm_scale), min(ppm_scale)]

    # Get point indices for the limits
    lim1, lim2 = sorted([misc.ppmfind(ppm_scale, limit)[0] for limit in limits])
    # Calculate the absolute intensity (or something that resembles it)
    A = np.trapezoid(np.abs(S)[lim1:lim2], dx=misc.calcres(ppm_scale*SFO1))*2*misc.calcres(acqus['t1'])
    _A = 1 * A
    # Baseline constant
    B = 10**np.floor(np.log10(A))
    _B = 1 * B
    # Make a sensitivity dictionary
    sens = {
            'u': np.abs(limits[0] - limits[1]) / 50,    # 1/50 of the SW
            'fwhm': 2.5,
            'k': 0.05,
            'b': 0.1,
            'phi': 10,
            'A': 10**(np.floor(np.log10(A)-1)),    # approximately
            'c0': 0.1,
            'c1': 0.1,
            'c2': 0.1,
            'c3': 0.1,
            'c4': 0.1,
            'B': 1,
            }
    _sens = dict(sens)                          # RESET value
    # baseline coefficients
    bas_c = np.zeros(5)
    basl = np.zeros_like(x_bsl)

    # Peaks dictionary
    peaks = {}

    # Initial figure
    fig = plt.figure('Manual Computation of Inital Guess')
    fig.set_size_inches(15, 8)
    plt.subplots_adjust(bottom=0.10, top=0.90, left=0.05, right=0.65)
    ax = fig.add_subplot()

    # make boxes for widgets
    slider_box = plt.axes([0.68, 0.10, 0.01, 0.65])     # Peak selector slider
    peak_box = plt.axes([0.875, 0.275, 0.10, 0.425])       # Radiobuttons
    up_box = plt.axes([0.815, 0.825, 0.08, 0.075])      # Increase sensitivity button
    down_box = plt.axes([0.894, 0.825, 0.08, 0.075])    # Decrease sensitivity button
    save_box = plt.axes([0.7, 0.825, 0.085, 0.04])      # Save button
    reset_box = plt.axes([0.7, 0.865, 0.085, 0.04])     # Reset button
    group_box = plt.axes([0.72, 0.50, 0.06, 0.04])      # Textbox for the group selection
    plus_box = plt.axes([0.72, 0.65, 0.08, 0.075])     # Add button
    minus_box = plt.axes([0.72, 0.55, 0.08, 0.075])    # Minus button
    basset_box = plt.axes([0.875, 0.71, 0.08, 0.04])    # baseline box
    basset_flagbox = plt.axes([0.955, 0.71, 0.02, 0.04])
    basset_flagbox.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
    basset_flagbox.set_fc('tab:red')

    # Make widgets
    #   Buttons
    up_button = Button(up_box, r'$\uparrow$', hovercolor='0.975')
    down_button = Button(down_box, r'$\downarrow$', hovercolor='0.975')
    save_button = Button(save_box, 'SAVE', hovercolor='0.975')
    reset_button = Button(reset_box, 'RESET', hovercolor='0.975')
    plus_button = Button(plus_box, '$+$', hovercolor='0.975')
    minus_button = Button(minus_box, '$-$', hovercolor='0.975')
    basset_button = Button(basset_box, 'Set BASL', hovercolor='0.975')

    #   Textbox
    group_tb = TextBox(group_box, 'Group', textalignment='center')

    #   Radiobuttons
    peak_name = [r'$\delta$ /ppm', r'$\Gamma$ /Hz', '$k$', r'$\beta$', r'$\phi$', '$A$',
                 r'$c_0$', r'$c_1$', r'$c_2$', r'$c_3$', r'$c_4$', r'$B$']
    peak_radio = RadioButtons(peak_box, peak_name, activecolor='tab:blue')      # Signal parameters
    peak_box.text(0, 0.5, '-'*30, ha='left', va='center', transform=peak_box.transAxes)

    #   Slider
    slider = Slider(ax=slider_box, label='Active\nSignal', valmin=0, valmax=1-1e-3, valinit=0, valstep=1e-10, orientation='vertical', color='tab:blue')

    # -------------------------------------------------------------------------------
    # SLOTS
    def make_x_basl(event):
        nonlocal x_bsl_lims, x_bsl, basl

        x_bsl_lims = sorted(ax.get_xlim())
        n_pts_basl = int(np.abs(x_bsl_lims[1] - x_bsl_lims[0]) / misc.calcres(ppm_scale))
        x_bsl = np.linspace(0, 1, n_pts_basl)
        x_bsl_2plot = np.linspace(*x_bsl_lims, n_pts_basl)[::-1]
        basl = B * misc.polyn(x_bsl, bas_c)
        basl_plot.set_data(x_bsl_2plot, basl)
        basset_flagbox.set_fc('tab:green')
        plt.draw()

    def redraw():
        for v1, v2 in zip(sorted(ax.get_xlim()), sorted(x_bsl_lims)):
            if np.abs(v1 - v2) > 1e-4:
                basset_flagbox.set_fc('tab:red')
                break
        plt.draw()

    def radio_changed(event):
        """ Change the printed value of sens when the radio changes """
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        write_sens(param)

    def up_sens(event):
        """ Doubles sensitivity of active parameter """
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        sens[param] *= 2
        write_sens(param)

    def down_sens(event):
        """ Halves sensitivity of active parameter """
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        sens[param] /= 2
        write_sens(param)

    def up_value(param, idx):
        """ Increase the value of param of idx-th peak """
        if param == 'A':        # It is outside the peaks dictionary!
            nonlocal A
            A += sens['A']
        elif param == 'B':
            nonlocal B
            B += sens['B']
        elif 'c' in param:
            i_c = int(eval(param.replace('c', '')))
            bas_c[i_c] += sens[param]
        else:
            peaks[idx].__dict__[param] += sens[param]
            # Make safety check for b
            if peaks[idx].b > 1:
                peaks[idx].b = 1

    def down_value(param, idx):
        """ Decrease the value of param of idx-th peak """
        if param == 'A':    # It is outside the peaks dictionary!
            nonlocal A
            A -= sens['A']
        elif param == 'B':
            nonlocal B
            B -= sens['B']
        elif 'c' in param:
            i_c = int(eval(param.replace('c', '')))
            bas_c[i_c] -= sens[param]
        else:
            peaks[idx].__dict__[param] -= sens[param]
            # Safety check for fwhm
            if peaks[idx].fwhm < 0:
                peaks[idx].fwhm = 0
            # Safety check for b
            if peaks[idx].b < 0:
                peaks[idx].b = 0

    def scroll(event):
        """ Connection to mouse scroll """
        # Get the active parameter and convert it into Peak's attribute
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        # Get the active peak
        if Np == 0:  # No peaks!
            idx = 0
        else:
            idx = int(np.floor(slider.val * Np) + 1)
        if Np != 0 or 'c' in param or 'B' in param:
            nonlocal whole_basl
            # Fork for up/down
            if event.button == 'up':
                up_value(param, idx)
            if event.button == 'down':
                down_value(param, idx)

            # Recompute the baseline
            basl = B * misc.polyn(x_bsl, bas_c)
            whole_basl = misc.sum_overlay(np.zeros_like(ppm_scale), basl, x_bsl_lims[1], ppm_scale)
            basl_plot.set_ydata(basl)

        if Np != 0:
            # Recompute the components
            for k, _ in enumerate(peaks):
                p_sgn[k+1].set_ydata(peaks[k+1](A)[lim1:lim2])

            # Recompute the total trace
            p_fit.set_ydata(whole_basl[lim1:lim2]+calc_total(peaks)[lim1:lim2])
        # Update the text
        write_par(idx)
        write_bpar()
        redraw()

    def write_bpar():
        valuesb_print.set_text('{:+7.3f}\n{:+7.3f}\n{:+7.3f}\n{:+7.3f}\n{:+7.3f}\n{:5.2e}'.format(*bas_c, B))

    def write_par(idx):
        """ Write the text to keep track of your amounts """
        if idx:     # Write the things
            dic = dict(peaks[idx].par())
            dic['A'] = A
            # Update the text
            values_print.set_text('{u:+7.3f}\n{fwhm:5.3f}\n{k:5.3f}\n{b:5.3f}\n{phi:+07.3f}\n{A:5.2e}\n{group:5.0f}'.format(**dic))
            # Color the heading line of the same color of the trace
            head_print.set_color(p_sgn[idx].get_color())
        else:   # Clear the text and set the header to be black
            values_print.set_text('')
            head_print.set_color('k')

    def write_sens(param):
        """ Updates the current sensitivity value in the text """
        text = f'Sensitivity: $\\pm${sens[param]:10.4g}'
        # Update the text
        sens_print.set_text(text)
        # Redraw the figure
        plt.draw()

    def set_group(text):
        """ Set the attribute 'group' of the active signal according to the textbox """
        if not Np:  # Clear the textbox and do nothing more
            group_tb.text_disp.set_text('')
            plt.draw()
            return
        # Get active peak
        idx = int(np.floor(slider.val * Np) + 1)
        try:
            group = int(eval(text))
        except Exception:
            group = peaks[idx].group
        group_tb.text_disp.set_text('')
        peaks[idx].group = group
        write_par(idx)
        write_bpar()
        redraw()

    def selector(event):
        """ Update the text when you move the slider """
        idx = int(np.floor(slider.val * Np) + 1)
        if Np:
            for key, line in p_sgn.items():
                if key == idx:
                    line.set_lw(3)
                else:
                    line.set_lw(0.8)
            write_par(idx)
            write_bpar()
        redraw()

    def key_binding(event):
        """ Keyboard """
        key = event.key
        if key == 'w':
            make_x_basl(0)
        if key == '<':
            down_sens(0)
        if key == '>':
            up_sens(0)
        if key == '+':
            add_peak(0)
        if key == '-':
            remove_peak(0)
        if key == 'pagedown':
            if slider.val - slider.valstep >= 0:
                slider.set_val(slider.val - slider.valstep)
            selector(0)
        if key == 'pageup':
            if slider.val + slider.valstep < 1:
                slider.set_val(slider.val + slider.valstep)
            selector(0)
        if key == 'up' or key == 'down':
            event.button = key
            scroll(event)

    def reset(event):
        """ Return everything to default """
        nonlocal A, sens, bas_c, basl, whole_basl, B
        bas_c = np.zeros(5)
        basl = np.zeros_like(x_bsl)
        basl_plot.set_ydata(basl)
        whole_basl = np.zeros_like(ppm_scale)
        Q = Np
        for k in range(Q):
            remove_peak(event)
        A = _A
        B = _B

        sens = dict(_sens)
        ax.set_xlim(*_xlim)
        ax.set_ylim(*_ylim)
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        write_sens(param)
        redraw()

    def add_peak(event):
        """ Add a component """
        nonlocal Np
        # Increase the number of peaks
        Np += 1
        # Add an entry to the dictionary labelled as last
        peaks[Np] = fit.Peak(acqus, u=np.mean(ax.get_xlim()), N=N, group=lastgroup)
        # Plot it and add the trace to the plot dictionary
        p_sgn[Np] = ax.plot(ppm_scale[lim1:lim2], peaks[Np](A)[lim1:lim2], lw=0.8)[-1]
        # Move the slider to the position of the new peak
        slider.set_val((Np - 1) / Np)
        # Recompute the step of the slider
        slider.valstep = 1 / Np
        # Calculate the total trace with the new peak
        total = calc_total(peaks)
        # Update the total trace plot
        p_fit.set_ydata(whole_basl[lim1:lim2] + total[lim1:lim2])
        # Update the text
        write_par(Np)
        write_bpar()
        redraw()

    def remove_peak(event):
        """ Remove the active component """
        nonlocal Np, peaks, p_sgn
        if Np == 0:
            return
        # Get the active peak
        idx = int(np.floor(slider.val * Np) + 1)
        # Decrease Np of 1
        Np -= 1
        # Delete the entry from the peaks dictionary
        _ = peaks.pop(idx)
        # Remove the correspondant line from the plot dictionary
        del_p = p_sgn.pop(idx)
        # Set it invisible because I cannot truly delete it
        del_p.set_visible(False)
        del del_p   # ...at least clear some memory
        # Change the labels to the dictionary
        peaks = rename_dic(peaks, Np)
        p_sgn = rename_dic(p_sgn, Np)
        # Calculate the total trace without that peak
        total = calc_total(peaks)
        # Update the total trace plot
        p_fit.set_ydata(whole_basl[lim1:lim2] + total[lim1:lim2])
        # Change the slider position
        if Np == 0:  # to zero and do not make it move
            slider.set_val(0)
            slider.valstep = 1e-10
            write_par(0)
            write_bpar()
        elif Np == 1:   # To zero and that's it
            slider.set_val(0)
            slider.valstep = 1 / Np
            write_par(1)
            write_bpar()
        else:   # To the previous point
            if idx == 1:
                slider.set_val(0)
            else:
                slider.set_val((idx - 2) / Np)     # (idx - 1) -1
            slider.valstep = 1 / Np
            write_par(int(np.floor(slider.val * Np) + 1))
            write_bpar()
        redraw()

    def save(event):
        """ Write a section in the output file """
        nonlocal prev
        # Adjust the intensities
        # Convert the baseline coefficients
        bas_c_norm = B / A * bas_c
        fit.write_vf(filename_x, peaks, ax.get_xlim(), A, prev, bas_c=bas_c_norm)
        prev += len(peaks)

        # Mark a region as "fitted" with a green box
        ax.axvspan(*ax.get_xlim(), color='tab:green', alpha=0.1)
        # Call reset to return at the initial situation
        reset(event)

    # -------------------------------------------------------------------------------

    ax.plot(ppm_scale[lim1:lim2], S[lim1:lim2], label='Experimental', lw=1.0, c='k')  # experimental
    p_fit = ax.plot(ppm_scale[lim1:lim2], np.zeros_like(S)[lim1:lim2], label='Fit', lw=0.9, c='b', zorder=10)[-1]  # Total trace
    basl_plot = ax.plot(x_bsl_2plot, basl, label='Baseline', lw=1.0, c='mediumorchid')[-1]  # Baseline
    p_sgn = {}  # Components

    # Header for current values print
    head_print = ax.text(0.75, 0.4750,
                         '{:>7s}\n{:>5}\n{:>5}\n{:>5}\n{:>7}\n{:>7}\n{:>7}'.format(
                             r'$\delta$', r'$\Gamma$', '$k$', r'$\beta$', r'$\phi$', '$A$', 'Group'),
                         ha='right', va='top', transform=fig.transFigure, fontsize=14, linespacing=1.5)
    # Text placeholder for the values - linspacing is different to align with the header
    values_print = ax.text(0.85, 0.4750, '',
                           ha='right', va='top', transform=fig.transFigure,
                           fontsize=14, linespacing=1.55)
    ax.text(0.75, 0.2250,
            '{:>7s}\n{:>5}\n{:>5}\n{:>5}\n{:>7}\n{:>7}'.format(
                r'$c_0$', r'$c_1$', '$c_2$', '$c_3$', '$c_4$', '$B$'),
            ha='right', va='top', transform=fig.transFigure, fontsize=14, linespacing=1.5, color='mediumorchid')
    # Text placeholder for the values - linspacing is different to align with the header
    valuesb_print = ax.text(0.85, 0.2250, '',
                            ha='right', va='top', transform=fig.transFigure,
                            fontsize=14, linespacing=1.55)
    write_bpar()
    # Text to display the active sensitivity values
    sens_print = ax.text(0.875, 0.775, f'Sensitivity: $\\pm${sens["u"]:10.4g}',
                         ha='center', va='bottom', transform=fig.transFigure, fontsize=14)
    # Text to remind keyboard shortcuts
    t_uparrow = r'$\uparrow$'
    t_downarrow = r'$\downarrow$'
    keyboard_text = '\n'.join([
        f'{"KEYBOARD SHORTCUTS":^50s}',
        f'{"Key":>5s}: Action',
        '-'*50,
        f'{"<":>5s}: Decrease sens.',
        f'{">":>5s}: Increase sens.',
        f'{"+":>5s}: Add component',
        f'{"-":>5s}: Remove component',
        f'{"Pg"+t_uparrow:>5s}: Change component, up',
        f'{"Pg"+t_downarrow:>5s}: Change component, down',
        f'{t_uparrow:>5s}: Increase value',
        f'{t_downarrow:>5s}: Decrease value',
        '-'*50,
        ])
    ax.text(0.86, 0.025, keyboard_text,
            ha='left', va='bottom', transform=fig.transFigure, fontsize=8, linespacing=1.55)

    # make pretty scales
    ax.set_xlim(max(limits[0], limits[1]), min(limits[0], limits[1]))
    misc.pretty_scale(ax, ax.get_xlim(), axis='x', n_major_ticks=10)
    misc.pretty_scale(ax, ax.get_ylim(), axis='y', n_major_ticks=10)
    misc.mathformat(ax)

    # RESET values for xlim and ylim
    _xlim = ax.get_xlim()
    _ylim = ax.get_ylim()

    # Connect the widgets to their slots
    plus_button.on_clicked(add_peak)
    minus_button.on_clicked(remove_peak)
    up_button.on_clicked(up_sens)
    down_button.on_clicked(down_sens)
    slider.on_changed(selector)
    group_tb.on_submit(set_group)
    reset_button.on_clicked(reset)
    save_button.on_clicked(save)
    peak_radio.on_clicked(radio_changed)
    basset_button.on_clicked(make_x_basl)
    fig.canvas.mpl_connect('scroll_event', scroll)
    fig.canvas.mpl_connect('key_press_event', key_binding)

    plt.show()  # Start event loop
    plt.close()


def make_iguess_auto(ppm, data, SW, SFO1, o1p, filename='iguess'):
    """
    GUI to create a `.ivf` file, used as initial guess for Voigt_Fit.
    The computation of the peak positions and linewidths employs ``scipy.signal.find_peaks`` and ``scipy.signal.peak_widths``, respectively.
    In addition, peak features may be added manually by clicking with the left button twice. Unwanted features can be removed with right clicks.
    If the FWHM of a peak cannot be computed automatically, a dummy FWHM of 1 Hz is assigned automatically.
    The file `<filename>.ivf` is written upon pressing the SAVE button.
    Press Z to activate/deactivate the cursor snap.

    Parameters
    ----------
    ppm : 1darray
        PPM scale of the spectrum
    data : 1darray
        real part of the spectrum to fit
    SW : float
        Spectral width /Hz
    SFO1 : float
        Nucleus Larmor Frequency /MHz
    o1p : float
        Carrier position /ppm
    filename : str or Path
        Path to the file where to save the initial guess. The .ivf extension is added automatically.

    Returns
    -------
    None

    .. seealso::

        :func:`klassez.fit.make_iguess`

        :func:`scipy.signal.find_peaks`

        :func:`scipy.signal.peak_widths`
    """

    # MISCELLANEOUS FUNCTIONS
    def is_in(x, lims):
        """ Checks if x is inside the lims interval """
        return min(lims) <= x and x <= max(lims)

    def check_values():
        """ Handles event when P is negative and IW is less than 1 """
        nonlocal P, IW
        if P < 0:
            P = 0
        if IW < 1:
            IW = 1

    def get_pos(x, y, H, P):
        """
        Find the position of the peaks given height and prominence with ``scipy.signal.find_peaks``

        Parameters
        ----------
        x : 1darray
            array of x values
        y : 1darray
            array of y values
        H : float
            Threshold values (height)
        P : float
            Threshold values (prominence)

        Returns
        -------
        ks : list
            List of indices where the program found peaks
        """
        ks, *_ = find_peaks(y, height=H, prominence=P)
        return ks

    def maketotal(xj):
        """
        Compute the model trace, given the peak positions

        Parameters
        ----------
        xj: list
            Indices of peak positions

        Returns
        -------
        peak_in: list of fit.Peak objects
            Model peaks
        """
        warnings.simplefilter('ignore')
        # Estimate peak fwhms, in ppm
        widths, *_ = peak_widths(s, xj)
        # Convert them to Hz
        fwhms = misc.ppm2freq(widths * misc.calcres(ppm), SFO1)
        # Make a dummy 1Hz fwhm for non detected onmes
        for k, x in enumerate(fwhms):
            if x == 0:
                fwhms[k] = 1
        # Estimate the integrals of the peaks
        As = []
        for k, u in enumerate(xj):
            lims = (freq[u] - IW * fwhms[k]/2, freq[u] + IW * fwhms[k]/2)
            try:
                As.append(processing.integrate(s, freq, dx=(0.5 * SW), lims=lims))
            except Exception:
                As.append(1)
                print(lims)

        # Make the fit.Peak objects with the estimated parameters
        q = [(fit.Peak(acqus,
                       ppm[x],
                       fwhms[j],
                       k=As[j],
                       ))
             for j, x in enumerate(xj)]
        # Select only the peaks that are within the window
        peak_in = [peak for peak in q if is_in(peak.u, ax.get_xlim())]
        # Make the total model trace
        if len(peak_in):    # only if there are peaks inside, to avoid errors
            total = np.sum([p() for p in peak_in], axis=0)
        else:   # if there are no peaks, set zero
            total = np.zeros_like(s)
        # Update the figure
        model.set_ydata(total)
        plt.draw()
        return peak_in

    #  Initialize variables
    # Write the info on the file
    filename = Path(filename)
    filename_x = filename.with_suffix('ivf')
    with filename_x.open('a', buffering=1) as f:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! Initial guess computed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

    prev = 0
    # Dwell time
    dw = 1/SW
    # Acquisition points
    TD = data.shape[-1]
    # Acquisition timescale
    t_aq = np.linspace(0, TD*dw, TD)
    # acquisition parameters for the fit.Peak class
    acqus = {'t1': t_aq, 'SFO1': SFO1, 'o1p': o1p, 'N': TD, }

    # Frequency scale
    freq = misc.ppm2freq(ppm, SFO1, o1p)
    # Real part of the spectrum
    s = np.copy(data.real)

    # Threshold for height detection
    H = np.max(s) / 4
    _H = np.max(s) / 4
    # Prominence value
    P = np.max(s) / 100
    _P = np.max(s) / 100
    # Integration window = IW * fwhm of the peak
    IW = 2
    _IW = 2

    # Sensitivity for H
    sensH = round(np.max(s) / 50, 2)
    _sensH = round(np.max(s) / 50, 2)
    # Sensitivity for P
    sensP = round(np.max(s) / 1000, 2)
    _sensP = round(np.max(s) / 1000, 2)
    # Sensitivity for IW
    sensIW = 0.25
    _sensIW = 0.25

    # Snap flag
    snap = True

    # Names of the radiobutton entries
    radio_labels = 'Height', 'Prominence', 'Int. Window'

    # Get the positions of the peaks automatically according to the values of H and P
    xj = get_pos(ppm, s, H, P)
    # Placeholder: manually added peaks
    xi = []
    # Placeholder: blacklist. Peak positions that are in here are ignored
    x_blacklist = []
    # Number of peaks
    n_p = len(xj) + len(xi)

    # SLOTS

    def up_sens(event):
        """
        Doubles the sensitivity of the active value
        """
        sens_fork(2)

    def down_sens(event):
        """
        Halves the sensitivity of the active value
        """
        sens_fork(0.5)

    def sens_fork(val):
        """
        Routes the sensitivity modifications to the correct value.
        Val is 2 if up_sens, 1/2 if down_sens
        """
        if radio.value_selected == 'Height':
            nonlocal sensH
            sensH *= val
        elif radio.value_selected == 'Prominence':
            nonlocal sensP
            sensP *= val
        elif radio.value_selected == 'Int. Window':
            nonlocal sensIW
            sensIW *= val
        redraw_scales()
        update_text()

    def update_text(null=0):
        """ Updates the values texts """
        Htext.set_text(f'{H:10.3g}'+r' $\pm$ '+f'{sensH:.3g}')
        Ptext.set_text(f'{P:10.3g}'+r' $\pm$ '+f'{sensP:.3g}')
        Wtext.set_text(f'{IW:5.3f}'+r'$\Gamma\,\pm$ '+f'{sensIW:.3g}')
        npeaks.set_text(f'Number of peaks detected: {n_p:5.0f}')
        plt.draw()

    def update(val):
        """
        Update the values according to mouse scrolls, compute the trace, updates the figure.
        val = +1 if scroll up, = -1 if scroll down, 0 in all other cases
        """
        nonlocal xj, n_p
        # Update the selected value in the radiobuttons
        if radio.value_selected == 'Height':
            nonlocal H
            H += val * sensH
        elif radio.value_selected == 'Prominence':
            nonlocal P
            P += val * sensP
        elif radio.value_selected == 'Int. Window':
            nonlocal IW
            IW += val * sensIW
        # Check if the values are meaningful
        check_values()
        # Update the green horizontal line
        thr.set_ydata((H,))

        # Compute peak positions
        xj_tmp = get_pos(ppm, s, H, P)
        # Select the peak positions that are inside the selected window and are not in blacklist
        xj = [x for x in xj_tmp if is_in(ppm[x], ax.get_xlim()) and x not in x_blacklist]
        # Compute number of peaks
        n_p = len(xj)
        # Draw the crosses for automatically detected peaks
        crosses.set_data(ppm[xj], s[xj])
        # Draw the plusses for manually added peaks
        squares.set_data(ppm[xi], s[xi])
        # Make xj to include all peaks
        xj.extend([x for x in xi])
        # Remove duplicates from xj
        xj = list(set(xj))
        # Compute the total trace
        maketotal(xj)
        # Make the scales
        redraw_scales()
        # Update the figure
        update_text()
        plt.draw()

    def on_scroll(event):
        """ Handles scroll events """
        if event.button == 'up':
            update(+1)
        if event.button == 'down':
            update(-1)
        update_text()

    def redraw_scales(null=0):
        """ Recompute the scales to fit the active zoom """
        misc.pretty_scale(ax, ax.get_xlim(), 'x')
        misc.pretty_scale(ax, ax.get_ylim(), 'y')
        misc.mathformat(ax)
        plt.draw()

    def clear_blacklist(event):
        """ Removes all excluded automatic peaks from the blacklist """
        nonlocal x_blacklist
        x_blacklist = []
        update(0)

    def tracker(event):
        """ Draws the crosshair """
        if event.inaxes:    # Make the crosshair visible
            h_track.set_visible(True)
            v_track.set_visible(True)
            o_track.set_visible(True)
        else:               # hide it
            h_track.set_visible(False)
            v_track.set_visible(False)
            o_track.set_visible(False)
            return
        # Find index of x position on ppm
        i = misc.ppmfind(ppm, event.xdata)[0]
        # x coordinate is ppm[i]
        x = ppm[i]
        if snap:    # y snaps to the spectrum
            y = s[i]
        else:       # y is just the y position of the cursor
            y = event.ydata
        # Update the crosshair
        h_track.set_ydata((y,))
        v_track.set_xdata((x,))
        o_track.set_data((x,), (y,))
        plt.draw()

    def keybindings(event):
        """ Handles key press """
        key = event.key
        if key == 'z':      # switches snap between True and False
            nonlocal snap
            snap = not snap
        if key == '<':      # halves sensitivity
            sens_fork(0.5)
        if key == '>':      # doubles sensitivity
            sens_fork(2)
        if key == 'pageup':  # cycle radiobuttons down
            i = radio_labels.index(radio.value_selected)
            j = i - 1
            if j < 0:   # e.g. j=-1
                j = len(radio.labels) + j
            radio.set_active(j)
        if key == 'pagedown':   # cycle radiobuttons up
            i = radio_labels.index(radio.value_selected)
            j = i + 1
            if j > len(radio.labels) - 1:    # e.g. j = 3, len(radio.labels) = 3
                j = j - len(radio.labels)
            radio.set_active(j)
        if key == 'up':     # as scrolling up
            update(+1)
        if key == 'down':   # as scrolling down
            update(-1)
        redraw_scales()

    def mouseclick(event):
        """ Manually adds/removes peaks """
        if not event.inaxes:    # do nothing
            return
        # Find the position of the mouse
        x = misc.ppmfind(ppm, event.xdata)[0]
        if (event.button == 1 and event.dblclick) or event.button == 2:    # Left double click or middle click
            xi.append(x)    # Append the value to the manual peak list
            if x in x_blacklist:    # Remove it
                i = x_blacklist.index(x)    # First find the index of x in the blacklist
                x_blacklist.pop(i)          # Then, remove it
            # Redraw everything
            update(0)
        if event.button == 3:   # Right single click
            # Find the closest peak to the point you clicked
            if len(xi):  # first in the manual list
                closest_i = misc.find_nearest(xi, x)
            else:   # if it is empty, set None
                closest_i = None
            if len(xj):  # then in the automatic list
                closest_j = misc.find_nearest(xj, x)
            else:   # if it is empty, set None
                closest_j = None

            if closest_i is not None:   # If there are any peaks in the manual list:
                # do things only if the closest manual peak is within 10 points to where you actually clicked
                if np.abs(x - closest_i) < 10:
                    # Find the position of such peak
                    i = xi.index(closest_i)
                    # Remove it from the list
                    xi.pop(i)
                    # Redraw everything, then stop
                    update(0)
                    return

            # It gets here only if the closest peak is not manually added and it is not within 10 points to where you actually clicked

            if closest_j is not None:
                # do things only if the closest automatic peak is within 10 points to where you actually clicked
                if np.abs(x - closest_j) < 10:
                    # Add this point to the blacklist
                    x_blacklist.append(closest_j)
                    # Redraw everything
                    update(0)
                    return

    def save(event):
        """ Write a section in the output file """
        nonlocal prev
        # Adjust the intensities

        peak_in = maketotal(xj)
        keys = np.arange(prev+1, prev+len(peak_in)+1, 1)
        peaks = {key: peak_in[k] for k, key in enumerate(keys)}
        # Use 1 as A because the relative intensities are calculated inside write_wf
        fit.write_vf(filename_x, peaks, ax.get_xlim(), 1, prev)
        prev += len(peak_in)

        # Mark a region as "fitted" with a green box
        ax.axvspan(*ax.get_xlim(), color='tab:green', alpha=0.1)
        # Call reset to return at the initial situation
        reset(event)

    def reset(event):
        # setta H e P come all'inizio
        nonlocal H, P, IW, sensH, sensP, sensIW
        H = _H
        P = _P
        IW = _IW
        sensH = _sensH
        sensP = _sensP
        sensIW = _sensIW
        # resetta i limiti
        ax.set_xlim(max(ppm), min(ppm))
        update(0)
        plt.draw()

    # -------------------------------------------------------------------------------------------------------------

    # Make the figure panel
    fig = plt.figure('Automatic Computation of Initial Guess')
    fig.set_size_inches(figures.figsize_large)
    ax = fig.add_subplot()
    plt.subplots_adjust(left=0.1, right=0.8, top=0.95, bottom=0.1)

    # Boxes where to place the widgets
    #    radiobuttons
    box_radio = plt.axes([0.825, 0.6, 0.15, 0.2])
    #   up_sens button
    box_up = plt.axes([0.825, 0.85, 0.075, 0.05])
    #   down_sens button
    box_down = plt.axes([0.900, 0.85, 0.075, 0.05])
    #   clear_blacklist button
    box_cbl = plt.axes([0.900, 0.10, 0.075, 0.05])
    #   save button
    box_save = plt.axes([0.825, 0.10, 0.075, 0.05])

    # Make the widgets
    #   radiobuttons
    radio = RadioButtons(box_radio, radio_labels)
    #   up_sens button
    up_button = Button(box_up, r'$\uparrow$', hovercolor='0.975')
    #   down_sens button
    down_button = Button(box_down, r'$\downarrow$', hovercolor='0.975')
    #   clear_blacklist button
    cbl_button = Button(box_cbl, r'Clear Blacklist', hovercolor='0.975')
    #   save button
    save_button = Button(box_save, r'SAVE', hovercolor='0.975')
    #   mouse position
    cursor = Cursor(ax, horizOn=False, vertOn=False, useblit=True, lw=0.2, color='tab:green')
    cursor.horizOn = False

    # Draw the spectrum in blue
    ax.plot(ppm, s, c='tab:blue', lw=0.8)
    # Draw the automatic peak positions as crosses
    crosses, = ax.plot(ppm[xj], s[xj], 'x', c='tab:orange')
    # Draw the manual peak positions as plusses
    squares, = ax.plot(ppm[xi], s[xi], '+', c='tab:orange')
    # Draw a placeholder for the total model trace
    model, = ax.plot(ppm, np.zeros_like(ppm), c='tab:red', lw=0.8)
    # Draw a line for the threshold
    thr = ax.axhline(H, c='tab:green', lw=0.5, ls='--')
    # Draw the crosshair, but invisible
    #   Horizontal line
    h_track = ax.axhline(0, c='g', lw=0.2, visible=False)
    #   Vertical line
    v_track = ax.axvline(0, c='g', lw=0.2, visible=False)
    #   Cross point
    o_track, = ax.plot((0,), (0,), c='g', marker='.', markersize=5, visible=False)
    # Compute the model
    maketotal(xj)

    # Make the text values to be updated
    #   Compute the positions of the text: in the middle between two subsequent radio labels
    radiolabel_pos = [label.get_position() for label in radio.labels]
    yshift = (radiolabel_pos[1][1] - radiolabel_pos[0][1]) / 2
    #   H value text
    Htext = box_radio.text(0.995, radiolabel_pos[0][1] + yshift, '', ha='right', va='center', transform=box_radio.transAxes)
    #   P value text
    Ptext = box_radio.text(0.995, radiolabel_pos[1][1] + yshift, '', ha='right', va='center', transform=box_radio.transAxes)
    #   IW value text
    Wtext = box_radio.text(0.995, radiolabel_pos[2][1] + yshift, '', ha='right', va='center', transform=box_radio.transAxes)
    #   Number of peaks text
    npeaks = plt.text(0.825, 0.5, '', ha='left', va='center', transform=fig.transFigure)
    update_text()

    # Instruction text
    uparrow_text = r'$\uparrow$'
    downarrow_text = r'$\downarrow$'
    keyboard_text = '\n'.join([
        f'{"KEYBOARD SHORTCUTS":^50s}',
        f'{"Key":>5s}: Action',
        '-'*50,
        f'{"<":>5s}: Decrease sens.',
        f'{">":>5s}: Increase sens.',
        f'{"Pg"+uparrow_text:>5s}: Change parameter, up',
        f'{"Pg"+downarrow_text:>5s}: Change parameter, down',
        f'{uparrow_text:>5s}: Increase value',
        f'{downarrow_text:>5s}: Decrease value',
        f'{"z":>5s}: Toggle snap on cursor',
        '-'*50,
        ])
    ax.text(0.86, 0.2, keyboard_text,
            ha='left', va='bottom', transform=fig.transFigure, fontsize=8, linespacing=1.55)

    # Adjust visual shit
    misc.pretty_scale(ax, (max(ppm), min(ppm)), 'x')
    misc.pretty_scale(ax, ax.get_ylim(), 'y')
    misc.mathformat(ax)

    # Connect widgets to slots
    up_button.on_clicked(up_sens)
    down_button.on_clicked(down_sens)
    cbl_button.on_clicked(clear_blacklist)
    save_button.on_clicked(save)
    fig.canvas.mpl_connect('motion_notify_event', tracker)
    fig.canvas.mpl_connect('scroll_event', on_scroll)
    fig.canvas.mpl_connect('button_press_event', mouseclick)
    fig.canvas.mpl_connect('key_press_event', keybindings)

    # Start event loop
    plt.show()

    # Re-enable warnings
    warnings.simplefilter('default')


# --------------------------------------------------------------------
def write_vf(filename, peaks, lims, Int, prev=0, header=False, bas_c=None):
    """
    Write a section in a fit report file, which shows the fitting region and the parameters of the peaks to feed into a Voigt lineshape model.

    Parameters
    ----------
    filename : str or Path
        Path to the file to be written
    peaks : dict
        Dictionary of ``fit.Peak`` objects
    lims : tuple
        (left limit /ppm, right limit /ppm)
    I : float
        Absolute intensity value
    prev : int
        Number of previous peaks already saved. Increases the peak index
    header : bool
        If True, adds a "!" starting line to separate fit trials
    bas_c : None or 1darray
        Baseline coefficients

    Returns
    -------
    None

    .. seealso::

        :func:`klassez.fit.read_vf`

        :func:`klassez.fit.make_iguess`
    """
    # Adjust the intensities
    r_i, I_corr = misc.molfrac([peak.k for _, peak in peaks.items()])

    # Open the file in append mode
    f = Path(filename).open('a', buffering=1)
    # Info on the region to be fitted
    if header:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! Fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))
    #   Header
    f.write('{:>16};\t{:>12}\n'.format('Region', 'Intensity'))
    f.write('-'*96+'\n')
    #   Values
    region = '{:-.3f}:{:-.3f}'.format(*lims)   # From the zoom of the figure
    f.write(f'{region:>16};\t{Int*I_corr:14.8e}\n\n')

    # Info on the peaks
    #   Header
    f.write('{:>4};\t{:>12};\t{:>12};\t{:>8};\t{:>8};\t{:>8};\t{:>8}\n'.format(
        '#', 'u', 'fwhm', 'Rel. I.', 'Phase', 'Beta', 'Group'))
    f.write('-'*96+'\n')
    #   Values
    for k, key in enumerate(peaks.keys()):
        peak = peaks[key]
        f.write('{:>4.0f};\t{:=12.8f};\t{:12.6f};\t{:8.6f};\t{:-8.3f};\t{:8.5f};\t{:>8.0f}\n'.format(
            k+prev+1, peak.u, peak.fwhm, r_i[k], peak.phi, peak.b, peak.group))
    f.write('-'*96+'\n\n')

    if bas_c is not None:
        f.write('BASLC:\t'+'; '.join([f'{w/(I_corr):+12.8f}' for w in bas_c])+'\n\n')

    # Add region separator and close the file
    f.write('='*96+'\n\n')
    f.close()


def read_vf(filename, n=-1):
    """
    Reads a `.ivf` (initial guess) or `.fvf` (final fit) file, containing the parameters for a lineshape deconvolution fitting procedure.
    The file is separated and unpacked into a list of dictionaries, each of which contains the limits of the fitting window,
    the total intensity value, and a dictionary for each peak with the characteristic values to compute it with a Voigt line.

    Parameters
    ----------
    filename : str or Path
        Path to the filename to be read
    n : int
        Number of performed fit to be read. Default: last one. The breakpoints are lines that start with "!". For this reason, ``n=0`` returns an empty dictionary, hence the first fit is ``n=1``.

    Returns
    -------
    regions: list
        List of dictionaries for running the fit.

    .. seealso::

        :func:`klassez.fit.write_vf`
    """
    def read_region(R):
        """ Creates a dictionary of parameters from a section of the input file.  """
        # Placeholder
        dic_r = {}
        # Separate the lines and remove the empty ones
        R = R.split('\n')
        for k, r in enumerate(R):
            if len(r) == 0 or r.isspace():
                _ = R.pop(k)

        n_bp = 0        # Number of breaking points (----)
        k_bp = 0        # Line of the last breaking point detected
        for k, r in enumerate(R):
            if '------' in r:   # Increase breakpoint and store the line number
                n_bp += 1
                k_bp = k
                continue

            if n_bp == 1 and k_bp == k-1:   # First section: region limits and total intensity
                line = r.split(';')  # Separate the values
                dic_r['limits'] = eval(line[0].replace(':', ', '))   # Get the limits
                dic_r['I'] = eval(line[-1])     # Get the intensity

            if n_bp == 2:       # Second section: peak parameters
                line = r.split(';')  # Separate the values
                # Unpack the line
                idx, u, fwhm, k, phi, b, group = [eval(w) for w in line]
                # Put the values in a dictionary
                dic_p = {
                        'u': u,
                        'fwhm': fwhm,
                        'k': k,
                        'b': b,
                        'phi': phi,
                        'group': group
                        }
                # Put the values in the returned dictionary
                dic_r[idx] = dic_p

            if n_bp == 3:   # End of file: stop reading
                if 'BASLC' in r:
                    line = r.split(':', 1)[-1]
                    bas_c = np.array(eval(line.replace(';', ',')))
                    dic_r['bas_c'] = bas_c
                break

        return dic_r

    # Read the file
    ff = Path(filename).read_text()
    # Get the actual section from an output file
    f = ff.split('!')[n]
    # Separate the bigger sections
    R = f.split('='*96)
    # Remove the empty lines
    for k, r in enumerate(R):
        if r.isspace():
            _ = R.pop(k)

    regions = []    # Placeholder for return values
    for r in R:  # Loop on the big sections to read them
        regions.append(read_region(r))
    return regions


def write_dy(filename, diff_c, diff_f, diff_e, label, intensity, offset, header=False):
    """
    Write a section in a fit report file, which shows the fitting region identifier and the parameters to feed into the DOSY model fitting.

    Parameters
    ----------
    filename : str or Path
        Path to the file to be written
    diff_c : list of float
        Diffusion coefficients in m^2/s
    diff_f : list of float
        Fractions of the various components
    diff_e : list of float or list of None
        Fit errors. Initial guess will have ``None`` for each entry
    label : str
        Region identifier for the fit
    intensity : float
        Intensity factor to match the model to the experimental data
    offset : float
        Offset factor to match the model to the experimental data

    Returns
    -------
    None

    .. seealso::

        :func:`klassez.fit.read_dy`

        :func:`klassez.fit.make_iguess_dosy`
    """
    # Adjust the intensities
    diff_f, I_corr = misc.molfrac(diff_f)

    # Open the file in append mode
    f = Path(filename).open('a', buffering=1)
    # Info on the region to be fitted
    if header:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! DOSY fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))
    #   Header
    f.write('{:>24}; {:>14}; {:>14}\n'.format('Region', 'Intensity', 'Offset'))
    f.write('-'*96+'\n')
    #   Values
    f.write(f'{label:>24}; {intensity*I_corr:14.8e}; {offset*I_corr:14.8e}\n\n')

    # Info on the components
    #   Header
    f.write('{:>16}; {:>16}; {:>16}\n'.format(
        'Dosy coeff.', 'Fraction', 'Error'))
    f.write('-'*96+'\n')
    #   Values
    for diffc, difff, diffe in zip(diff_c, diff_f, diff_e):
        f.write('{:=16.8e}; {:16.8f}; '.format(diffc, difff))
        if diffe is None:
            f.write(f'{"None":>16s}\n')
        else:
            f.write(f'{diffe:16.8e}\n')
    f.write('-'*96+'\n\n')

    # Add region separator and close the file
    f.write('='*96+'\n\n')
    f.close()


def read_dy(filename, n=-1):
    """
    Reads a `.idy` (initial guess) or `.fdy` (final fit) file, containing the parameters for a DOSY fitting procedure.
    The file is separated and unpacked into a list of dictionaries, each of which contains the region identifier,
    the total intensity value and the offset, the diffusion coefficients, relative weight of the components, and the fit errors.

    Parameters
    ----------
    filename : str or Path
        Path to the filename to be read
    n : int
        Number of performed fit to be read. Default: last one. The breakpoints are lines that start with "!". For this reason, ``n=0`` returns an empty dictionary, hence the first fit is ``n=1``.

    Returns
    -------
    regions: list
        List of dictionaries for running the fit.

    .. seealso::

        :func:`klassez.fit.write_dy`
    """
    def read_region(R):
        """ Creates a dictionary of parameters from a section of the input file.  """
        # Placeholder
        dic_r = {}
        # Separate the lines and remove the empty ones
        R = R.split('\n')
        for k, r in enumerate(R):
            if len(r) == 0 or r.isspace():
                _ = R.pop(k)

        n_bp = 0        # Number of breaking points (----)
        k_bp = 0        # Line of the last breaking point detected
        for k, r in enumerate(R):
            if '------' in r:   # Increase breakpoint and store the line number
                n_bp += 1
                k_bp = k
                continue

            if n_bp == 1 and k_bp == k-1:   # First section: region identifiere, intensity and offset
                line = r.split(';')  # Separate the values
                dic_r['label'] = line[0].replace(' ', '')   # Get the label
                dic_r['I'] = eval(line[1])     # Get the intensity
                dic_r['q'] = eval(line[2])     # Get the offset
                # Create placeholders for the values
                dic_r['diff_c'] = []
                dic_r['diff_f'] = []
                dic_r['diff_e'] = []

            if n_bp == 2:       # Second section: peak parameters
                line = r.split(';')  # Separate the values
                # Unpack the line
                diffc, difff, diffe = [eval(w) for w in line]
                # Put the values in the dictionary
                dic_r['diff_c'].append(diffc)
                dic_r['diff_f'].append(difff)
                dic_r['diff_e'].append(diffe)

            if n_bp == 3:   # End of file: stop reading
                break

        return dic_r

    # Read the file
    ff = Path(filename).read_text()
    # Get the actual section from an output file
    f = ff.split('!')[n]
    # Separate the bigger sections
    R = f.split('='*96)
    # Remove the empty lines
    for k, r in enumerate(R):
        if r.isspace():
            _ = R.pop(k)

    regions = []    # Placeholder for return values
    for r in R:  # Loop on the big sections to read them
        regions.append(read_region(r))
    return regions


# --------------------------------------------------------------------


def test_randomsign(data, thresh=1.96):
    """
    Test an array of residuals for the randomness of the sign changes.
    The result it True if the sequence is recognized as random.

    Parameters
    ----------
    data : 1darray
        Residuals to test
    thresh : float
        Significance level. The default is 1.96, which corresponds to 5% significance level.

    Returns
    -------
    test : bool
        True if the signs are random, False otherwise
    """
    # Size of the data
    N = len(data)
    # Signs of the entries in the residual
    signs = np.sign(data)
    # Number of > 0 residuals
    n_p = len([x for x in signs if x > 0])
    # Number of < 0 residuals
    n_n = len([x for x in signs if x < 0])

    # Where the residuals change sign
    signchange = ((np.roll(signs, 1) - signs) != 0).astype(int)
    # Number of adjacent pieces of the residuals with the same sign
    n_runs = np.sum(signchange)

    # The statistical distribution of runs can be approximated with a Gaussian
    # with mean = u and std = sigma
    u = (2 * n_p * n_n) / N + 1
    sigma = ((u - 1) * (u - 2) / (N - 1))**0.5

    # If z < thresh, the sign distribution is random
    z = np.abs(n_runs - u) / sigma
    return z < thresh


def test_correl(data, subtract_mean=True):
    r"""
    Tests an array of residuals for their correlation.
    It compares the unit-lag autocorrelation `P` of the ``data`` (see below) with the theoretical value for non-correlated data `T_P`:

    .. math::

        P = \sum_k^{N-1} r[k] \, r[k+1] ;\quad T_P = \sqrt{N-1} \sum_k r[k]^2

    If :math:`P < T_P`, the residuals are not correlated, and the result is True.

    Parameters
    ----------
    data: 1darray
        Residuals to be test
    subtract_mean: bool
        If True, subtracts from the residuals their mean.

    Returns
    -------
    test: bool
        True if the residuals are non correlated, False otherwise
    """
    # Shallow copy of the residuals
    r = np.copy(data)
    # Size of the data
    N = len(r)
    if subtract_mean:    # Subtract from the residuals their mean
        r -= np.mean(r)

    # Compute the discrete correlation function of the residuals P
    r_roll = np.roll(r, 1)
    P = np.sum((r * r_roll)[:-1])
    # Compute threshold for correlation
    T_P = 1 / (N - 1)**0.5 * np.sum(r**2)

    # Residuals are not correlated if P < T_P
    return np.abs(P) < T_P


def test_ks(data, thresh=0.05):
    """
    Performs the Kolmogorov-Smirnov test on the residuals to check if they are drawn from a normal distribution.
    The implementation is :func:`scipy.stats.kstest`.
    The result is True if the residuals are Gaussian.

    Parameters
    ----------
    data : 1darray
        Residuals to test
    thresh : float
        Significance level for the test. Default is 5%

    Returns
    -------
    test : bool
        True if the residuals are Gaussian, False otherwise
    """
    # Shallow copy
    r = np.copy(data)
    from scipy.stats import kstest
    ksstat = kstest(r, "norm", args=(np.mean(r), np.std(r)))
    # Residuals are gaussian distributed if p_value > thresh
    return ksstat.pvalue > thresh


def test_residuals(res, alpha=0.05):
    """
    Tests an array of residuals for their randomness, correlation, and underlying distribution.
    To do this, it uses the functions :func:`klassez.fit.test_randomsign`, :func:`klassez.fit.test_correl`, :func:`klassez.fit.test_ks`.
    The results of the tests will be print in standard output and returned.

    Parameters
    ----------
    res : ndarray
        Residuals to be tested
    alpha : float
        Significance level

    Returns
    -------
    test_random : bool
        Randomness of the residuals (True = random)
    test_correlation : bool
        Correlation of the residuals (True = non-correlated)
    test_gaussian : bool
        Normal-distribution of the residuals (True = normally-distributed)

    .. seealso::

        :func:`klassez.fit.test_randomsign`

        :func:`klassez.fit.test_correl`

        :func:`klassez.fit.test_ks`
    """
    from scipy.stats import norm

    # Get the z-score from the significance level
    z_value = norm.ppf(1 - alpha/2)

    # Convert the residuals to 1D
    r = np.copy(res).flatten()

    # Compute the tests
    test_randomness = fit.test_randomsign(r, z_value)
    test_correlation = fit.test_correl(r, False)
    test_gaussian = fit.test_ks(r, alpha)

    # Print the results
    print('Residual test', c='tab:orange', s='underline')
    print('\n'.join([
        f'{"Random":22s}: {test_randomness}',
        f'{"Non-correlated":22s}: {test_correlation}',
        f'{"Normally distributed":22s}: {test_gaussian}',
        ]))

    return test_randomness, test_correlation, test_gaussian


def gaussian_fit(x, y, s_in=None):
    """
    Fit ``y`` with a gaussian function, built using ``x`` as independent variable

    Parameters
    ----------
    x : 1darray
        x-scale
    y : 1darray
        data to be fitted
    s_in : float or None
        initial guess for the standard deviation of the gaussian. If None, ``np.std(y)`` is used

    Returns
    -------
    u : float
        mean
    s : float
        standard deviation
    A : float
        Integral

    .. seealso::

        :func:`klassez.sim.f_gaussian`
    """

    # Make parameter dictionary
    param = lmfit.Parameters()
    param.add('u', value=np.mean(y), min=min(y), max=max(y))
    param.add('s', value=np.std(y), min=0, max=np.inf)
    if s_in:
        param['s'].set(value=s_in)
    param.add('A', value=np.trapz(y, dx=misc.calcres(x)), min=0, max=5*np.trapz(y, dx=misc.calcres(x)))

    def f2min(param, x, y):
        # Cost function
        par = param.valuesdict()
        G = sim.f_gaussian(x, par['u'], par['s'], par['A'])
        return y - G

    minner = lmfit.Minimizer(f2min, param, fcn_args=(x, y))
    result = minner.minimize(method='leastsq', max_nfev=10000, xtol=1e-15, ftol=1e-15)

    # Return the result
    popt = result.params.valuesdict()
    return popt['u'], popt['s'], popt['A']

# -------------------------------------------------------------------------------------------


class Voigt_Fit:
    """
    This class offers an "interface" to fit a 1D NMR spectrum.

    Attributes
    ----------
    ppm_scale : 1darray
        Self-explanatory
    S : 1darray
        Spectrum to fit. Only real part
    t_AQ : 1darray
        acquisition timescale of the spectrum
    SW : float
        Spectral width /Hz
    SFO1 : float
        Larmor frequency of the nucleus
    o1p : float
        Pulse carrier frequency
    filename : str or Path
        Root of the names of the files that will be saved
    X_label : str
        Label for the chemical shift axis in the figures
    i_guess : list of dict
        Initial guess for the fit, read by a `.ivf` file with :func:`klassez.fit.read_vf`
    result : list of dict
        Result the fit, read by a `.fvf` file with :func:`klassez.fit.read_vf`
    """

    def __init__(self, ppm_scale, S, t_AQ, SFO1, o1p, nuc=None, filename='fit'):
        """
        Initialize the class with common values.

        Parameters
        ----------
        ppm_scale : 1darray
            ppm scale of the spectrum
        S : 1darray
            Spectrum to be fitted
        t_AQ : 1darray
            Acquisition timescale
        SFO1 : float
            Larmor frequency of the observed nucleus, in MHz
        o1p : float
            Carrier position, in ppm
        nuc : str
            Observed nucleus. Used to customize the x-scale of the figures.
        filename : str
            Root of the name of the files that will be saved
        """
        self.ppm_scale = ppm_scale
        self.S = S
        self.t_AQ = processing.extend_taq(t_AQ, self.S.shape[-1])
        self.SFO1 = SFO1
        self.SW = np.abs((max(ppm_scale) - min(ppm_scale)) * SFO1)
        self.o1p = o1p
        self.filename = filename
        if nuc is None:
            self.X_label = r'$\delta\,$ /ppm'
        elif isinstance(nuc, str):
            fnuc = misc.nuc_format(nuc)
            self.X_label = r'$\delta$ ' + fnuc + ' /ppm'

    def iguess(self, filename=None, n=-1, ext='ivf', auto=False):
        """
        Reads, or computes, the initial guess for the fit.
        If the file is there already, it just reads it with ``fit.read_vf``. Otherwise, it calls ``fit.make_iguess`` to make it.

        Parameters
        ----------
        filename: str or None
            Path to the input file. If None, `"<self.filename>.ivf"` is used
        n: int
            Index of the initial guess to be read (default: last one)
        ext: str
            Extension of the file to be used
        auto: bool
            If True, uses the GUI for automatic peak picking, if False, the manual one

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.make_iguess`

            :func:`klassez.fit.make_iguess_auto`

            :func:`klassez.fit.read_vf`
        """
        # Set the default filename, if not given
        if filename is None:
            filename = f'{self.filename}'
        filename = Path(filename)
        filename_x = filename.with_suffix(f'.{ext}')
        # Check if the file exists
        in_file_exist = filename_x.exists()

        if in_file_exist is True:       # Read everything you need from the file
            regions = fit.read_vf(filename_x)
        else:                           # Make the initial guess interactively and save the file.
            if auto:
                fit.make_iguess_auto(self.ppm_scale, self.S, self.SW, self.SFO1, self.o1p, filename=filename)
            else:
                fit.make_iguess(self.S, self.ppm_scale, self.t_AQ, self.SFO1, self.o1p, filename=filename)
            regions = fit.read_vf(filename_x)
        # Store it
        self.i_guess = regions
        print(f'{filename_x} loaded as input file.', c='tab:blue')

    def load_fit(self, filename=None, n=-1, ext='fvf'):
        """
        Reads a file with ``fit.read_vf`` and stores the result in ``self.result``.

        Parameters
        ----------
        filename: str or Path
            Path to the .fvf file to be read. If None, "<self.filename>.fvf" is used.
        n: int
            Index of the fit to be read (default: last one)
        ext: str
            Extension of the file to be used

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.make_iguess`

            :func:`klassez.fit.make_iguess_auto`

            :func:`klassez.fit.read_vf`
        """
        # Set the default filename, if not given
        if filename is None:
            filename = f'{self.filename}'
        filename = Path(filename)
        filename_x = filename.with_suffix(f'.{ext}')
        # Check if the file exists
        if filename_x.exists():
            regions = fit.read_vf(filename_x, n=n)
        else:
            raise FileNotFoundError(f'{filename_x} does not exist.')
        # Store
        self.result = regions
        print(f'{filename_x} loaded as fit result file.', c='tab:blue')

    def dofit(self, indep=True, u_lim=1, f_lim=10, k_lim=(0, 3), vary_phase=False, vary_b=True, itermax=10000, fit_tol=1e-8, filename=None, method='leastsq', basl_fit='no'):
        """
        Perform a lineshape deconvolution fitting.
        The initial guess is read from the attribute ``self.i_guess``.
        The components can be considered to be all independent from one to another by setting ``indep=True``: this means that the fit will be done using ``fit.voigt_fit_indep``.
        The ``indep=False`` option has not been implemented yet.

        Parameters
        ----------
        indep : bool
            True to consider all the components to be independent
        u_lim : float
            Determines the displacement of the chemical shift (in ppm) from the starting value.
        f_lim : float
            Determines the displacement of the linewidth (in Hz) from the starting value.
        k_lim : float or tuple
            If tuple, minimum and maximum allowed values for k during the fit. If float, maximum displacement from the initial guess
        vary_phase : bool
            Allow the peaks to change phase (True) or not (False)
        vary_b : bool
            Allow the peaks to change Lorentzian/Gaussian ratio
        itermax : int
            Maximum number of allowed iterations
        fit_tol : float
            Value of the target function to be set as x_tol and f_tol
        filename : str or Path
            Path to the output file. If None, "<self.filename>.fvf" is used
        method : str or list of str
            Method to be used for the optimization. See lmfit for details. There is the option to run multiple optimizations in series.
        basl_fit : str
            How to address the baseline fit. The options are:
            * "no" : Do not use baseline (default)
            * "fixed" : The baseline is computed once and kept fixed during the optimization
            * "fit"  : The baseline coefficients enter as fit parameters during the nonlinear optimization
            * "calc" : The baseline coefficients are calculated during the optimization via linear least-squares optimization

        Returns
        -------
        lmfit_results : list of lmfit.minimizer.MinimizerResult
            Sequence of the fit results, ordered as the regions dictionary

        .. seealso::

            :func:`klassez.fit.voigt_fit_indep`
        """

        # Make a shallow copy of the real part of the experimental spectrum
        S = np.copy(self.S.real)
        # Check if the initial guess was loaded correctly
        if not isinstance(self.i_guess, list):
            raise ValueError('Initial guess not correctly loaded')
        # Set the output filename, if not given
        if filename is None:
            filename = f'{self.filename}'
        filename = Path(filename)

        # Do the fit
        if indep is True:
            lmfit_results = fit.voigt_fit_indep(S, self.ppm_scale, self.i_guess, self.t_AQ, self.SFO1, self.o1p,
                                                u_lim=u_lim, f_lim=f_lim, k_lim=k_lim, vary_phase=vary_phase, vary_b=vary_b,
                                                itermax=itermax, fit_tol=fit_tol, filename=filename, method=method, basl_fit=basl_fit)
        else:
            raise NotImplementedError('More and more exciting adventures in the next release!')
        # Store
        self.result = fit.read_vf(filename.with_suffix('.fvf'))
        return lmfit_results

    def plot(self, what='result', show_total=True, show_res=False, res_offset=0, show_basl=False, labels=None, filename=None, ext='png', dpi=600, dim=None):
        """
        Plots either the initial guess or the result of the fit, and saves all the figures. Calls :func:`fit.plot_fit`.
        The figure `<filename>_full` will show the whole model and the whole spectrum.
        The figures labelled with `_R<k>` will depict a detail of the fit in the k-th fitting region.
        Optional labels for the components can be given: in this case, the structure of `labels` should match the structure of ``self.result`` (or ``self.i_guess``).
        This means that the length of the outer list must be equal to the number of fitting region, and the length of the inner lists must be equal to the number of peaks in that region.

        Parameters
        ----------
        what : str
            'iguess' to plot the initial guess, 'result' to plot the fitted data
        show_total : bool
            Show the total trace (i.e. sum of all the components) or not
        show_res : bool
            Show the plot of the residuals
        res_offset : float
            Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental spectrum. ``res_offset`` > 0 will move the residuals BELOW the zero-line!
        show_basl : bool
            If True, displays the baseline on the spectrum and uses it to compute the total trace.
        labels : list of list
            Optional labels for the components. The structure of this parameter must match the structure of ``self.result``
        filename : str or Path
            Root of the name of the figures that will be saved. If None, `<self.filename>` is used
        ext : str
            Format of the saved figures
        dpi : int
            Resolution of the figures, in dots per inches
        dim : tuple
            Dimension of the figure in inches

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.plot_fit`
        """
        # select the correct object to plot
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Set the filename, if not given
        if filename is None:
            filename = f'{self.filename}'
        filename = Path(filename)

        # Make the figures
        S = np.copy(self.S.real)
        fit.plot_fit(S, self.ppm_scale, regions, self.t_AQ, self.SFO1, self.o1p, show_total=show_total,
                     show_res=show_res, res_offset=res_offset, show_basl=show_basl, X_label=self.X_label,
                     labels=labels, filename=filename, ext=ext, dpi=dpi, dim=dim)

    def get_fit_lines(self, what='result'):
        """
        Calculates the components, and the total fit curve used as initial guess, or as fit results.
        The components will be returned as a list, not split by region.

        Parameters
        ----------
        what : str
            'iguess' or 'result'

        Returns
        -------
        signals : list of 1darray
            Components used for the fit
        total : 1darray
            Sum of all the signals
        limits_list : list
            List of region delimiters, in ppm
        whole_basl : 1darray
            Computed baseline
        """
        # Select the correct object
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Make the acqus dictionary for the fit.Peak objects
        acqus = {'t1': self.t_AQ, 'SFO1': self.SFO1, 'o1p': self.o1p, }
        # Placeholders
        signals = []
        limits_list = []
        whole_basl = np.zeros_like(self.ppm_scale)
        # Loop on the regions
        for region in regions:
            # Remove the limits and the intensity from the region dictionary
            param = deepcopy(region)
            limits = param.pop('limits')
            Int = param.pop('I')
            if 'bas_c' in region.keys():
                bas_c = Int * region['bas_c']
                param.pop('bas_c')
            else:
                bas_c = np.zeros(5)
            # Convert the limits from ppm to points and make the slice
            limits_pt = misc.ppmfind(self.ppm_scale, limits[0])[0], misc.ppmfind(self.ppm_scale, limits[1])[0]
            # Baseline x-scale
            x = np.linspace(0, 1, int(np.abs(limits_pt[1]-limits_pt[0])))
            # Compute baseline
            basl = misc.polyn(x, bas_c)
            whole_basl = misc.sum_overlay(whole_basl, basl, max(limits), self.ppm_scale)
            # Make the fit.Peak objects
            peaks = {key: fit.Peak(acqus, N=self.S.shape[-1], **value) for key, value in param.items()}
            # Get the arrays from the dictionary and put them in the list
            signals.extend([p(Int) for _, p in peaks.items()])
            limits_list.append(limits)
        # Compute the total trace
        total = np.sum(signals, axis=0)
        return signals, total, limits_list, whole_basl

    def res_histogram(self, what='result', nbins=500, density=True, f_lims=None, xlabel='Residuals', x_symm=True, barcolor='tab:green', fontsize=20, filename=None, ext='png', dpi=300):
        """
        Computes the histogram of the residuals and saves it.
        Employs :func:`klassez.fit.histogram` to make the figure.

        Parameters
        ----------
        what : str
            'iguess' or 'result'
        nbins  : int
            number of bins to be calculated
        density  : bool
            True for normalize data
        f_lims  : tuple or None
            limits for the x axis of the figure
        xlabel  : str or None
            Text to be displayed under the x axis
        x_symm  : bool
            set it to True to make symmetric x-axis with respect to 0
        barcolor : str
            Color of the bins
        fontsize : float
            Biggest fontsize in the figure
        filename  : str or Path
            name for the figure to be saved
        ext : str
            Format of the image
        dpi : int
            Resolution of the image in dots per inches

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.histogram`
        """
        # Filename check
        if filename is None:
            filename = f'{self.filename}'
        filename += '_rhist'
        filename = Path(filename)

        # Select the correct object
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Get the total function and the limits
        _, total, limits_list, whole_basl = self.get_fit_lines(what)
        # Convert the limits in points according to the ppm scale
        limits_pt_list = [[misc.ppmfind(self.ppm_scale, w)[0] for w in lims]
                          for lims in limits_list]

        # Placeholders
        exp_trim, total_trim = [], []
        for k, region in enumerate(regions):        # loop on the regions
            # Compute the slice
            lims = slice(min(limits_pt_list[k]), max(limits_pt_list[k]))
            # Trim the experimental data and the total
            exp_trim.append(self.S[..., lims].real)
            total_trim.append((total + whole_basl)[..., lims])
        # Sum on different regions
        exp_trim = np.concatenate(exp_trim, axis=-1)
        total_trim = np.concatenate(total_trim, axis=-1)

        # Compute the residuals
        residual_arr = exp_trim - total_trim

        fit.histogram(residual_arr, nbins=nbins, density=density, f_lims=f_lims,
                      xlabel=xlabel, x_symm=x_symm, barcolor=barcolor,
                      fontsize=fontsize, filename=filename, ext=ext, dpi=dpi)

    def to_tragico(self, which='iguess', filename=None):
        """
        Writes input 1 and input 2 for a TrAGICo run, on the basis of either the initial guess or the results of a fit.
        The files will be named `'<filename>_inp1'` and `'<filename>_inp2'`, respectively.

        Parameters
        ----------
        which : str
            'iguess' or 'result'
        filename : str or Path
            Name of the file that will be saved. If None, the file will be saved in the spectrum directory
        """
        def write_inp1(reg, filename):
            """
            Write the input 1 file for a tragico run, on the basis of ``regions``.
            The file name will be `<filename>_inp1`.

            Parameters
            ----------
            reg : list of dict
                self.i_guess or self.result
            filename : Path
                Name of the file that will be saved
            """

            # Shallow copy to prevent overwriting of the original "regions"
            regions = deepcopy(reg)

            # Add "inp1" to the filename
            fname = filename.with_stem(f'{filename.stem}_inp1')
            # Open the file and write the header
            f = fname.open('w')
            header = 'name\tppm1\tppm2\tv\tmult\n'
            f.write(header)

            # Loop on the regions
            for region in regions:
                # Get the region limits
                ppm1, ppm2 = region.pop('limits')
                # Remove intensity and baseline coefficients, in inp1 they are not needed
                region.pop('I')
                region.pop('bas_c')

                # Sort the indices of the peaks
                idxs = sorted(list(region.keys()))

                # Loop on the peaks
                for idx in idxs:
                    # Get the chemical shift and the group identifier
                    v = region[idx]['u']
                    mult = region[idx]['group']
                    # Write the line in the input file
                    line = f'{"true"}\t{ppm1:.5f}\t{ppm2:.5f}\t{v:.5f}\t{mult}\n'
                    f.write(line)
            f.close()

            print(f'Input 1 for TrAGICo written in {fname}.', c='tab:blue')

        def write_inp2(reg, ppm, spectrum, filename):
            """
            Write the input 2 file for a tragico run, on the basis of ``regions``.
            The file name will be `<filename>_inp1`.

            Parameters
            ----------
            reg : list of dict
                ``self.i_guess`` or ``self.result``
            filename : Path
                Name of the file that will be saved
            """
            def xbaslfact(ppm, lims, bas_c):
                """
                Conversion of the klassez baseline to the tragico baseline
                """
                # Make a shallow copy of the ppm scale
                x = deepcopy(ppm)
                # Cut it in the interested region
                ppm_trimmed, _ = misc.trim_data(x, x, lims)
                # Tragico scale goes from w to 0
                w = max(ppm_trimmed) - min(ppm_trimmed)
                # In tragico, the baseline is computed on a scale that goes from 0 to w, and it is inverted
                # with respect to the one used by klassez. To make the conversion, one must consider that
                #   x_k = 1 - (x_t / w)
                # and the two polynomia should be equal. Solving this gives the given solution for new_coeff
                new_coeff = np.array(
                        [
                            np.sum(bas_c),
                            np.sum([(j+1)*bas_c[j+1] for j in range(4)]) / w,
                            (bas_c[2] + 3 * bas_c[3] + 6 * bas_c[4]) / w**2,
                            (bas_c[3] + 4 * bas_c[4]) / w**3,
                            bas_c[4] / w**4,
                        ])
                return new_coeff

            # Conversion factor used by tragico
            maxr = np.max(spectrum)

            # Make a shallow copy to prevent overwriting of the original "regions"
            regions = deepcopy(reg)

            # Add "inp2" to the filename
            fname = filename.with_stem(f'{filename.stem}_inp2')
            # Open the file and write the header
            f = fname.open('w')
            header = 'i\tppm1\tppm2\tk\tfwhm\tphi\txg\tA\tB\tC\tD\tE\t\n'
            f.write(header)

            # Loop inside the regions
            for region in regions:
                # Get the region limits
                ppm1, ppm2 = region.pop('limits')
                # Get the intensity factor
                Int = region.pop('I')
                # Get the baseline coefficients and return them to their absolute value
                bas_c = region.pop('bas_c') * Int
                # Convert the baseline coefficients to tragico
                bas_c = xbaslfact(ppm, (ppm1, ppm2), bas_c) / maxr

                # At this point, only the peaks indices are left in region
                # Sort the indices of the peaks
                idxs = sorted(list(region.keys()))

                # Loop on the peaks
                for idx in idxs:
                    # Convert the intensity
                    k = region[idx]['k'] * Int / maxr
                    # The fwhm in tragico is in ppm
                    fwhm = misc.freq2ppm(region[idx]['fwhm'], self.SFO1)
                    # The phase in tragico is in radians
                    phi = region[idx]['phi'] * np.pi / 180
                    # Get the fraction of gaussianity
                    xg = region[idx]['b']

                    # Compute the line to write and write it
                    line = f'{idx}\t{ppm1:.1f}\t{ppm2:.1f}\t' + '\t'.join([f'{w:.5e}' for w in [k, fwhm, phi, xg, *bas_c]]) + '\n'
                    f.write(line)
            f.close()

            print(f'Input 2 for TrAGICo written in {fname}.', c='tab:blue')

        # Discriminate who do you want to save
        if which == 'result':
            regions = deepcopy(self.result)
        elif which == 'iguess':
            regions = deepcopy(self.i_guess)
        else:
            raise NameError('which must be "iguess" or "result"')

        if filename is None:
            filename = deepcopy(self.filename)
        filename = Path(filename)

        write_inp1(regions, filename)
        write_inp2(regions, self.ppm_scale, self.S, filename)
        print()


def peak_pick_2D(ppm_f1, ppm_f2, data, coord_filename='coord.tmp'):
    """
    Make interactive peak_picking.
    The position of the selected signals are saved in ``coord_filename``.
    If ``coord_filename`` already exists, the new signals are appended at its bottom: nothing is overwritten.
    Calls :func:`klassez.anal.select_traces` for the selection.

    Parameters
    ----------
    ppm_f1: 1darray
        ppm scale for the indirect dimension
    ppm_f2: 1darray
        ppm scale for the direct dimension
    data: 2darray
        Spectrum to peak-pick. The dimension should match the scale sizes.
    coord_filename: str or Path
        Path to the file where to save the peak coordinates

    Returns
    -------
    coord: list
        List of (u2, u1) for each peak
    """
    # Check for the existence of coord_filename
    coord_filename = Path(coord_filename)
    if coord_filename.exists():
        # number of already present signals: last linei, first value before tab
        n_C = eval(coord_filename.readlines()[-1].split('\t')[0])
        C = coord_filename.open('a', buffering=1)
    else:
        C = coord_filename.open('w', buffering=1)
        C.write(r'#'+'\t'+f'{"u2":^8s},{"u1":^8s}'+'\n')    # Header line
        n_C = 0

    # Make peak_picking
    coord = anal.select_traces(ppm_f1, ppm_f2, data)

    # Update the fucking coord file
    for k, obj in enumerate(coord):
        C.write(f'{k+1+n_C}'+'\t'+f'{obj[0]:-8.3f},{obj[1]:-8.3f}'+'\n')
    C.close()

    return coord


def gen_iguess_2D(ppm_f1, ppm_f2, tr1, tr2, u1, u2, acqus, fwhm0=100, procs=None):
    """
    Generate the initial guess for the fit of a 2D signal.
    The employes model is the one of a 2D Voigt signal, acquired with the States-TPPI scheme in the indirect dimension (i.e. :func:`klassez.sim.t_2DVoigt`).
    The program allows for the inclusion of up to 10 components for the signal, in order to improve the fit.
    The acqus dictionary must contain the following keys:

        * t1: acquisition timescale in the indirect dimension (States)
        * t2: acquisition timescale in the direct dimension
        * SFO1: Larmor frequency of the nucleus in the indirect dimension
        * SFO2: Larmor frequency of the nucleus in the direct dimension
        * o1p: carrier position in the indirect dimension /ppm
        * o2p: carrier position in the direct dimension /ppm

    The signals will be processed according to the values in the ``procs`` dictionary, if given; otherwise, they will be just zero-filled up to the data size (i.e. ``(len(ppm_f1), len(ppm_f2))`` ).

    Parameters
    ----------
    ppm_f1 : 1darray
        ppm scale for the indirect dimension
    ppm_f2 : 1darray
        ppm scale for the direct dimension
    tr1 : 1darray
        Trace of the original 2D peak in the indirect dimension
    tr2 : 1darray
        Trace of the original 2D peak in the direct dimension
    u1 : float
        Chemical shift of the original 2D peak in the indirect dimension /ppm
    u2 : float
        Chemical shift of the original 2D peak in the direct dimension /ppm
    acqus : dict
        Dictionary of acquisition parameters
    fwhm0 : float
        Initial value for FWHM in both dimensions
    procs : dict
        Dictionary of processing parameters

    Returns
    -------
    final_parameters : 2darray
        Matrix of dimension (# signals, 6) that contains, for each row: v1(Hz), v2(Hz), fwhm1(Hz), fwhm2(Hz), A, b
    fit_interval : tuple of tuple
        Fitting window. ( (left_f1, right_f1), (left_f2, right_f2) )
    """

    # FIRST OF ALL, THE FIGURE!
    fig = plt.figure('Manual Computation of Initial Guess - 2D')
    fig.set_size_inches(figures.figsize_large)
    plt.subplots_adjust(left=0.05, right=0.65, top=0.90, bottom=0.10, wspace=0.2)
    ax2, ax1 = [fig.add_subplot(1, 2, w+1) for w in range(2)]

    # INITIALIZE THE VALUES

    # Values to be returned
    final_parameters = []
    fit_interval = None, None

    # limits of the window
    lim_f1 = u1 + 100/np.abs(acqus['SFO1']), u1 - 100/np.abs(acqus['SFO1'])
    lim_f2 = u2 + 100/np.abs(acqus['SFO2']), u2 - 100/np.abs(acqus['SFO2'])

    V = [{
        'u1': u1,   # ppm
        'u2': u2,   # ppm
        'fwhm1': fwhm0,  # Hz
        'fwhm2': fwhm0,  # Hz
        'k': 0.1,   # relative intensity
        'b': 0.5,   # fraction of gaussianity
        } for w in range(10)]
    I1 = processing.integrate(tr1, x=ppm_f1, dx=(2 * acqus['dw']), lims=lim_f1)
    I2 = processing.integrate(tr2, x=ppm_f2, dx=(2 * acqus['dw']), lims=lim_f2)
    A = (I1 + I2) / (2*np.pi*fwhm0)

    # Sensitivity for mouse
    sens = {
        'u1': 0.25,   # ppm
        'u2': 0.25,   # ppm
        'fwhm1': 10,     # Hz
        'fwhm2': 10,    # Hz
        'k': 0.01,      # 1%
        'b': 0.1,   # 10%
        'A': np.floor(np.log10(A)) - 1      # This goes according to order of magnitudes
        }

    # Copy initial values for reset
    V_in = [dict(q) for q in V]
    A_in = np.copy(A)
    sens_in = dict(sens)

    # conversion of the names from radio labels to dict keys
    conv_r2d = {
            r'$\delta$ /ppm': 'u',
            r'$\Gamma$ /Hz': 'fwhm',
            r'$k$': 'k',
            r'$\beta$': 'b',
            }

    # --------------------------------------------------------------------------
    # SETUP OF THE INTERACTIVE FIGURE PANEL

    # make boxes for widgets
    tb2_boxes = [   # limits for the direct dimension
            plt.axes([0.050, 0.025, 0.05, 0.03]),   # left
            plt.axes([0.275, 0.025, 0.05, 0.03]),   # right
            ]
    tb1_boxes = [   # limits for the indirect dimension
            plt.axes([0.370, 0.025, 0.05, 0.03]),   # left
            plt.axes([0.600, 0.025, 0.05, 0.03]),   # right
            ]

    slider_box = plt.axes([0.68, 0.10, 0.01, 0.65])     # peak selector
    peak_box = plt.axes([0.72, 0.45, 0.10, 0.3])        # radiobuttons
    su_box = plt.axes([0.815, 0.825, 0.08, 0.075])      # increase sensitivity button
    giu_box = plt.axes([0.894, 0.825, 0.08, 0.075])     # decrease sensitivity button
    save_box = plt.axes([0.7, 0.825, 0.085, 0.04])      # SAVE button
    reset_box = plt.axes([0.7, 0.865, 0.085, 0.04])     # RESET button
    p_or_s_box = plt.axes([0.73, 0.78, 0.04, 0.03])     # F1 or F2 selector
    check_box = plt.axes([0.85, 0.1, 0.1, 0.7])         # Peak checker

    # Make widgets
    #   Buttons
    up_button = Button(su_box, r'$\uparrow$', hovercolor='0.975')         # increase sensitivity button
    down_button = Button(giu_box, r'\downarrow$', hovercolor='0.975')    # decrease sensitivity button
    save_button = Button(save_box, 'SAVE', hovercolor='0.975')            # SAVE button
    reset_button = Button(reset_box, 'RESET', hovercolor='0.975')         # RESET button

    #   textboxes
    TB1 = [TextBox(box, '', initial=f'{value:.2f}', textalignment='center') for box, value in zip(tb1_boxes, lim_f1)]   # set limits for F1
    TB2 = [TextBox(box, '', initial=f'{value:.2f}', textalignment='center') for box, value in zip(tb2_boxes, lim_f2)]   # set limits for F2

    #   Radiobuttons for parameter selection
    peak_name = [r'$\delta$ /ppm', r'$\Gamma$ /Hz', r'$k$', r'$\beta$', r'$A$']         # Labels for the parameters
    peak_radio = RadioButtons(peak_box, peak_name, active=2, activecolor='tab:blue')      # Actual radiobuttons

    #   Sliders
    #       Peak selector
    slider = Slider(ax=slider_box, label='Active\nSignal', valmin=1, valmax=10, valinit=1, valstep=1, orientation='vertical', color='tab:blue')
    #       Ruler for slider
    for i, H in enumerate(np.linspace(0.10, 0.75, 10)):
        plt.text(0.685, H, '$-$', ha='center', va='center', fontsize=20, color=COLORS[i], transform=fig.transFigure)
    #       Dimension selector
    f1_or_f2 = Slider(p_or_s_box, 'F', valmin=1, valmax=2, valinit=2, valstep=1, track_color='tab:blue', color='tab:orange')

    #   Checkbox: peak checker
    check_name = [str(w+1) for w in range(10)]      # 1, 2, 3...
    check_status = [False if w else True for w in range(10)]    # Only the first
    check = CheckButtons(check_box, check_name, check_status)   # Make the checkbutton

    #       Customize checkbox appearance
    #       ... make boxes more squared
    misc.edit_checkboxes(check, xadj=0, yadj=0.001, dim=100, color=COLORS)

    # Text that shows the current values of the parameters
    head_print = plt.text(0.725, 0.4,
                          '{:9s}:'.format(r'$\delta$ F2') + f'{V[0]["u2"]:-9.2f}\n' +
                          '{:9s}:'.format(r'$\delta$ F1') + f'{V[0]["u1"]:-9.2f}\n' +
                          '{:9s}:'.format(r'$\Gamma$ F2') + f'{V[0]["fwhm2"]:-9.2f}\n' +
                          '{:9s}:'.format(r'$\Gamma$ F1') + f'{V[0]["fwhm1"]:-9.2f}\n' +
                          '{:9s}:'.format(r'$k$') + f'{V[0]["k"]:-9.2f}\n' +
                          '{:9s}:'.format(r'$\beta$') + f'{V[0]["b"]:-9.2f}\n' +
                          '{:9s}:'.format(r'$A$') + f'{A:-9.2e}\n',
                          ha='left', va='top', transform=fig.transFigure, fontsize=12, color=COLORS[0])

    # --------------------------------------------------------------------------
    # SLOTS
    def reset(event):
        """ Bring all parameters and sens to the starting values """
        nonlocal V, A, sens
        # Copy initial values for reset
        V = [dict(q) for q in V_in]
        A = np.copy(A_in)
        sens = dict(sens_in)
        [update(q) for q in range(10)]

    def edit_lims(text):
        """ Read the limits from the textboxes and change the scales accordingly.  """
        nonlocal lim_f1, lim_f2
        lim_f1 = [eval(TB.text) for TB in TB1]
        lim_f2 = [eval(TB.text) for TB in TB2]
        misc.pretty_scale(ax2, lim_f2, 'x')
        misc.pretty_scale(ax1, lim_f1, 'x')
        fig.canvas.draw()

    def set_visibility(event):
        """
        Set the signals visible or invisible according to their status in the checkbutton.
        Recomputes the total function considering only the active signals.
        """
        slider.set_val(eval(event))     # Moves the selector to the "new" peak for easier handling
        for k, stat in enumerate(check.get_status()):
            s1_plot[k].set_visible(stat)
            s2_plot[k].set_visible(stat)

        total_1 = np.sum([sgn_1[k] for k in range(len(V)) if check.get_status()[k]], axis=0)
        t1_plot.set_ydata(total_1)
        total_2 = np.sum([sgn_2[k] for k in range(len(V)) if check.get_status()[k]], axis=0)
        t2_plot.set_ydata(total_2)
        fig.canvas.draw()

    def make_sgn_2D(values, acqus, N=None, procs=None):
        """
        Create a 2D signal according to the final parameters returned by make_iguess_2D.

        Parameters
        ----------
        final_parameters: list or 2darray
            sequence of the parameters: u1, u2, fwhm1, fwhm2, I, b
        acqus: dict
            2D-like acqus dictionary containing the acquisition timescales (keys t1 and t2)
        N: tuple of int
            Zero-filling values (F1, F2). Not read if procs is not None
        procs: dict
            2D-like procs dictionary.

        Returns
        -------
        peaks: list of 2darray
            rr part of the generated signals
        """
        # Shallow copy of acquisition timescales
        t1 = np.copy(acqus['t1'])
        t2 = np.copy(acqus['t2'])
        # Organize the parameters
        to_pass = [
                misc.ppm2freq(values[0], acqus['SFO1'], acqus['o1p']),  # u1 from ppm to Hz
                misc.ppm2freq(values[1], acqus['SFO2'], acqus['o2p']),  # u2 from ppm to Hz
                values[2] * 2 * np.pi,    # fwhm1 from Hz to radians
                values[3] * 2 * np.pi,    # fwhm2 from Hz to radians
                values[4],  # Intensity
                values[5],   # b
                ]
        signal = sim.t_2Dvoigt(t1, t2, *to_pass)    # Make the 2D signal

        if procs is not None:   # Processing according to procs
            peak, *_ = processing.xfb(signal, wf=procs['wf'], zf=procs['zf'])
        else:   # just zero-fill before FT
            peak, *_ = processing.xfb(signal, zf=N)     # Just zero-fill

        # Extract the traces
        tr_f1 = anal.get_trace(peak, ppm_f2, ppm_f1, a=values[1], column=True)   # F2 @ u1 ppm
        tr_f2 = anal.get_trace(peak, ppm_f2, ppm_f1, a=values[0], column=False)  # F1 @ u2 ppm

        return tr_f1, tr_f2

    def get_key2edit():
        """ Makes the conversion between the radiobutton labels and the keys of V/ A """
        F = f'{f1_or_f2.val}'   # F1 or F2
        label = peak_radio.value_selected    # active parameter
        if label in conv_r2d.keys():    # i.e. it is not A
            key2edit = f'{conv_r2d[label]}'
            if 'k' not in key2edit and 'b' not in key2edit:   # i.e. it is u or fwhm
                key2edit += F   # add 1 for f1 or 2 for f2
        else:
            key2edit = 'A'
        return key2edit

    def update(s_idx):
        """
        Computes the s_idx-th 2D signal, extract the traces in F1 and F2, then redraws them.
        Updates the total functions with the sum of the active signals.
        """
        # Organize the parameters
        values = [V[s_idx][f'{key}'] for key in ('u1', 'u2', 'fwhm1', 'fwhm2', 'k', 'b')]
        values[-2] *= A
        # Compute the 2D signal and extract the traces
        sgn_1[s_idx], sgn_2[s_idx] = make_sgn_2D(values, acqus, N=(N1, N2), procs=procs)

        # Update the plots:
        #   F1
        s1_plot[s_idx].set_ydata(sgn_1[s_idx])  # update plot
        total_1 = np.sum([sgn_1[k] for k in range(len(V)) if check.get_status()[k]], axis=0)
        t1_plot.set_ydata(total_1.real)
        #   F2
        s2_plot[s_idx].set_ydata(sgn_2[s_idx])
        total_2 = np.sum([sgn_2[k] for k in range(len(V)) if check.get_status()[k]], axis=0)
        t2_plot.set_ydata(total_2.real)

        redraw_text(0)      # Update the text with the new values
        fig.canvas.draw()

    def roll(event):
        """ Slot for the mouse wheel """
        nonlocal A
        s_idx = slider.val - 1  # active signal
        key2edit = get_key2edit()   # active parameter
        if key2edit == 'A':     # move A
            if event.button == 'up':
                A += 10**sens['A']
            elif event.button == 'down':
                A -= 10**sens['A']
        else:   # Move the selected parameter
            if event.button == 'up':
                V[s_idx][key2edit] += sens[key2edit]
            elif event.button == 'down':
                V[s_idx][key2edit] -= sens[key2edit]
        # Safety check for FWHM
        if V[s_idx]['fwhm1'] <= 1:
            V[s_idx]['fwhm1'] = 1
        if V[s_idx]['fwhm2'] <= 1:
            V[s_idx]['fwhm2'] = 1
        # Safety check for b
        if V[s_idx]['b'] <= 0:
            V[s_idx]['b'] = 0
        elif V[s_idx]['b'] >= 1:
            V[s_idx]['b'] = 1
        update(s_idx)   # Redraw everything and update text

    def up_sens(event):
        """ Slot for the up-arrow button"""
        key2edit = get_key2edit()
        if key2edit == 'A':  # increase it by one order of magnitude
            sens['A'] += 1
        else:    # double
            sens[key2edit] *= 2

    def down_sens(event):
        """ Slot for the down-arrow button"""
        key2edit = get_key2edit()
        if key2edit == 'A':
            sens['A'] -= 1  # decrease it by one order of magnitude
        else:   # halve
            sens[key2edit] /= 2

    def redraw_text(event):
        """ Updates the text according to the current values. Also changes its color. """
        s_idx = slider.val - 1  # python numbering
        value_string = '\n'.join([
            '{:9s}:'.format(r'$\delta$ F2') + f'{V[s_idx]["u2"]:-9.2f}',
            '{:9s}:'.format(r'$\delta$ F1') + f'{V[s_idx]["u1"]:-9.2f}',
            '{:9s}:'.format(r'$\Gamma$ F2') + f'{V[s_idx]["fwhm2"]:-9.2f}',
            '{:9s}:'.format(r'$\Gamma$ F1') + f'{V[s_idx]["fwhm1"]:-9.2f}',
            '{:9s}:'.format(r'$k$') + f'{V[s_idx]["k"]:-9.2f}',
            '{:9s}:'.format(r'$\beta$') + f'{V[s_idx]["b"]:-9.2f}',
            '{:9s}:'.format(r'$A$') + f'{A:-9.2e}',
            '',
            ])
        head_print.set_text(value_string)
        head_print.set_color(COLORS[s_idx])  # color of the active signal
        fig.canvas.draw()

    def save(event):
        """ Slot for the save button: store the current values into the final variables """
        nonlocal final_parameters, fit_interval
        final_parameters = [[   # u1, u2, fwhm1, fwhm2, k*A, b
                    misc.ppm2freq(V[x]['u1'], acqus['SFO1'], acqus['o1p']),
                    misc.ppm2freq(V[x]['u2'], acqus['SFO2'], acqus['o2p']),
                    V[x]['fwhm1'],
                    V[x]['fwhm2'],
                    V[x]['k'] * A,
                    V[x]['b'],
                    ] for x in range(len(V)) if check.get_status()[x]]
        # ( (L_F1, R_F1), (L_F2, R_F2) )
        fit_interval = tuple([eval(x.text) for x in TB1]), tuple([eval(x.text) for x in TB2])

    # --------------------------------------------------------------------------

    N1, N2 = ppm_f1.shape[-1], ppm_f2.shape[-1]         # Zero-filling dimension
    if procs is None:   # I do not care
        proc1s, proc2s = None, None
    else:   # Split it into two 1D-like procs dictionaries
        proc1s, proc2s = misc.split_procs_2D(procs)

    # Figure titles
    ax2.set_title(f'F2 trace @ {u1:.1f} ppm')
    ax1.set_title(f'F1 trace @ {u2:.1f} ppm')

    # red dashed line as marker for the initially selected chemical shifts
    ax2.axvline(u2, c='r', lw=0.3, ls='--')
    ax1.axvline(u1, c='r', lw=0.3, ls='--')

    # Draw the experimental spectrum
    ax2.plot(ppm_f2, tr2, c='k', lw=1.0, label='Exp.')
    ax1.plot(ppm_f1, tr1, c='k', lw=1.0, label='Exp.')

    # Initialize the simulated signals with the starting values
    #   F1
    sgn_1, sgn_2 = [], []
    for k, Vline in enumerate(V_in):
        # Organize parameters
        values = [Vline[f'{key}'] for key in ('u1', 'u2', 'fwhm1', 'fwhm2', 'k', 'b')]
        values[-2] *= A
        # Build the 2D signal and extract the traces
        tmp1, tmp2 = make_sgn_2D(values, acqus, N=(N1, N2), procs=procs)
        sgn_1.append(tmp1)
        sgn_2.append(tmp2)

    s1_plot = []        # lines
    for i in range(len(V)):
        temp1, = ax1.plot(ppm_f1, sgn_1[i].real, c=COLORS[i], lw=1.0, ls='--')
        s1_plot.append(temp1)
        s1_plot[i].set_visible(check.get_status()[i])
    total_1 = np.sum([sgn_1[k] for k in range(len(V)) if check.get_status()[k]], axis=0)    # spectrum
    t1_plot, = ax1.plot(ppm_f1, total_1.real, label='Fit', c='blue', lw=1.0)    # line

    s2_plot = []        # lines
    for i in range(len(V)):
        temp2, = ax2.plot(ppm_f2, sgn_2[i].real, c=COLORS[i], lw=1.0, ls='--')
        s2_plot.append(temp2)
        s2_plot[i].set_visible(check.get_status()[i])
    total_2 = np.sum([sgn_2[k] for k in range(len(V)) if check.get_status()[k]], axis=0)    # spectrum
    t2_plot, = ax2.plot(ppm_f2, total_2.real, label='Fit', c='blue', lw=1.0)    # line

    # Fancy shit
    #   x scales
    ax2.set_xlabel(r'$\delta$ '+f'{misc.nuc_format(acqus["nuc2"])}'+r' /ppm')
    ax1.set_xlabel(r'$\delta$ '+f'{misc.nuc_format(acqus["nuc1"])}'+r' /ppm')
    misc.pretty_scale(ax2, lim_f2, 'x')
    misc.pretty_scale(ax1, lim_f1, 'x')
    for ax in (ax2, ax1):
        # y scales
        misc.pretty_scale(ax, ax.get_ylim(), 'y')
        misc.mathformat(ax, 'y')
        # Draw legend
        ax.legend()
        # Bigger fontsizes
        misc.set_fontsizes(ax, 14)

    # Connect widgets to the slots
    #   Textboxes
    [TB.on_submit(edit_lims) for TB in TB1]
    [TB.on_submit(edit_lims) for TB in TB2]
    #   Checkbox
    check.on_clicked(set_visibility)
    #   Slider
    slider.on_changed(redraw_text)
    #   Mouse scroll
    fig.canvas.mpl_connect('scroll_event', roll)

    #   up-down buttons
    up_button.on_clicked(up_sens)
    down_button.on_clicked(down_sens)

    #   reset and save
    reset_button.on_clicked(reset)
    save_button.on_clicked(save)

    # fill the return variables with the starting ones
    save('initial values')

    plt.show()
    plt.close()

    return final_parameters, fit_interval


def build_2D_sgn(parameters, acqus, N=None, procs=None):
    """
    Create a 2D signal according to the final parameters returned by :func:`klassez.fit.make_iguess_2D`.
    Process it according to ``procs``.

    Parameters
    ----------
    parameters : list or 2darray
        sequence of the parameters: u1, u2, fwhm1, fwhm2, I, b. Multiple components are allowed
    acqus : dict
        2D-like acqus dictionary containing the acquisition timescales (keys t1 and t2)
    N : tuple of int
        Zero-filling values (F1, F2). Read only if procs is None
    procs : dict
        2D-like procs dictionary.

    Returns
    -------
    peak : 2darray
        rr part of the generated signal
    """
    parameters = np.array(parameters)
    if len(parameters.shape) == 1:
        parameters = parameters.reshape(1, -1)
    # Get timescales from acqus
    t1 = np.copy(acqus['t1'])
    t2 = np.copy(acqus['t2'])
    signals = []        # Time domain
    for k, values in enumerate(parameters):
        values[0] = misc.ppm2freq(values[0], acqus['SFO1'], acqus['o1p'])
        values[1] = misc.ppm2freq(values[1], acqus['SFO2'], acqus['o2p'])
        values[2] *= 2*np.pi    # fwhm1 from Hz to radians
        values[3] *= 2*np.pi    # fwhm2 from Hz to radians
        sgn = sim.t_2Dvoigt(t1, t2, *values)    # Make the signal in the time domain
        signals.append(sgn)
    signal = np.sum(signals, axis=0)    # Sum the components

    if procs is not None:    # Process the data according to procs
        peak, *_ = processing.xfb(signal, wf=procs['wf'], zf=procs['zf'])
    else:
        peak, *_ = processing.xfb(signal, zf=N)     # Just zero-fill

    return peak
# ----------------------------------------------------------------------------------------------------------------------


class Voigt_Fit_2D:
    """
    Class that wraps methods for the fit of 2D spectra with a set of 2D Voigtian lines.

    .. warning::

        This is work in progress.
    """
    def __init__(self, ppm_f1, ppm_f2, data, acqus, procs=None, label_list=None):
        """
        Initialize the class with ppm scales, experimental spectrum, acqus and procs dictionaries.

        Parameters
        ----------
        ppm_f1 : 1darray
            ppm scale for the indirect dimension
        ppm_f2 : 1darray
            ppm scale for the direct dimension
        data : 2darray
            Spectrum to fit. The dimension should match the scale sizes.
        acqus : dict
            Dictionary of acquisition parameters
        procs : dict
            Dictionary of processing parameters
        label_list : list
            Labels for the peaks
        """
        self.ppm_f1 = np.copy(ppm_f1)
        self.ppm_f2 = np.copy(ppm_f2)
        self.data = np.copy(data)
        self.acqus = dict(acqus)
        if procs is None:
            self.procs = None
        else:
            self.procs = dict(procs)
        self.label_list = label_list

    def plot(self, filename=None, show_exp=True, dpi=600, **kwargs):
        """
        Draw a plot of the guessed/fitted peaks.

        Parameters
        ----------
        filename : str or Path or None
            Filename for the figure. If it is None, the figure is shown.
        show_exp : bool
            Choose if to plot the experimental spectrum or not
        dpi : int
            Resolution of the saved image
        kwargs : keyworded arguments
            Additional parameters to be passed to figures.ax2D.
        """

        # Generate the full spectrum to make the plot computationally less expensive
        fitted_data = np.sum(self.peaks, axis=0)

        # Make the figure
        fig = plt.figure('Fit')
        fig.set_size_inches(figures.figsize_large)
        ax = fig.add_subplot()
        if 'cmap' in kwargs.keys():
            kwargs.pop('cmap')

        if show_exp:
            if 'lvl' in kwargs.keys():
                l_E = kwargs['lvl']
                kwargs.pop('lvl')
            else:
                l_E = 0.1
            # Plot experimental spectrum
            figures.ax2D(ax, self.ppm_f2, self.ppm_f1, self.data, cmap=CM['Greys_r'], lvl=l_E, **kwargs)
            m_E = np.max(self.data)
            m_C = np.max(fitted_data)
            l_C = l_E * m_E / m_C
        else:
            if 'lvl' in kwargs.keys():
                l_C = kwargs['lvl']
                kwargs.pop('lvl')
            else:
                l_C = 0.1

        # Plot fitted spectrum
        figures.ax2D(ax, self.ppm_f2, self.ppm_f1, fitted_data, cmap=CM['Greens_r'], lvl=l_C, **kwargs)
        # Draw the labels of the fitted peaks according to coord and peak_labels
        if 'fontsize' in kwargs.keys():
            labelsize = kwargs['fontsize'] - 2
        else:
            labelsize = 8
        self.draw_crossmarks(self.coord, ax, markersize=5, labelsize=labelsize, label_list=self.label_list)

        # Visual shit
        ax.set_xlabel(r'$\delta$ '+f'{misc.nuc_format(self.acqus["nuc2"])}'+r' /ppm')
        ax.set_ylabel(r'$\delta$ '+f'{misc.nuc_format(self.acqus["nuc1"])}'+r' /ppm')
        # Save/plot the figure
        if filename is None:
            misc.set_fontsizes(ax, 14)
            plt.show()
        else:
            plt.savefig(Path(filename).with_suffix('.svg'), dpi=dpi)
        plt.close()

    @staticmethod
    def draw_crossmarks(coord, ax, label_list=None, markersize=5, labelsize=8, markercolor='tab:blue', labelcolor='b'):
        """
        Draw crossmarks and peak labels on a figure.

        Parameters
        ----------
        ax : matplotlib.Subplot object
            Subplot where to plot the crossmarks and the labels.
        label_list : list
            Labels for the peaks. If None, they are computed as 1, 2, 3, ...
        markersize : int
            Dimension of the crossmark
        labelsize : int
            Fontsize for the labels
        markercolor : str
            Color of the crossmark
        labelcolor : str
            Color of the labels
        """

        for k, C in enumerate(coord):
            x, y = C    # position of the crossmark
            ax.plot(x, y, '+', c=markercolor, ms=markersize)    # Plot crossmark
            # Draw the text on the top-right of the crossmark
            if label_list is None:
                ax.text(x, y, f'{k+1}', c=labelcolor, ha='left', va='bottom', fontsize=labelsize)
            else:
                ax.text(x, y, f'{label_list[k]}', c=labelcolor, ha='left', va='bottom', fontsize=labelsize)

    def peak_pick(self, coord_filename='coord.tmp'):
        """
        Performs peak_picking by calling fit.peak_pick.
        Saves the list of peak positions in the attribute coord

        Parameters
        ----------
        coord_filename : str
            Path to the file where to save the peak coordinates
        """
        fit.peak_pick_2D(self.ppm_f1, self.ppm_f2, self.data, coord_filename)

    def load_coord(self, coord_filename='coord.tmp'):
        """
        Read the values from the coord filename and save them into the attribute "coord".

        Parameters
        ----------
        coord_filename : str
            Path to the file to be read
        """
        R = Path(coord_filename).readlines()

        coord = []
        label_list = []
        for k, line in enumerate(R):
            if line[0] == '#' or line.isspace():    # Skip comments and empty lines
                continue
            else:
                x, y = eval(line.split('\t', 2)[1].strip('\n'))  # second and third column
                coord.append([x, y])
                if len(line.split('\t', 2)) > 2:  # If there is the label
                    label = line.split("\t", 2)[-1].strip("\n")
                    if not label.isspace():
                        label_list.append(f'{label}')
        # Store coord into the attribute coord
        self.coord = coord
        print(f'Loaded {coord_filename} as coord.', c='tab:blue')

        # Update label_list, if there are labels in the coord file
        if len(label_list) > 0:
            self.label_list = label_list
        if self.label_list is not None:
            if len(self.label_list) < len(self.coord):
                raise ValueError('The number of provided labels is not enough for the peaks.')

    def draw_coord(self, filename=None, labelsize=8, ext='png', dpi=600, **kwargs):
        """
        Makes a figure with the experimental dataset and the peak-picked signals as crosshairs.

        Parameters
        ----------
        filename : str or Path or None
            Filename for the figure to be saved. If None, it is shown instead.
        labelsize : float
            Font size for the peak index
        ext : str
            Format of the image
        dpi : int
            Resolution of the saved image in dots per inches
        kwargs : keyworded arguments
            Additional options for figures.ax2D
        """

        fig = plt.figure('Picked Peaks')
        fig.set_size_inches(figures.figsize_large)
        ax = fig.add_subplot()

        figures.ax2D(ax, self.ppm_f2, self.ppm_f1, self.data, **kwargs)
        self.draw_crossmarks(self.coord, ax, label_list=self.label_list, markersize=5, labelsize=labelsize, markercolor='tab:blue', labelcolor='b')

        ax.set_xlabel(r'$\delta$ '+f'{misc.nuc_format(self.acqus["nuc2"])}' + r' /ppm')
        ax.set_ylabel(r'$\delta$ '+f'{misc.nuc_format(self.acqus["nuc1"])}' + r' /ppm')
        misc.set_fontsizes(ax, 14)
        if filename is None:
            plt.show()
        else:
            plt.savefig(Path(filename).with_suffix(f'.{ext}'), dpi=dpi)
        plt.close()

    @cron
    def make_peaks(self, idx, V):
        """
        Calculate the set of 2D peaks, given the matrix of their parameters and their index.
        The array of indexes is required in order to recognize the different components that contribute to a single peak.
        The attribute peaks of the class will be cleared and updated.

        Parameters
        ----------
        idx: 1darray
            Array of indexes of the peaks.
        V: list or 2darray
            List of parameters that describe the peaks.
        """

        self.peaks = []     # Clear peaks attribute
        # Get the number of signals
        if isinstance(V, np.ndarray):
            n_sgn = V.shape[0]
        elif isinstance(V, list):
            n_sgn = len(V)

        print('Calculation of the peaks from the input file.\nThis might take a while...')
        for k in range(n_sgn):
            peak_index = k + 1  # Peak index
            # Read the whole file, append only the parameters labelled with peak_index
            tmp_par = [V[k] for k in range(n_sgn) if idx[k] == peak_index]
            if len(tmp_par) == 0:   # No peaks found -> skip
                del tmp_par
                continue
            # Compute the signal and put it into peaks
            self.peaks.append(fit.build_2D_sgn(tmp_par, self.acqus, N=(len(self.ppm_f1), len(self.ppm_f2)), procs=self.procs))
            del tmp_par  # Clear memory
        print('Done.', end=' ')  # remove \n so that the runtime is shown in the same line

    def load_iguess(self, filename='peaks.inp'):
        """
        Reads the initial guess file with the parameters of the peaks, separates the values and stores them into attributes.
        In particular:

            * idx will contain the peak index (first column of the file),
            * Vi will contain [u1, u2, fwhm1, fwhm2, Im, b] for each peak,
            * Wi will contain the fitting interval as ( (L_f1, R_f1), (L_f2, R_f2) )

        Parameters
        ----------
        filename : str
            Path to the input file to be read
        """

        # Safety check: if filename does exist
        if Path(filename).exists():    # open the file and reads the lines,
            R = Path(filename).readlines()
        else:   # raises error
            raise FileNotFoundError(f'{filename} does not exist.')

        # Initialize empty attributes
        self.idx = []
        self.Vi = []
        self.Wi = []

        for k, line in enumerate(R):    # Loop on the lines of the file
            if line[0] == r'#' or line.isspace():
                continue    # Skip empty lines and comments
            index, values, fit_interval = self._read_par_line(line)
            self.idx.append(index)
            self.Vi.append(values)
            self.Wi.append(fit_interval)
        self.make_peaks(self.idx, self.Vi)

    def load_fit(self, filename='fit.out'):
        """
        Reads the file with the parameters of the fitted peaks, separates the values and stores them into attributes.
        Then, uses these values to compute the peaks and save them into self.peaks.
        In particular:

            * idx will contain the peak index (first column of the file),
            * Vf will contain [u1, u2, fwhm1, fwhm2, Im, b] for each peak,
            * Wf will contain the fitting interval as ( (L_f1, R_f1), (L_f2, R_f2) )

        Parameters
        ----------
        filename: str
            Path to the input file to be read
        """

        # Safety check: if filename does exist
        path = Path(filename)
        if path.exists():     # open the file and reads the lines,
            R = path.readlines()
        else:   # raises error
            raise NameError(f'{path} does not exist.')

        # Initialize empty attributes
        self.idx = []
        self.Vf = []
        self.Wf = []

        for k, line in enumerate(R):    # Loop on the lines of the file
            if line[0] == r'#' or line.isspace():
                continue    # Skip empty lines and comments
            index, values, fit_interval = self._read_par_line(line)
            self.idx.append(index)
            self.Vf.append(values)
            self.Wf.append(fit_interval)
        self.make_peaks(self.idx, self.Vf)  # Update the peaks attribute

    def iguess(self, filename='peaks.inp', start_index=1, only_edit=None, fwhm0=100, overwrite=False, auto=False):
        """
        Make the initial guess for all the peaks.

        Parameters
        ----------
        filename : str or Path
            Path to the file where the peak parameters will be written
        start_index : int
            Index of the first peak to be guessed.
        only_edit : sequence of ints or None
            Index of the peak that have to be guessed interactively. The ones that do not appear here are guessed automatically.
        fwhm0 : float
            Default value for fwhm in both dimension for automatic guess
        overwrite : bool
            Choose if to overwrite the file or append the new peaks at the bottom
        auto : bool
            Allow automatic guess for the peaks. To be used in conjunction with only_edit: if auto is False, all the peaks are guessed interactively!

        """
        def auto_val(ppm_f1, ppm_f2, tr1, tr2, u1, u2, fwhm0, acqus):
            """ Compute initial guess automatically """
            # Limits
            lim_f1 = u1 + 100/np.abs(acqus['SFO1']), u1 - 100/np.abs(acqus['SFO1'])
            lim_f2 = u2 + 100/np.abs(acqus['SFO2']), u2 - 100/np.abs(acqus['SFO2'])
            interval = lim_f1, lim_f2
            # Parameters
            parameters = [[   # u1, u2, fwhm1, fwhm2, k*A, b
                misc.ppm2freq(u1, acqus['SFO1'], acqus['o1p']),  # v1 /Hz
                misc.ppm2freq(u2, acqus['SFO2'], acqus['o2p']),  # v2 /Hz
                fwhm0,  # fwhm1 /Hz
                fwhm0,  # fwhm2 /Hz
                1,     # I
                0.5,    # b
                ]]
            # Integral
            A0 = np.max(self.data) / np.prod(self.data.shape)**0.5
            parameters[0][-2] = A0

            return parameters, interval

        # -------------------------------------------------------------------------------

        path = Path(filename)
        if path.exists() and overwrite is False:    # append next peaks
            f = path.open('a', buffering=1)
        else:   # create a new file
            f = path.open('w', buffering=1)
            self._write_head_line(f)

        # Make the generator where to loop on peaks
        def extract(coord):
            """ Generator: yields the chemical shifts and the traces onto which to loop """
            for x, y in coord:   # u2, u1
                tr1 = anal.get_trace(self.data, self.ppm_f2, self.ppm_f1, x, column=True)   # F1 @ u2 ppm
                tr2 = anal.get_trace(self.data, self.ppm_f2, self.ppm_f1, y, column=False)   # F2 @ u1 ppm
                yield (y, tr1), (x, tr2)    # (u1, f1), (u2, f2)
        peaks_coord = extract(self.coord)   # Call the generator

        # Start looping
        peak_index = 1
        for TR1, TR2 in peaks_coord:
            if peak_index < start_index:    # Do not guess
                peak_index += 1
                continue
            print(f'Preparing iguess for {peak_index:4.0f}/{len(self.coord):4.0f} peak', end='\r', c='tab:orange')
            # Unpack TR1 and TR2
            u1, tr1 = TR1
            u2, tr2 = TR2

            if auto is True and only_edit is None:  # All automatic
                parameters, interval = auto_val(self.ppm_f1, self.ppm_f2, tr1, tr2, u1, u2, fwhm0, self.acqus)
            elif auto is True and only_edit is not None:    # Interactively guess only the given peaks, all the others automatically
                if peak_index in only_edit:
                    parameters, interval = fit.gen_iguess_2D(self.ppm_f1, self.ppm_f2, tr1, tr2, u1, u2, self.acqus, fwhm0, self.procs)
                else:
                    parameters, interval = auto_val(self.ppm_f1, self.ppm_f2, tr1, tr2, u1, u2, fwhm0, self.acqus)
            else:   # All interactively
                parameters, interval = fit.gen_iguess_2D(self.ppm_f1, self.ppm_f2, tr1, tr2, u1, u2, self.acqus, fwhm0, self.procs)

            for values in parameters:   # Write the parameters
                self._write_par_line(f, self.acqus, peak_index, values, interval_f1=interval[0], interval_f2=interval[1])
            peak_index += 1  # Increment the peak index
        print('')

    def fit(self, filename='fit.out', overwrite=False, start_index=1, **fit_kws):
        """
        Perform the fit of all the 2D peaks, one by one, by reading the starting values from Vi.
        """

        # Flag: did you set a logfile?
        if 'logfile' in fit_kws.keys():
            use_logfile = True
        else:
            use_logfile = False

        # Generator: group the parameters with the same index.
        def values_loop(V, idx):
            if isinstance(V, list):
                n_sgn = len(V)
            else:
                n_sgn = V.shape[0]

            for k in range(n_sgn):
                peak_index = k + 1
                if peak_index > max(idx):
                    break
                tmp_par = [V[k] for k in range(n_sgn) if idx[k] == peak_index]
                yield peak_index, tmp_par
        looped_values = values_loop(self.Vi, self.idx)  # Call the generator

        path = Path(filename)
        if path.exists() and overwrite is False:    # append next peaks
            f = path.open('a', buffering=1)
        else:   # create a new file
            f = path.open('w', buffering=1)
            self._write_head_line(f)

        # Loop for the fit
        for peak_index, peak_values in looped_values:
            if use_logfile:  # Redirect the standard output to the logfile
                sys.stdout = open(fit_kws['logfile'], 'a', buffering=1)
            print(f'Fitting peak {peak_index:4.0f} / {max(self.idx):4.0f}', c='tab:orange')
            if peak_index < start_index:    # Skip
                continue
            if len(peak_values) == 0:   # Skip empty parameters
                continue
            lim_f1, lim_f2 = self.Wi[peak_index-1]  # Get the window for the fit
            # Call the fit
            fit_parameters = voigt_fit_2D(self.ppm_f2, self.ppm_f1, self.data, peak_values,
                                          lim_f1, lim_f2, self.acqus, N=(len(self.ppm_f1), len(self.ppm_f2)),
                                          procs=self.procs, **fit_kws)
            # Write the output in the new file
            for fit_values in fit_parameters:
                self._write_par_line(f, self.acqus, peak_index, fit_values, interval_f1=lim_f1, interval_f2=lim_f2, conv_u=False)

        # Revert standard output to default
        if use_logfile:
            sys.stdout = sys.__stdout__

    # PRIVATE METHODS
    def _write_head_line(self, f):
        """
        Writes the header of the output file.

        Parameters
        ----------
        f : TextIOWrapper
            writable file generated by either ``open(filename, 'w'/'a')`` or  ``filename.open('w'/'a')``
        """
        f.write(f'{"#":<4s}\t{"clu":>4s}\t{"u1":>8s}\t{"u2":>8s}\t{"fwhm1":>8s}\t{"fwhm2":>8s}\t{"I":>8s}\t{"b":>8s}\t{"Fit. interv.":>20s}\n')

    def _write_par_line(self, f, acqus, index, clu, values, interval_f1=None, interval_f2=None, conv_u=True):
        """
        Writes a line of parameters to the output file.

        Parameters
        ----------
        f : TextIOWrapper
            writable file generated by either ``open(filename, 'w'/'a')`` or  ``filename.open('w'/'a')``
        acqus : dict
            2D-like acquisition parameters
        index : int
            Index of the peak
        clu : int
            Cluster index
        values : 1darray
            u1, u2, fwhm1, fwhm2, I, b
        interval_f1 : tuple
            left limit F1, right limit F1
        interval_f2 : tuple
            left limit F2, right limit F2
        conv_u : bool
            Conversion of u1 and u2 from Hz to ppm
        """
        if interval_f1 is None:
            interval_f1 = ('SW', 'SW')  # Whole SW
        else:
            interval_f1 = tuple([eval(f'{x:4.1f}') for x in interval_f1])
        if interval_f2 is None:
            interval_f2 = ('SW', 'SW')  # Whole SW
        else:
            interval_f2 = tuple([eval(f'{x:4.1f}') for x in interval_f2])
        interval = f'{interval_f1},{interval_f2}'

        v1, v2, fwhm1, fwhm2, I, b = values   # Unpack
        if conv_u:
            # Convert u1, u2 from Hz to ppm
            u1, u2 = [misc.freq2ppm(x, acqus[f'SFO{y}'], acqus[f'o{y}p']) for x, y in zip([v1, v2], [1, 2])]
        else:
            u1, u2 = v1, v2

        # Write the line
        f.write(f'{index:4.0f}\t{clu:4.0f}\t{u1:8.2f}\t{u2:8.2f}\t{fwhm1:8.1f}\t{fwhm2:8.1f}\t{I:8.3e}\t{b:8.4f}\t{interval}\n')

    def _read_par_line(self, line):
        """ Splits the line into the three attributes """
        split = line.split('\t')
        index = eval(split[0])
        clu = eval(split[1])
        values = [eval(w) for w in split[2:-1]]
        fit_interval = eval(split[-1])
        return index, clu, values, fit_interval


# -------------------------------------------------------------------------------------


class CostFunc:
    r"""
    Class that groups several ways to compute the target of the minimization in a fitting procedure.
    It includes the classic squared sum of the residuals, as well as some other non-quadratic cost functions.
    Let `x` be the residuals and `s` the chosen threshold value. Then the objective value `R` is computed as:

    .. math::

        R = \sum_k f(x[k])

    where :math:`f(x)` can be chosen between the following options:

    * Quadratic:

        .. math::

            f(x) = x^2

    * Truncated Quadratic:

        .. math::

            f(x) = \begin{cases}
            x^2 & \text{if } |x| < s\\
            s^2 & \text{otherwise}\\
            \end{cases}

    * Huber function:

        .. math::

            f(x) = \begin{cases}
            x^2 & \text{if } |x| < s\\
            2s|x| - s^2 & \text{otherwise}\\
            \end{cases}

    * Asymmetric Truncated Quadratic:

        .. math::

            f(x) = \begin{cases}
            x^2 & \text{if } x < s\\
            s^2 & \text{otherwise}\\
            \end{cases}

    * Asymmetric Huber function:

        .. math::

            f(x) = \begin{cases}
            x^2 & \text{if } x < s\\
            2sx - s^2 & \text{otherwise}\\
            \end{cases}

    Attributes
    ----------
    method : function
        Function to be used for the computation of the objective value. It must take as input the array of the residuals and the threshold, no matter if the latter is actually used or not.
    s : float
        Threshold value
    """
    def __init__(self, method='q', s=None):
        """
        Initialize the method according to your choice, then stores the threshold value in the attribute ``s``.
        Allowed choices are:

        * "q": Quadratic
        * "tq": Truncated Quadratic
        * "huber": Huber function
        * "atq": Asymmetric Truncated Quadratic
        * "ahuber": Asymmetric Huber function

        Parameters
        ----------
        method : str
            Label for the method selection
        s : float
            Threshold value
        """
        self.method = self.method_selector(method)
        self.s = s

    def method_selector(self, method):
        """
        Performs the selection of the method according to the identifier string.

        Parameters
        ----------
        method : str
            Method label

        Returns
        -------
        f : function
            Selected model
        """
        if method == 'q':
            return self.squared_sum
        elif method == 'tq':
            return self.truncated_quadratic
        elif method == 'huber':
            return self.huber
        elif method == 'atq':
            return self.asymm_truncated_quadratic
        elif method == 'ahuber':
            return self.asymm_huber
        else:
            raise ValueError(f'{method} method not recognized')

    def __call__(self, x):
        """
        Computes the objective value according to the chosen method and the residuals array ``x``.

        Parameters
        ----------
        x: 1darray
            Array of the residuals

        Returns
        -------
        R: 1darray
            Computed objective function to be given to a least-squares solver
        """
        return self.method(x, self.s)

    @staticmethod
    def squared_sum(r, s=0):
        """ Quadratic everywhere """
        return r

    @staticmethod
    def truncated_quadratic(r, s):
        """ Constant behaviour above s """
        x = np.copy(r)
        for i, x_i in enumerate(x):
            if np.abs(x_i) < s:
                pass
            else:
                x[i] = np.sign(x_i) * s
        return x

    @staticmethod
    def huber(r, s):
        """ Linear behaviour above s """
        x = np.copy(r)
        for i, x_i in enumerate(x):
            if np.abs(x_i) < s:
                pass
            else:
                x[i] = np.sign(x_i) * (2*s*np.abs(x_i) - s**2)**0.5
        return x

    @staticmethod
    def asymm_huber(r, s):
        """ Linear behaviour above s, penalizes negative entries """
        x = np.copy(r)
        for i, x_i in enumerate(x):
            if x_i < s:
                pass
            else:
                x[i] = (2*s*x_i - s**2)**0.5
        return x

    @staticmethod
    def asymm_truncated_quadratic(r, s):
        """ Constant behaviour above s, penalizes negative entries """
        x = np.copy(r)
        for i, x_i in enumerate(x):
            if x_i < s:
                pass
            else:
                x[i] = s
        return x


def lsp(y, x, n=5, w=None):
    """
    Linear-System Polynomion
    Make a polynomial fit on the experimental data `y` by solving the linear system

    .. math::

        y = T c

    where `T` is the Vandermonde matrix of the x-scale and `c` is the set of coefficients that minimize the problem in the least-squares sense.
    It is also possible to make it weighted by using an array of weights ``w``.

    Parameters
    ----------
    y : 1darray
        Experimental data
    x : 1darray
        Independent variable (better if normalized)
    n : int
        Order of the polynomion + 1, i.e. number of coefficients
    w : 1darray
        Array of weights for the data. If None, the nonweighted approach is used

    Returns
    -------
    c : 1darray
        Set of minimized coefficients
    """
    # Make the Vandermonde matrix of the x-scale
    T = np.array(
            [x**k for k in range(n)]
            ).T
    if w is None:
        # Pseudo-invert it
        Tpinv = np.linalg.pinv(T)
    else:
        # Equivalent implementation to Tw = np.diag(w) @ T, but faster
        Tw = T * w[:, None]
        Tpinv = np.linalg.pinv(Tw)

    # Solve the system
    c = Tpinv @ y
    return c


def polyn_basl(y, n=5, method='huber', s=0.2, c_i=None, itermax=1000):
    """
    Fit the baseline of a spectrum with a low-order polynomion using a non-quadratic objective function.

    Let ``y`` be an array of ``N`` points. The polynomion is generated on a normalized scale that goes from -1 to 1 in ``N`` steps,
    and the coefficients are initialized either from outside through the parameter ``c_i`` or with the ordinary least squares fit.
    Then, the guess is refined using the objective function of choice employing the trust-region reflective least-squares algorithm.


    Parameters
    ----------
    y : 1darray
        Experimental data
    n : int
        Order of the polynomion + 1, i.e. number of coefficients
    method : str
        Objective function of choice. 'q': quadratic, 'tq': truncated quadratic, 'huber': Huber, 'atq': asymmetric truncated quadratic, 'ahuber': asymmetric huber
    s : float
        Relative threshold value for the non-quadratic behaviour of the objective function
    c_i : sequence or None
        Initial guess for the polynomion coefficient. If None, the least-squares fit is used
    itermax : int
        Number of maximum iterations

    Returns
    -------
    px : 1darray
        Fitted polynomion
    c : list
        Set of coefficients of the polynomion
    """
    def f2min_real(param, y, x, n, res_f):
        """
        Minimizer function.

        Parameters
        ----------
        param : lmfit.Parameters object
            Parameters to be optimized
        y : 1darray
            Experimental data
        x : 1darray
            Scale on which to build the model
        n : int
            Number of coefficients
        res_f : function
            Returns the objective value to be minimized
        """
        # Unpack the parameters
        par = param.valuesdict()
        # Make a list of coefficients from the dictionary
        c = [par[f'c_{k}'].real for k in range(n)]
        # Make the polynomion
        px = par['I'] * misc.polyn(x, c)
        # Compute the residual
        r = y - px
        return res_f(r)

    def f2min_cplx(param, y, x, n, res_f):
        """
        Minimizer function.

        Parameters
        ----------
        param : lmfit.Parameters object
            Parameters to be optimized
        y : 1darray
            Experimental data
        x : 1darray
            Scale on which to build the model
        n : int
            Number of coefficients
        res_f : function
            Returns the objective value to be minimized
        """
        # Unpack the parameters
        par = param.valuesdict()
        # Make a list of coefficients from the dictionary
        c = [par[f'c_{k}'] + 1j*par[f'c_{k}'] for k in range(n)]
        # Make the polynomion
        px = par['I'] * misc.polyn(x, c)
        # Compute the residual
        r_r = y.real - px.real
        r_i = y.imag - px.imag
        r = np.concatenate((r_r, r_i), axis=-1)
        return res_f(r)

    cplx = np.iscomplexobj(y)

    # Make the normalized scales
    x = np.linspace(-1, 1, y.shape[-1])

    # Make initial guess of the polynomion coefficients
    print('Make initial guess of the polynomion coefficients...', c='tab:orange')
    if c_i:
        c = np.copy(c_i)
    else:
        c = fit.lsp(y, x, n)
    px_iguess = misc.polyn(x, c)
    print('Done.', c='tab:orange')

    # Compute an intensity factor to decrease the weight on the fit procedure
    Int = fit.fit_int(np.abs(y), np.abs(px_iguess))[0]
    s *= Int      # Set absolute threshold values
    c /= Int      # Normalize the coefficients to I

    # Generate the parameters for the fit
    param = lmfit.Parameters()
    param.add('I', value=Int, vary=False)    # Just to keep track of it

    for k in range(n):
        param.add(f'c_{k}', value=c[k].real)

    # Get the objective function of choice
    R = fit.CostFunc(method, s)

    print('Optimizing the baseline...', c='tab:orange')
    # Make the fit
    if cplx:
        f2min = f2min_cplx
    else:
        f2min = f2min_real
    minner = lmfit.Minimizer(f2min, param, fcn_args=(y, x, n, R))
    result = minner.minimize(method='least_squares', max_nfev=int(itermax), gtol=1e-15)
    print(f'The fit has ended. {result.message}.\nNumber of function evaluations: {result.nfev}')

    # Get the fitted parameters
    popt = result.params.valuesdict()

    # Make a list of the fitted coefficients from the dictionary
    if cplx:
        c_opt = [popt['I'] * (popt[f'c_{k}'] + 1j*popt[f'c_{k}']) for k in range(n)]
    else:
        c_opt = [popt['I'] * popt[f'c_{k}'] for k in range(n)]
    # Build the polynomion with them
    px = misc.polyn(x, c_opt)

    return px, c_opt


class SINC_ObjFunc:
    r"""
    Computes the objective function as explained in M. Sawall et al., Journal of Magnetic Resonance 289 (2018), 132-141.
    The cost function is computed as:

    .. math::

        f(d) = \sum_{i=1}^3  \gamma_i g_i(d|e_i)

    where `d` is the real part of the NMR spectrum.

    Attributes
    ----------
    gamma1 : float
        Weighting factor for function g1
    gamma2 : float
        Weighting factor for function g2
    gamma3 : float
        Weighting factor for function g3
    e1 : float
        Tolerance value for function g1
    e2 : float
        Tolerance value for function g2
    """
    def __init__(self, gamma1=10, gamma2=0.01, gamma3=0, e1=0, e2=0):
        """
        Initialize the coefficients used to weigh the objective function.

        Parameters
        ----------
        gamma1 : float
            Weighting factor for function g1
        gamma2 : float
            Weighting factor for function g2
        gamma3 : float
            Weighting factor for function g3
        e1 : float
            Tolerance value for function g1
        e2 : float
            Tolerance value for function g2
        """
        self.gamma1 = gamma1
        self.gamma2 = gamma2
        self.gamma3 = gamma3
        self.e1 = e1
        self.e2 = e2

    def __call__(self, d):
        """ Computes the objective function f as explained in the paper """
        # Normalize d with respect to the sup-norm
        norm = np.max(np.abs(d))
        d_norm = np.copy(d) / norm
        # Compute the three contributions
        g1 = self.g1(d_norm, self.e1)
        g2 = self.g2(d_norm, self.e2)
        g3 = self.g3(d_norm)
        # Weigh the three contribution by the three coefficients
        f = self.gamma1 * g1 + self.gamma2 * g2 + self.gamma3 * g3
        return f

    @staticmethod
    def g1(d, e1=0):
        """
        Penalty function for negative entries of the spectrum

        Parameters
        ----------
        d : 1darray
            Spectrum
        e1 : float
            Tolerance for negative entries
        """

        data = np.copy(d) + e1
        datac = np.array([min(0, x) for x in data])
        g = np.sum(datac**2)
        return g

    @staticmethod
    def g2(d, e2=0):
        """
        Regularization function that favours the smallest integral.

        Parameters
        ----------
        d : 1darray
            Spectrum
        e2 : float
            Tolerance for ideal baseline
        """

        data = np.abs(np.copy(d)) - e2
        datac = np.array([max(0, x) for x in data])
        g = np.sum(datac**2)
        return g

    @staticmethod
    def g3(d):
        """
        Regularization function for the smoothing.

        Parameters
        ----------
        d : 1darray
            Spectrum
        """
        diffd = np.diff(d, 2)
        g = np.sum(diffd**2)
        return g


def sinc_phase(data, gamma1=10, gamma2=0.01, gamma3=0, e1=0, e2=0, **fit_kws):
    """
    Perform automatic phase correction according to the SINC algorithm, as described in M. Sawall et. al., Journal of Magnetic Resonance 289 (2018), 132–141.
    The fitting method defaults to "least_squares".

    Parameters
    ----------
    data : 1darray
        Spectrum to phase-correct
    gamma1 : float
        Weighting factor for function g1: non-negativity constraint
    gamma2 : float
        Weighting factor for function g2: smallest-integral constraint
    gamma3 : float
        Weighting factor for function g3: smoothing constraint
    e1 : float
        Tolerance factor for function g1: adjustment for noise
    e2 : float
        Tolerance factor for function g2: adjustment for non-ideal baseline
    fit_kws : keyworded arguments
        additional parameters for the fit function. See :func:`lmfit.Minimizer.minimize` for details. Do not use "leastsq" because the cost function returns a scalar value!

    Returns
    -------
    p0 : float
        Fitted zero-order phase correction angle, in degrees
    p1 : float
        Fitted first-order phase correction angle, in degrees
    """

    def f2min(param, data, r_func):
        """ Cost function for the fit. Applies the algorithm. """
        # Unpack the parameters
        par = param.valuesdict()
        p0 = par['p0']
        p1 = par['p1']

        # Phase data and take real part
        Rp, *_ = processing.ps(data, p0=p0, p1=p1)
        R = Rp.real
        # Compute the objective function using the phased data
        return r_func(R)

    # Safety checks
    if not np.iscomplexobj(data):
        raise ValueError('Input data is not complex.')

    if 'method' not in fit_kws.keys():
        fit_kws['method'] = 'least_squares'

    # Shallow copy to prevent overwriting
    d = np.copy(data)

    # Create the Parameters object
    param = lmfit.Parameters()
    param.add('p0', value=0, min=-180, max=180)
    param.add('p1', value=0, min=-720, max=720)

    # Create the objective function
    R = fit.SINC_ObjFunc(gamma1, gamma2, gamma3, e1, e2)

    # Minimize using the method of choice. "leastsq" not accepted!
    print('Starting phase correction...', c='tab:orange')
    minner = lmfit.Minimizer(f2min, param, fcn_args=(d, R))
    result = minner.minimize(**fit_kws)
    print(f'The fit has ended. {result.message}.\nNumber of function evaluations: {result.nfev}')
    popt = result.params.valuesdict()

    return popt['p0'], popt['p1']


def write_vf_P2D(filename, peaks, lims, prev=0):
    """
    Write a section in a fit report file, which shows the fitting region and the parameters of the peaks to feed into a Voigt lineshape model.

    Parameters
    ----------
    filename : str or Path
        Path to the file to be written
    peaks : list of dict
        list of dictionares of :class:`klassez.fit.Peak` objects, one per experiment
    lims : tuple
        (left limit /ppm, right limit /ppm)
    prev : int
        Number of previous peaks already saved. Increases the peak index
    """

    # Open the file in append mode
    f = Path(filename).open('a', buffering=1)
    # Info on the region to be fitted
    #   Header
    f.write('{:>16};\n'.format('Region'))
    f.write('-'*96+'\n')
    #   Values
    region = '{:-.3f}:{:-.3f}'.format(*lims)   # From the zoom of the figure
    f.write(f'{region:>16};\n\n')

    # Info on the peaks
    #   Header
    f.write('{:>4};\t{:>8};\t{:>8};\t{:>8};\t{:>8};\t{:>8}\n'.format(
        '#', 'u', 'fwhm', 'Phase', 'Beta', 'Group'))
    f.write('-'*96+'\n')
    #   Values
    for k, key in enumerate(peaks[0].keys()):
        peak = peaks[0][key]
        f.write('{:>4.0f};\t{:=8.3f};\t{:8.3f};\t{:-8.3f};\t{:8.3f};\t{:>8.0f}\n'.format(
            k+prev+1, peak.u, peak.fwhm, peak.phi, peak.b, peak.group))
    f.write('-'*96+'\n\n')

    #   Intensities
    f.write('Intensities\n')
    f.write(f'{"#":>4};\t'+';\t'.join(['{:>12}'.format('Exp'+f'{w+1}') for w in range(len(peaks))])+'\n')
    f.write('-'*96+'\n')

    peak_keys = peaks[0].keys()
    for k, key in enumerate(peak_keys):
        f.write(f'{k+prev+1:>4}')
        for _, dicpeaks in enumerate(peaks):
            f.write(';\t{:12.5e}'.format(dicpeaks[key].k))
        f.write('\n')
    f.write('-'*96+'\n\n')

    # Add region separator and close the file
    f.write('='*96+'\n\n')
    f.close()


def read_vf_P2D(filename, n=-1):
    """
    Reads a .ivf (initial guess) or .fvf (final fit) file, containing the parameters for a lineshape deconvolution fitting procedure.
    The file is separated and unpacked into a list of list of dictionaries, each of which contains the limits of the fitting window,
    and a dictionary for each peak with the characteristic values to compute it with a Voigt line.

    Parameters
    ----------
    filename : str
        Path to the filename to be read
    n : int
        Number of performed fit to be read. Default: last one. The breakpoints are lines that start with "!". For this reason, n=0 returns an empty dictionary, hence the first fit is n=1.

    Returns
    -------
    regions : list of list of dict
        List of dictionaries for running the fit.
    """
    def read_region(R):
        """ Creates a dictionary of parameters from a section of the input file.  """
        # Placeholder
        dic_r = {}
        # Separate the lines and remove the empty ones
        R = R.split('\n')
        for k, r in enumerate(R):
            if len(r) == 0 or r.isspace():
                _ = R.pop(k)

        n_bp = 0        # Number of breaking points (----)
        k_bp = 0        # Line of the last breaking point detected
        flag = True     # To create the final list of dictionaries to be returned only once
        for k, r in enumerate(R):
            if '------' in r:   # Increase breakpoint and store the line number
                n_bp += 1
                k_bp = k
                continue

            if n_bp == 1 and k_bp == k-1:   # First section: region limits and total intensity
                line = r.split(';')  # Separate the values
                dic_r['limits'] = eval(line[0].replace(':', ', '))   # Get the limits

            if n_bp == 2:       # Second section: peak parameters
                line = r.split(';')  # Separate the values
                # Unpack the line
                idx, u, fwhm, phi, b, group = [eval(w) for w in line]
                # Put the values in a dictionary
                dic_p = {
                        'u': u,
                        'fwhm': fwhm,
                        'k': 0,     # they will be added later, in section 3
                        'b': b,
                        'phi': phi,
                        'group': group
                        }
                # Put the values in the returned dictionary
                dic_r[idx] = dic_p

            # Skip n_bp == 3 because it is the end of section 2

            if n_bp == 4:       # Third section: intensity values for all the peaks for each experiment
                line = r.split(';')  # Separate the values
                if flag:    # Create the final dictionary to be returned and turn off the flag
                    dic_rr = [deepcopy(dic_r) for q in range(len(line)-1)]
                    flag = False

                # Unpack the line
                eval_line = [eval(w) for w in line]
                idx = int(eval_line[0])     # index of the peak
                Ks = eval_line[1:]          # Intensity, each for each experiment
                for q, K in enumerate(Ks):
                    dic_rr[q][idx]['k'] = K  # Overwrite the intensities

            if n_bp == 5:   # End of file: stop reading
                break

        return dic_rr

    # Read the file
    ff = Path(filename).readlines()
    # Get the actual section from an output file
    f = ff.split('!')[n]
    # Separate the bigger sections
    R = f.split('='*96)
    # Remove the empty lines
    for k, r in enumerate(R):
        if r.isspace():
            _ = R.pop(k)

    regions = []    # Placeholder for return values
    for r in R:  # Loop on the big sections to read them
        regions.append(read_region(r))
    return regions


def make_iguess_P2D(S_in, ppm_scale, expno, t_AQ, SFO1=701.125, o1p=0, filename='i_guess'):
    """
    Creates the initial guess for a lineshape deconvolution fitting procedure of a pseudo-2D experiment, using a dedicated GUI.
    It will be donw on only one experiment of the whole pseudo-2D.
    The GUI displays the experimental spectrum in black and the total function in blue.
    First, select the region of the spectrum you want to fit by focusing the zoom on it using the lens button.
    Then, use the "+" button to add components to the spectrum. The black column of text under the textbox will be colored with the same color of the active peak.
    Use the mouse scroll to adjust the parameters of the active peak. Write a number in the "Group" textbox to mark the components of the same multiplet.
    Group 0 identifies independent peaks, not part of a multiplet (default).
    The sensitivity of the mouse scroll can be regulated using the "up arrow" and "down arrow" buttons.
    The active peak can be changed in any moment using the slider.

    When you are satisfied with your fit, press "SAVE" to write the information in the output file.
    Then, the GUI is brought back to the initial situation, and the region you were working on will be marked with a green rectangle.
    You can repeat the procedure as many times as you wish, to prepare the guess on multiple spectral windows.

    Keyboard shortcuts:

    * "increase sensitivity" : '>'
    * "decrease sensitivity" : '<'
    * mouse scroll up: 'up arrow key'
    * mouse scroll down: 'down arrow key'
    * "add a component": '+'
    * "remove the active component": '-'
    * "change component, forward": 'page up'
    * "change component, backward": 'page down'


    Parameters
    ----------
    S_in : 1darray
        Experimental spectrum
    ppm_scale : 1darray
        PPM scale of the spectrum
    expno : int
        Index of experiment of the pseudo 2D on which to compute the initial guess, in python numbering
    t_AQ : 1darray
        Acquisition timescale
    SFO1 : float
        Nucleus Larmor frequency /MHz
    o1p : float
        Carrier frequency /ppm
    filename : str
        Path to the filename where to save the information. The '.ivf' extension is added automatically.
    """

    # -----------------------------------------------------------------------
    # USEFUL STRUCTURES
    def rename_dic(dic, Np):
        """
        Change the keys of a dictionary with a sequence of increasing numbers, starting from 1.

        Parameters
        ----------
        dic : dict
            Dictionary to edit
        Np : int
            Number of peaks, i.e. the sequence goes from 1 to Np

        Returns
        -------
        new_dic : dict
            Dictionary with the changed keys
        """
        old_keys = list(dic.keys())         # Get the old keys
        new_keys = [int(i+1) for i in np.arange(Np)]    # Make the new keys
        new_dic = {}        # Create an empty dictionary
        # Copy the old element in the new dictionary at the correspondant point
        for old_key, new_key in zip(old_keys, new_keys):
            new_dic[new_key] = dic[old_key]
        del dic
        return new_dic

    def calc_total(peaks):
        """
        Calculate the sum trace from a collection of peaks stored in a dictionary.
        If the dictionary is empty, returns an array of zeros.

        Parameters
        ----------
        peaks : dict
            Components

        Returns
        -------
        total : 1darray
            Sum spectrum
        """
        # Get the arrays from the dictionary
        T = [p(A) for _, p in peaks.items()]
        if len(T) > 0:  # Check for any peaks
            total = np.sum(T, axis=0)
            return total
        else:
            return np.zeros_like(ppm_scale)

    # -------------------------------------------------------------------------------

    # Initial figure
    fig = plt.figure('Manual Computation of Initial Guess - Pseudo2D')
    fig.set_size_inches(15, 8)
    plt.subplots_adjust(bottom=0.10, top=0.90, left=0.05, right=0.65)
    ax = fig.add_subplot()

    # Write the info on the file
    with open(f'{filename}.ivf', 'a', buffering=1) as f:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! Initial guess computed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

    # Remove the imaginary part from the experimental data and make a shallow copy
    if np.iscomplexobj(S_in):
        S = np.copy(S_in).real[expno]
    else:
        S = np.copy(S_in)[expno]

    n_exp = S_in.shape[0]   # Number of experiments
    N = S.shape[-1]         # Number of points
    Np = 0                  # Number of peaks
    lastgroup = 0           # Placeholder for last group added
    prev = 0                # Number of previous peaks

    # Make an acqus dictionary based on the input parameters.
    acqus = {'t1': t_AQ, 'SFO1': SFO1, 'o1p': o1p}

    # Set limits
    limits = [max(ppm_scale), min(ppm_scale)]

    # Get point indices for the limits
    lim1 = misc.ppmfind(ppm_scale, limits[0])[0]
    lim2 = misc.ppmfind(ppm_scale, limits[1])[0]
    # Calculate the absolute intensity (or something that resembles it)
    A = processing.integrate(S, x=ppm_scale*SFO1, dx=(2 * misc.calcres(acqus['t1'])), lims=[w*SFO1 for w in limits])
    _A = 1 * A
    # Make a sensitivity dictionary
    sens = {
            'u': np.abs(limits[0] - limits[1]) / 50,    # 1/50 of the SW
            'fwhm': 2.5,
            'k': 0.05,
            'b': 0.1,
            'phi': 10,
            'A': 10**(np.floor(np.log10(A)-1))    # approximately
            }
    _sens = dict(sens)                          # RESET value
    # Peaks dictionary
    peaks = {}

    # make boxes for widgets
    slider_box = plt.axes([0.68, 0.10, 0.01, 0.65])     # Peak selector slider
    peak_box = plt.axes([0.72, 0.45, 0.10, 0.30])       # Radiobuttons
    up_box = plt.axes([0.815, 0.825, 0.08, 0.075])      # Increase sensitivity button
    down_box = plt.axes([0.894, 0.825, 0.08, 0.075])    # Decrease sensitivity button
    save_box = plt.axes([0.7, 0.825, 0.085, 0.04])      # Save button
    reset_box = plt.axes([0.7, 0.865, 0.085, 0.04])     # Reset button
    group_box = plt.axes([0.76, 0.40, 0.06, 0.04])      # Textbox for the group selection
    plus_box = plt.axes([0.894, 0.65, 0.08, 0.075])     # Add button
    minus_box = plt.axes([0.894, 0.55, 0.08, 0.075])    # Minus button

    # Make widgets
    #   Buttons
    up_button = Button(up_box, r'$\uparrow$', hovercolor='0.975')
    down_button = Button(down_box, r'$\downarrow$', hovercolor='0.975')
    save_button = Button(save_box, 'SAVE', hovercolor='0.975')
    reset_button = Button(reset_box, 'RESET', hovercolor='0.975')
    plus_button = Button(plus_box, '$+$', hovercolor='0.975')
    minus_button = Button(minus_box, '$-$', hovercolor='0.975')

    #   Textbox
    group_tb = TextBox(group_box, 'Group', textalignment='center')

    #   Radiobuttons
    peak_name = [r'$\delta$ /ppm', r'$\Gamma$ /Hz', '$k$', '$x_{g}$', r'$\phi$', '$A$']
    peak_radio = RadioButtons(peak_box, peak_name, activecolor='tab:blue')      # Signal parameters

    #   Slider
    slider = Slider(ax=slider_box, label='Active\nSignal', valmin=0, valmax=1-1e-3, valinit=0, valstep=1e-10, orientation='vertical', color='tab:blue')

    # -------------------------------------------------------------------------------
    # SLOTS

    def redraw():
        misc.pretty_scale(ax, ax.get_xlim(), 'x')
        plt.draw()

    def radio_changed(event):
        """ Change the printed value of sens when the radio changes """
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        write_sens(param)

    def up_sens(event):
        """ Doubles sensitivity of active parameter """
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        sens[param] *= 2
        write_sens(param)

    def down_sens(event):
        """ Halves sensitivity of active parameter """
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        sens[param] /= 2
        write_sens(param)

    def up_value(param, idx):
        """ Increase the value of param of idx-th peak """
        if param == 'A':        # It is outside the peaks dictionary!
            nonlocal A
            A += sens['A']
        else:
            peaks[idx].__dict__[param] += sens[param]
            # Make safety check for b
            if peaks[idx].b > 1:
                peaks[idx].b = 1

    def down_value(param, idx):
        """ Decrease the value of param of idx-th peak """
        if param == 'A':    # It is outside the peaks dictionary!
            nonlocal A
            A -= sens['A']
        else:
            peaks[idx].__dict__[param] -= sens[param]
            # Safety check for fwhm
            if peaks[idx].fwhm < 0:
                peaks[idx].fwhm = 0
            # Safety check for b
            if peaks[idx].b < 0:
                peaks[idx].b = 0

    def scroll(event):
        """ Connection to mouse scroll """
        if Np == 0:  # No peaks!
            return
        # Get the active parameter and convert it into Peak's attribute
        active = peak_name.index(peak_radio.value_selected)
        param = list(sens.keys())[active]
        # Get the active peak
        idx = int(np.floor(slider.val * Np) + 1)
        # Fork for up/down
        if event.button == 'up':
            up_value(param, idx)
        if event.button == 'down':
            down_value(param, idx)

        # Recompute the components
        for k, _ in enumerate(peaks):
            p_sgn[k+1].set_ydata(peaks[k+1](A)[lim1:lim2])
        # Recompute the total trace
        p_fit.set_ydata(calc_total(peaks)[lim1:lim2])
        # Update the text
        write_par(idx)
        redraw()

    def write_par(idx):
        """ Write the text to keep track of your amounts """
        if idx:     # Write the things
            dic = dict(peaks[idx].par())
            dic['A'] = A
            # Update the text
            values_print.set_text('{u:+7.3f}\n{fwhm:5.3f}\n{k:5.3f}\n{b:5.3f}\n{phi:+07.3f}\n{A:5.2e}\n{group:5.0f}'.format(**dic))
            # Color the heading line of the same color of the trace
            head_print.set_color(p_sgn[idx].get_color())
        else:   # Clear the text and set the header to be black
            values_print.set_text('')
            head_print.set_color('k')

    def write_sens(param):
        """ Updates the current sensitivity value in the text """
        # Discriminate between total intensity and other parameters
        if param == 'A':
            text = f'Sensitivity: $\\pm${sens[param]:10.4g}'
        else:
            text = f'Sensitivity: $\\pm${sens[param]:10.4g}'
        # Update the text
        sens_print.set_text(text)
        # Redraw the figure
        plt.draw()

    def set_group(text):
        """ Set the attribute 'group' of the active signal according to the textbox """
        if not Np:  # Clear the textbox and do nothing more
            group_tb.text_disp.set_text('')
            plt.draw()
            return
        # Get active peak
        idx = int(np.floor(slider.val * Np) + 1)
        try:
            group = int(eval(text))
        except Exception:
            group = peaks[idx].group
        group_tb.text_disp.set_text('')
        peaks[idx].group = group
        write_par(idx)
        redraw()

    def selector(event):
        """ Update the text when you move the slider """
        idx = int(np.floor(slider.val * Np) + 1)
        if Np:
            write_par(idx)
        redraw()

    def key_binding(event):
        """ Keyboard """
        key = event.key
        if key == '<':
            down_sens(0)
        if key == '>':
            up_sens(0)
        if key == '+':
            add_peak(0)
        if key == '-':
            remove_peak(0)
        if key == 'pagedown':
            if slider.val - slider.valstep >= 0:
                slider.set_val(slider.val - slider.valstep)
            selector(0)
        if key == 'pageup':
            if slider.val + slider.valstep < 1:
                slider.set_val(slider.val + slider.valstep)
            selector(0)
        if key == 'up' or key == 'down':
            event.button = key
            scroll(event)

    def reset(event):
        """ Return everything to default """
        nonlocal A, sens
        Q = Np
        for k in range(Q):
            remove_peak(event)
        A = _A
        sens = dict(_sens)
        ax.set_xlim(*_xlim)
        ax.set_ylim(*_ylim)
        redraw()

    def add_peak(event):
        """ Add a component """
        nonlocal Np
        # Increase the number of peaks
        Np += 1
        # Add an entry to the dictionary labelled as last
        peaks[Np] = fit.Peak(acqus, u=np.mean(ax.get_xlim()), N=N, group=lastgroup)
        # Plot it and add the trace to the plot dictionary
        p_sgn[Np] = ax.plot(ppm_scale[lim1:lim2], peaks[Np](A)[lim1:lim2], lw=0.8)[-1]
        # Move the slider to the position of the new peak
        slider.set_val((Np - 1) / Np)
        # Recompute the step of the slider
        slider.valstep = 1 / Np
        # Calculate the total trace with the new peak
        total = calc_total(peaks)
        # Update the total trace plot
        p_fit.set_ydata(total[lim1:lim2])
        # Update the text
        write_par(Np)
        redraw()

    def remove_peak(event):
        """ Remove the active component """
        nonlocal Np, peaks, p_sgn
        if Np == 0:
            return
        # Get the active peak
        idx = int(np.floor(slider.val * Np) + 1)
        # Decrease Np of 1
        Np -= 1
        # Delete the entry from the peaks dictionary
        _ = peaks.pop(idx)
        # Remove the correspondant line from the plot dictionary
        del_p = p_sgn.pop(idx)
        # Set it invisible because I cannot truly delete it
        del_p.set_visible(False)
        del del_p   # ...at least clear some memory
        # Change the labels to the dictionary
        peaks = rename_dic(peaks, Np)
        p_sgn = rename_dic(p_sgn, Np)
        # Calculate the total trace without that peak
        total = calc_total(peaks)
        # Update the total trace plot
        p_fit.set_ydata(total[lim1:lim2])
        # Change the slider position
        if Np == 0:  # to zero and do not make it move
            slider.set_val(0)
            slider.valstep = 1e-10
            write_par(0)
        elif Np == 1:   # To zero and that's it
            slider.set_val(0)
            slider.valstep = 1 / Np
            write_par(1)
        else:   # To the previous point
            if idx == 1:
                slider.set_val(0)
            else:
                slider.set_val((idx - 2) / Np)     # (idx - 1) -1
            slider.valstep = 1 / Np
            write_par(int(np.floor(slider.val * Np) + 1))
        redraw()

    def save(event):
        """ Write a section in the output file """
        nonlocal prev
        # Adjust the intensities
        for _, peak in peaks.items():
            peak.k *= A
        write_vf_P2D(f'{filename}.ivf', [peaks for w in range(n_exp)], ax.get_xlim(), prev)
        prev += len(peaks)

        # Mark a region as "fitted" with a green box
        ax.axvspan(*ax.get_xlim(), color='tab:green', alpha=0.1)
        # Call reset to return at the initial situation
        reset(event)

    # -------------------------------------------------------------------------------

    ax.plot(ppm_scale[lim1:lim2], S[lim1:lim2], label='Experimental', lw=1.0, c='k')  # experimental
    p_fit = ax.plot(ppm_scale[lim1:lim2], np.zeros_like(S)[lim1:lim2], label='Fit', lw=0.9, c='b')[-1]  # Total trace
    p_sgn = {}  # Components

    # Header for current values print
    head_print = ax.text(0.75, 0.35,
                         '{:>7s}\n{:>5}\n{:>5}\n{:>5}\n{:>7}\n{:>7}\n{:>7}'.format(
                             r'$\delta$', r'$\Gamma$', '$k$', r'$\beta$', 'Phase', '$A$', 'Group'),
                         ha='right', va='top', transform=fig.transFigure, fontsize=14, linespacing=1.5)
    # Text placeholder for the values - linspacing is different to align with the header
    values_print = ax.text(0.85, 0.35, '',
                           ha='right', va='top', transform=fig.transFigure, fontsize=14, linespacing=1.55)
    # Text to display the active sensitivity values
    sens_print = ax.text(0.875, 0.775, f'Sensitivity: $\\pm${sens["u"]:10.4g}',
                         ha='center', va='bottom', transform=fig.transFigure, fontsize=14)
    # Text to remind keyboard shortcuts
    t_uparrow = r'$\uparrow$'
    t_downarrow = r'$\downarrow$'
    keyboard_text = '\n'.join([
        f'{"KEYBOARD SHORTCUTS":^50s}',
        f'{"Key":>5s}: Action',
        '-'*50,
        f'{"<":>5s}: Decrease sens.',
        f'{">":>5s}: Increase sens.',
        f'{"+":>5s}: Add component',
        f'{"-":>5s}: Remove component',
        f'{"Pg"+t_uparrow:>5s}: Change component, up',
        f'{"Pg"+t_downarrow:>5s}: Change component, down',
        f'{t_uparrow:>5s}: Increase value',
        f'{t_downarrow:>5s}: Decrease value',
        '-'*50,
        ])
    ax.text(0.86, 0.025, keyboard_text,
            ha='left', va='bottom', transform=fig.transFigure, fontsize=8, linespacing=1.55)

    # make pretty scales
    ax.set_xlim(max(limits[0], limits[1]), min(limits[0], limits[1]))
    misc.pretty_scale(ax, ax.get_xlim(), axis='x', n_major_ticks=10)
    misc.pretty_scale(ax, ax.get_ylim(), axis='y', n_major_ticks=10)
    misc.mathformat(ax)

    # RESET values for xlim and ylim
    _xlim = ax.get_xlim()
    _ylim = ax.get_ylim()

    # Connect the widgets to their slots
    plus_button.on_clicked(add_peak)
    minus_button.on_clicked(remove_peak)
    up_button.on_clicked(up_sens)
    down_button.on_clicked(down_sens)
    slider.on_changed(selector)
    group_tb.on_submit(set_group)
    reset_button.on_clicked(reset)
    save_button.on_clicked(save)
    peak_radio.on_clicked(radio_changed)
    fig.canvas.mpl_connect('scroll_event', scroll)
    fig.canvas.mpl_connect('key_press_event', key_binding)

    plt.show()  # Start event loop
    plt.close()


def make_iguess_dosy_panel(x, label, y, model, model_args, diff_c_0=1e-10, filename='dosy_fit'):
    """
    Make the initial guess for the fit of a DOSY profile by using a GUI to visually adjust the value of
    the diffusion coefficient and the number of components to use.

    The goal is to try to match the thin solid blue line to the trend of the black dots.

    Use the mouse scroll to modify the values and redraw the figure.
    The radio buttons at the bottom will make you choose if to edit the diffusion coefficient or the fraction
    of the given component.
    The sensitivity can be modified by using the radiobutton as well (coarse/fine) or using the up/down buttons.

    Use the + button to add a component. Use the - button to remove a the currently selected component.
    You can use the slider to change the active component. The headers above the current values of diffusion
    coefficient and fraction will appear of the same color as the active component.

    The intensity to match the model with the experimental data is computed and applied automatically.
    Disabling this option will allow you to play with the values more freely.
    There is also the option to include an offset, however **this should be used only in case of severe systematic errors**
    during the integration procedure.

    Upon pressing the "SAVE" button, a section of the ``<filename>.idy`` file will be written and the GUI will close.


    Parameters
    ----------
    x : 1darray
        Independent variable for the model (usually the gradient list)
    label : str
        Identifier for the region, typically the integration window or peak number
    y : 1darray
        Integrated dosy profile
    model : callable
        Functional model for the DOSY profile. Signature:

        ::

            def model(x, diffc, **model_args):
                return 1darray

    model_args : dict of keyworded arguments
        Additional parameters for ``model``.
    diff_c_0 : float
        Default initial value for the diffusion coefficient, in m^2/s
    filename : str
        The output file of the procedure will be ``<filename>.idy``


    Returns
    -------
    None

    .. seealso::

        :func:`klassez.fit.make_iguess_dosy`

        :func:`klassez.fit.write_dy`
    """
    # Initialize variables
    diff_c = [diff_c_0]         # Initial diffusion coefficient
    diff_f = [1]                # Fraction is obviously 1

    A, q = 0, 0                 # Intensity and offset
    Np = 1                      # Number of components

    # "_" version are the originals for the "reset" function
    #   Multiplier for the diffc [coarse, fine]
    lvl_step = [1.6, 1.05]
    _lvl_step = deepcopy(lvl_step)
    #   Added factor for the sensitivity
    lvl_step_incr = [0.05, 0.005]
    _lvl_step_incr = deepcopy(lvl_step_incr)
    #   Adding factor for the difff [coarse, fine]
    frc_step = [0.1, 0.01]
    _frc_step = deepcopy(frc_step)

    # ---------------------------------------------------------------
    # Make the figure panel
    fig = plt.figure('Initialization of diffusion coefficient')
    fig.set_size_inches(figures.figsize_large)
    plt.subplots_adjust(left=0.10, bottom=0.10, right=0.80, top=0.90)
    ax = fig.add_subplot()

    # Make boxes for widgets
    check_box = plt.axes([0.825, 0.80, 0.15, 0.10])         # Toggle intensity and offset
    up_box = plt.axes([0.825, 0.15, 0.04, 0.05])            # Increase sensitivity
    down_box = plt.axes([0.825, 0.10, 0.04, 0.05])          # Decrease sensitivity
    plus_box = plt.axes([0.88, 0.675, 0.045, 0.075])        # Add component
    minus_box = plt.axes([0.93, 0.675, 0.045, 0.075])       # Remove component
    save_box = plt.axes([0.905, 0.015, 0.07, 0.06])         # Save button
    reset_box = plt.axes([0.825, 0.015, 0.07, 0.06])        # Reset button
    radio_box = plt.axes([0.88, 0.10, 0.095, 0.10])         # Coarse/fine
    dorf_box = plt.axes([0.88, 0.225, 0.095, 0.10])         # Move diffc or difff
    slider_box = plt.axes([0.84, 0.25, 0.01, 0.50])         # Selector slider

    # Widgets
    #   Only factor is toggled at the beginning
    checkbox = CheckButtons(check_box, labels=['Calc. factor', 'Use offset'], actives=[True, False])
    misc.edit_checkboxes(checkbox, xadj=0, yadj=0.05, dim=100)
    #   Buttons to increase and decrease sensitivity
    up_button = Button(up_box, r'$\uparrow$', hovercolor='0.895')
    down_button = Button(down_box, r'$\downarrow$', hovercolor='0.895')
    #   Add/remove component
    plus_button = Button(plus_box, '$+$', hovercolor='0.975')
    minus_button = Button(minus_box, '$-$', hovercolor='0.975')
    #   Save and reset
    save_button = Button(save_box, 'SAVE', hovercolor='0.975')
    reset_button = Button(reset_box, 'RESET', hovercolor='0.975')
    #   Radiobuttons
    radio = RadioButtons(radio_box, ['Coarse', 'Fine'], active=0)
    dorf = RadioButtons(dorf_box, ['Diff. Coeff.', 'Fraction'], active=0)
    #   Selector slider, these are just random number inside.
    #   The actual slider will go from 0 to slightly less than 1 in Np step
    slider = Slider(ax=slider_box, label='# Component',
                    valmin=0, valmax=1-1e-3, valinit=0, valstep=1e-10,
                    orientation='vertical', color='tab:blue')

    # -----------------------------------------------------------------------------------------
    # USEFUL FUNCTIONS
    def calc_f(x, y, diffc, difff=1):
        """ Model for a single component """
        # Compute the model and multiply it by its fraction
        f = difff * model(x, diffc, **model_args)
        return f

    def calc_t(x, y, diff_c, diff_f):
        """ Compute all the components """
        # Loop over calc_f using all the values
        yc = [calc_f(x, y, diffc, difff) for diffc, difff in zip(diff_c, diff_f)]
        # The total trace is the sum of all the components
        t = np.sum(yc, axis=0)
        # Update I and q only if the option is toggled in the checkbox
        if checkbox.get_status()[0]:
            nonlocal A, q
            A, q = fit.fit_int(y, t, q=checkbox.get_status()[1])
        # Apply A and q to the total. If the option is unactive, it uses the previous one
        t = A * t + q
        # Update all the components as well
        for k, y_c in enumerate(yc):
            yc[k] = A * y_c + q

        return t, yc

    # SLOTS
    def selector(event):
        """ When you move the slider """
        # Get the index of the selected component
        idx = int(np.floor(slider.val * Np))

        for k, line in enumerate(fit_plot):
            # Set the linewidth of the active plot to 3 and the rest to 1
            if k == idx:
                line.set_lw(3)
            else:
                line.set_lw(1.5)
        # Update the values
        update_text(idx)

    def update_frac_increment(w):
        """ Update the sensitivity value for the fraction. w = +/-1 """
        # radio -> coarse / fine
        # increase -> w = +1 -> *2
        # decrease -> w = -1 -> /2
        frc_step[radio.index_selected] *= 2**w

    def update_diff_increment(w):
        """ Update the sensitivity value for the diffusion coefficient. w = +/-1 """
        # radio -> coarse / fine
        # increase -> w = +1 -> + lvl_step_incr[radio]
        # decrease -> w = -1 -> - lvl_step_incr[radio]
        lvl_step[radio.index_selected] += w * lvl_step_incr[radio.index_selected]

        # make sure these do not reach 1 otherwise the plot will not update anymore
        for k in range(2):
            if lvl_step[k] <= 1:
                lvl_step[k] = 1.01

    def up_sens(event):
        """ Fork for the UP button """
        if dorf.index_selected == 0:    # diff_c
            update_diff_increment(+1)
        else:       # diff_f
            update_frac_increment(+1)

    def down_sens(event):
        """ Fork for the DOWN button """
        if dorf.index_selected == 0:    # diff_c
            update_diff_increment(-1)
        else:       # diff_f
            update_frac_increment(-1)

    def update_diff(event):
        """ Main function to redraw the plot and update the values """
        # event can be None to not update the values, or ScrollEvent
        # Get active component from the slider
        idx = int(np.floor(slider.val * Np))

        if event is not None:       # Fork -> direction of the scroll
            if event.button == 'up':
                w = +1
            elif event.button == 'down':
                w = -1

            if dorf.index_selected == 0:    # Update diffc
                # w = +1 => * // w = -1 => /
                diff_c[idx] *= lvl_step[radio.index_selected]**w
            else:                           # Update fraction
                # w = +1 => + // w = -1 => -
                diff_f[idx] += frc_step[radio.index_selected] * w

        # Recompute the total trace and the components using the current values
        t, yc = calc_t(x, y, diff_c, diff_f)

        # Update the plots
        tot_plot.set_ydata(t)
        for k, y_c in enumerate(yc):
            fit_plot[k].set_ydata(y_c)
        # Update the texts and redraw the artists
        update_text(idx)
        fig.canvas.draw()

    def toggle_check(label):
        """ Redraw everything knowing that either I or q behavior changed """
        update_diff(None)

    def add_comp(event):
        """ Add a component """
        nonlocal Np
        # Increase the number of components
        Np += 1
        # add a default entry to the values lists
        diff_c.append(diff_c_0)
        diff_f.append(1)

        # Add a placeholder in the component plot lists
        fit_plot.append(ax.plot(x, np.zeros_like(x), '--', lw=1)[-1])
        # Redraw everything taking also the new component into account
        update_diff(None)

        # Move the slider to the position of the new component
        slider.set_val((Np - 1) / Np)
        # Recompute the step of the slider
        slider.valstep = 1 / Np
        # Update linewidths
        selector(None)

    def remove_comp(event):
        """ Remove the active component """
        nonlocal Np
        if Np == 1:     # At least one must remain!
            return

        # Get the active peak
        idx = int(np.floor(slider.val * Np))
        # Decrease Np by 1
        Np -= 1
        # Remove the current values from the values lists
        _ = diff_c.pop(idx)
        _ = diff_f.pop(idx)
        # Remove the correspondant line from the plot list
        del_p = fit_plot.pop(idx)
        del_p.remove()      # Erase the artist

        if Np == 1:   # To zero and that's it
            slider.set_val(0)
            slider.valstep = 1 / Np
        else:   # To the previous point
            if idx == 1:
                slider.set_val(0)
            else:
                slider.set_val((idx - 1) / Np)
            slider.valstep = 1 / Np

        # Redraw everything and adjust linewidths
        update_diff(None)
        selector(None)

    def update_text(idx):
        """ Change colors to the headers and update the values """
        legend_text.set_color(fit_plot[idx].get_color())
        value_text.set_text(f'\n{diff_c[idx]:.5g}\n\n\n{diff_f[idx]:.5g}\n\n')
        fig.canvas.draw()

    def reset(event):
        """ Restore all values to the default """
        nonlocal diff_c, diff_f, lvl_step, lvl_step_incr, frc_step

        # Remove the curves one by one
        for _ in range(Np):
            remove_comp(None)

        # Reset the values
        diff_c = [diff_c_0]
        diff_f = [1]

        # Reset the increments
        lvl_step = deepcopy(_lvl_step)
        lvl_step_incr = deepcopy(_lvl_step_incr)
        frc_step = deepcopy(_frc_step)

        # Redraw everything
        update_diff(None)
        selector(None)
        fig.canvas.draw()

    def save(event):
        """ Write a section in the output file """
        # Placeholder for the errors: initial guess is errorless by definition :)
        diff_e = [None for w in range(Np)]
        fit.write_dy(f'{filename}.idy', diff_c, diff_f, diff_e, label, A, q)
        # Close everything
        plt.close()

    # -------------------------------------------------------------------------------
    # Get the initial total trace and the components (it is only one but I like consistency)
    t, yc = calc_t(x, y, diff_c, diff_f)

    # Plots
    ax.plot(x, y, '.', ms=10, c='k', label='Experimental')
    # The total is thinner hence it can appear on top
    tot_plot, = ax.plot(x, t, '-', lw=1, label='Total Fit', zorder=1000)
    # Components as a list
    fit_plot = [ax.plot(x, y_c, '--', lw=3)[-1] for y_c in yc]

    # Text on the right
    # Same position for header and value, the text itself is interleaved
    legend_text = fig.text(0.925, 0.60,     # here 1st and 4th line
                           'Diff. C.\n\n\nFraction\n\n\n',
                           ha='center', va='top', transform=fig.transFigure,
                           fontsize=16)
    # Here just a placeholder because...
    value_text = fig.text(0.925, 0.60, '',  # here 2nd and 5th line
                          ha='center', va='top', transform=fig.transFigure,
                          fontsize=16)
    # ... this is the function that applies color to header and correct text in value
    update_text(0)

    # Fancy shit
    #   title and axes labels
    ax.set_title(f'{label}')
    ax.set_xlabel(r'Gradient /T m$^{-1}$')
    ax.set_ylabel('Intensity /a.u.')

    #   ticks and co.
    misc.pretty_scale(ax, ax.get_xlim(), 'x')
    misc.pretty_scale(ax, ax.get_ylim(), 'y')
    misc.mathformat(ax)

    #   legend
    ax.legend()
    misc.set_fontsizes(ax, 14)

    # Connect widgets to slots
    up_button.on_clicked(up_sens)
    down_button.on_clicked(down_sens)
    plus_button.on_clicked(add_comp)
    minus_button.on_clicked(remove_comp)
    slider.on_changed(selector)
    reset_button.on_clicked(reset)
    save_button.on_clicked(save)
    checkbox.on_clicked(toggle_check)
    fig.canvas.mpl_connect('scroll_event', update_diff)

    # Start event loop
    plt.show()


def make_iguess_dosy(x, labels, data, model, model_args, diff_c_0=1e-10, filename='dosy_fit'):
    """
    Make the initial guess for the fit of a DOSY spectrum by using a GUI to visually adjust the value of
    the diffusion coefficient and the number of components to use.
    Calls :func:`fit.make_iguess_dosy_panel` in a loop. A section of the output file is written at the end
    of each loop.

    Parameters
    ----------
    x : 1darray
        Independent variable for the model (usually the gradient list)
    labels : list of str
        Identifier for the region, typically the integration window or peak number
    data : list of 1darray or 2darray
        Integrated profiles to fit
    model : callable
        Functional model for the DOSY profile. Signature:

        ::

            def model(x, diffc, **model_args):
                return 1darray

    model_args : dict of keyworded arguments
        Additional parameters for ``model``.
    diff_c_0 : float
        Default initial value for the diffusion coefficient, in m^2/s
    filename : str
        The output file of the procedure will be ``<filename>.idy``

    Returns
    -------
    None

    .. seealso::

        :func:`klassez.fit.make_iguess_dosy_panel`

        :func:`klassez.write_dy`
    """
    # Write the header of the idy file as it would in write_dy with header=True
    with open(f'{filename}.idy', 'a', buffering=1) as f:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! DOSY fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

    # Make a loop: call the GUI for each set of integrals
    for k, (label, y) in enumerate(zip(labels, data)):
        print(f'Region {label} [ # {k+1} of {len(labels)}]', end='\r')
        fit.make_iguess_dosy_panel(x, label, y, model, model_args, diff_c_0, filename)
    print(f'\n{filename}.idy saved.', c='tab:blue')


def plot_fit_P2D(S, ppm_scale, regions, t_AQ, SFO1, o1p, show_total=False, show_res=False, res_offset=0, X_label=r'$\delta$ /ppm', labels=None, filename='fit', ext='png', dpi=600):
    """
    Plots either the initial guess or the result of the fit, and saves all the figures.
    A new folder named <filename>_fit will be created.
    The figure `<filename>_full` will show the whole model and the whole spectrum.
    The figures labelled with `_R<k>` will depict a detail of the fit in the k-th fitting region.
    Optional labels for the components can be given: in this case, the structure of ``labels`` should match the structure of ``regions``.
    This means that the length of the outer list must be equal to the number of fitting region, and the length of the inner lists must be equal to the number of peaks in that region.

    Parameters
    ----------
    S : 2darray
        Spectrum to be fitted
    ppm_scale : 1darray
        ppm scale of the spectrum
    regions : list of dict
        Generated by fit.read_vf_P2D
    t_AQ : 1darray
        Acquisition timescale
    SFO1 : float
        Larmor frequency of the observed nucleus, in MHz
    o1p : float
        Carrier position, in ppm
    nuc : str
        Observed nucleus. Used to customize the x-scale of the figures.
    show_total : bool
        Show the total trace (i.e. sum of all the components) or not
    show_res : bool
        Show the plot of the residuals
    res_offset : float
        Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental spectrum. ``res_offset`` > 0 will move the residuals BELOW the zero-line!
    X_label : str
        Text to show as label for the chemical shift axis
    labels : list of list
        Optional labels for the components. The structure of this parameter must match the structure of self.result
    filename : str
        Root of the name of the figures that will be saved.
    ext : str
        Format of the saved figures
    dpi : int
        Resolution of the figures, in dots per inches
    """

    def calc_total(peaks, A=1):
        """
        Calculates the sum trace from a collection of peaks stored in a dictionary.
        If the dictionary is empty, returns an array of zeros.

        Parameters
        ----------
        peaks: dict
            Components
        A: float
            Absolute intensity

        Returns
        -------
        total: 1darray
            Sum spectrum
        """

        # Get the arrays from the dictionary
        T = [p(A) for _, p in peaks.items()]
        if len(T) > 0:  # Check for any peaks
            total = np.sum(T, axis=0)
            return total.real
        else:
            return np.zeros_like(ppm_scale)

    # Try to create the new directory for the figures
    figdir = Path(f'{filename}_fit')
    figdir.mkdir(exist_ok=True, parents=True)
    # Update the filename for the figures by including the new directory
    filename = figdir / filename
    print('Saving figures...', c='tab:cyan')
    # Shallow copy of the real part of the experimental spectrum
    S_r = np.copy(S.real)
    # Make the acqus dictionary to be fed into the fit.Peak objects
    acqus = {'t1': t_AQ, 'SFO1': SFO1, 'o1p': o1p, }

    # Single regions
    # Loop on the regions
    for k, region in enumerate(regions):
        # Get limits from the dictionary
        peaklist = deepcopy(region)
        for peaks in peaklist:
            if 'limits' in list(peaks.keys()):
                limits = peaks.pop('limits')

        # Convert the limits from ppm to points
        limits_pt = misc.ppmfind(ppm_scale, limits[0])[0], misc.ppmfind(ppm_scale, limits[1])[0]
        #   Make the slice
        lims = slice(min(limits_pt), max(limits_pt))

        # Make the calculated spectrum
        fit_peaks = [{} for w in range(S.shape[0])]
        list_signals = []
        signals = []
        for j, peaks in enumerate(peaklist):        # j runs on the experiments
            for key, peakval in peaks.items():
                fit_peaks[j][key] = fit.Peak(acqus, N=S.shape[-1], **peakval)
            # Get the arrays from the dictionary and put them in the list
            list_signals.append([p() for _, p in fit_peaks[j].items()])
        signals.extend(list_signals)     # Dimensions (n. experiments, n.peaks per experiment, n.points per experiment)

        # Compute the total trace
        total = np.sum(signals, axis=1)  # sum the peaks

        # Trim the ppm scale according to the fitting region
        t_ppm = ppm_scale[lims]

        # One figure per experiment
        for i, _ in enumerate(S):
            # Make the figure
            fig = plt.figure(f'Fit {i+1}')
            fig.set_size_inches(figures.figsize_large)
            ax = fig.add_subplot()
            plt.subplots_adjust(left=0.10, bottom=0.10, top=0.90, right=0.95)

            # Plot the experimental dataset
            ax.plot(t_ppm, S_r[i, lims], c='k', lw=1, label='Experimental')

            if show_total is True:  # Plot the total trace in blue
                ax.plot(t_ppm, total[i, lims], c='b', lw=0.5, label='Fit')

            for key, peak in zip(fit_peaks[i].keys(), signals[i]):  # Plot the components
                p_sgn, = ax.plot(t_ppm, peak[lims], lw=0.6, label=f'{key}')
                if labels is not None:  # Set the custom label
                    p_sgn.set_label(labels[k][key-1])

            if show_res is True:    # Plot the residuals
                # Compute the absolute value of the offset
                r_off = min(S_r[i, lims]) + res_offset * (max(S_r[i, lims])-min(S_r[i, lims]))
                ax.plot(t_ppm, (S_r - total)[i, lims] - r_off, c='g', ls=':', lw=0.6, label='Residuals')

            # Visual adjustments
            ax.set_xlabel(X_label)
            ax.set_ylabel('Intensity /a.u.')
            misc.pretty_scale(ax, (max(t_ppm), min(t_ppm)), axis='x')
            misc.pretty_scale(ax, ax.get_ylim(), axis='y')
            misc.mathformat(ax)
            ax.legend()
            misc.set_fontsizes(ax, 20)
            # Save the figure
            plt.savefig(f'{filename}_R{k+1}_E{i+1}.{ext}', dpi=dpi)
            plt.close()
            continue

    # Total
    # One figure per experiment
    for i, _ in enumerate(S):
        # Make the figure
        fig = plt.figure(f'Fit {i+1}')
        fig.set_size_inches(figures.figsize_large)
        ax = fig.add_subplot()
        plt.subplots_adjust(left=0.10, bottom=0.10, top=0.90, right=0.95)
        # Plot the experimental dataset
        ax.plot(ppm_scale, S_r[i], c='k', lw=1, label='Experimental')

        if show_total is True:  # Plot the total trace
            ax.plot(ppm_scale, total[i], c='b', lw=0.5, label='Fit', zorder=2)

        for key, peak in zip(fit_peaks[i].keys(), signals[i]):   # Plot the components
            p_sgn, = ax.plot(ppm_scale, peak, lw=0.6, label=f'{key}')
            if labels is not None:  # Set the custom label
                p_sgn.set_label(labels[k][key-1])

        # Visual adjustments
        ax.set_xlabel(X_label)
        ax.set_ylabel('Intensity /a.u.')
        misc.pretty_scale(ax, (max(ppm_scale), min(ppm_scale)), axis='x')
        misc.pretty_scale(ax, ax.get_ylim(), axis='y')
        misc.mathformat(ax)
        ax.legend()
        misc.set_fontsizes(ax, 20)
        # Save the figure
        plt.savefig(f'{filename}_full_E{i+1}.{ext}', dpi=dpi)
        plt.close()
    print('Done.', c='tab:cyan')


def voigt_fit_P2D(S, ppm_scale, regions, t_AQ, SFO1, o1p, u_tol=1, f_tol=10, vary_phase=False, vary_b=False, itermax=10000, filename='fit'):
    """
    Performs a lineshape deconvolution fit on a pseudo-2D experiment using a Voigt model.
    The initial guess must be read from a .ivf file. All components are treated as independent, regardless from the value of the "group" attribute.
    The fitting procedure operates iteratively one window at the time.
    During the fit routine, the peak positions and lineshapes will be varied consistently on all the experiments; only the intensities are allowed to change in a different way.

    Parameters
    ----------
    S: 2darray
        Experimental spectrum
    ppm_scale: 1darray
        PPM scale of the spectrum
    regions: dict
        Generated by ``fit.read_vf_P2D``
    t_AQ: 1darray
        Acquisition timescale
    SFO1: float
        Nucleus Larmor frequency /MHz
    o1p: float
        Carrier frequency /ppm
    u_tol: float
        Maximum allowed displacement of the chemical shift from the initial value /ppm
    f_tol: float
        Maximum allowed displacement of the linewidth from the initial value /ppm
    vary_phase: bool
        Allow the peaks to change phase
    vary_b: bool
        Allow the peaks to change Lorentzian/Gaussian ratio
    itermax: int
        Maximum number of allowed iterations
    filename: str
        Name of the file where the fitted values will be saved. The .fvf extension is added automatically
    """

    # USED FUNCTIONS

    def calc_total(list_peaks, A=1):
        """
        Calculates the sum trace from a collection of peaks stored in a dictionary.
        If the dictionary is empty, returns an array of zeros.

        Parameters
        ----------
        peaks : dict
            Components
        A : float
            Absolute intensity

        Returns
        -------
        total : 1darray
            Sum spectrum
        """
        # Get the arrays from the dictionary
        total_arr = []
        for q, peaks in enumerate(list_peaks):
            T = [p(A) for _, p in peaks.items()]
            if len(T) > 0:  # Check for any peaks
                total = np.sum(T, axis=0)
            else:
                total = np.zeros_like(ppm_scale)
            total_arr.append(total)
        return np.array(total_arr)

    def peaks_frompar(peaks, par, e_idx):
        """
        Replaces the values of a "peaks" dictionary, which contains a fit.Peak object for each key "idx", with the values contained in the "par" dictionary.
        The par dictionary keys must have keys of the form <parameter>_<idx>, where <parameter> is in [u, fwhm, k, 'b', 'phi'], and <idx> are the keys of the peaks dictionary.
        The intensity values of the peaks are stored as k_<idx>_<experiment>, where <experiment> is the associated trace of the pseudo-2D, starting from 1.

        Parameters
        ----------
        peaks : dict
            Collection of fit.Peak objects
        par : dict
            New values for the peaks
        e_idx : int
            Number of experiment of which to change the intensity of the peak

        Returns
        -------
        peaks : dict
            Updated peaks dictionary with the new values
        """
        for idx, peak in peaks.items():
            peak.u = par[f'u_{idx}']
            peak.fwhm = par[f'fwhm_{idx}']
            peak.k = par[f'k_{idx}_{e_idx}']
            peak.b = par[f'b_{idx}']
            peak.phi = par[f'phi_{idx}']
        return peaks

    def f2min(param, S, fit_peaks, Int, lims):
        """
        Function that calculates the residual to be minimized in the least squares sense.
        This function requires a set of pre-built fit.Peak objects, stored in a dictionary.
        The parameters of the peaks are replaced on this dictionary according to the values in the lmfit.Parameter object.
        At this point, the total trace is computed and the residual is returned as the difference between the experimental spectrum and the total trace,
        only in the region delimited by the "lims" tuple.

        Parameters
        ----------
        param : lmfit.Parameters object
            Usual lmfit stuff
        S : 2darray
            Experimental spectrum
        fit_peaks : list of dict
            Collection of fit.Peak objects
        I : list or 1darray
            Absolute intensity values for all experiments
        lims : slice
            Trimming region corresponding to the fitting window, in points

        Returns
        -------
        residual : 1darray
            Experimental - calculated, in the fitting window, concatenated through all the experiments
        """
        param['count'].value += 1
        # Unpack the lmfit.Parameters object
        par = param.valuesdict()
        # create a shallow copy of the fit_peaks dictionary to prevent overwriting
        fit_peaks_in = deepcopy(fit_peaks)
        fit_peaks_up = []   # placeholder
        for j, peaks in enumerate(fit_peaks_in):
            # Update the peaks dictionary according to how lmfit is moving the fit parameters
            peaks_up = peaks_frompar(peaks, par, j+1)
            fit_peaks_up.append(peaks_up)
        # Compute the total trace and the residuals
        total = calc_total(fit_peaks_up, 1)
        residual = np.concatenate([S[j, lims] / Int[j] - total[j, lims] for j, _ in enumerate(fit_peaks_in)])

        print(f'Step: {par["count"]:6.0f} | Target: {np.sum(residual**2):10.5e}', end='\r')
        return residual

    def gen_reg(regions):
        """
        Generator function that loops on the regions and extracts the limits of the fitting window, the limits, and the dictionary of peaks.
        """
        for k, region in enumerate(regions):
            # Get limits and total intensity from the dictionary of the first region
            limits = region[0]['limits']
            if 1:   # Switch: turn this print on and off
                print(f'Fitting of region {k+1}/{Nr}. [{limits[0]:.3f}:{limits[1]:.3f}] ppm', c='tab:orange')
            # Make a copy of the region dictionary and remove what is not a peak
            peaklist = deepcopy(region)
            for peaks in peaklist:
                if 'limits' in list(peaks.keys()):
                    peaks.pop('limits')
            yield limits, peaklist

    # -----------------------------------------------------------------------------
    # Make the acqus dictionary to be fed into the fit.Peak objects
    acqus = {'t1': t_AQ, 'SFO1': SFO1, 'o1p': o1p, }

    N = S.shape[-1]     # Number of points of the spectrum
    Nr = len(regions)   # Number of regions to be fitted

    # Write info on the fit in the output file
    with open(f'{filename}.fvf', 'a', buffering=1) as f:
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! Fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

    # Generate the values from the regions dictionary with the gen_reg generator
    Q = gen_reg(regions)

    # Start fitting loop
    prev = 0
    for q in Q:
        limits, peaklist = q    # Unpack
        Np = len(peaklist[0].keys())  # Number of Peaks

        # Convert the limits from ppm to points and make the slice
        limits_pt = misc.ppmfind(ppm_scale, limits[0])[0], misc.ppmfind(ppm_scale, limits[1])[0]
        lims = slice(min(limits_pt), max(limits_pt))

        # Create a list of dictionaries which contains Peak objects
        fit_peaks = [{} for w in range(S.shape[0])]
        # Fill them up
        for j, peaks in enumerate(peaklist):    # j is the index of the experiment
            for key, peakval in peaks.items():  # loop on the items of the single experiment
                # Make the fit.Peak objects
                fit_peaks[j][key] = fit.Peak(acqus, N=N, **peakval)
            # Convert the intensities into smaller values
            Ks = [fit_peaks[j][key].k for key in fit_peaks[j].keys()]
            Ks_norm, _ = misc.molfrac(Ks)
            # Replace with the new ones
            for K, key in zip(Ks_norm, peaks.keys()):
                fit_peaks[j][key].k = K

        # Compute the initial guess array of the fit
        fit_peaks_iguess = deepcopy(fit_peaks)
        i_guess = calc_total(fit_peaks_iguess, 1)
        # Compute the matching-intensity factors
        I_arr = []
        for j in range(S.shape[0]):
            I_arr.append(fit.fit_int(S[j, lims], i_guess[j, lims])[0])
        I_arr = np.array(I_arr)

        # Make the lmfit.Parameters object
        param = lmfit.Parameters()
        # Add the peaks' parameters to a lmfit Parameters object
        peak_keys = ['u', 'fwhm', 'k', 'b', 'phi']

        for j, peaks in enumerate(fit_peaks):       # j is the index of the experiment
            for idx, peak in peaks.items():
                # Name of the object: <parameter>_<index>
                p_key = f'_{idx}'

                # Fill the Parameters object
                for key in peak_keys:
                    if 'k' in key:  # add also the experiment index
                        par_key = f'{key}{p_key}_{j+1}'
                    else:           # add only the parameter
                        par_key = f'{key}{p_key}'   # Add the parameter to the label
                    val = peak.par()[key]       # Get the value from the input dictionary
                    param.add(par_key, value=val)   # Make the Parameter object

                    # Set the limits for each parameter, and fix the ones that have not to be varied during the fit
                    if 'u' in key:  # u: [u-u_tol, u+u_tol]
                        param[par_key].set(min=val-u_tol, max=val+u_tol)
                    elif 'fwhm' in key:  # fwhm: [max(0, fwhm-f_tol), fwhm+f_tol] (avoid negative fwhm)
                        param[par_key].set(min=max(0, val-f_tol), max=val+f_tol)
                    elif 'k' in key:    # k: [0, 2]
                        param[par_key].set(min=0, max=2)
                    elif 'phi' in key:  # phi: [-180°, +180°]
                        param[par_key].set(min=-180, max=180, vary=vary_phase)
                    elif 'b' in key:  # b: [0, 1]
                        param[par_key].set(min=0, max=1, vary=vary_b)

        # Wrap the fitting routine in a function in order to use @cron for measuring the runtime of the fit
        @cron
        def start_fit():
            param.add('count', value=0, vary=False)
            minner = lmfit.Minimizer(f2min, param, fcn_args=(S, fit_peaks, I_arr, lims))
            result = minner.minimize(method='leastsq', max_nfev=int(itermax), xtol=1e-8, ftol=1e-8)
            print(f'{result.message} Number of function evaluations: {result.nfev}.')
            return result
        # Do the fit
        result = start_fit()
        # Unpack the fitted values
        popt = result.params.valuesdict()

        # Replace the initial values with the fitted ones
        fit_peaks_opt = []
        for j, peaks in enumerate(fit_peaks):
            fit_peaks_opt.append(peaks_frompar(peaks, popt, j+1))

        # Correct the intensities
        for j, peaks in enumerate(fit_peaks_opt):
            for _, idx in enumerate(peaks.keys()):
                peaks[idx].k *= I_arr[j]

        # Write a section of the output file
        write_vf_P2D(f'{filename}.fvf', fit_peaks_opt, limits, prev)
        prev += Np


class Voigt_Fit_P2D:
    """
    This class offers an "interface" to fit a pseudo 2D NMR spectrum.

    Attributes
    ----------
    ppm_scale : 1darray
        Self-explanatory
    S  : 2darray
        Spectrum to fit. Only real part
    t_AQ : 1darray
        acquisition timescale of the spectrum
    SFO1 : float
        Larmor frequency of the nucleus
    o1p  : float
        Pulse carrier frequency
    filename : str
        Root of the names of the files that will be saved
    X_label : str
        Label for the chemical shift axis in the figures
    i_guess : list
        Initial guess for the fit, read by a .ivf file with fit.read_vf_P2D
    result : list
        Result the fit, read by a .fvf file with fit.read_vf_P2D
    """

    def __init__(self, ppm_scale, S, t_AQ, SFO1, o1p, nuc=None, filename='fit'):
        """
        Initialize the class with common values.

        Parameters
        ----------
        ppm_scale : 1darray
            ppm scale of the spectrum
        S : 2darray
            Spectrum to be fitted
        t_AQ : 1darray
            Acquisition timescale
        SFO1 : float
            Larmor frequency of the observed nucleus, in MHz
        o1p : float
            Carrier position, in ppm
        nuc : str
            Observed nucleus. Used to customize the x-scale of the figures.
        filename : str or None
            Root of the name of the files that will be saved
        """
        self.ppm_scale = ppm_scale
        self.S = S
        self.t_AQ = t_AQ
        self.SFO1 = SFO1
        self.o1p = o1p
        self.filename = filename
        if nuc is None:
            self.X_label = r'$\delta\,$ /ppm'
        elif isinstance(nuc, str):
            fnuc = misc.nuc_format(nuc)
            self.X_label = r'$\delta$ ' + fnuc + ' /ppm'

    def iguess(self, input_file=None, expno=0, n=-1,):
        """
        Reads, or computes, the initial guess for the fit.
        If the file is there already, it just reads it with fit.read_vf. Otherwise, it calls fit.make_iguess to make it.

        Parameters
        ----------
        input_file : str or None
            Path to the input file. If None, "<self.filename>.ivf" is used
        expno : int
            Number of the experiment on which to compute the initial guess, in python numbering
        n : int
            Index of the initial guess to be read (default: last one)
        """
        # Set the default filename, if not given
        if input_file is None:
            input_file = f'{self.filename}'
        path = Path(f'{input_file}.ivf')
        # Check if the file exists
        if path.exists():       # Read everything you need from the file
            regions = fit.read_vf_P2D(path)
        else:                           # Make the initial guess interactively and save the file.
            fit.make_iguess_P2D(self.S, self.ppm_scale, expno, self.t_AQ, self.SFO1, self.o1p, filename=path.parent / path.stem)
            regions = fit.read_vf_P2D(path)
        # Store it
        self.i_guess = regions
        print(f'{path} loaded as input file.', c='tab:blue')

    def load_fit(self, output_file=None, n=-1):
        """
        Reads a file with fit.read_vf_P2D and stores the result in self.result.

        Parameters
        ----------
        output_file : str
            Path to the .fvf file to be read, without the `.fvf` extension. If None, "<self.filename>" is used.
        n : int
            Index of the fit to be read (default: last one)
        """
        # Set the default filename, if not given
        if output_file is None:
            output_file = f'{self.filename}'
        path = Path(f'{output_file}.fvf')
        # Check if the file exists
        if path.exists():       # Read everything you need from the file
            regions = fit.read_vf_P2D(path, n=n)
        else:
            raise NameError(f'{path} does not exist.')
        # Store
        self.result = regions
        print(f'{path} loaded as fit result file.', c='tab:blue')

    def dofit(self, u_tol=1, f_tol=10, vary_phase=False, vary_b=True, itermax=10000, filename=None):
        """
        Perform a lineshape deconvolution fitting by calling ``fit.voigt_fit_P2D``.
        The initial guess is read from the attribute ``self.i_guess``.

        Parameters
        ----------
        u_tol : float
            Determines the displacement of the chemical shift (in ppm) from the starting value.
        f_tol : float
            Determines the displacement of the linewidth (in Hz) from the starting value.
        vary_phase : bool
            Allow the peaks to change phase (True) or not (False)
        vary_b : bool
            Allow the peaks to change Lorentzian/Gaussian ratio
        itermax : int
            Maximum number of allowed iterations
        filename : str
            Path to the output file. If None, "<self.filename>.fvf" is used
        """

        # Make a shallow copy of the real part of the experimental spectrum
        S = np.copy(self.S.real)
        # Check if the initial guess was loaded correctly
        if not isinstance(self.i_guess, list):
            raise ValueError('Initial guess not correctly loaded')
        # Set the output filename, if not given
        if filename is None:
            filename = f'{self.filename}'

        # Do the fit
        fit.voigt_fit_P2D(S, self.ppm_scale, self.i_guess, self.t_AQ, self.SFO1, self.o1p, u_tol=u_tol, f_tol=f_tol, vary_phase=vary_phase, vary_b=vary_b, itermax=itermax, filename=filename)
        # Store
        self.result = fit.read_vf_P2D(f'{filename}.fvf')

    def plot(self, what='result', show_total=True, show_res=False, res_offset=0, labels=None, filename=None, ext='png', dpi=600):
        """
        Plots either the initial guess or the result of the fit, and saves all the figures. Calls fit.plot_fit_P2D.
        The figures <filename>_full will show the whole model and the whole spectrum.
        The figures labelled with _R<k> will depict a detail of the fit in the k-th fitting region.
        Optional labels for the components can be given: in this case, the structure of 'labels' should match the structure of self.result (or self.i_guess).
        This means that the length of the outer list must be equal to the number of fitting region,
        and the length of the inner lists must be equal to the number of peaks in that region.

        Parameters
        ----------
        what : str
            'iguess' to plot the initial guess, 'result' to plot the fitted data
        show_total : bool
            Show the total trace (i.e. sum of all the components) or not
        show_res : bool
            Show the plot of the residuals
        res_offset : float
            Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental spectrum. res_offset > 0 will move the residuals BELOW the zero-line!
        labels : list of list
            Optional labels for the components. The structure of this parameter must match the structure of self.result
        filename : str
            Root of the name of the figures that will be saved. If None, <self.filename> is used
        ext : str
            Format of the saved figures
        dpi : int
            Resolution of the figures, in dots per inches
        """
        # select the correct object to plot
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Set the filename, if not given
        if filename is None:
            filename = f'{self.filename}'

        # Make the figures
        S = np.copy(self.S.real)
        fit.plot_fit_P2D(S, self.ppm_scale, regions, self.t_AQ, self.SFO1, self.o1p, show_total=show_total,
                         show_res=show_res, res_offset=res_offset, X_label=self.X_label, labels=labels,
                         filename=filename, ext=ext, dpi=dpi)

    def get_fit_lines(self, what='result'):
        """
        Calculates the components, and the total fit curve used as initial guess, or as fit results..
        The components will be returned as a list, not split by region.

        Parameters
        ----------
        what : str
            'iguess' or 'result'

        Returns
        -------
        signals : list of list of 1darray
            Components used for the fit
        total : 2darray
            Sum of all the signals
        limits_list : list
            List of the region delimiters, in ppm
        """
        # Select the correct object
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Make the acqus dictionary for the fit.Peak objects
        acqus = {'t1': self.t_AQ, 'SFO1': self.SFO1, 'o1p': self.o1p, }
        # Placeholder
        signals = []
        limits_list = []

        # Loop on the regions
        for region in regions:
            # Get limits from the dictionary
            peaklist = list(region)
            for peaks in peaklist:
                if 'limits' in list(peaks.keys()):
                    limits = peaks.pop('limits')
            limits_list.append(limits)

            # Placeholder
            fit_peaks = [{} for w in range(self.S.shape[0])]
            list_signals = []
            for j, peaks in enumerate(peaklist):        # j runs on the experiments
                for key, peakval in peaks.items():
                    # Create the fit.Peak objects
                    fit_peaks[j][key] = fit.Peak(acqus, N=self.S.shape[-1], **peakval)
                # Get the arrays from the dictionary and put them in the list
                list_signals.append([p() for _, p in fit_peaks[j].items()])
            signals.extend(list_signals)     # Dimensions (n. experiments, n.peaks per experiment, n.points per experiment)

        # Compute the total trace
        total = np.sum(signals, axis=1)  # sum the peaks
        return signals, total, limits_list

    def res_histogram(self, what='result', nbins=500, density=True, f_lims=None, xlabel='Residuals', x_symm=True, barcolor='tab:green', fontsize=20, filename=None, ext='png', dpi=300):
        """
        Computes the histogram of the residuals and saves it in the same folder of the fit figures.
        Employs fit.histogram to make the figure.

        Parameters
        ----------
        what : str
            'iguess' or 'result'
        nbins  : int
            number of bins to be calculated
        density  : bool
            True for normalize data
        f_lims  : tuple or None
            limits for the x axis of the figure
        xlabel  : str or None
            Text to be displayed under the x axis
        x_symm  : bool
            set it to True to make symmetric x-axis with respect to 0
        barcolor : str
            Color of the bins
        fontsize : float
            Biggest fontsize in the figure
        name  : str
            name for the figure to be saved
        ext : str
            Format of the image
        dpi : int
            Resolution of the image in dots per inches
        """
        # Filename check
        if filename is None:
            filename = f'{self.filename}'
        figdir = Path(f'{filename}_fit')
        figdir.mkdir(exist_ok=True, parents=True)
        filename = figdir / filename + '_rhist'

        # Select the correct object
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Get the total function and the limits
        _, total, limits_list = self.get_fit_lines(what)
        # Convert the limits in points according to the ppm scale
        limits_pt_list = [[misc.ppmfind(self.ppm_scale, w)[0] for w in lims]
                          for lims in limits_list]

        # Placeholders
        exp_trim, total_trim = [], []
        for k, region in enumerate(regions):        # loop on the regions
            # Compute the slice
            lims = slice(min(limits_pt_list[k]), max(limits_pt_list[k]))
            # Trim the experimental data and the total
            exp_trim.append(self.S[..., lims].real)
            total_trim.append(total[..., lims])
        # Sum on different regions
        exp_trim = np.sum(exp_trim, axis=0)
        total_trim = np.sum(total_trim, axis=0)

        # Compute the residuals and concatenate them
        residual = exp_trim - total_trim
        residual_arr = np.concatenate([r for r in residual], axis=-1)

        fit.histogram(residual_arr, nbins=nbins, density=density, f_lims=f_lims, xlabel=xlabel, x_symm=x_symm, barcolor=barcolor, fontsize=fontsize, name=filename, ext=ext, dpi=dpi)


def fit_dosy(x, y, iguess, model, model_args, d_bds=3, f_bds=[0, 3], vary_q=False):
    """
    Perform a fit of a DOSY profile using the specified ``model``.

    Parameters
    ----------
    x : 1darray
        Independent variable, typically the gradient strength in T/m
    y : 1darray
        Experimental data
    iguess : dict
        Initial guess for the fit, as generated by :func:`klassez.fit.make_iguess_dosy_panel`
    model : callable
        Model function. Signature:

    model_args : dict of kwargs
        Additional arguments to model
    d_bds : float or list
        Bounds for the diffusion coefficient.
        If it is a single ``float``, the bounds will be set to ``-d_bds`` and ``+d_bds``
        orders of magnitude with respect to the initial guess. E.g.: if ``diffc = 1e-10`` and
        ``d_bds = 2``, the bounds will be ``[1e-12, 1e-8]``.
        If it is a list of two ``float``s, the bounds will be set to ``-d_bds[0]` and ``+d_bds[1]``
        orders of magnitude with respect to the initial guess. E.g.: if ``diffc = 1e-10`` and
        ``d_bds = [1, 3]``, the bounds will be ``[1e-11, 1e-7]``
    f_bds : float or list
        Bounds for the fraction of component.
        If it is a single ``float``, the bounds will be set to ``-f_bds`` and ``+f_bds``
        with respect to the initial fraction. E.g.: if ``difff = 0.5`` and ``f_bds = 0.3``,
        the bounds will be ``[0.2, 0.8]``
        If it is a list of two ``float``s, the bounds will be set to ``f_bds[0]` and ``f_bds[1]``,
        regardless of what the initial fraction is.
    vary_q : bool
        Include the computation of the offset in the parameters. **Strongly discouraged!**

    Returns
    -------
    dic_result : dict
        Dictionary of optimized parameters, with the same format and shape of ``iguess``.

    .. seealso::

        :func:`klassez.fit.make_iguess_dosy`

        :func:`klassez.fit.make_iguess_dosy_panel`

    """

    def f2min(param, x, y, model, model_args, first_residual=1):
        """ Cost function for the fit """
        # Increase the iteration counter
        param['count'].value += 1
        # Unpack the Parameters object into a normal dictionary
        par = param.valuesdict()

        # Make diffusion coefficient and fractions as lists
        diff_c = [par[key] for key in par.keys() if 'D' in key]
        diff_f = [par[key] for key in par.keys() if 'f' in key]

        # Compute the models
        yc = [difff * model(x, diffc, **model_args) for diffc, difff in zip(diff_c, diff_f)]
        # Sum them to get the total trace
        total = np.sum(yc, axis=0)
        # Compute the residuals
        residual = (y - par['q']) / par['I'] - total
        print(f'Step: {par["count"]:6.0f} | Target: {np.sum(residual**2)/first_residual:10.5e}', end='\r')
        return residual

    # Number of components
    Np = len(iguess['diff_c'])
    # If bounds are single numbers, make them lists in order to do
    # what the docstring tells they do
    if isinstance(d_bds, (int, float)):
        d_bds = [d_bds, d_bds]
    if isinstance(f_bds, (int, float)):
        rel_f = True    # f_bds are relative to the value of f
    else:
        rel_f = False   # or not

    # Initialize the Parameters object
    param = lmfit.Parameters()
    # Common parameters to all components
    #   Intensity factor - adjusted by the single fractions
    param.add('I', value=iguess['I'], vary=False)
    #   Offset (normally 0 and do not move it)
    param.add('q', value=iguess['q'], vary=vary_q)
    #   Iteration counter
    param.add('count', value=0, vary=False)
    # Component-dependent parameters
    for k, (diffc, difff) in enumerate(zip(iguess['diff_c'], iguess['diff_f'])):
        # Bounds for D
        oom = np.log10(diffc)       # order of magnitude of D
        minD = diffc - 10**(oom-d_bds[0])
        maxD = diffc + 10**(oom+d_bds[1])
        # Add diffusion coefficient
        param.add(f'D_{k+1}', value=diffc, min=minD, max=maxD)

        # Bounds for f
        if rel_f:
            minf = difff - f_bds
            maxf = difff + f_bds
        else:
            minf = min(f_bds)
            maxf = max(f_bds)
        # Add fraction
        param.add(f'f_{k+1}', value=difff, min=minf, max=maxf)

    @cron
    def start_fit():
        # We need the first residual to match what lmfit thinks f_tol is
        with open(os.devnull, 'w') as sys.stdout:
            first_residual = np.sum(f2min(param, x, y, model, model_args)**2)
            # Reset the iteration counter
            param['count'].set(value=0)
        # Redirect output to stdout
        sys.stdout = sys.__stdout__

        # Use nelder because normally the guess is very good, hence leastsq might go crazy
        minner = lmfit.Minimizer(f2min, param, fcn_args=(x, y, model, model_args, first_residual))
        result = minner.minimize(method='nelder', max_nfev=10000)
        print(f'\n{result.message}\nNumber of function evaluations: {result.nfev}')
        return result
    result = start_fit()

    # Get the fitted parameters
    popt = result.params
    # Normalize the fractions to make them add up to 1
    diff_f_opt = [popt[f'f_{k+1}'].value for k in range(Np)]
    diff_f_norm, I_corr = misc.molfrac(diff_f_opt)
    # Make the output dictionary
    dic_result = {
            'diff_c': [popt[f'D_{k+1}'].value for k in range(Np)],
            'diff_e': [popt[f'D_{k+1}'].stderr for k in range(Np)],
            'diff_f': [float(difff) for difff in diff_f_norm],
            'I': float(popt['I'].value * I_corr),      # ofc it must take the correction by the fractions into account
            'q': popt['q'].value,
            }
    return dic_result


def fit_dosy_multi(x, y, iguess, model, model_args, d_bds=3, f_bds=[0, 3], vary_I=True, vary_q=False):
    """
    Perform a fit of a DOSY profile using the specified ``model``.

    Parameters
    ----------
    x : 1darray
        Independent variable, typically the gradient strength in T/m
    y : 1darray
        Experimental data
    iguess : dict
        Initial guess for the fit, as generated by :func:`klassez.fit.make_iguess_dosy_panel`
    model : callable
        Model function. Signature:

    model_args : dict of kwargs
        Additional arguments to model
    d_bds : float or list
        Bounds for the diffusion coefficient.
        If it is a single ``float``, the bounds will be set to ``-d_bds`` and ``+d_bds``
        orders of magnitude with respect to the initial guess. E.g.: if ``diffc = 1e-10`` and
        ``d_bds = 2``, the bounds will be ``[1e-12, 1e-8]``.
        If it is a list of two ``float``s, the bounds will be set to ``-d_bds[0]` and ``+d_bds[1]``
        orders of magnitude with respect to the initial guess. E.g.: if ``diffc = 1e-10`` and
        ``d_bds = [1, 3]``, the bounds will be ``[1e-11, 1e-7]``
    f_bds : float or list
        Bounds for the fraction of component.
        If it is a single ``float``, the bounds will be set to ``-f_bds`` and ``+f_bds``
        with respect to the initial fraction. E.g.: if ``difff = 0.5`` and ``f_bds = 0.3``,
        the bounds will be ``[0.2, 0.8]``
        If it is a list of two ``float``s, the bounds will be set to ``f_bds[0]` and ``f_bds[1]``,
        regardless of what the initial fraction is.
    vary_I : bool
        Include the computation of the intensity factor for each interval. If ``False``, all the intensity differences
        will be handled by the fractions parameters.
    vary_q : bool
        Include the computation of the offsets in the parameters. **Strongly discouraged!**
        If ``False``, the initial value read from the initial guess is kept thoughout the whole fitting.

    Returns
    -------
    dic_result : dict
        Dictionary of optimized parameters, with the same format and shape of ``iguess``.

    .. seealso::

        :func:`klassez.fit.make_iguess_dosy`

        :func:`klassez.fit.make_iguess_dosy_panel`

    """
    def f2min(param, x, yy, model, model_args, first_residual=1, calc_I=True, calc_q=False, show_result=False):
        """ Cost function for the fit """
        # Increase the iteration counter
        param['count'].value += 1
        # Unpack the Parameters object into a normal dictionary
        par = param.valuesdict()

        # Make diffusion coefficient and fractions as lists
        diff_c = np.asarray([par[f'D_{k+1}'] for k in range(Nc)])
        diff_f = np.asarray([[par[f'f_{k+1}_p{p}'] for k in range(Nc)] for p in range(Np)])

        # Compute the models
        yyc = np.array([
                [diff_f[p, k] * model(x, diff_c[k], k=p, **model_args)
                 for k in range(Nc)]
                for p in range(Np)
                ])

        # Sum them to get the total trace
        totals = np.sum(yyc, axis=1)
        if calc_I and calc_q:
            # Compute them both analytically
            for p in range(Np):
                I, q = fit.fit_int(yy[p], totals[p], calc_q)
                param[f'I_p{p}'].set(value=float(I))
                param[f'q_p{p}'].set(value=float(q))
        elif calc_I and not calc_q:
            # Compute only the I leaving the q fixed
            for p in range(Np):
                I, _ = fit.fit_int(yy[p]-par[f'q_p{p}'], totals[p], calc_q)
                param[f'I_p{p}'].set(value=float(I))
        else:
            # Both I and q stay fixed
            pass

        # Apply I and q
        for p in range(Np):
            totals[p] *= param[f'I_p{p}'].value
            totals[p] += param[f'q_p{p}'].value

        y = np.concatenate(yy, axis=-1)
        total = np.concatenate(totals, axis=-1)

        # Compute the residuals
        residual = y - total

        print(f'Step: {par["count"]:6.0f} | Target: {np.sum(residual**2)/first_residual:10.5e}', end='\r')
        return residual

    # Number of planes, number of components
    Np, Nc = np.asarray(iguess['diff_f']).shape
    # If bounds are single numbers, make them lists in order to do
    # what the docstring tells they do
    if isinstance(d_bds, (int, float)):
        d_bds = [d_bds, d_bds]
    if isinstance(f_bds, (int, float)):
        rel_f = True    # f_bds are relative to the value of f
    else:
        rel_f = False   # or not

    # Initialize the Parameters object
    param = lmfit.Parameters()
    # Common parameters to all components
    #   Intensity factor - adjusted by the single fractions
    for p in range(Np):
        param.add(f'I_p{p}', value=iguess['I'][p], vary=False)
        #   Offset (normally 0 and do not move it)
        param.add(f'q_p{p}', value=iguess['q'][p], vary=vary_q)

    #   Iteration counter
    param.add('count', value=0, vary=False)
    # Component-dependent parameters
    for k, diffc in enumerate(iguess['diff_c']):
        # Bounds for D
        oom = np.log10(diffc)       # order of magnitude of D
        minD = diffc - 10**(oom-d_bds[0])
        maxD = diffc + 10**(oom+d_bds[1])
        # Add diffusion coefficient
        param.add(f'D_{k+1}', value=diffc, min=minD, max=maxD)

    for p in range(Np):
        for k, difff in enumerate(iguess['diff_f'][p]):
            # Bounds for f
            if rel_f:
                minf = difff - f_bds
                maxf = difff + f_bds
            else:
                minf = min(f_bds)
                maxf = max(f_bds)
            # Add fraction
            param.add(f'f_{k+1}_p{p}', value=difff, min=minf, max=maxf)
            if Nc == 1 and vary_I:
                param[f'f_{k+1}_p{p}'].set(vary=False)

    @cron
    def start_fit():
        # We need the first residual to match what lmfit thinks f_tol is
        with open(os.devnull, 'w') as sys.stdout:
            first_residual = np.sum(f2min(param, x, y, model, model_args, calc_I=vary_I, calc_q=vary_q)**2)
            # Reset the iteration counter
            param['count'].set(value=0)
        # Redirect output to stdout
        sys.stdout = sys.__stdout__

        # Use nelder because normally the guess is very good, hence leastsq might go crazy
        minner = lmfit.Minimizer(f2min, param, fcn_args=(x, y, model, model_args, first_residual, vary_I, vary_q))
        result = minner.minimize(method='nelder', max_nfev=20000)
        print(f'\n{result.message}\nNumber of function evaluations: {result.nfev}')
        return result
    result = start_fit()

    # Get the fitted parameters
    popt = result.params

    dic_result = []     # placeholder
    for p in range(Np):
        # Normalize the fractions to make them add up to 1
        diff_f_opt = [popt[f'f_{k+1}_p{p}'].value for k in range(Nc)]
        diff_f_norm, I_corr = misc.molfrac(diff_f_opt)
        # Make the output dictionary
        dic_result.append({
                'diff_c': [popt[f'D_{k+1}'].value for k in range(Nc)],
                'diff_e': [popt[f'D_{k+1}'].stderr for k in range(Nc)],
                'diff_f': [float(difff) for difff in diff_f_norm],
                'I': float(popt[f'I_p{p}'].value * I_corr),      # ofc it must take the correction by the fractions into account
                'q': popt[f'q_p{p}'].value,
                })
    return dic_result


def plot_fit_dosy(x, label, y, total, yc, region, show_total=True, show_res=False, res_offset=0, filename=None, ext='png', dpi=600, dim=None):
    """
    Make a plot of a DOSY fit.

    Parameters
    ----------
    x : 1darray
        Independent variable, normally the gradient strength in T/m
    label : str
        This will appear as figure title
    y : 1darray
        Experimental data
    total : 1darray
        Total fit trace
    yc : list of 1darray or 2darray
        Component traces
    region : dict
        Dictionary that contains the fitting values. Used to show the legend entries.
    show_total : bool
        Show the total trace (i.e. sum of all the components) or not
    show_res : bool
        Show the plot of the residuals
    res_offset : float
        Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental data.
        ``res_offset`` > 0 will move the residuals BELOW the zero-line!
    filename : str
        Filename of the figure that will be saved.
    ext : str
        Format of the saved figures
    dpi : int
        Resolution of the figures, in dots per inches
    dim : tuple
        Size of the figure in inches (length, height)
    """

    # Make the figure panel
    fig = plt.figure()
    if dim is None:
        fig.set_size_inches(figures.figsize_large)
    else:
        fig.set_size_inches(dim)
    plt.subplots_adjust(left=0.10, bottom=0.10, top=0.90, right=0.95)
    ax = fig.add_subplot()

    # Plots
    #   Experimental data
    ax.plot(x, y, '.', ms=10, c='k', label='Experimental')
    #   Total trace
    if show_total is True:
        ax.plot(x, total, c='b', lw=1.5, label='Total Fit')
    #   Components
    for k, y_c in enumerate(yc):
        if region['diff_e'][k] is None:
            error = ''
        else:
            error = r' $\pm$ ' + f'{region["diff_e"][k]:.5e}'
        legend_entry = '\n'.join([
            f'Component {k+1} ({region["diff_f"][k]*100:.2f}%)',
            f'D = {region["diff_c"][k]:.5e}' + error + r' m$^2$/s',
            ])
        ax.plot(x, y_c, '--', lw=1.0, label=legend_entry)
    #   Residuals
    if show_res is True:    # Plot the residuals
        # Compute the absolute value of the offset
        maxy = np.max(np.concatenate([y, total], axis=0))
        miny = np.min(np.concatenate([y, total], axis=0))
        r_off = res_offset * (maxy - miny)
        # actual plot
        ax.plot(x, y - total - r_off, c='g', ls=':', lw=0.8, label='Residuals')

    # Fancy shit
    #   title and axes labels
    ax.set_title(f'{label}')
    ax.set_xlabel(r'Gradient /T m$^{-1}$')
    ax.set_ylabel('Intensity /a.u.')

    #   ticks and co.
    misc.pretty_scale(ax, ax.get_xlim(), 'x')
    misc.pretty_scale(ax, ax.get_ylim(), 'y')
    misc.mathformat(ax)

    #   legend
    ax.legend()
    misc.set_fontsizes(ax, 20)

    # Show/save the figure
    if filename is None:
        plt.show()
    else:
        plt.savefig(f'{filename}.{ext}', dpi=dpi)
    plt.close()


def plot_fit_dosy_multi(x, yy, totals, components, region, bigdeltas=None, colors=None, filename=None, ext='png', dpi=300, dim=None):
    """
    Makes a cumulative plot of a DOSY fit performed with the :class:`klassez.fit.DosyFit_pp3D` class.

    Parameters
    ----------
    x : 1darray
        Gradients in T/m
    yy : 2darray
        Experimental data. Dimension: ``(plane, integrals)``
    totals : 2darray
        Fitted trends.


        """

    # Default figure dimension
    if dim is None:
        dim = 16, 9
    # Number of planes
    Np = yy.shape[0]

    # Get the diffusion coefficients and their errors from the region dict
    diff_c = list(region['diff_c'])
    diff_e = list(region['diff_e'])

    # Make the figure
    fig = plt.figure()
    fig.set_size_inches(dim)
    plt.subplots_adjust(left=0.05, right=0.975)
    # One subplot per plane, top row
    axs = [fig.add_subplot(2, Np, w+1) for w in range(Np)]
    # Unique subplot that spans the whole bottom row
    axt = fig.add_subplot(2, Np, (Np+1, 2*Np+1))

    # Labels for the subplots that will appear in the legends
    if bigdeltas is None:
        titles = [f'Plane {k+1}' for k in range(Np)]
    else:
        titles = [r'$\Delta = $' + f'{bigdelta*1e3:.4g}' + ' ms' for bigdelta in bigdeltas]

    # Handle the colors
    if colors is None:
        colors = COLORS
    else:
        # Check if you have enough colors for the rendering
        assert len(colors) >= yy.shape[0], f'You need at least {yy.shape[0]} colors!'

    # Bottom panel first (easier)
    for y, yc, c, label in zip(yy, totals, colors, titles):
        # Plot the experimental trends
        yplot, = axt.plot(x, y, '.', c=c)
        # Plot the fit line with the same color
        axt.plot(x, yc, c=c, label=label)

    # Write the value of the diffusion coefficient as the title of the legend
    legend_title = '\n'.join([
        'Diffusion coefficients:',
        *[misc.expformat(diffc) + r'$ \pm $' +
          misc.expformat(diffe) if diffe else '' +
          r' m$^2$ s$^{-1}$'
          for diffc, diffe in zip(diff_c, diff_e)],
        ])

    # Fancy stuff
    axt.set_xlabel(r'Gradients /T m$^{-1}$')
    axt.set_ylabel(r'Intensity /a.u.')
    misc.pretty_scale(axt, axt.get_xlim(), 'x')
    misc.pretty_scale(axt, axt.get_ylim(), 'y')
    misc.mathformat(axt)
    misc.set_fontsizes(axt, 16)
    axt.legend(title=legend_title, fontsize=12, title_fontsize=14)

    # Now the top row
    axs[0].set_ylabel('Intensity /a.u.')    # label of the y axis only for the first subplot

    for ax, y, yc, comps, c, label in zip(axs, yy, totals, components, colors, titles):
        # Experimentals as black dots
        ax.plot(x, y, 'k.')
        # Fit line with the same color they have in the bottom panel
        ax.plot(x, yc, c=c, label=label)
        # Draw the components ONLY if there are more than one
        if comps.shape[0] > 1:
            for comp in comps:
                ax.plot(x, comp, c=c, ls='--', lw=0.8)

        # Fancy stuff
        ax.set_xlabel(r'/T m$^{-1}$')
        ax.legend()
        misc.pretty_scale(ax, ax.get_xlim(), 'x', 4)
        misc.pretty_scale(ax, ax.get_ylim(), 'y', 6)
        misc.mathformat(ax)
        misc.set_fontsizes(ax, 16)

    # Cut the white spaces as much as possible, it is a very big and complicated figure
    fig.tight_layout()
    # save/plot the figure
    if filename is None:
        plt.show()
    else:
        plt.savefig(f'{filename}.{ext}', dpi=dpi)
    plt.close()


class DosyFit:
    """
    Class to fit a DOSY spectrum.

    Attributes
    ----------
    filename : str
        Name for files, figures, and so on
    g : 1darray
        Gradient strength in T/m
    dosy_par : dict
        Dictionary of dosy parameters.
        ::

            dosy_par = {
                    'gamma' : # gyromagnetic ratio in MHz/T,
                    'D'     : # big delta in seconds,
                    'd'     : # little delta in seconds,
                    'tau'   : # tau in seconds,
                    'p90'    : # 90° pulse duration in seconds,
                    }

    keys : list of str
        Identifiers for the profiles to fit
    data : dict of 1darray
        ``data = {key : profile (1darray)  for key in self.keys}``
    i_guess : list of dict
        Dictionary of the initial guess, generated by ``self.iguess``.
        ::

            i_guess = [{
                'I' : # intensity factor (float),
                'q' : # offset (float),
                'diff_c' : # diffusion coefficients (1darray),
                'diff_f' : # fractions (1darray)
                'diff_e' : # fit errors for the diffusion coefficients (2darray),
                } for _ in self.keys]

    result : list of dict
        Dictionary of fit results, generated by either ``self.dofit`` or ``self.read_fit``.
        Same structure and shape of ``self.i_guess``
    """
    def __init__(self, s, pprog='stebp', difflist=None, input_data=None, filename=None):
        """
        Initialize the fitting interface using the DOSY spectrum as input.

        Parameters
        ----------
        s : klassez.DOSY object
            DOSY spectrum. You must have either integrated or fitted it to get the profiles.
        pprog : str
            Pulse sequence used for the acquisition. Modifies the fitting model and the parameters
            to be read on the basis. It can be read from the ``acqus`` dictionary, but in general there
            are two important parameters: if it is single or double stimulated echo (``ste``/``dste``),
            and if it uses bipolar gradients (``bp``) or not.
            Hence, these can be ``ste``, ``stebp``, ``dste``, ``dstebp``. However, something like
            ``xxxdsteyy23ybp47`` also works.
        difflist : 1darray or None
            List of the gradients strength, in Gauss/cm (as the instrument gives them).
            If ``None``, it reads the `difflist` file that should be in ``s.datadir``
        input_data : dict or None
            Dictionary returned by :func:`klassez.anal.integrate_p2D`. If ``None``,
            it will try to read ``s.integrals``.
            The reading from a fit (i.e. Voigt_Fit_p2D) has not been implemented yet.
        filename : str or None
            root filename for all the figures and files that will be generated.
            If ``None``, ``s.filename`` is used.
        """
        # Filename
        if filename is None:
            self.filename = s.filename

        dste, bp = self.parse_pprog(pprog)

        # Reads the parameters from the original spectrum,
        # instances the attributes g and dosy_par
        self.fetch_dosy_par(s, difflist, bp)

        self.select_model(dste, bp)

        # Get the data
        if input_data is not None:
            # Will store self.keys as well
            self.data = input_data
        else:
            # Try to read the integrals from s
            if hasattr(s, 'integrals'):
                if len(s.integrals.keys()) == 0:
                    raise ValueError('No integrals detected')
                self.data = s.integrals
            # Try to read the fit TODO NOTIMPLEMENTED)
            elif hasattr(s.F, 'result'):
                self.data = s.F.result
            else:
                raise ValueError('Neither integrals nor fit were detected in the input DOSY spectrum.')

    @staticmethod
    def parse_pprog(pprog):
        """
        Tries to understand from the name of the pulse program if the experiment was acquired with single or double stimulated echoes, and with or without bipolar gradients.

        Parameters
        ----------
        pprog : str
            Name of the pulse program

        Returns
        -------
        dste : bool
            Double (``True``) or single (``False``) stimulated echo
        bp : bool
            Bipolar gradients (``True``) or without (``False``)
        """
        pprog = pprog.lower()
        if 'dste' in pprog:
            dste = True         # double stimulated echo
        else:
            dste = False        # just stimulated echo
        if 'bp' in pprog:
            bp = True           # bipolar gradients
        else:
            bp = False          # no bipolar gradients
        return dste, bp

    def iguess(self, filename=None, ext='idy', diff_c_0=1e-10):
        """
        Either makes or reads an initial guess file for the fit.
        The resulting dictionary will be saved in ``self.i_guess``

        Parameters
        ----------
        filename : str
            Will try to read ``<filename>.<ext>``. If it does not exist, will write in ``<filename>.idy``
        ext : str
            Extension for the file, either `idy` or `fdy`
        diff_c_0 : float
            Initial default value for the diffusion coefficient.

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.fit.make_iguess_dosy`

            :func:`klassez.fit.write_dy`

            :func:`klassez.fit.read_dy`
        """
        if filename is None:
            filename = f'{self.filename}'
        path = Path(f'{filename}')
        if not path.with_suffix(f'.{ext}').exists():       # Read everything you need from the file
            ext = 'idy'
            fit.make_iguess_dosy(self.g, labels=self.keys, data=self._data,
                                 model=self.model, model_args=self.dosy_par,
                                 diff_c_0=diff_c_0, filename=path)
        path_x = path.with_suffix(f'.{ext}')
        # Store it
        self.i_guess = fit.read_dy(path_x)
        print(f'{path_x} loaded as input file.\n', c='tab:blue')

    def dofit(self, filename=None, d_bds=3, f_bds=[0, 3], vary_q=False):
        """
        Performs the fit of the profiles. Saves a `.fdy` file and stores the results
        in the attribute ``self.result``.

        Parameters
        ----------
        filename : str or None
            File where to save the results of the fit: ``<filename>.fdy``
        d_bds : float or list
            See :func:`klassez.fit.fit_dosy`
        f_bds : float or list
            See :func:`klassez.fit.fit_dosy`
        vary_q : bool
            Include the offset in the fit **strongly discouraged**

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.fit_dosy`

            :func:`klassez.fit.write_dy`
        """

        if filename is None:
            filename = f'{self.filename}'
        path = Path(filename).with_suffix('.fdy')
        # Check if the file exists
        self.result = []

        f = path.open('a', buffering=1)
        # Info on the region to be fitted
        now = datetime.now()
        date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
        f.write('! DOSY fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

        for k, label in enumerate(self.keys):
            print(f'Fitting region {label} [ # {k+1} of {len(self.keys)}]', c='tab:orange')
            dic_result = fit.fit_dosy(self.g, self._data[k], self.i_guess[k],
                                      self.model, self.dosy_par,
                                      d_bds=d_bds, f_bds=f_bds, vary_q=vary_q)
            fit.write_dy(path, dic_result['diff_c'], dic_result['diff_f'], dic_result['diff_e'],
                         label, dic_result['I'], dic_result['q'])
            dic_result['label'] = label
            self.result.append(dic_result)
        print(f'{path} saved.\n', c='tab:blue')

    def load_fit(self, filename=None, n=-1, ext='fdy'):
        """
        Reads a file with ``fit.read_dy`` and stores the result in ``self.result``.

        Parameters
        ----------
        filename: str
            Path to the .fdy file to be read. If None, "<self.filename>.fdy" is used.
        n: int
            Index of the fit to be read (default: last one)
        ext: str
            Extension of the file to be used

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.make_iguess_dosy`

            :func:`klassez.fit.make_iguess_dosy_panel`

            :func:`klassez.fit.fit_dosy`

            :func:`klassez.fit.read_dy`
        """
        # Set the default filename, if not given
        if filename is None:
            filename = f'{self.filename}'
        # Check if the file exists
        path_x = Path(filename).with_suffix(f'.{ext}')
        if path_x.exists():       # Read everything you need from the file
            regions = fit.read_dy(path_x, n=n)
        else:
            raise FileNotFoundError(f'{path_x} does not exist.')
        # Store
        self.result = regions
        print(f'{path_x} loaded as fit result file.\n', c='tab:blue')

    def plot(self, what='result', show_res=False, res_offset=0, filename=None, ext='png', dpi=600, dim=None):
        """
        Plots either the initial guess or the result of the fit, and saves all the figures. Calls :func:`fit.plot_fit_dosy`.
        The figures will be saved in the directory `Figures_<filename>/<what>/<label>.png`.

        Parameters
        ----------
        what : str
            'iguess' to plot the initial guess, 'result' to plot the fitted data
        show_res : bool
            Show the plot of the residuals
        res_offset : float
            Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental data.
            ``res_offset`` > 0 will move the residuals BELOW the zero-line!
        filename : str
            Determines the name of the directory where the figures will be saved. If None, `<self.filename>` is used
        ext : str
            Format of the saved figures
        dpi : int
            Resolution of the figures, in dots per inches
        dim : tuple
            Dimension of the figure in inches

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.plot_fit_dosy`
        """
        # select the correct object to plot
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Set the filename, if not given
        if filename is None:
            filename = f'{self.filename}'

        # Make the directories
        figure_path = Path(f'Figures_{filename}') / f'{what}'
        figure_path.mkdir(exist_ok=True, parents=True)

        # Make the figures
        totals, components = self.get_fit_lines(what)
        print(f'Saving figures in {figure_path}...', c='tab:cyan')
        for k, (y, total, yc, region) in enumerate(zip(self._data, totals, components, regions)):
            print(f'{k+1}/{len(totals)}', end='\r')
            label = region['label']
            # Figures will be "Figures_{filename}/{what}/{label}.{ext}
            fit.plot_fit_dosy(self.g, label, y, total, yc, region,
                              show_res=show_res, res_offset=res_offset,
                              filename=figure_path/label, ext=ext, dpi=dpi, dim=dim)
        print('Done.\n', c='tab:cyan')

    def get_fit_lines(self, what='result'):
        """
        Calculates the components, and the total fit curve used as initial guess, or as fit results.

        Parameters
        ----------
        what : str
            ``'iguess'`` or ``'result'``

        Returns
        -------
        totals : list of 1darray
            Sum of all the signals, per region
        components : list of 2darray
            Components fitted for each region.
            Note that regions with only one component will be 2darrays anyways.
        """
        def calc_f(x, diffc, difff, A, q):
            """ Model for a single component """
            # Compute the model and multiply it by its fraction
            f = A * difff * self.model(x, diffc, **self.dosy_par) + q
            return f

        def calc_t(x, diff_c, diff_f, A, q):
            """ Compute all the components """
            # Loop over calc_f using all the values
            yc = [calc_f(x, diffc, difff, A, q) for diffc, difff in zip(diff_c, diff_f)]
            # The total trace is the sum of all the components
            t = np.sum(yc, axis=0)
            return t, yc

        # Discriminate if you want to calculate the initial guess or the result
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # placeholders
        totals, components = [], []
        # Loop inside either iguess or result
        for region in regions:
            # Compute the totals and the components
            t, yc = calc_t(self.g, region['diff_c'], region['diff_f'], region['I'], region['q'])
            # Update the placeholders
            totals.append(t)
            components.append(np.array(yc))

        return totals, components

    @property
    def data(self) -> dict:
        """ Each time you call for it you will get a dictionary with the labels for each profile """
        return {key: self._data[k] for k, key in enumerate(self.keys)}

    @data.setter
    def data(self, input_data: (list, dict)):
        """ Get the data from the integrals (dict) or from the fit (list). ``_data`` is the 2darray! """
        if isinstance(input_data, dict):
            self.keys, self._data = self.data_from_integrals(input_data)
        else:
            self.keys, self._data = self.data_from_vf(input_data)

    @staticmethod
    def data_from_integrals(input_data):
        """ Fetch data from the integrals dictionary """
        keys = list(input_data.keys())
        data = np.array([input_data[key] for key in keys])
        return keys, data

    @staticmethod
    def data_from_vf(input_data):
        """ Fetch data from the fit list """
        raise NotImplementedError('WIP')

    def fetch_dosy_par(self, s, difflist=None, bp=True):
        """
        Reads the acquisition parameters to get the DOSY parameters.

        Creates the ``self.dosy_par`` attribute.

        Parameters
        ----------
        s : DOSY object
            Input spectrum that contains the ``ngdic`` attribute
        difflist : 1darray or None
            Gradient list in Gauss/cm, if existing. If ``None``, it will
            be read as well from ``<s.datadir>/difflist``
        bp : bool
            True if the sequence uses bipolar gradients, False if it does not.
            It is needed because in the former case the little delta is ``2 * p30``
        """
        #   Gradient list
        if difflist is None:
            difflist = np.loadtxt(s.datadir / 'difflist')
        # difflist is in G/cm -> we need T/m
        self.g = difflist * 1e-2

        # Bipolar gradient sequences have littledelta = 2 * p30
        if bp:
            multi_p30 = 2
        else:
            multi_p30 = 1

        # Reading the parameter from acqus
        d20 = s.ngdic['acqus']['D'][20]     # s
        d16 = s.ngdic['acqus']['D'][16]     # s
        p30 = s.ngdic['acqus']['P'][30]     # us
        p1 = s.ngdic['acqus']['P'][1]       # us

        self.dosy_par = {
                'gamma': sim.gamma[f'{s.acqus["nuc"]}'],    # MHz/T
                'D': d20,                                   # big delta /s
                'd': p30 * multi_p30 * 1e-6,                # little delta /s
                'tau': d16,                                 # tau /s
                'p90': p1 * 1e-6,                           # 90degree pulse /s
                }

    def select_model(self, dste, bp):
        """
        Select the model on the following scheme:

        -   ``dste = False`` and ``bp = False``: :func:`klassez.fit.model_ste`
        -   ``dste = False`` and ``bp = True``: :func:`klassez.fit.model_stebp`
        -   ``dste = True`` and ``bp = False``: :func:`klassez.fit.model_dste`
        -   ``dste = True`` and ``bp = True``: :func:`klassez.fit.model_dstebp`

        The selected function will be stored in ``self.model``.

        Parameters
        ----------
        dste : bool
            Single (``False``) or double (``True``) stimulated echo
        bp : bool
            Uses bipolar gradients (``True``) or not (``False``).
        """
        if dste and bp:
            self.model = fit.model_dstebp
        elif dste and not bp:
            self.model = fit.model_dste
        elif not dste and bp:
            self.model = fit.model_stebp
        elif not dste and not bp:
            self.model = fit.model_ste
        else:
            raise NameError('Unrecognized model')


@safe_kws
def model_ste(g, diff_c, gamma, D, d):
    r"""
    Model for Stimulated Echo.
    Basic Stejskal-Tanner equation.

    .. math::

        y(g) = \exp \{ - D (2 \pi \gamma g \delta)^2 \, (\Delta - \delta / 3) \}


    Parameters
    ----------
    g : 1darray
        Gradient strength T/m
    diff_c : float
        Diffusion coefficient m^2/s
    gamma : float
        Gyromagnetic ratio MHz/T
    D : float
        Big delta seconds
    d : float
        Little delta seconds

    Returns
    -------
    y : 1darray
        Computed model
    """
    # 1e6 is to convert MHz -> Hz for the gamma
    A = 2 * np.pi * gamma * d * 1e6
    B = (D - d/3)
    arg = - diff_c * g**2 * A**2 * B
    y = np.exp(arg)
    return y


@safe_kws
def model_stebp(g, diff_c, gamma, D, d, tau, p90):
    r"""
    Model for Stimulated Echo with Bipolar Gradients.
    Stejskal-Tanner equation modified by Jerschaw and Müller.

    .. math::

        y(g) = \exp \{ - D (2 \pi \gamma g \delta)^2 \, (\Delta - \delta / 3 - \tau - 4p_{90}) \}


    Parameters
    ----------
    g : 1darray
        Gradient strength T/m
    diff_c : float
        Diffusion coefficient m^2/s
    gamma : float
        Gyromagnetic ratio MHz/T
    D : float
        Big delta seconds
    d : float
        Little delta seconds
    tau : float
        Tau seconds
    p90 : float
        90 degree pulse seconds

    Returns
    -------
    y : 1darray
        Computed model
    """
    # 1e6 is to convert MHz -> Hz for the gamma
    A = 2 * np.pi * gamma * d * 1e6
    B = (D - d/3 - tau/2 - 4*p90)
    arg = - diff_c * g**2 * A**2 * B
    y = np.exp(arg)
    return y


@safe_kws
def model_dste(g, diff_c, gamma, D, d, tau, p90):
    r"""
    Model for Double Stimulated Echo.
    Stejskal-Tanner equation modified by Jerschaw and Müller.

    .. math::

        y(g) = \exp \{ - D (2 \pi \gamma g \delta)^2 \, (\Delta - 5 \delta / 3 - \tau - 4 p_{90}) \}


    Parameters
    ----------
    g : 1darray
        Gradient strength T/m
    diff_c : float
        Diffusion coefficient m^2/s
    gamma : float
        Gyromagnetic ratio MHz/T
    D : float
        Big delta seconds
    d : float
        Little delta seconds
    tau : float
        Tau seconds
    p90 : float
        90 degree pulse seconds

    Returns
    -------
    y : 1darray
        Computed model
    """
    # 1e6 is to convert MHz -> Hz for the gamma
    A = 2 * np.pi * gamma * d * 1e6
    B = (D - 5 * d/3 - tau - 4*p90)
    arg = - diff_c * g**2 * A**2 * B
    y = np.exp(arg)
    return y


@safe_kws
def model_dstebp(g, diff_c, gamma, D, d, tau, p90):
    r"""
    Model for Double Stimulated Echo with Bipolar Gradients.
    Stejskal-Tanner equation modified by Jerschaw and Müller.

    .. math::

        y(g) = \exp \{ - D (2 \pi \gamma g \delta)^2 \, (\Delta - 5 \delta / 3 - 3 \tau - 8 p_{90}) \}


    Parameters
    ----------
    g : 1darray
        Gradient strength T/m
    diff_c : float
        Diffusion coefficient m^2/s
    gamma : float
        Gyromagnetic ratio MHz/T
    D : float
        Big delta seconds
    d : float
        Little delta seconds
    tau : float
        Tau seconds
    p90 : float
        90 degree pulse seconds

    Returns
    -------
    y : 1darray
        Computed model
    """
    # 1e6 is to convert MHz -> Hz for the gamma
    A = 2 * np.pi * gamma * d * 1e6
    B = (D - 5 * d/3 - 3 * tau - 8*p90)
    arg = - diff_c * g**2 * A**2 * B
    y = np.exp(arg)
    return y


class DosyFit_pp3D(fit.DosyFit):
    """
    Interface for the fitting of a :class:`klassez.Spectra.DOSY_T1` object, i.e. a 3D spectrum where the DOSY is acquired along the `31` dimension and the big delta (``d20``) is increased along F2.

    Attributes
    ----------
    datadir : str
        Path where to save files and figures
    filename : str
        Name for files, figures, and so on
    planes : list of :class:`klassez.Spectra.pDOSY` object
        Projection of the original spectrum along the `31` direction
    g : 1darray
        Gradient strength in T/m
    dosy_par : dict
        Dictionary of dosy parameters.
        ::

            dosy_par = {
                    'gamma' : # gyromagnetic ratio in MHz/T,
                    'D'     : # list of big delta in seconds,
                    'd'     : # little delta in seconds,
                    'tau'   : # tau in seconds,
                    'p90'    : # 90° pulse duration in seconds,
                    }

    keys : list of str
        Identifiers for the profiles to fit
    data : dict of 2darray
        ``data = {key : profile for each plane (plane, integrals) for key in self.keys}``
    i_guess : list of dict
        Dictionary of the initial guess, generated by ``self.iguess``.
        ::

            i_guess = [{
                'I' : # intensity factor (1darray, long as planes),
                'q' : # offset (1darray, long as planes),
                'diff_c' : # diffusion coefficients (1darray, long as components),
                'diff_f' : # fractions (2darray, (planes, components))
                'diff_e' : # fit errors for the diffusion coefficients (1darray, long as components),
                } for _ in self.keys]

    result : list of dict
        Dictionary of fit results, generated by either ``self.dofit`` or ``self.read_fit``.
        Same structure and shape of ``self.i_guess``

    """
    def __init__(self, S, datadir=None, filename=None):
        """
        Initialize the class.
        This function will slice ``S`` along the DOSY dimension (`31`) and store the planes in the ``self.planes`` attribute.
        Then, it will try to load the integrals. If these are not found, it will ask you to integrate the first plane, and
        it will compute all the integrals by itself.

        Parameters
        ----------
        S : klassez.Spectra.DOSY_T1 object
            Input dataset, after processing
        datadir : str
            Custom path where to save files and figures. If ``None``, *./``filename``* is created
        filename : str
            Custom filename for files and figures. If ``None``, ``S.filename`` is used.
        """
        # Filename
        if filename is None:    # Same name of the input dataset
            self.filename = S.filename
        else:
            self.filename = filename

        # Datadir
        if datadir is None:     # ./<filename>
            self.datadir = Path.cwd() / self.filename
        else:
            self.datadir = Path(datadir)

        self.datadir.mkdir(exist_ok=True, parents=True)

        # Get the DOSY parameters
        self.fetch_dosy_par(S)

        # Set the model
        dste, bp = self.parse_pprog(S.ngdic['acqus']['PULPROG'])
        self.select_model(dste, bp)

        # Take all the planes in the DOSY dimension (31)
        self.planes = [S.getplane(k, dim='31') for k in range(len(S.x_f2))]

        # Compute the integrals for all the planes
        self.integrate_planes()

    def integrate_planes(self, keys=None, use_bas=False):
        """
        Get/compute the integrals for all the planes.
        Stores the integrals in ``self.data``

        Parameters
        ----------
        keys : list of str
            Keys that identify the regions to integrate. If None, the integration is performed interactively through GUI
        use_bas : bool
            Use the baseline or not.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.key_to_limits`

            :func:`klassez.Spectra.pDOSY.integrate`

        """
        # Cycle through the planes
        for k, plane in enumerate(self.planes):
            # Do the following only if plane does not have the integrals already
            if hasattr(plane, 'integrals'):
                continue
            # Look if there is the integral filename
            integrals_filename = self.datadir / f'{plane.filename}'
            integrals_filename_x = integrals_filename.with_suffix('.igrl')
            if integrals_filename_x.exists():
                # There is --> Read it
                plane.read_integrals(integrals_filename_x)
                if k == 0:  # First plane: save the regions identifiers and the integration limits
                    keys = deepcopy(list(plane.integrals.keys()))
                    lims = misc.key_to_limits(keys)
            else:   # There is not ---> We have to integrate
                # First plane and the keys are not given from outside
                if k == 0 and keys is None:
                    lims = None     # --> GUI
                # First plane and the keys are given, OR, another plane => same keys of the first plane
                elif (k == 0 and keys is not None) or k:
                    lims = misc.key_to_limits(keys)     # transform to limits

            # Compute the integrals
            plane.integrate(filename=integrals_filename, lims=lims, use_bas=use_bas)
            # Overwrite the keys
            keys = deepcopy(list(plane.integrals.keys()))

        # Store the data
        big_dic = {  # Placeholder to save the data
                key: np.array([plane.integrals[key] for plane in self.planes])
                for key in self.planes[0].integrals.keys()
                }
        self.data = big_dic

    def fetch_dosy_par(self, s, bp=True):
        """
        Reads the acquisition parameters to get the DOSY parameters.

        Creates the ``self.dosy_par`` attribute.

        Parameters
        ----------
        s : DOSY object
            Input spectrum that contains the ``ngdic`` attribute
        bp : bool
            True if the sequence uses bipolar gradients, False if it does not.
            It is needed because in the former case the little delta is ``2 * p30``
        """
        # Gradient list
        #   difflist is in G/cm -> we need T/m
        self.g = s.x_f1 * 1e-2

        # Bipolar gradient sequences have littledelta = 2 * p30
        if bp:
            multi_p30 = 2
        else:
            multi_p30 = 1

        # Reading the parameter from acqus
        d16 = s.ngdic['acqus']['D'][16]     # s
        p30 = s.ngdic['acqus']['P'][30]     # us
        p1 = s.ngdic['acqus']['P'][1]       # us

        # Store the dosy parameters
        self.dosy_par = {
                'gamma': sim.gamma[f'{s.acqus["nuc"]}'],    # MHz/T
                'D': s.x_f2,                                # big delta /s
                'd': p30 * multi_p30 * 1e-6,                # little delta /s
                'tau': d16,                                 # tau /s
                'p90': p1 * 1e-6,                           # 90degree pulse /s
                }

    def iguess(self, filename=None, ext='idy', diff_c_0=1e-10, ref=0):
        """
        Either makes or reads an initial guess file for the fit. Operates only on _one_ plane, chosen as reference.
        The resulting dictionary will be saved in ``self.i_guess``

        Parameters
        ----------
        filename : str
            Will try to read ``<filename>.<ext>``. If it does not exist, will write in ``<filename>.idy``
        ext : str
            Extension for the file, either `idy` or `fdy`
        diff_c_0 : float
            Initial default value for the diffusion coefficient.
        ref : int
            Index of the reference plane (python numbering)

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.fit.make_iguess_dosy`

            :func:`klassez.fit.write_dy`

            :func:`klassez.fit.read_dy`
        """
        if filename is None:
            filename = self.datadir / f'{self.planes[ref].filename}'
        else:
            filename = Path(filename)
        # Check if the file exists
        filename_x = filename.with_suffix(f'.{ext}')

        if not filename_x.exists():       # Read everything you need from the file
            filename_x = filename.with_suffix('.idy')
            dosy_par_ref = deepcopy(self.dosy_par)
            dosy_par_ref['D'] = self.dosy_par['D'][ref]
            fit.make_iguess_dosy(self.g, labels=self.keys, data=np.asarray(self._data)[:, ref],
                                 model=self._model, model_args=dosy_par_ref,
                                 diff_c_0=diff_c_0, filename=filename)
        regions = fit.read_dy(filename_x)
        # Store it
        for plane in self.planes:
            plane.D.i_guess = regions
        print(f'{filename_x} loaded as input file.\n', c='tab:blue')

        self.merge_planes('iguess')

    def merge_planes(self, what='iguess'):
        """
        Rewrites either the ``self.i_guess`` or ``self.result`` attribute by merging
        the information relative to each plane.

        Parameters
        ----------
        what : str
            ``'iguess'`` or ``'result'``. Determines which attribute to read and write.
        """
        if what == 'iguess':
            tostore = [
                {
                    'label': label,
                    'I': [plane.D.i_guess[k]['I'] for plane in self.planes],
                    'q': [plane.D.i_guess[k]['q'] for plane in self.planes],
                    'diff_c': self.planes[0].D.i_guess[k]['diff_c'],
                    'diff_f': [plane.D.i_guess[k]['diff_f'] for plane in self.planes],
                    'diff_e': self.planes[0].D.i_guess[k]['diff_e'],
                    }
                for k, label in enumerate(self.keys)
                ]
            self.i_guess = tostore
        else:
            tostore = [
                {
                    'label': label,
                    'I': [plane.D.result[k]['I'] for plane in self.planes],
                    'q': [plane.D.result[k]['q'] for plane in self.planes],
                    'diff_c': self.planes[0].D.result[k]['diff_c'],
                    'diff_f': [plane.D.result[k]['diff_f'] for plane in self.planes],
                    'diff_e': self.planes[0].D.result[k]['diff_e'],
                    }
                for k, label in enumerate(self.keys)
                ]
            self.result = tostore

    def dofit(self, seq=False, filename=None, d_bds=3, f_bds=[0, 3], vary_q=False):
        """
        Performs the fit of the profiles. Saves a `.fdy` file and stores the results
        in the attribute ``self.result``.

        Parameters
        ----------
        seq : bool
            If ``False``, the fit is performed by forcing the diffusion coefficient to be the same across the same region in different planes.
            If ``True``, the diffusion coefficient can vary, and the fit is performed using the internal fit method of the planes objects.
        filename : str or None
            File where to save the results of the fit: ``<filename>.fdy``
        d_bds : float or list
            See :func:`klassez.fit.fit_dosy`
        f_bds : float or list
            See :func:`klassez.fit.fit_dosy`
        vary_q : bool
            Include the offset in the fit **strongly discouraged**

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.fit_dosy`

            :func:`klassez.fit.write_dy`
        """
        if filename is None:
            filenames = [self.datadir / f'{plane.filename}' for plane in self.planes]
        else:
            filenames = [Path(f'{filename}_{k}') for k in range(len(self.planes))]

        if seq:
            for fn, plane in zip(filenames, self.planes):
                plane.D.dofit(filename=filename)
        else:
            dic_results = []
            for k, label in enumerate(self.keys):
                print(f'Fitting region {label} [ # {k+1} of {len(self.keys)}]', c='tab:orange')
                dic_result = fit.fit_dosy_multi(self.g, self.data[label], self.i_guess[k],
                                                self.model, self.dosy_par,
                                                d_bds=d_bds, f_bds=f_bds, vary_q=vary_q)
                for p, _ in enumerate(self.planes):
                    dic_result[p]['label'] = label
                dic_results.append(dic_result)

            for p, plane in enumerate(self.planes):
                to_save = [dic_results[w][p] for w, _ in enumerate(self.keys)]
                plane.D.result = to_save

                f = filenames[p].with_suffix('.fdy').open('a', buffering=1)
                # Info on the region to be fitted
                now = datetime.now()
                date_and_time = now.strftime("%d/%m/%Y at %H:%M:%S")
                f.write('! DOSY fit performed by {} on {}\n\n'.format(getpass.getuser(), date_and_time))

                for k, result in enumerate(plane.D.result):
                    fit.write_dy(filenames[p].with_suffix('.fdy'), result['diff_c'], result['diff_f'],
                                 result['diff_e'], label, result['I'], result['q'])
                f.close()

        self.merge_planes('result')

    def load_fit(self, filename=None, ext='fdy'):
        """
        Reads the output of an already performed fit by reading the files
        `<plane.filename>.<ext>` for all the planes, reorganizes them, and stores
        the outcome in ``self.result``

        Parameters
        ----------
        ext : str
            ``'fdy'`` or ``'idy'``

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.fit.read_dy`
        """
        if filename is None:
            filenames = [self.datadir / f'{plane.filename}' for plane in self.planes]
        else:
            filenames = [Path(f'{filename}_{k}') for k in range(len(self.planes))]

        for plane, fn in zip(self.planes, filenames):
            regions = fit.read_dy(fn.with_suffix(f'.{ext}'))
            # Store it
            plane.D.result = regions

        self.merge_planes('result')

    def plot(self, what='result', only_all=False, show_res=False, res_offset=0, figdir=None, filename=None, ext='png', dpi=100, dim=None):
        """
        Plots either the initial guess or the result of the fit, and saves all the figures. Calls :func:`fit.plot_fit_dosy`.
        The figures will be saved in the directory `<figdir>/<what>/<label>.png`.

        Parameters
        ----------
        what : str
            'iguess' to plot the initial guess, 'result' to plot the fitted data
        only_all = bool
            Plot only the figure with all the planes together
        show_res : bool
            Show the plot of the residuals
        res_offset : float
            Displacement of the residuals plot from 0, to be given as a fraction of the height of the experimental data.
            ``res_offset`` > 0 will move the residuals BELOW the zero-line!
        figdir : str
            Base path for the figure. This function will create a directory named `<figdir>/<what>` and will save all the plots therein.
            The default is `Figures_<self.filename>`.
        filename : str
            Determines the name of the directory where the figures will be saved. If None, `<self.filename>` is used
        ext : str
            Format of the saved figures
        dpi : int
            Resolution of the figures, in dots per inches
        dim : tuple
            Dimension of the figure in inches

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.plot_fit_dosy`

            :func:`klassez.fit.plot_fit_dosy_multi`
        """
        # select the correct object to plot
        if what == 'iguess':
            regions = deepcopy(self.i_guess)
        elif what == 'result':
            regions = deepcopy(self.result)
        else:
            raise ValueError('Specify what you want to plot: "iguess" or "result"')

        # Use the default figdir
        if figdir is None:
            base = Path(f'Figures_{self.filename}') / what
        else:
            base = Path(figdir) / what

        # Create the directories, if they do not exist
        base.mkdir(exist_ok=True, parents=True)

        print(f'Saving figures in "{base}".', c='tab:cyan')

        # Get the fitted traces
        totals, components = self.get_fit_lines(what)

        for k, (total, comps, region) in enumerate(zip(totals, components, regions)):
            print(f'Saving figures of the {region["label"]} region ({k+1}/{len(regions)})...')
            # Get the experimental data from the one inside
            exp_data = self.data[region['label']]
            # Example: testspectrum-R_2.000:1.641_all
            if filename is None:
                fn = f'{self.filename}-R_{region["label"]}_all'
            else:
                fn = f'{filename}-R_{region["label"]}_all'
            # Make the super giga figure
            fit.plot_fit_dosy_multi(self.g, exp_data, total, comps, region,
                                    bigdeltas=self.dosy_par['D'],
                                    filename=base / fn,
                                    ext=ext, dpi=dpi)

            # Break here
            if only_all:
                continue

            # Make the minifigures
            for p, plane in enumerate(self.planes):
                # Adjust the filename
                if filename is None:
                    # Use filename of the plane itself, which already contains the p
                    fn = plane.filename
                else:
                    # Use the given filename, but add the p
                    fn = f'{filename}_p{p}'
                fn += '-R_' + region['label']
                # Regions contains the I, the q and the f of all planes!
                region_p = {
                        'I': region['I'][p],
                        'q': region['q'][p],
                        'diff_c': region['diff_c'],
                        'diff_e': region['diff_e'],
                        'diff_f': region['diff_f'][p],
                        }

                # Make the minifigure
                fit.plot_fit_dosy(self.g, region['label'], exp_data[p], total[p], comps[p], region_p,
                                  show_res=show_res, res_offset=res_offset,
                                  filename=base/fn,
                                  ext=ext, dpi=dpi, dim=dim)

        print('Done.\n', c='tab:cyan')

    def get_fit_lines(self, what='result'):
        """
        Computes the fit lines using the internal methods of :class:`klassez.fit.DosyFit`, and then collects it together.

        Parameters
        ----------
        what : str
            ``'iguess'`` or ``'result'``

        Returns
        -------
        totals : 3darray
            Collection of the total dosy profiles, per plane. Dimension: ``(number of planes, number of regions, pts)``
        components : 4darray
            Collection of the components that generate the correspondant total trace. Dimension: ``(number of planes, number of regions, number of components, pts)``

        .. seealso::

            :func:`klassez.fit.DosyFit.get_fit_lines`
        """
        # Unpack the Parameters object into a normal dictionary
        if what == 'iguess':
            dic = self.i_guess
        elif what == 'result':
            dic = self.result
        else:
            raise NameError('what must be either "iguess" or "result"')

        Np = self._data.shape[1]    # Number of planes
        # Nr = self._data.shape[0]    # Number of regions

        all_totals, all_components = [], []     # Placeholders
        for r, region in enumerate(dic):
            # Make diffusion coefficient and fractions as lists
            diff_c = np.asarray(region['diff_c'])       # shape: (Np, )
            diff_f = np.asarray(region['diff_f'])       # shape: (Np, Nr)
            Nc = diff_c.shape[-1]

            # Compute the models
            yyc = np.array([
                    [region['q'][p] + region['I'][p] * diff_f[p, k] *
                     self.model(self.g, diff_c[k], k=p, **self.dosy_par)
                     for k in range(Nc)]
                    for p in range(Np)
                    ])

            # Sum them to get the total trace
            totals = np.sum(yyc, axis=1)

            # Apply intensity and offset
            all_totals.append(totals)
            all_components.append(yyc)

        return np.asarray(all_totals), np.asarray(all_components)

    def model(self, g, diff_c, gamma, D, d, tau=0, p90=0, k=None):
        """
        Calls ``self._model``, instanced by :func:`klassez.DosyFit_pp3D.select_model`, with the passed arguments and, if the bigdelta ``D`` is an array, to plane ``k``.

        Parameters
        ----------
        g : 1darray
            Gradient strength T/m
        diff_c : float
            Diffusion coefficient m^2/s
        gamma : float
            Gyromagnetic ratio MHz/T
        D : list or float
            Big delta seconds
        d : float
            Little delta seconds
        tau : float
            Tau seconds
        k : int or None
            Index for the difflist that corresponds to the actual bigdelta.
            If ``None`` and ``D`` is a sequence, all the models will be computed.

        Returns
        -------
        y : 1darray
            Computed model
        """
        # We need a copy to exploit @safe_kws in _model. We pass D explicitly
        in_par = dict(gamma=gamma, d=d, tau=tau, p90=p90)

        if isinstance(D, float):        # D is a number
            y = self._model(g, diff_c, D=D, **in_par)
        elif isinstance(k, int):        # D is a list -> kth entry
            y = self._model(g, diff_c, D=D[k], **in_par)
        else:                           # D is a list -> all entries
            y = np.array([
                self._model(g, diff_c, D=in_D, **in_par)
                for in_D in D])
        return y

    def select_model(self, dste, bp):
        """
        Select the model on the following scheme:

        -   ``dste = False`` and ``bp = False``: :func:`klassez.fit.model_ste`
        -   ``dste = False`` and ``bp = True``: :func:`klassez.fit.model_stebp`
        -   ``dste = True`` and ``bp = False``: :func:`klassez.fit.model_dste`
        -   ``dste = True`` and ``bp = True``: :func:`klassez.fit.model_dstebp`

        The selected function will be stored in ``self.model``.

        Parameters
        ----------
        dste : bool
            Single (``False``) or double (``True``) stimulated echo
        bp : bool
            Uses bipolar gradients (``True``) or not (``False``).
        """
        if dste and bp:
            self._model = fit.model_dstebp
        elif dste and not bp:
            self._model = fit.model_dste
        elif not dste and bp:
            self._model = fit.model_stebp
        elif not dste and not bp:
            self._model = fit.model_ste
        else:
            raise NameError('Unrecognized model')
